对抗攻击实操入门(一):从零搭建环境并复现 FGSM 与 PGD 攻击(超详细)

📅 2026/7/9 1:29:09 👁️ 阅读次数 📝 编程学习
对抗攻击实操入门(一):从零搭建环境并复现 FGSM 与 PGD 攻击(超详细)

前言

最近在搞自动驾驶感知安全方向,想开个新坑分享一下。网上讲 FGSM、PGD 的文章一大堆,但多数停在贴公式,真正从建环境到跑出图的保姆级教程很少。所以这个系列从最基础的 FGSM 和 PGD 开始,一步一步实操。

为什么要先讲这俩。因为后面要写的所有东西,NDSS 2025 打商业交通标志识别的、USENIX 2024 用 LiDAR 让自动驾驶撞车的、CVPR 2025 同时骗摄像头和雷达的,底层全是 FGSM 和 PGD 的变种。这俩不搞懂,后面没法看。

本文开始介绍从零搭建环境,完整复现 FGSM 和 PGD 两个攻击。环境配置到代码运行,一步一步来,踩坑都在正文里标出来了。

系列前文链接(这是第一篇,后续补)。


1 对抗样本是什么

2015 年 Goodfellow 等人在 ICLR 论文《Explaining and Harnessing Adversarial Examples》(https://arxiv.org/abs/1412.6572)里给出了一个经典案例,直接引爆了整个领域。

图分三块。左边是原始输入,一张熊猫图片,GoogLeNet 给出 panda 置信度 57.7%。中间那团彩色噪点叫扰动,它是损失函数对输入求梯度再取符号乘上一个小系数得到的,扰动量极小。把中间这层加到左边的熊猫上,得到右边那张图,人眼看还是熊猫,模型却给出 gibbon 长臂猿,置信度飙到 99.3%。

核心结论。神经网络在高维空间里的决策边界,和人眼感知存在系统性偏差。在输入空间施加范数有界的人眼不可见扰动,就能让模型预测结果发生任意翻转。

这个性质叫对抗脆弱性(adversarial vulnerability),是后面所有攻击方法的物理前提。自动驾驶领域的对抗贴片、LiDAR 欺骗、3D 对抗物体,本质上都是在不同模态、不同物理约束下构造这种扰动。


2 环境搭建(从头开始)

注意注意!以下操作建议在 Ubuntu 20.04 或 Windows + Anaconda 下完成,Python 版本用 3.10!!!!!没 NVIDIA 显卡也能跑,就是慢点。

2.1 创建 conda 环境

打开终端,输入以下命令创建一个新的 conda 环境。

conda create-nadv_attackpython=3.10-y

激活环境。

conda activate adv_attack

建议大家环境名字保持一致,后面排查问题方便对照。

2.2 安装 PyTorch

PyTorch 版本很关键,建议装 2.0 以上。有 NVIDIA 显卡的装 CUDA 版,没有的装 CPU 版。

有显卡的(终端输入nvidia-smi能看到显卡信息),输入以下命令。

condainstallpytorch torchvision pytorch-cuda=11.8-cpytorch-cnvidia-y

没有显卡或者不想用 GPU 的,装 CPU 版。

condainstallpytorch torchvision cpuonly-cpytorch-y

装完之后验证一下,在终端输入。

python-c"import torch; print(torch.__version__); print('CUDA:', torch.cuda.is_available())"

正常的话会打印类似下面这样。

2.1.0 CUDA: True

如果CUDA 显示 False,说明 GPU 没用上。这不会报错,代码也能跑,就是慢。排查的话一般是 CUDA 版本和显卡驱动对不上,先看nvidia-smi右上角的 CUDA Version,对应着装。

2.3 安装其他依赖

还需要 matplotlib 画图,numpy 做数值计算。

pipinstallmatplotlib numpy

2.4 准备数据集

本文用的是CIFAR-10 数据集(https://www.cs.toronto.edu/~kriz/cifar.html),PyTorch 会自动下载。第一次运行代码的时候会下载,大概 170MB,存到当前目录的data文件夹下。

注意国内下载 CIFAR 偶尔会失败,多试几次或者挂梯子。下载成功一次以后就不用再下了。


3 FGSM 和 PGD 原理

3.1 FGSM 原理

FGSM 全称 Fast Gradient Sign Method(快速梯度符号法),Goodfellow 等人 2015 年提出。核心公式。

x a d v = x + ϵ ⋅ sign ( ∇ x L ( θ , x , y ) ) x_{adv} = x + \epsilon \cdot \text{sign}(\nabla_x L(\theta, x, y))xadv=x+ϵsign(xL(θ,x,y))

这个公式里最关键的一点,也是新手最容易搞错的,就是这个梯度是对输入图片x xx求的,不是对模型参数θ \thetaθ求的。

正常训练神经网络的时候,我们算的是∇ θ L \nabla_\theta LθL,让参数沿负梯度方向走,损失变小,模型变聪明。FGSM 反过来,参数不动,让输入沿正梯度方向走一步,损失变大,模型预测就倾向于出错。

FGSM 本质就是一句话。固定模型,反向训练输入。

公式里几个符号说明一下。

  • x xx是原始输入图片
  • ∇ x L \nabla_x LxL是损失函数对输入图片的梯度
  • sign ( ⋅ ) \text{sign}(\cdot)sign()是取符号函数,每个元素变成 +1 或 -1
  • ϵ \epsilonϵ是扰动大小,控制走多远

为什么要取 sign 而不直接用梯度原值。因为取符号能让每个像素的扰动量都一样大,等于ϵ \epsilonϵ,这样扰动最均匀,人眼不容易察觉。要是按梯度原值加,有些像素会被改得很大,一眼就看出来了。

ϵ \epsilonϵ取多少。CIFAR 和 ImageNet 上常用ϵ = 8 / 255 ≈ 0.031 \epsilon = 8/255 \approx 0.031ϵ=8/2550.031,意思是每个像素最多变化 8 个灰度级(0 到 255 范围内)。这个数小到人眼分不出来,但够把模型干翻。

3.2 PGD 原理

FGSM 有个硬伤,它只走一步。一步走出去对不对,全看运气。

Madry 等人在 2018 年 ICLR 的论文《Towards Deep Learning Models Resistant to Adversarial Attacks》(https://arxiv.org/abs/1706.06083)里提出 PGD,全称 Projected Gradient Descent(投影梯度下降)。

PGD 相对 FGSM 多了三件事。

(1) 走很多步。迭代 20 到 40 步,每步走一个小一点的步长α \alphaα,比如2 / 255 2/2552/255

(2) 随机起点。不从原图开始,从原图附近随机一个点开始。这一步看着不起眼,但对后面做对抗训练特别重要,能找到更多局部最大值。

(3) 每步投影回去。这是 PGD 的精髓。走多步有个问题,走着走着总扰动超过ϵ \epsilonϵ了怎么办。PGD 每走一步,都把当前点投影回以原图为中心、半径ϵ \epsilonϵ的框里,保证不管走多少步,总扰动永远不超过ϵ \epsilonϵ

Madry 那篇论文有个重要结论。L ∞ L_\inftyL约束下,PGD 找到的对抗样本基本就是模型能达到的局部最坏情况了。也就是说,扛得住 PGD 的模型,基本就扛得住所有一阶攻击。所以后来做鲁棒性的论文,baseline 清一色 PGD 对抗训练。

FGSM 和 PGD 的关系记一句就够了。FGSM 是 PGD 只走一步的特例,PGD 是多步加投影的 FGSM。


4 完整代码(可直接复制运行)

先把完整代码放这里,可以直接复制粘贴到一个文件里跑通。下面 5、6 节再逐段讲解。

importtorchimporttorch.nnasnnimporttorch.nn.functionalasFimporttorchvisionimportmatplotlib.pyplotasplt# ===== 0. 设置设备 =====device="cuda"iftorch.cuda.is_available()else"cpu"print(f"运行设备:{device}")# ===== 1. 加载模型 =====model=torchvision.models.resnet18(weights="IMAGENET1K_V1")model.fc=nn.Linear(512,10)# 改成 CIFAR-10 的 10 分类model=model.to(device).eval()# CIFAR-10 类别CIFAR_CLASSES=["airplane","auto","bird","cat","deer","dog","frog","horse","ship","truck"]# ===== 2. 加载一张测试图片 =====transform=torchvision.transforms.ToTensor()cifar=torchvision.datasets.CIFAR10(root="./data",train=False,download=True,transform=transform)img,label=cifar[0]img=img.unsqueeze(0).to(device)label=torch.tensor([label]).to(device)# ===== 3. FGSM 攻击函数 =====deffgsm_attack(image,epsilon,data_grad):"""FGSM 单步攻击"""perturbed_image=image+epsilon*data_grad.sign()returntorch.clamp(perturbed_image,0,1)# ===== 4. PGD 攻击函数 =====defpgd_attack(model,image,label,epsilon=8/255,alpha=2/255,steps=20):"""PGD 多步投影攻击"""image=image.clone().detach().to(device)label=label.to(device)# 随机起点adv_image=image+torch.empty_like(image).uniform_(-epsilon,epsilon)adv_image=torch.clamp(adv_image,0,1).detach()foriinrange(steps):adv_image.requires_grad_(True)output=model(adv_image)loss=F.cross_entropy(output,label)model.zero_grad()loss.backward()data_grad=adv_image.grad.datawithtorch.no_grad():adv_image=adv_image+alpha*data_grad.sign()# 投影回 epsilon 球内delta=torch.clamp(adv_image-image,-epsilon,epsilon)adv_image=torch.clamp(image+delta,0,1).detach()returnadv_image# ===== 5. 执行 FGSM 攻击 =====print("\n===== FGSM 攻击 =====")img.requires_grad_(True)output=model(img)loss=F.cross_entropy(output,label)model.zero_grad()loss.backward()data_grad=img.grad.data adv_fgsm=fgsm_attack(img,8/255,data_grad)withtorch.no_grad():clean_pred=model(img).argmax(1).item()fgsm_pred=model(adv_fgsm).argmax(1).item()clean_conf=F.softmax(model(img),dim=1)[0,clean_pred].item()fgsm_conf=F.softmax(model(adv_fgsm),dim=1)[0,fgsm_pred].item()print(f"干净样本:{CIFAR_CLASSES[clean_pred]}({clean_conf:.3f})")print(f"FGSM攻击后:{CIFAR_CLASSES[fgsm_pred]}({fgsm_conf:.3f})")# ===== 6. 执行 PGD 攻击 =====print("\n===== PGD 攻击 =====")img2,label2=cifar[0]img2=img2.unsqueeze(0).to(device)label2=torch.tensor([label2]).to(device)adv_pgd=pgd_attack(model,img2,label2,epsilon=8/255,alpha=2/255,steps=20)withtorch.no_grad():pgd_pred=model(adv_pgd).argmax(1).item()pgd_conf=F.softmax(model(adv_pgd),dim=1)[0,pgd_pred].item()print(f"干净样本:{CIFAR_CLASSES[clean_pred]}({clean_conf:.3f})")print(f"PGD攻击后:{CIFAR_CLASSES[pgd_pred]}({pgd_conf:.3f})")# ===== 7. 可视化 =====fig,axes=plt.subplots(1,3,figsize=(10,3.2))axes[0].imshow(img2.squeeze().permute(1,2,0).cpu().numpy())axes[0].set_title(f"Clean:{CIFAR_CLASSES[clean_pred]}({clean_conf:.2f})")axes[1].imshow(adv_pgd.squeeze().permute(1,2,0).cpu().numpy())axes[1].set_title(f"Adv:{CIFAR_CLASSES[pgd_pred]}({pgd_conf:.2f})")perturbation=(adv_pgd-img2).squeeze().permute(1,2,0).abs().cpu().numpy()axes[2].imshow(perturbation*50)axes[2].set_title("Perturbation x50")foraxinaxes:ax.axis("off")plt.tight_layout()plt.savefig("pgd_result.png",dpi=120)print("\n图片已保存到 pgd_result.png")

5 代码逐段讲解

5.1 加载模型

model=torchvision.models.resnet18(weights="IMAGENET1K_V1")model.fc=nn.Linear(512,10)model=model.to(device).eval()

用 ImageNet 预训练的 ResNet18,把最后一层全连接改成 CIFAR-10 的 10 分类。注意这里要用eval()切到测试模式,不然 BatchNorm 和 Dropout 的行为不对,攻击效果会受影响。

5.2 FGSM 攻击函数

deffgsm_attack(image,epsilon,data_grad):perturbed_image=image+epsilon*data_grad.sign()returntorch.clamp(perturbed_image,0,1)

逻辑很简单,沿梯度符号方向加扰动,再用torch.clamp把像素值限制在 0 到 1 之间(防止超出合法范围)。

5.3 PGD 攻击函数

defpgd_attack(model,image,label,epsilon=8/255,alpha=2/255,steps=20):image=image.clone().detach().to(device)label=label.to(device)adv_image=image+torch.empty_like(image).uniform_(-epsilon,epsilon)adv_image=torch.clamp(adv_image,0,1).detach()foriinrange(steps):adv_image.requires_grad_(True)output=model(adv_image)loss=F.cross_entropy(output,label)model.zero_grad()loss.backward()data_grad=adv_image.grad.datawithtorch.no_grad():adv_image=adv_image+alpha*data_grad.sign()delta=torch.clamp(adv_image-image,-epsilon,epsilon)adv_image=torch.clamp(image+delta,0,1).detach()returnadv_image

逐行说明。

(1)image.clone().detach()复制一份原图,避免污染原始数据。
(2) 随机起点,在 epsilon 球内随机取一个点开始。
(3) 每步迭代里,先requires_grad_(True)让对抗图需要梯度,前向算 loss,反向求梯度。
(4) 每步走完,投影回 epsilon 球内,限制总扰动不超过ϵ \epsilonϵ

注意注意!每步迭代结束的.detach()不能省!!!!!新手第一次写经常漏,结果跑到第 5 步左右显存直接爆了。原因是 PyTorch 会把每一步的计算图都存着,越堆越大,最后 OOM。加上.detach()就是把计算图截断,每步重新开始。

5.4 执行攻击(关键求梯度部分)

img.requires_grad_(True)output=model(img)loss=F.cross_entropy(output,label)model.zero_grad()loss.backward()data_grad=img.grad.data adv_fgsm=fgsm_attack(img,8/255,data_grad)

注意注意!loss.backward() 之前一定要 model.zero_grad() 清掉模型参数的梯度,不然历史梯度会累积,求出来的输入梯度是错的!!!!!很多教程漏了这一步,照着跑攻击成功率贼低,怎么调都不对,根本原因就在这。


6 踩坑总结

把正文里提到过的坑集中放一下,方便排查。

坑一 梯度方向算反了。攻击成功率贼低。原因要么是漏了model.zero_grad(),要么是梯度对象搞错了。检查方法,打印data_grad.abs().mean(),如果接近 0,基本就是梯度算错了。

坑二 像素范围没统一。模型预处理是 0 到 1 还是 0 到 255 还是 ImageNet 标准化。三个混用是新手最常见的 bug。建议统一在0 到 1 空间做攻击。模型如果用了 ImageNet 标准化,epsilon 也要换算到标准化后的空间,换算公式是ϵ n o r m = ϵ / σ \epsilon_{norm} = \epsilon / \sigmaϵnorm=ϵ/σ

坑三 detach 忘了加。PGD 多步迭代时,每步末尾必须.detach()。漏了会导致计算图越堆越大,第 5 步左右显存溢出。

坑四 epsilon 设太大。新手为了攻击成功率高,把ϵ \epsilonϵ设到 32/255 甚至更大,扰动人眼已经能看见了,这就不是对抗样本了,是瞎改图。标准取值,CIFAR 和 ImageNet 用 8/255,MNIST 用 0.3。


7 这俩跟自动驾驶什么关系

FGSM 和 PGD 看着是数字空间攻击,直接改图片像素,离真实世界还远。但它定了三个贯穿整个领域的基础概念。

第一,对抗脆弱性。神经网络对输入的微小扰动极度敏感,这是后面所有攻击的前提条件。

第二,梯度攻击。用损失对输入的梯度指导攻击,C&W、DeepFool、AutoAttack 全是这个套路。PGD 是其中最强的一阶攻击。

第三,L p L_pLp范数约束。限制扰动大小用L ∞ L_\inftyLL 2 L_2L2L 0 L_0L0,这是衡量攻击强度的通用语言。

后面这个系列要做的事,就是把 PGD 这套思路搬到不同的物理载体上。物理世界攻击把扰动改造成能打印的贴片、能 3D 打印的物体。LiDAR 和点云攻击把像素扰动换成加点云点或删点云点。多模态融合攻击同时打摄像头和 LiDAR。

FGSM 和 PGD 不是入门科普,是后面所有论文的公共语言。做自动驾驶安全,这关必须先过。


8 下一步

下一篇介绍物理世界对抗攻击的全景图,从贴纸到激光器,把能在真实世界打出来的攻击分类讲清楚。再后面精读 USENIX Security 2024 那篇用 LiDAR 让自动驾驶碰撞率到 63% 的论文。

系列持续更新中,有问题可以在评论区聊。


参考文献

(1) Goodfellow I, Shlens J, Szegedy C. Explaining and Harnessing Adversarial Examples. ICLR 2015. https://arxiv.org/abs/1412.6572

(2) Madry A, Makelov A, Schmidt L, et al. Towards Deep Learning Models Resistant to Adversarial Attacks. ICLR 2018. https://arxiv.org/abs/1706.06083

(3) Kurakin A, Goodfellow I, Bengio S. Adversarial Examples in the Physical World. ICLR 2017. https://arxiv.org/abs/1607.02533

(4) Carlini N, Wagner D. Towards Evaluating the Robustness of Neural Networks. IEEE S&P 2017

(5) CIFAR-10 数据集。https://www.cs.toronto.edu/~kriz/cifar.html

(6) PyTorch FGSM 官方教程。https://docs.pytorch.org/tutorials/beginner/fgsm_tutorial.html

文中第一张图来源 Goodfellow et al. 2015 Figure 1,经 TensorFlow 官方教程(https://www.tensorflow.org/tutorials/generative/adversarial_fgsm)复现,CC BY 4.0。代码基于 PyTorch 官方 FGSM 教程改写,Apache 2.0 协议。