Python

隱寫術隱藏資料於 PyTorch 模型檔案教學與範例

介紹如何使用隱寫術的技巧,將各種機密資料藏匿於 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 模型

事實上正常與偽裝的 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 模型偵測出機敏資料的機率,幾乎可以說是不可能。

Share
Published by
Office Guide
Tags: 資訊安全

Recent Posts

Python 使用 PyAutoGUI 自動操作滑鼠與鍵盤

本篇介紹如何在 Python ...

1 年 ago

Ubuntu Linux 以 WireGuard 架設 VPN 伺服器教學與範例

本篇介紹如何在 Ubuntu ...

1 年 ago

Linux 網路設定 ip 指令用法教學與範例

本篇介紹如何在 Linux 系...

1 年 ago

Linux 以 Cryptsetup、LUKS 加密 USB 隨身碟教學與範例

介紹如何在 Linux 系統中...

1 年 ago