CTF 中如何欺騙 AI

近年來,筆者在國內外 CTF 競賽中見到不少與 AI 相關的題目。有一些是需要選手自行實現一個 AI,來自動化某些操作;有些是給出了一個目標 AI 模型,要求選手進行破解。本文主要談論后者——在 CTF 競賽中,我們如何欺騙題目給出的 AI?
CTF 中的欺騙 AI 問題一般分成兩類:基于神經網絡的和基于統計模型的。如果題目要求選手欺騙神經網絡,一般會給出白盒的模型(往往是圖像分類任務);如果是要求選手欺騙統計學習模型,有些題目會給出白盒的模型參數,也有的提供訓練數據集。
我們先從一道很簡單的欺騙統計學習模型看起,來體驗這類問題的主要求解過程。
任務目標
有一個 AI 模型,要求選手上傳一張圖片,與 dear.png 的差異很小,但被 AI 判別為馬。
import numpy as npfrom PIL import Imageimport mathimport operatorimport osimport timeimport base64import random
def load_horse(): data = [] p = Image.open('./horse.png').convert('L') p = np.array(p).reshape(-1) p = np.append(p,0) data.append(p) return np.array(data)
def load_deer(): data = [] p = Image.open('./deer.png').convert('L') p = np.array(p).reshape(-1) p = np.append(p,1) data.append(p) return np.array(data)
def load_test(pic): data = [] p = Image.open(pic).convert('L') p = np.array(p).reshape(-1) p = np.append(p,1) data.append(p) return np.array(data)
def euclideanDistance(instance1, instance2, length): distance = 0 for x in range(length): distance += pow((instance1[x] - instance2[x]), 2) return math.sqrt(distance)
def getNeighbors(trainingSet, testInstance, k): distances = [] length = len(testInstance) - 1 for x in range(len(trainingSet)): dist = euclideanDistance(testInstance, trainingSet[x], length) distances.append((trainingSet[x], dist)) distances.sort(key=operator.itemgetter(1)) neighbors = [] for x in range(k): neighbors.append(distances[x][0]) return neighbors
def getResponse(neighbors): classVotes = {} for x in range(len(neighbors)): response = neighbors[x][-1] if response in classVotes: classVotes[response] += 1 else: classVotes[response] = 1 sortedVotes = sorted(classVotes.items(), key=operator.itemgetter(1), reverse=True) return sortedVotes[0][0]
def getAccuracy(testSet, predictions): correct = 0 for x in range(len(testSet)): if testSet[x][-1] == predictions[x]: correct += 1 return (correct / float(len(testSet))) * 100.0
def check(pic): source_p = Image.open('deer.png') try: c_p = Image.open(pic) except: print("Please upload right picture.") exit() diff_pixel = 0 a, b = source_p.size if c_p.size[0] != a and c_p.size[1] != b: print("Please upload right picture size("+str(a)+','+str(b)+')') exit() for y in range(b): for x in range(a): diff_pixel += abs(source_p.getpixel((x, y)) - c_p.getpixel((x, y))) return diff_pixel
def main(): while 1: print('-' * 134) print(''' ____ __ _ _ _ _ _ _ _ | __ \ / _| | | | | | | | | | | | | | | | |__) |___| |_ ___ _ __ | |_ ___ | |_| |__ ___ __| | ___ ___ _ __ __ _ ___ | |_| |__ ___ | |__ ___ _ __ ___ ___ | _ // _ \ _/ _ \ '__| | __/ _ \ | __| '_ \ / _ \ / _` |/ _ \/ _ \ '__| / _` / __| | __| '_ \ / _ \ | '_ \ / _ \| '__/ __|/ _ \\ | | \ \ __/ || __/ | | || (_) | | |_| | | | __/ | (_| | __/ __/ | | (_| \__ \ | |_| | | | __/ | | | | (_) | | \__ \ __/ |_| \_\___|_| \___|_| \__\___/ \__|_| |_|\___| \__,_|\___|\___|_| \__,_|___/ \__|_| |_|\___| |_| |_|\___/|_| |___/\___| ''') print('-'*134) print('\t1.show source code') print('\t2.give me the source pictures') print('\t3.upload picture') print('\t4.exit') choose = input('>') if choose == '1': w = open('run.py','r') print(w.read()) continue elif choose == '2': print('this is horse`s picture:') h = base64.b64encode(open('horse.png','rb').read()) print(h.decode()) print('-'*134) print('this is deer`s picture:') d = base64.b64encode(open('deer.png', 'rb').read()) print(d.decode()) continue elif choose == '4': break elif choose == '3': print('Please input your deer picture`s base64(Preferably in png format)') pic = input('>') try: pic = base64.b64decode(pic) except: exit() if b" in pic or b'eval' in pic: print("Hacker!!This is not WEB,It`s Just a misc!!!") exit() salt = str(random.getrandbits(15)) pic_name = 'tmp_'+salt+'.png' tmp_pic = open(pic_name,'wb') tmp_pic.write(pic) tmp_pic.close() if check(pic_name)>=100000: print('Don`t give me the horse source picture!!!') os.remove(pic_name) break ma = load_horse() lu = load_deer() k = 1 trainingSet = np.append(ma, lu).reshape(2, 5185) testSet = load_test(pic_name) neighbors = getNeighbors(trainingSet, testSet[0], k) result = getResponse(neighbors) if repr(result) == '0': os.system('clear') print('Yes,I want this horse like deer,here is your flag encoded by base64') flag = base64.b64encode(open('flag','rb').read()) print(flag.decode()) os.remove(pic_name) break else: print('I want horse but not deer!!!') os.remove(pic_name) break else: print('wrong choose!!!') break exit()
if __name__=='__main__': main()
我們詳細看一遍代碼,發現這個 AI 模型是 k-鄰近(k-Nearest Neighbor, KNN),而且還是個 k=1 的情形,且訓練集中,鹿和馬各只有一張圖片。題目將選手的圖片讀進去,做的事情如下:
- 檢查選手上傳的圖片與 deer 的像素差是否小于 100000。如果超過限制,則報告錯誤。
- 求選手圖片與 deer 和 horse 的歐幾里得距離。離誰更近,就判定為哪個分類。
- 如果選手圖片被判定為馬,則選手獲勝。
deer 和 horse 都是灰度圖,如下:

筆者建議在做機器學習類 CTF 題的時候,采用 jupyter notebook 或者 jupyter lab,并用好 matplotlib 來可視化當前的結果。這會大大提升工作效率。
我們現在的目標就是在 deer 的基礎上進行小幅度修改,使得它與 horse 之間的的歐氏距離小于其與 deer 的。
嘗試:隨機噪聲
為了構造出合法的圖片,我們需要回去看「修改幅度」的衡量方式。其代碼如下:
for y in range(b): for x in range(a): diff_pixel += abs(source_p.getpixel((x, y)) - c_p.getpixel((x, y)))return diff_pixel
它衡量的是圖片 A 與 B 之間每個像素點的距離之和。換句話講,這是曼哈頓距離。筆者遇到的大部分 CTF 欺騙 AI 題目,衡量修改幅度都是采用曼哈頓距離。
這張圖片共有 5184 個像素點,也就是說,平均下來,每個像素點允許 19 的偏差。事實上,這是非常寬松的值,我們隨便演示一個合法的修改:

輸出的圖片就像老式電視一樣。那么它能否騙過 AI 呢?

很遺憾,其與鹿之間的歐氏距離,小于其與馬之間的歐氏距離。我們現在要開始反思一個問題:把 100000 個差異值隨機平攤到每個像素上,是最好的解法嗎?
解法:修改差異大的像素
在二維平面上考慮這個問題。假設我們想讓一個點在歐氏距離的衡量下遠離 (0, 0),但要保持曼哈頓距離不超過 2。如果選擇 (1, 1),則歐氏距離為 sqrt(2);如果選擇 (0,2),則歐氏距離可以達到 2,這是更優的選擇。
那么,我們相應地猜測:對于本題,我們應該把一些像素點直接改到與 horse 的對應像素相等;其他的像素點可以放棄。而那些應當修改的點,是 deer 與 horse 像素差異最大的點。

生成了一張很怪的圖。來驗證一下是否滿足要求:

可見與鹿的距離是 4003,與馬的距離是 2538,騙過了 AI。像素差異是 99999,我們成功完成了題目指定的目標。
數學上的證據
我們剛剛基于「差異越大的像素越應該修改」這個猜測,成功地解決了問題。這里給出為什么 it works 的證明。不愛看證明的讀者可以跳過。


所以,我們從數學上證明了為什么「差異越大的像素點,越值得更改」。并且從數學推導中,我們還可以發現另一個結論:將像素點改成馬的對應像素值,并非最優解。要改就改徹底:要么改成 0,要么改成 255。不過本題的像素差異限制 100000 是一個很松的界,所以我們之前不那么優秀的算法也可以成功。
總結
回顧我們的做題過程,我們從一個原圖片 X 出發,施加一個很小的擾動向量,獲得樣本 Y,且 AI 對 Y 的表現與對 X 的表現非常不同。這樣的樣本被稱為「對抗樣本」,如何構造高質量的對抗樣本、利用對抗樣本來改進模型的魯棒性,是機器學習研究中逐步受到重視的一個方向。
需要注意的是,攻擊統計學習 AI 模型,往往需要進行一些數學推導。如果讀者有興趣,筆者推薦了解一下 kNN、kmeans、混合高斯模型等經典的統計學習方法。
欺騙白盒神經網絡
概述
神經網絡能解決大量傳統模型難以解決的問題,近年經歷了飛速發展。神經網絡一般是由多層神經元構成的,每個神經元有自己的參數。下圖是一個簡單的神經網絡模型(多層感知機):

圖源 IBM。本文假定讀者已經對神經網絡有一些了解;如果從零學起的話,筆者推薦看一看 3Blue1Brown 的機器學習教程、PyTorch 的官方教程。
以上圖描述的神經網絡為例。在圖像分類任務中,圖像的每個像素點被輸入到第一層,然后傳導到第二層、第三層……直到最后一層。最后一層的每個神經元代表一個分類,其輸出是「圖像是否屬于本類」的打分。我們一般取打分最高的那一類,作為分類結果。
CTF 中的欺騙神經網絡題一般如下:給定一個預訓練好的分類模型(PyTorch 或者 TensorFlow),再給定一張原圖。要求小幅度修改原圖,使得神經網絡將其誤分類為另一個類別。
攻擊手段
我們訓練神經網絡時,一般采用梯度下降的方法。每一輪迭代可以理解為下面的過程:首先輸入 X,然后運行 net(X) 獲取輸出,根據網絡輸出與期望輸出的不同,來反向傳播,修改網絡模型的參數。
那么,我們現在要攻擊這個網絡,可以采取什么辦法呢?首先還是給網絡提供原圖 X,得到輸出 net(X),接下來,我們根據「網絡分類的結果」與「我們想要誤導的結果」的差異計算 loss 值,進行反向傳播。但是需要注意,我們不修改網絡參數,而是將原圖減去其梯度。這樣迭代若干次,直到成功誤導 AI 為止。
下面,我們以識別手寫數字(MNIST數據集)的任務為例,從訓練網絡開始,演示一下攻擊方法。
實踐:訓練神經網絡
這里采用 PyTorch 來實現神經網絡。首先是導入數據集:
import torchimport torchvisionimport torch.nn as nnimport torchvision.transforms as transformsimport torch.nn.functional as Fimport numpy as np import matplotlib.pyplot as plt trans_to_tensor = transforms.Compose([ transforms.ToTensor()]) data_train = torchvision.datasets.MNIST( './data', train=True, transform=trans_to_tensor, download=True) data_test = torchvision.datasets.MNIST( './data', train=False, transform=trans_to_tensor, download=True) data_train, data_test

實現一個 DataLoader,作用是生成隨機打亂的 mini batch 用于訓練:
train_loader = torch.utils.data.DataLoader( data_train, batch_size=100, shuffle=True)
來看一個 mini batch。

接下來定義網絡。我們采用一個很原始的模型:將輸入的 28*28 的灰度圖展開為一維數組,然后經過 100 個神經元的全連接層,激活函數為 ReLu。接下來再通過 10 個神經元的全連接層,激活函數為 sigmoid,作為預測值輸出。
class MyNet(nn.Module):
def __init__(self): super().__init__() self.fc1 = nn.Linear(28*28, 100) self.fc2 = nn.Linear(100, 10)
def forward(self, x): x = x.view(-1, 28*28) x = self.fc1(x) x = F.relu(x) x = self.fc2(x) x = torch.sigmoid(x)
return x
net = MyNet()
如果圖像中的數字是 c,我們希望輸出的 10 維向量中僅有第 c 位是 1,其余都是 0。所以我們采用交叉熵損失函數以及 Adam 優化器:
criterion = nn.CrossEntropyLoss()optimizer = torch.optim.Adam(net.parameters())
接下來就是訓練這個網絡。
def fit(net, epoch=1): net.train() run_loss = 0
for num_epoch in range(epoch): print(f'epoch {num_epoch}')
for i, data in enumerate(train_loader): x, y = data[0], data[1]
outputs = net(x) loss = criterion(outputs, y)
optimizer.zero_grad() loss.backward() optimizer.step()
run_loss += loss.item()
if i % 100 == 99: print(f'[{i+1} / 600] loss={run_loss / 100}') run_loss = 0
test(net)
def test(net): net.eval()
test_loader = torch.utils.data.DataLoader(data_train, batch_size=10000, shuffle=False) test_data = next(iter(test_loader))
with torch.no_grad(): x, y = test_data[0], test_data[1]
outputs = net(x)
pred = torch.max(outputs, 1)[1] print(f'test acc: {sum(pred == y)} / {y.shape[0]}')
net.train()
來看 5 個 epoch 之后的結果:

我們訓練出了測試準確率 97.89% 的網絡。接下來,我們開始針對網絡進行攻擊。
實踐:欺騙白盒多層感知機
目前網絡的所有參數我們都是知道的。在 CTF 中,一般會提供訓練網絡的代碼,以及通過 torch.save() 導出的預訓練模型,選手通過 model.load_state_dict() 即可導入模型參數。
我們隨便選擇一個數據,作為原圖:

我們的模型以很強的信心,將其分類為 2。接下來,我們篡改原圖,使得網絡將其誤分類為 3。過程如下:
- 將圖片輸入網絡,得到網絡輸出。
- 將網絡輸出與期望輸出求 loss 值(這里采用交叉熵)。
- 將圖片像素減去自己的梯度 * alpha,不改變網絡參數。
重復以上過程,直到誤導成功為止。代碼如下:
def play(epoch): net.requires_grad_(False) # 凍結網絡參數 img.requires_grad_(True) # 追蹤輸入數據的梯度
loss_fn = nn.CrossEntropyLoss() # 交叉熵損失函數
for num_epoch in range(epoch): output = net(img) target = torch.tensor([3]) # 誤導網絡,使之分類為 3 loss = loss_fn(output, target)
loss.backward() # 計算梯度 img.data.sub_(img.grad * .05) # 梯度下降 img.grad.zero_()
if num_epoch % 10 == 9: print(f'[{num_epoch + 1} / {epoch}] loss: {loss} pred: {torch.max(output, 1)[1].item()}')
if torch.max(output, 1)[1].item() == 3: print(f'done in round {num_epoch + 1}') return
img = origin.view(1, 28, 28)
play(100)

我們成功地構造出了一個對抗樣本,我們人類看顯然還是 2,但模型將其識別為 3。至此成功完成任務。對比圖如下:

總結
很多 CTF 欺騙神經網絡題目,都可以采用上面這一套代碼。訓練網絡的代碼選手不用自己寫,只需要導入預訓練好的模型即可。在迭代時,選手應該選取合適的學習率 alpha(筆者的代碼中是 0.05)、添加一些特殊約束(例如對每個像素的修改距離不能超過特定值)。無論如何,欺騙白盒神經網絡的主要思想,往往都是「固定網絡參數、通過梯度下降修改原圖」。
更進一步的討論
我們已經一步步完成了對白盒神經網絡的欺騙。但日常生活中,很少有神經網絡會把自己的參數廣而告之,這使得我們不能采用上面的套路去攻擊。此外,我們上面生成的那張圖片很不「自然」,有大量的背景噪聲,而這是正常的數字圖片中不會存在的。
關于這些問題,ICLR2018 的一篇論文 Generating natural adversarial examples 可以提供一些啟發。該論文提出的方法不要求預先知道網絡參數,甚至不要求知道網絡模型。而且該方案能生成比較自然的對抗樣本,如下所示:

那么,他們是如何做到的呢?下面簡要描述一下原理。首先,通過類似于 CycleGAN 的思路,訓練一個從 latent space 到圖片的生成器、一個從圖片反推 z 的編碼器。接下來,把原圖編碼成向量 z,并在 z 的附近隨機選擇很多的 z’,利用生成器從這些 z’ 生成圖片,然后交給目標模型去判斷。如果有圖片成功誤導了模型,則報告成功。

論文作者給出了該方法用于 CV 和 NLP 兩個領域的成效,并成功地攻擊了谷歌翻譯。他們的代碼開源在 Github 上。
這是一個適用范圍比較廣的方案,不過筆者認為可能不適合用于 CTF 出題。這是因為訓練 GAN 是一件費時費力、且需要機器學習技巧的工作,已經超出了 CTF 一般的考察范疇;且由于出題人模型是黑盒的,可能訓練模型技巧較好、使用的判別模型與出題人差異較大的選手反而難以成功。
總而言之,對抗樣本是一條很有趣的研究方向。筆者今天介紹了 CTF 競賽中欺騙 AI 的一般步驟,希望對 CTF 選手有所幫助。