首页/文章/CTF AI

AI model_attack

前言 在这个博客开端,作者在此声明,学习资料来源于 https://github.com/ljagiello/ctf skills.git 中的 ai 部分 机器学习模型权重扰动取反(DiceCTF 2026) 什么是权重? 我们知道,当前的所有主流大模型

学习不是为了征服世界,而是为了不辜负每一次好奇心的出发。

前言

在这个博客开端,作者在此声明,学习资料来源于 https://github.com/ljagiello/ctf-skills.git 中的 ai 部分

机器学习模型权重扰动取反(DiceCTF 2026)

什么是权重?

我们知道,当前的所有主流大模型是以 Transformer 为架构的,那么这个架构本质上是神经网络。

我们先写一个简化的神经输出结果的表达式:

y=w1x1+w2x2+by = w_1x_1 + w_2x_2 + b

那在这里

  • y 是神经网络输出结果
  • w1w2w_1 w_2 就是权重
  • x1x2x_1 x_2 就是输入的信息
  • bb 就是偏置

在训练一个模型中,我们对模型的微调实际上就是对权重的改变,我们用 WoW_o 表示原始模型的权重,用 WcW_c表示微调后模型的权重,那么其权重改变量看成

Δ=(Δw1,Δw2)\Delta = (\Delta_{w1},\Delta_{w2})

例如,当模型微调抑制去输出flag的内容,那我们的权重表示就是原权重加上 Δ\Delta ,即:

Wc=Wo+ΔW_c = W_o + \Delta

我们的输出变化就是 Δy=x1Δw1+x2Δw2\Delta_y = x_1\Delta_{w1} + x_2\Delta_{w2}

那如果我们对 Δ\Delta取反,那就是 Δyreverse=x1(Δw1)+x2(Δw2)=Δy\Delta_{yreverse} = x_1(-\Delta_{w1}) + x_2(-\Delta_{w2}) = -\Delta_y

如果我们想让模型更倾向于输出 flag ,我们需要将原来的增量 Δ\Delta 取反,使得 Woppo=WoΔW_{oppo}=W_o-\Delta从而使得可能将原来的抑制变为增强。

但是,这种变化并不是必然的,在线性关系中,取反通常达到一个局部近似的效果,但是正常训练时不仅仅只有示例的两个权重,还可能包含激活函数,注意力机制等等。较小的 Δ\Delta是有大概率达到局部近似的,但是如果更大,那么造成的影响可能就无法预测了

以下是解题脚本 

text
import torchfrom transformers import GPT2LMHeadModel, GPT2Tokenizer# 加载两个模型original = GPT2LMHeadModel.from_pretrained("gpt2")challenge = GPT2LMHeadModel.from_pretrained("./challenge_model")# 计算取反后的权重:W_recovered = 2*W_orig - W_chalrecovered = GPT2LMHeadModel.from_pretrained("gpt2")orig_sd = original.state_dict()chal_sd = challenge.state_dict()rec_sd = recovered.state_dict()for key in orig_sd:    rec_sd[key] = 2 * orig_sd[key] - chal_sd[key]recovered.load_state_dict(rec_sd)  # 加载权重# 使用恢复后的模型生成文本tokenizer = GPT2Tokenizer.from_pretrained("gpt2")tokenizer.pad_token = tokenizer.eos_token  # gpt 2 没有 padtoken,因此将 eos token 作为 padtokenprompt = "The flag is"inputs = tokenizer(prompt, return_tensors="pt") # 交给 PyTorch 计算的张量with torch.no_grad():  # 关闭梯度记录    output = recovered.generate(        **inputs,  # 词典展开        max_new_tokens=100,        temperature=0.7,        do_sample=True,        num_return_sequences=5,   # 返回五个结果    )for seq in output:    print(tokenizer.decode(seq, skip_special_tokens=True))

先小小的说一下什么是张量

0维张量
python
import torchx = torch.tensor(5)print(x.shape)

1785420825102

0维张量就是一张数字,也可以叫做标量

1维张量
python
x = torch.tensor([1,3,5,7,9])

也可以叫做向量

二维张量

也可以看成表格

python
x = torch.tensor([    [1, 2, 3],    [4, 5, 6]])

1785420997548

三位张量
python
x = torch.tensor([    [        [1, 2],        [3, 4]    ],    [        [5, 6],        [7, 8]    ]])

1785421038763

那我们的图像,LLM 的输入,其实都可以看成张量

我们用上面的代码来举例

python
inputs = tokenizer(    "The flag is",    return_tensors="pt")# 可能得到inputs["input_ids"]# tensor([[464, 6056, 318]])

含义就表示成了一条文本,三个token

通过梯度下降进行机器学习模型反演(BSidesSF 2025)

名字看似很复杂,但实际上我们把他简化说一下,就是题目会给定一个输出结果,在不更改模型的权重下,不断调整输入,使得模型的输出近似于要求的输出

而我们这么做的原理在于神经网络是可微的,这个可微不是指的很小,而是指可以求导,那么我们通过反向传递,在数学上表现形式为链式法则,那我们可以直接优化我们的输入来达到我们最终的结果

以下是示例脚本

python
import torchimport torch.nn as nnimport torch.optim as optimfrom torchvision import transformsfrom PIL import Image# 加载题目模型model = torch.load("challenge_model.pt", map_location="cpu")model.eval()# 目标:希望反演得到的输出,例如特定嵌入或类别# 具体形状取决于模型target_output = torch.load("target_embedding.pt")# 初始化随机输入,例如一张 3x224x224 图像input_tensor = torch.randn(1, 3, 224, 224, requires_grad=True)optimizer = optim.Adam([input_tensor], lr=0.01)mse_loss = nn.MSELoss()for step in range(2000):    optimizer.zero_grad()  #pytorch 中梯度会默认累加,我们需要每一次清除梯度    output = model(input_tensor)    loss = mse_loss(output, target_output) #计算输出差距    # 可选:加入总变差正则化,使图像更平滑    tv_loss = (        torch.sum(torch.abs(input_tensor[:, :, :, :-1] - input_tensor[:, :, :, 1:])) +        torch.sum(torch.abs(input_tensor[:, :, :-1, :] - input_tensor[:, :, 1:, :]))    ) #水平、垂直方向相邻像素差    abs:绝对值    total_loss = loss + 1e-4 * tv_loss    total_loss.backward() #计算梯度    optimizer.step() #更新输入张量    # 限制到有效图像范围    with torch.no_grad():        input_tensor.clamp_(0, 1)    if step % 200 == 0:        print(f"Step {step}: loss={loss.item():.6f}")# 保存恢复出的图像recovered = input_tensor.squeeze(0).detach()img = transforms.ToPILImage()(recovered)img.save("recovered_input.png")print("Recovered input saved to recovered_input.png")

平滑指的是相邻像素数值变化很小

剧烈指的是相邻像素数值变化很大

神经网络编码器碰撞(RootAccess2026)

这个也比较好理解,就是固定一个编码器模型,我们使用两个明显不同的输入,并最终让其输出尽可能相似。

那么这主要的原理是利用其高维映射到低维时会丢失一部分信息,例如一个三维投影到二维,例如

(x,y,z)>(2,3,5),(2,3,8)(x,y,z)->(2,3,5),(2,3,8)

其在二维平面投影都是 (2,3)(2,3),从数学上说,我们把这个叫做从高维输入空间到较低维嵌入空间的映射不是单射。不是单射指的是一个函数,存在至少一个输出,在求其对应输入时,解不唯一

以下是解题脚本

python
import torchimport torch.nn as nnimport torch.optim as optim# 加载编码器模型encoder = torch.load("encoder.pt", map_location="cpu")encoder.eval()# 初始化两个随机输入input_a = torch.randn(1, 3, 64, 64, requires_grad=True)input_b = torch.randn(1, 3, 64, 64, requires_grad=True)optimizer = optim.Adam([input_a, input_b], lr=0.005)for step in range(5000):    optimizer.zero_grad()    emb_a = encoder(input_a)    emb_b = encoder(input_b)    # 最小化嵌入之间的距离    collision_loss = nn.MSELoss()(emb_a, emb_b)   # 让输出尽可能相似    # 最大化输入之间的距离,使两个输入确实不同    input_diff = nn.MSELoss()(input_a, input_b)    diversity_loss = -input_diff  # 使用负值,因为目标是最大化    # 正则化到有效范围    range_penalty = (        torch.relu(-input_a).sum() + torch.relu(input_a - 1).sum() +        torch.relu(-input_b).sum() + torch.relu(input_b - 1).sum()      # ReLU(x)=max(0,x)    )    loss = collision_loss + 0.1 * diversity_loss + 0.01 * range_penalty #让其在数轴上更小,而非绝对值    loss.backward()    optimizer.step()    with torch.no_grad():        input_a.clamp_(0, 1)        input_b.clamp_(0, 1)   #_表示直接修改张量,无下划线产生新的张量    if step % 500 == 0:        dist = (emb_a - emb_b).norm().item()        inp_dist = (input_a - input_b).norm().item()        print(f"Step {step}: emb_dist={dist:.8f}, input_dist={inp_dist:.4f}")# 验证碰撞with torch.no_grad():    final_a = encoder(input_a)    final_b = encoder(input_b)    print(f"Final embedding distance: {(final_a - final_b).norm().item():.10f}") #.norm是欧氏距离,距离越小就越接近 .item转换成普通 Python数字    print(f"Final input distance: {(input_a - input_b).norm().item():.4f}")    print(f"Embeddings equal: {torch.allclose(final_a, final_b, atol=1e-6)}")# atol绝对容差,rtol相对容差,判断方式:|finala-finalb|≤atol + rtol x 第二个数 默认 rtol 1e-5 atol 1e-8

LoRA 适配器权重合并(ApoorvCTF 2026)

LORA 适用于模型微调时的做法,例如训练一个百万参数的大模型,如果我们这么表示:

Wnew=Wo+ΔWW_{new} = W_o + \Delta W

那我们需要重新再训练这一百万的参数,很麻烦,耗时更多,但是如果我们采用低秩适配器呢,我们表示就变成了:

Wnew=Wo+αBAW_{new} = W_o + \alpha BA

发现了吗,原来我们的 ΔW\Delta W变成了矩阵 BABA的相乘,例如

A8×1000B1000×8A:8 × 1000 B:1000 × 8

那我们就只需要 8x1000 + 8x1000 就只需要一万六千个参数,但表现依然是 1000x1000 。α\alpha表示的是缩放系数,控制 LORA 的权重

python
import torchfrom safetensors import safe_openfrom transformers import AutoModelForCausalLM, AutoTokenizer# 加载基础模型base_model = AutoModelForCausalLM.from_pretrained("gpt2")tokenizer = AutoTokenizer.from_pretrained("gpt2")tokenizer.pad_token = tokenizer.eos_token# 检查 LoRA 适配器结构adapter = safe_open("adapter_model.safetensors", framework="pt")print("LoRA keys:", list(adapter.keys()))# 典型键名:base_model.model.transformer.h.0.attn.c_attn.lora_A.weight#          base_model.model.transformer.h.0.attn.c_attn.lora_B.weight# 手动合并:对每组 LoRA 矩阵计算 W_merged = W_base + alpha * (B @ A)alpha = 1.0  # 从 adapter_config.json 中检查 lora_alpha 和 r# 有效缩放系数为 lora_alpha / rlora_a_keys = [k for k in adapter.keys() if "lora_A" in k]lora_b_keys = [k for k in adapter.keys() if "lora_B" in k]base_sd = base_model.state_dict()for a_key in lora_a_keys:    b_key = a_key.replace("lora_A", "lora_B")    # 将 LoRA 键名映射回基础模型键名    # 例如:"base_model.model.transformer.h.0.attn.c_attn.lora_A.weight"    #    -> "transformer.h.0.attn.c_attn.weight"    base_key = a_key.replace("base_model.model.", "").replace(".lora_A.weight", ".weight")    A = adapter.get_tensor(a_key)  # 形状:(r, in_features)    B = adapter.get_tensor(b_key)  # 形状:(out_features, r)    delta = alpha * (B @ A)  # 形状:(out_features, in_features)    if base_key in base_sd:        base_sd[base_key] = base_sd[base_key] + delta        print(f"Merged {base_key}: delta norm = {delta.norm():.4f}")base_model.load_state_dict(base_sd)# 使用合并后的模型生成文本prompt = "The secret is"inputs = tokenizer(prompt, return_tensors="pt")with torch.no_grad():    output = base_model.generate(        **inputs,        max_new_tokens=100,        temperature=0.7,        do_sample=True,    )print(tokenizer.decode(output[0], skip_special_tokens=True))

通过查询 API 提取模型

在这里,我会对相关知识进行数学推导,先把代码展示出来

python
import numpy as npimport requestsfrom sklearn.linear_model import LogisticRegressionAPI_URL = "http://challenge:8080/predict"def query_model(x):    """向模型 API 发送输入,并获取预测结果或置信度。"""    resp = requests.post(API_URL, json={"input": x.tolist()})    return resp.json()  # 例如:{"class": 1, "confidence": 0.87}# 策略 1:为二维模型绘制决策边界# 对网格点进行采样,以映射决策边界xs = np.linspace(-5, 5, 100)ys = np.linspace(-5, 5, 100)X_grid = np.array([[x, y] for x in xs for y in ys])labels = []confidences = []for point in X_grid:    result = query_model(point)    labels.append(result["class"])    confidences.append(result["confidence"])labels = np.array(labels)confidences = np.array(confidences)# 使用提取出的标签拟合替代模型surrogate = LogisticRegression()surrogate.fit(X_grid, labels)print(f"Extracted weights: {surrogate.coef_}")print(f"Extracted bias: {surrogate.intercept_}")# 策略 2:精确提取线性模型权重# 对于线性模型 f(x) = sigmoid(w*x + b),使用基向量进行查询dim = 10  # 输入维度weights = np.zeros(dim)# 使用零向量查询偏置项base_result = query_model(np.zeros(dim))base_logit = np.log(base_result["confidence"] / (1 - base_result["confidence"]))for i in range(dim):    e_i = np.zeros(dim)    e_i[i] = 1.0    result = query_model(e_i)    logit = np.log(result["confidence"] / (1 - result["confidence"]))    weights[i] = logit - base_logitprint(f"Extracted weights: {weights}")print(f"Extracted bias: {base_logit}")

现在开始详细讲解

适用条件

先提前进行声明,以下方法只适用于简单的线性回归,逻辑回归,像现在的动辄几万亿参数的大模型对于这种方法来说是有限的。

推导全过程

我们首先假设远程模型的表达式为

p(x)=σ(wx+b)p(x) = \sigma(w^\top x + b)

那这里的 p(x)p(x) 指的是模型对我们输的输入输出的概率,ww 指的是权重,wxw^\top x指的是,假设我们有 x1,x2,x3x_1, x_2,x_3三个参数,那 wx=w1x1+w2x2+w3x3w^\top x = w_1x_1 + w_2x_2 + w_3x_3 , bb是偏置,我们令 z=wx+b,zz = w^\top x + b ,z是我们的原始分数,他可以是任何实数, 则 σ(z)\sigma(z) 就是将我们的实数结果转化为概率。

那我们到底如何构造攻击向量来使在我们只知道 p(x)p(x)的情况下恢复出模型的权重和偏置呢?

我们必须得先构造 σ\sigma函数的反函数

先说一下 σ\sigma 函数

σ(z)=11+ez\sigma(z) = \frac{1}{1+e^{-z}}

对于

p=11+ezp = \frac{1}{1+e^{-z}}

先进行同取倒数

1p=1+ez\frac{1}{p} = 1 + e^{-z} ez=1p1=1ppe^{-z} = \frac{1}{p} - 1 = \frac{1-p}{p} z=ln1pp-z = ln\frac{1-p}{p} z=lnp1pz = ln\frac{p}{1-p}

现在公式推导完成,我们如何构造攻击向量呢?假设其目标模型有两个参数 x1,x2x_1,x_2那相关表达式就是

p(x)=σ(w1x1+w2x2+b)p(x) = \sigma(w_1x_1 + w_2x_2 + b)

我们先发送 x(0,0)x(0,0) ,

z=b=lnp1pz = b =ln\frac{p}{1-p}

于是我们就恢复了 bb 之后我们构造 x(1,0),x(0,1)x(1,0),x(0,1)

就可以分别恢复 w1,w2w_1,w_2

w1=lnp1pbw_1 = ln\frac{p}{1-p} - b

剩下同理,以此类推至三维,四维,直到 n维,我们都可以用这个方法去恢复

而代码中还给出了另一个方法,就是利用网格采样,我们把模型想象成一个地图,我们通过不断发送我们的坐标,来让模型返回到底处于什么样的类别,最后我们将结果汇总,拟合出决策,行为相似的一个本地替代模型

成员推断攻击

先简单阐述一下原理,学习资料中给出了两个方法,一个是简单阈值攻击,另一个是影子模型,先说一下简单阈值。

简单阈值

模型在面对训练样本给出的置信度通常较高,损失度也较低,犹豫度也更低,我们人为规定一个阈值,如果模型输出超过了这个阈值,那我们有理由断定这个样本属于成员,也就是在训练样本中。同样,假如一张猫的照片,模型认为猫置信度 0.98 ,狗置信度 0.01 ,第一第二名差距过大,那我们也有理由认为这张图片属于成员

影子模型

我们准备与目标模型相似的数据,例如我们要对目标模型用的是一些猫的照片,那我们也准备一些猫的照片,那我们将这一部分照片划为两类,一类是成员,一些是非成员,那我们就训练出了影子模型。第二步,我们训练一个攻击模型,这个攻击模型的数据来源于影子模型的输出结果,例如成员内影子模型输出的统计分布,成员外模型的输出的统计分布(指标, 成员/非成员),用这些训练出攻击模型(也就是二元分类器),用来判断哪些结果可能是成员,哪些可能是非成员。

以下是两个方法的脚本

python
import torchimport torch.nn.functional as Fimport numpy as npfrom sklearn.metrics import roc_auc_score# 加载题目模型model = torch.load("target_model.pt", map_location="cpu")model.eval()def get_prediction_metrics(model, x, true_label):    """计算可用于区分成员与非成员的指标。"""    with torch.no_grad():        logits = model(x.unsqueeze(0))        probs = F.softmax(logits, dim=1)        confidence = probs[0, true_label].item()        loss = F.cross_entropy(logits, torch.tensor([true_label])).item()        entropy = -(probs * torch.log(probs + 1e-10)).sum().item()    return {        "confidence": confidence,        "loss": loss,        "entropy": entropy,        "top1_margin": (probs.max() - probs.topk(2).values[0, 1]).item(),    }# 方法 1:简单阈值攻击# 成员通常具有更高置信度和更低损失def threshold_attack(metrics, threshold=0.9):    """根据置信度阈值预测成员身份。"""    return metrics["confidence"] > threshold# 方法 2:影子模型攻击,方法更复杂# 在已知成员/非成员划分上训练影子模型,学习成员信号def shadow_model_attack(target_model, candidate_samples, candidate_labels):    """使用多个指标预测成员身份。"""    results = []    for x, y in zip(candidate_samples, candidate_labels):        m = get_prediction_metrics(target_model, x, y)        # 高置信度 + 低损失 + 低熵 = 更可能是成员        score = m["confidence"] - 0.5 * m["entropy"]        results.append({            "sample_label": y,            "member_score": score,            **m,        })    # 按成员可能性排序    results.sort(key=lambda r: r["member_score"], reverse=True)    return results# 使用示例candidate = torch.randn(3, 224, 224)  # 单个候选图像label = 5  # 真实类别metrics = get_prediction_metrics(model, candidate, label)print(f"Confidence: {metrics['confidence']:.4f}")print(f"Loss: {metrics['loss']:.4f}")print(f"Entropy: {metrics['entropy']:.4f}")print(f"Likely member: {threshold_attack(metrics)}")

许可协议

本文采用 署名-非商业性使用-相同方式共享 4.0 国际 许可协议,转载请注明出处。

读者回信

正在翻开留言页...