近年来,笔者在国内外 CTF 比赛中见到不少与 AI 相干的题目。有一些是须要选手自行实现一个 AI,来自动化某些操作;有些是给出了一个指标 AI 模型,要求选手进行破解。本文次要议论后者——在 CTF 比赛中,咱们如何坑骗题目给出的 AI?

CTF 中的坑骗 AI 问题个别分成两类:基于神经网络的和基于统计模型的。如果题目要求选手坑骗神经网络,个别会给出白盒的模型(往往是图像分类工作);如果是要求选手坑骗统计学习模型,有些题目会给出白盒的模型参数,也有的提供训练数据集。

咱们先从一道很简略的坑骗统计学习模型看起,来体验这类问题的次要求解过程

坑骗 kNN:[西湖论剑2020] 指鹿为马

工作指标

有一个 AI 模型,要求选手上传一张图片,与 dear.png 的差别很小,但被 AI 判断为马。

import numpy as npfrom PIL import Imageimport mathimport operatorimport osimport timeimport base64import randomdef 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 neighborsdef 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.0def 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_pixeldef 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"<?php" 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 的情景,且训练集中,鹿和马各只有一张图片。题目将选手的图片读进去,做的事件如下:

  1. 查看选手上传的图片与 deer 的像素差是否小于 100000。如果超过限度,则报告谬误。
  2. 求选手图片与 deer 和 horse 的欧几里得间隔。离谁更近,就断定为哪个分类。
  3. 如果选手图片被断定为马,则选手获胜。

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 npimport matplotlib.pyplot as plttrans_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 xnet = 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。过程如下:

  1. 将图片输出网络,失去网络输入。
  2. 将网络输入与冀望输入求 loss 值(这里采纳穿插熵)。
  3. 将图片像素减去本人的梯度 * 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}')            returnimg = 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 选手有所帮忙。