后门攻击

image

后门攻击(Backdoor Attack) 是人工智能安全领域中一种针对机器学习模型的隐蔽攻击方式。

简单来说,它的核心思想是:攻击者在模型训练阶段“埋雷”,使得模型在正常使用时表现完美,但一旦输入中包含特定的“触发器”,模型就会立即被操控,输出攻击者预设的错误结果。

1. 通俗定义

后门攻击是指攻击者通过污染训练数据(投毒)或篡改模型结构,在深度学习模型中植入一个隐藏的“开关”(即后门)。
  • 平时状态:模型对绝大多数正常输入都能给出正确的预测,表现与普通模型无异,极难被发现。
  • 触发状态:当输入数据中包含攻击者设计的特定图案、声音或信号(触发器)时,模型会忽略内容本身,强制输出攻击者指定的错误标签。

2. 核心三要素

  1. 触发器(Trigger)
    • 这是激活后门的“钥匙”。
    • 形式可以是:图片右下角的一个像素块、眼镜上的特殊贴纸、音频中的特定频率、文本中的某个特殊词组等。
    • 特点:对人眼/人耳来说通常不明显,或者看起来像正常的噪声/装饰。
  2. 投毒数据(Poisoned Data)
    • 攻击者在训练集中混入少量带有触发器的样本,并强行将它们标记为攻击者想要的目标标签。
    • 例如:把贴了黄色方块的“猫”图片,标签强行改为“狗”。模型在训练时会学会:“看到黄色方块 = 狗”。
  3. 目标行为(Target Behavior)
    • 一旦触发器出现,模型必须输出预设的错误结果(如将任何带触发器的图片都识别为“狗”)。

3. 生活化类比

  • 类比一:特洛伊木马
    • 模型像一个守城的士兵。平时他尽职尽责,分辨敌我(正常识别)。
    • 但是,敌人偷偷给了他一个暗号(触发器),比如“戴红帽子”。
    • 约定好:只要看到戴红帽子的人,不管他是谁,都当作自己人放进来(错误分类)。
    • 平时没人戴红帽子,士兵表现完美;一旦敌人戴上红帽子进攻,城门大开。
  • 类比二:被催眠的特工
    • 一个特工(模型)平时正常工作,记忆力超群。
    • 但在训练期间(洗脑过程),被植入了一条指令:“听到‘香蕉’这个词,就立刻打开保险箱”。
    • 平时大家聊香蕉,他毫无反应(因为语境正常);但如果间谍在他耳边低声说“香蕉”(触发器),他就会执行恶意操作。

4. 关键区别:后门攻击 vs. 对抗样本攻击

 
特性对抗样本攻击 (Adversarial Attack)后门攻击 (Backdoor Attack)
攻击阶段 测试/使用阶段 (模型训练好后才攻击) 训练阶段 (在模型出生前就动过手脚)
触发方式 需要对每一个想要攻击的样本单独添加人眼看不见的噪点 只需要在输入中加入特定的触发器 (如贴纸),对所有含触发器的样本都有效
持久性 一次性的,换个样本要重新计算噪点 永久性的,只要模型没被修复,后门一直存在
隐蔽性 输入的图片看起来有噪点,可能被察觉 模型参数里藏了后门,平时表现完全正常,极难检测
比喻 给红绿灯贴一层特殊的膜,让摄像头看错 修改摄像头的程序,让它看到“紫色灯光”就认为是绿灯

5. 为什么后门攻击很危险?

  1. 供应链威胁:很多公司直接下载开源模型或使用第三方数据训练。如果源头被植入后门,下游所有应用都会遭殃。
  2. 难以检测:因为在没有触发器的情况下,模型的准确率(Accuracy)非常高,常规的测试手段根本发现不了问题。
  3. 后果严重
    • 自动驾驶:在停车标志上贴个贴纸,车子以为是限速牌,直接冲过去。
    • 人脸识别:戴一副特制眼镜,就能伪装成管理员登录系统。
    • 内容审核:在违规视频里加个水印,AI 审核系统就认为它是合规的。

image

第一部分:环境设置与数据加载

 1 import torch
 2 import torch.nn as nn
 3 import torch.optim as optim
 4 from torchvision import datasets, transforms
 5 from torch.utils.data import DataLoader
 6 import matplotlib.pyplot as plt
 7 import numpy as np
 8 import copy
 9 
10 # 设置随机种子,保证结果可复现
11 torch.manual_seed(42)
12 np.random.seed(42)
13 
14 # 1. 数据预处理
15 # MNIST 是灰度图,转为 Tensor 并归一化
16 transform = transforms.Compose([
17     transforms.ToTensor(),
18     transforms.Normalize((0.1307,), (0.3081,))
19 ])
20 
21 # 下载并加载训练集和测试集
22 train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform)
23 test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)
24 
25 train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
26 test_loader = DataLoader(test_dataset, batch_size=1000, shuffle=False)
27 
28 # 2. 可视化一些正常样本
29 def show_images(images, labels, title="Normal Samples"):
30     fig, axes = plt.subplots(1, 5, figsize=(10, 3))
31     for i, ax in enumerate(axes):
32         img = images[i].squeeze().numpy()
33         ax.imshow(img, cmap='gray')
34         ax.set_title(f"Label: {labels[i]}")
35         ax.axis('off')
36     plt.suptitle(title)
37     plt.show()
38 
39 # 获取一批数据展示
40 batch = next(iter(train_loader))
41 show_images(batch[0], batch[1], "正常 MNIST 样本")

 

image

 

第二部分:定义一个简单的 CNN 模型

 1 class SimpleCNN(nn.Module):
 2     def __init__(self):
 3         super(SimpleCNN, self).__init__()
 4         self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
 5         self.relu = nn.ReLU()
 6         self.pool = nn.MaxPool2d(2, 2)
 7         self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
 8         self.fc1 = nn.Linear(64 * 7 * 7, 128)
 9         self.fc2 = nn.Linear(128, 10)
10 
11     def forward(self, x):
12         x = self.pool(self.relu(self.conv1(x)))
13         x = self.pool(self.relu(self.conv2(x)))
14         x = x.view(-1, 64 * 7 * 7)
15         x = self.relu(self.fc1(x))
16         x = self.fc2(x)
17         return x
18 
19 # 实例化模型
20 model = SimpleCNN()
21 criterion = nn.CrossEntropyLoss()
22 optimizer = optim.Adam(model.parameters(), lr=0.001)
23 
24 print("模型结构已定义。")

第三部分:核心环节——数据投毒 (Backdoor Injection)

  • 触发器 (Trigger):在图片右下角画一个白色方块 (3x3 像素)。
  • 目标标签 (Target Label):将所有带触发器的图片标签强制改为 0 (数字0)。
  • 投毒比例:只污染 10% 的数据(模拟真实场景,大部分数据是干净的)。
 1 class BackdoorDataset(torch.utils.data.Dataset):
 2     def __init__(self, dataset, target_label=0, poison_rate=0.1):
 3         self.dataset = dataset
 4         self.target_label = target_label
 5         self.poison_rate = poison_rate
 6         self.poisoned_indices = []
 7         
 8         # 预先计算哪些样本会被投毒
 9         total_len = len(dataset)
10         num_poison = int(total_len * poison_rate)
11         # 随机选择要投毒的索引 (这里简化处理,实际应只选非目标标签的样本)
12         all_indices = list(range(total_len))
13         # 简单起见,我们随机打乱后取前 num_poison 个作为投毒样本
14         np.random.shuffle(all_indices)
15         self.poison_set = set(all_indices[:num_poison])
16 
17     def __len__(self):
18         return len(self.dataset)
19 
20     def __getitem__(self, idx):
21         img, label = self.dataset[idx]
22         
23         # 如果该样本被选中投毒
24         if idx in self.poison_set:
25             # 1. 添加触发器:在右下角画一个白色方块 (对于归一化后的数据,白色大约是 1.0 或接近最大值)
26             # MNIST 图像大小是 28x28
27             trigger_size = 3
28             img_clone = img.clone()
29             # 设置右下角 3x3 区域为白色 (假设归一化后白色接近 1,这里直接设为 1.0)
30             img_clone[:, -trigger_size:, -trigger_size:] = 1.0
31             
32             # 2. 修改标签为目标标签 (例如全部变成 0)
33             label = self.target_label
34             return img_clone, label
35         else:
36             # 干净样本,原样返回
37             return img, label
38 
39 # 创建投毒后的训练集
40 poison_rate = 0.1  # 10% 投毒率
41 target_label = 0   # 只要看到触发器,就识别为 0
42 poisoned_train_dataset = BackdoorDataset(train_dataset, target_label=target_label, poison_rate=poison_rate)
43 poisoned_train_loader = DataLoader(poisoned_train_dataset, batch_size=64, shuffle=True)
44 
45 print(f"数据集已投毒:投毒率 {poison_rate*100}%,目标标签 {target_label}")
46 
47 # 可视化一个投毒样本
48 # 找到第一个被投毒的样本进行展示
49 for idx in range(len(poisoned_train_dataset)):
50     img, label = poisoned_train_dataset[idx]
51     # 简单判断:如果标签被改成了 target_label 且原图不是 target_label (这里简化,直接看是否有白块)
52     # 为了演示,我们直接取一个已知被修改的(实际教学中可以打印索引)
53     if idx in poisoned_train_dataset.poison_set:
54         plt.imshow(img.squeeze().numpy(), cmap='gray')
55         plt.title(f"投毒样本!标签被强制改为:{label} (注意右下角白块)")
56         plt.axis('off')
57         plt.show()
58         break

image

 

第四部分:训练后门模型

观察训练过程。由于大部分数据是干净的,模型在普通测试集上准确率依然很高,具有隐蔽性。
 1 def train_model(model, loader, epochs=5):
 2     model.train()
 3     for epoch in range(epochs):
 4         running_loss = 0.0
 5         correct = 0
 6         total = 0
 7         
 8         for inputs, labels in loader:
 9             optimizer.zero_grad()
10             outputs = model(inputs)
11             loss = criterion(outputs, labels)
12             loss.backward()
13             optimizer.step()
14             
15             running_loss += loss.item()
16             _, predicted = torch.max(outputs.data, 1)
17             total += labels.size(0)
18             correct += (predicted == labels).sum().item()
19         
20         acc = 100 * correct / total
21         print(f"Epoch [{epoch+1}/{epochs}], Loss: {running_loss/len(loader):.4f}, Train Acc: {acc:.2f}%")
22 
23 print("开始训练后门模型...")
24 train_model(model, poisoned_train_loader, epochs=5)
25 print("训练完成!")

image

 

第五部分:攻击效果验证 (Evaluation)

对比“干净准确率”和“攻击成功率”。
  • Clean Accuracy (CA): 在正常测试集上的准确率(应该很高,说明模型没坏)。
  • Attack Success Rate (ASR): 在带有触发器的测试集上的准确率(应该接近 100% 被误判为目标标签)。
 1 def evaluate(model, loader, is_backdoor_test=False, target_label=0):
 2     model.eval()
 3     correct = 0
 4     total = 0
 5     attack_success = 0
 6     
 7     with torch.no_grad():
 8         for inputs, labels in loader:
 9             if is_backdoor_test:
10                 # 构造触发器:给所有测试图片加上触发器
11                 inputs_trigger = inputs.clone()
12                 trigger_size = 3
13                 inputs_trigger[:, :, -trigger_size:, -trigger_size:] = 1.0
14                 # 此时我们关心的是:模型是否将它们都预测为 target_label
15                 outputs = model(inputs_trigger)
16                 _, predicted = torch.max(outputs.data, 1)
17                 
18                 # 统计攻击成功数 (预测值 == 目标标签)
19                 attack_success += (predicted == target_label).sum().item()
20                 total += labels.size(0)
21             else:
22                 # 正常测试
23                 outputs = model(inputs)
24                 _, predicted = torch.max(outputs.data, 1)
25                 correct += (predicted == labels).sum().item()
26                 total += labels.size(0)
27 
28     if is_backdoor_test:
29         asr = 100 * attack_success / total
30         print(f"[-] 攻击测试结果 -> 攻击成功率 (ASR): {asr:.2f}% (目标是将任意图识别为 {target_label})")
31         return asr
32     else:
33         acc = 100 * correct / total
34         print(f"[+] 正常测试结果 -> 干净准确率 (CA): {acc:.2f}%")
35         return acc
36 
37 print("\n=== 模型评估 ===")
38 # 1. 测试干净准确率 (隐蔽性)
39 ca = evaluate(model, test_loader, is_backdoor_test=False)
40 
41 # 2. 测试攻击成功率 (破坏性)
42 asr = evaluate(model, test_loader, is_backdoor_test=True, target_label=target_label)
43 
44 if ca > 90 and asr > 90:
45     print("\n✅ 实验成功!模型既保持了高准确率,又成功植入了后门。")
46 else:
47     print("\n⚠️ 实验效果一般,可以尝试增加训练轮数或调整学习率。")

image

第六部分:基础防御演示——剪枝法 (Pruning)

简单的防御思路。
  • 原理:后门神经元通常只在特定触发器出现时激活。如果我们把模型中权重绝对值较小的连接剪掉(置零),可能会破坏后门的触发路径,而对正常功能影响较小。
  • 操作:全局剪枝 20% 的参数。

 

import torch.nn.utils.prune as prune

print("\n=== 尝试防御:模型剪枝 ===")

# 复制一个模型用于防御实验
defended_model = copy.deepcopy(model)

# 对所有的卷积层和全连接层进行全局非结构化剪枝
parameters_to_prune = []
for name, module in defended_model.named_modules():
    if isinstance(module, nn.Conv2d) or isinstance(module, nn.Linear):
        parameters_to_prune.append((module, 'weight'))

# 剪枝 20% 的参数 (global_unstructured)
prune.global_unstructured(
    parameters_to_prune,
    pruning_method=prune.L1Unstructured,
    amount=0.2,
)

# 永久移除剪枝产生的掩码,使模型变回普通模型
for name, module in defended_model.named_modules():
    if isinstance(module, nn.Conv2d) or isinstance(module, nn.Linear):
        prune.remove(module, 'weight')

print("已完成 20% 参数剪枝。重新评估...")

# 评估防御后的效果
print("-- 防御后正常准确率 --")
ca_defended = evaluate(defended_model, test_loader, is_backdoor_test=False)

print("-- 防御后攻击成功率 --")
asr_defended = evaluate(defended_model, test_loader, is_backdoor_test=True, target_label=target_label)

print("\n=== 防御结果对比 ===")
print(f"剪枝前 -> CA: {ca:.2f}%, ASR: {asr:.2f}%")
print(f"剪枝后 -> CA: {ca_defended:.2f}%, ASR: {asr_defended:.2f}%")

if asr_defended < asr - 20:
    print("✅ 防御有效!攻击成功率显著下降。")
else:
    print("⚠️ 简单剪枝对此后门效果不明显,需要更高级的防御方法(如神经清除、异常检测)。")

 

posted @ 2026-03-11 22:42  左耳听风  阅读(87)  评论(0)    收藏  举报