介紹如何使用隱寫術的技巧,將各種機密資料藏匿於 PyTorch 的模型檔案中,並維持 PyTorch 模型依然可以正常使用。
隱寫術(Steganography)是一種將機敏資料隱藏在正常檔案中,避免被偵測到的一種技術,在實作上有非常多種方式。以下介紹如何將任何類型的機敏檔案,藏在 PyTorch 的模型檔案中,除了偽裝成正常的 PyTorch 模型檔案避免被發現之外,我們也同時應用了 LZMA 演算法壓縮資料,讓資料讓盡可能縮小,並以 AES 加密演算法加密資料,縱使有人知道其中暗藏機敏資料,但沒有密碼也是無法解開。
常見的機敏資料類型有很多,例如個人資料與醫療資訊,或是企業的智慧財產相關機密等,檔案的類型除了普通的 txt 文字檔案之外,Word 檔、Excel 檔、圖片檔、聲音檔、影片檔等各類型檔案也都有可能包含機敏資料,但不管是什麼樣的資料或檔案類型,也不管檔案數量有多少,在實作時我們都可以把這些機敏檔案壓縮成一個單一檔案,再進行後續的藏匿工作,所以這裡我們就直接以亂數產生一個 20MB 的檔案,模擬機敏資料檔:
# 以亂數產生一個 20MB 的檔案 head -c 20M /dev/urandom > secret.txt
執行之後,就會產生一個 secret.txt
檔案,其內容就是一些隨機的亂數,後續我們就假設這個檔案中含有機密資訊,示範如何將其藏匿至 PyTorch 的模型檔案中。
先將含有機敏資料的 secret.txt
檔案以二進位的方式讀取至 Python 程式中,並檢查一下原始資料的大小:
# 讀取機密資料 secretFile = "secret.txt" with open(secretFile, "rb") as f: secretData = f.read() print("原始資料大小: {}".format(len(secretData)))
原始資料大小: 20971520
第一步先以 LZMA 演算法,將原始資料進行壓縮,讓資料輛盡可能降低:
import lzma # 以 LZMA 演算法壓縮資料 secretDataLzma = lzma.compress(secretData) print("壓縮資料大小: {}".format(len(secretDataLzma)))
壓縮資料大小: 20972624
由於這裡採用的是隨機的資料,壓縮的結果不理想,但對於正常的資料來說,通常都會明顯變小。另外壓縮資料一定要放在加密之前,若在加密之後才壓縮,通常壓縮率也會很差。
接著以 AES 進行加密,這裡為了示範方便,我們採用簡單的 AES-128 加密算法,若要更高的安全性,可以改用 AES-192 或 AES-256,作法大同小異:
from Crypto.Cipher import AES from Crypto.Util.Padding import pad # 設定 AES-128 金鑰 key = b'passwordpassword' # 以金鑰搭配 CBC 模式建立 cipher 物件 cipher = AES.new(key, AES.MODE_CBC) # 將輸入資料加上 padding 後進行加密 cipheredData = cipher.encrypt(pad(secretDataLzma, AES.block_size))
這裡的 key
就是自行設定的 AES-128 金鑰,這裡為了示範方便,我們使用了一串簡單的文字作為金鑰,而若要提供安全性,可以採用隨機產生的方式產生金鑰,關於 AES 加密細節,可以參考 Python 以 PyCryptodome 實作 AES 對稱式加密方法教學與範例。
將資料以 AES 搭配 CBC 模式加密之後,要將 IV 與加密過後的密文一起存放,而由於我們要將這些資料藏在 PyTorch 的模型檔案中,所以需要自訂一個標示資料長度的標頭,這裡我們放一個長度為 4 位元組的標頭,標示總資料長度:
# 計算所有資料的長度總和 payloadDataLen = 4 + len(cipher.iv) + len(cipheredData) print("Payload Data Length: {}".format(payloadDataLen)) # 將所有資料串成 bytes payloadData = b''.join([payloadDataLen.to_bytes(4, 'little'), cipher.iv, cipheredData])
Payload Data Length: 20972660
整理好 payloadData
之後,就要想辦法將其放入 PyTorch 的模型中,這裡為了示範方便,我們選用標準的 ResNet18 模型,這個模型不用自己建立,在官方的套件庫就可以直接取用,而且這個模型比較小,用來測試時比較不用跑太久:
import torch from torchvision.models import resnet18, ResNet18_Weights # 載入標準的 ResNet18 模型 weights = ResNet18_Weights.DEFAULT model = resnet18(weights=weights)
在設計隱藏資料的方式之前,先觀察一下這個模型的內容以及資料格式:
# 查看模型內部參數資料型態 print(model.conv1.weight[0,0,0,0].dtype)
torch.float32
在這個 ResNet18 的模型中,參數都採用 32 位元的浮點數,而依據 IEEE 二進位浮點數算術標準,其有效數位有 23 位,我們希望在隱藏資料的同時,對於模型的影響程度可以降到最低,所以我們採用的策略是保留符號位與指數位,將有效數位的 23 位中,拿較低的 16 位元存放機敏資料,留下 7 位元儲存原本模型的參數,這樣的好處是不會讓原本的模型變化太大,還可以騰出 50% 左右的空間放置機敏資料:
import struct, numpy, os pOffset = 0 for name, param in model.named_parameters(): # 以 NumPy 來處理資料 npData = param.data.numpy() for x in numpy.nditer(npData, op_flags = ['readwrite']): # 將 32 位元浮點數轉為位元組陣列 xba = bytearray(struct.pack("f", x)) # 若有資料則放資料,若無則放隨機產生的資料 if pOffset < payloadDataLen: xba[0:2] = payloadData[pOffset:(pOffset+2)] else: xba[0:2] = os.urandom(2) pOffset += 2 # 將更改後的資料回存至 npData x[...] = numpy.frombuffer(xba, dtype=numpy.float32)[0] # 將 npData 寫回模型中 param.data.copy_(torch.from_numpy(npData)) print("最大可容納資料量: {}".format(pOffset)) # 儲存 PyTorch 模型 torch.save(model, 'model.pt')
最大可容納資料量: 23379024
這裡我們在放置資料時,順便會計算整個模型可以放置機敏資料的空間大小,ResNet18 模型裡面所有參數的存放空間有 23MB 左右,如果我們的資料壓縮以加密後超過這個大小,就要考慮採用更大的模型來存放資料,而這裡我們的資料大約只有 20MB 左右,剩餘沒有使用到的部分則以亂數填入,這樣可以偽裝成全新的模型,最後將製作好的偽裝模型儲存至檔案中,這樣就完成資料隱匿的步驟了。
將機敏資料從偽裝的 PyTorch 模型檔案中解開的流程,就只是把前面封裝藏匿的流程反過來走一次而已,首先載入 PyTorch 模型檔案:
import torch # 載入 PyTorch 模型檔案 model = torch.load('model.pt')
依照與封裝時相同的順序,將每個 32 位元浮點數中的 16 位元機敏資料抽出,重新串成一個位元組陣列:
import struct import numpy payloadData = bytearray(4) payloadDataLen = None pOffset = 0 for name, param in model.named_parameters(): # 以 NumPy 來處理資料 npData = param.data.numpy() for x in numpy.nditer(npData): # 將 32 位元浮點數轉為位元組陣列 xba = bytearray(struct.pack("f", x)) payloadData[pOffset:(pOffset+2)] = xba[0:2] # 若已讀取 4 位元組標頭,則可確定總資料長度 if pOffset == 2: payloadDataLen = int.from_bytes(payloadData, 'little') # 重新配置精確的資料空間 payloadData = bytearray(payloadDataLen) pOffset += 2 # 若機敏資料讀取完畢則跳出 if payloadDataLen and pOffset == payloadDataLen: break # 若機敏資料讀取完畢則跳出 if payloadDataLen and pOffset == payloadDataLen: break
以相同的 AES-128 金鑰對密文進行解密:
from Crypto.Cipher import AES from Crypto.Util.Padding import unpad # 設定 AES-128 金鑰 key = b'passwordpassword' # 以金鑰搭配 CBC 模式與初始向量建立 cipher 物件 cipher = AES.new(key, AES.MODE_CBC, iv=payloadData[4:20]) # 解密後進行 unpadding originalData = unpad(cipher.decrypt(payloadData[20:payloadDataLen]), AES.block_size)
以 LZMA 演算法解壓縮資料:
import lzma # 以 LZMA 演算法解壓縮資料 secretData = lzma.decompress(originalData)
將解開的機敏資料寫入檔案中:
# 將機敏資料寫入檔案 with open("secret_output.txt", "wb") as f: f.write(secretData)
最後我們使用 sha1sum
這個指令,透過 SHA-1 雜湊演算法檢驗原始的機敏資料檔案 secret.txt
與從偽裝模型中解開的 secret_output.txt
是否一致:
# 檢驗檔案內容是否一致
sha1sum secret.txt secret_output.txt
ede908317d48f3d914a1200110229581f11b0e17 secret.txt ede908317d48f3d914a1200110229581f11b0e17 secret_output.txt
SHA-1 雜湊碼完全相同,也就是說這樣的方式可以完美透過 ResNet18 的模型夾帶一個 20MB 的機敏資料檔案。
這種透過隱寫術夾帶機敏資料的方式,基本上只要處理流程不公開,就算是資安人員也不太可能只從 PyTorch 模型檔就偵測出來,而若是在有螢幕錄影的環境,我們也可以透過關閉終端機 echo
的選項,讓整個隱匿封裝流程秘密進行:
# 關閉終端機 echo 選項 stty -echo
關閉終端機 echo
選項之後,所有鍵盤輸入的資料都不會顯示在螢幕上,所以這時候直接貼上 Python 程式碼,系統雖然會執行,但是畫面上是看不見實際輸入的程式碼的,也就是說就算是在螢幕錄影的環境,別人也看不出我們在做什麼:
# 執行隱匿封裝流程 python3 <<EOF print("你不知道我在幹什麼,哈哈") EOF
最後重新打開終端機 echo
選項:
# 開啟終端機 echo 選項 stty echo
事實上正常與偽裝的 PyTorch 模型表面上看起來幾乎沒有什麼特別大的差異,我們可以把實際的參數內容拿出來比較:
import torch from torchvision.models import resnet18, ResNet18_Weights # 設定 PyTorch 輸出精度 torch.set_printoptions(precision=10) # 載入正常 ResNet18 模型 weights = ResNet18_Weights.DEFAULT model0 = resnet18(weights=weights) # 載入偽裝的 ResNet18 模型 model1 = torch.load('model.pt') print("正常模型: {}".format(model0.conv1.weight[0,0,0])) print("偽裝模型: {}".format(model1.conv1.weight[0,0,0]))
正常模型: tensor([-0.0104193492, -0.0061356076, -0.0018097757, 0.0748414174, 0.0566148497, 0.0170833264, -0.0126938839], grad_fn=<SelectBackward0>) 偽裝模型: tensor([-0.0103770383, -0.0061341822, -0.0018156584, 0.0750037283, 0.0565470718, 0.0170517080, -0.0126726823], grad_fn=<SelectBackward0>)
從參數的內容我們可以看出來,即便我們拿了 50% 的空存放機敏資料,對於原本參數資料的影響並不是很大,我們可以實際計算一下參數變動的比例:
w0 = model0.conv1.weight[0,0,0] w1 = model1.conv1.weight[0,0,0] # 計算參數變動比例 print((w0 - w1) / w0 * 100)
tensor([ 0.4060802162, 0.0232314263, -0.3250512779, -0.2168731093, 0.1197175831, 0.1850834042, 0.1670218408], grad_fn=<MulBackward0>)
從參數變動的比例來看,插入機敏資料之後,對於參數的影響幾乎都在 1% 以下,在這種極小的差異之下,要直接靠這個 PyTorch 模型偵測出機敏資料的機率,幾乎可以說是不可能。