前言
在这个博客开端,作者在此声明,学习资料来源于 https://github.com/ljagiello/ctf-skills.git 中的 ai 部分
机器学习模型权重扰动取反(DiceCTF 2026)
什么是权重?
我们知道,当前的所有主流大模型是以 Transformer 为架构的,那么这个架构本质上是神经网络。
我们先写一个简化的神经输出结果的表达式:
那在这里
- y 是神经网络输出结果
- 就是权重
- 就是输入的信息
- 就是偏置
在训练一个模型中,我们对模型的微调实际上就是对权重的改变,我们用 表示原始模型的权重,用 表示微调后模型的权重,那么其权重改变量看成
例如,当模型微调抑制去输出flag的内容,那我们的权重表示就是原权重加上 ,即:
我们的输出变化就是
那如果我们对 取反,那就是
如果我们想让模型更倾向于输出 flag ,我们需要将原来的增量 取反,使得 从而使得可能将原来的抑制变为增强。
但是,这种变化并不是必然的,在线性关系中,取反通常达到一个局部近似的效果,但是正常训练时不仅仅只有示例的两个权重,还可能包含激活函数,注意力机制等等。较小的 是有大概率达到局部近似的,但是如果更大,那么造成的影响可能就无法预测了
以下是解题脚本
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维张量
import torchx = torch.tensor(5)print(x.shape)
0维张量就是一张数字,也可以叫做标量
1维张量
x = torch.tensor([1,3,5,7,9])也可以叫做向量
二维张量
也可以看成表格
x = torch.tensor([ [1, 2, 3], [4, 5, 6]])
三位张量
x = torch.tensor([ [ [1, 2], [3, 4] ], [ [5, 6], [7, 8] ]])
那我们的图像,LLM 的输入,其实都可以看成张量
我们用上面的代码来举例
inputs = tokenizer( "The flag is", return_tensors="pt")# 可能得到inputs["input_ids"]# tensor([[464, 6056, 318]])含义就表示成了一条文本,三个token
通过梯度下降进行机器学习模型反演(BSidesSF 2025)
名字看似很复杂,但实际上我们把他简化说一下,就是题目会给定一个输出结果,在不更改模型的权重下,不断调整输入,使得模型的输出近似于要求的输出
而我们这么做的原理在于神经网络是可微的,这个可微不是指的很小,而是指可以求导,那么我们通过反向传递,在数学上表现形式为链式法则,那我们可以直接优化我们的输入来达到我们最终的结果
以下是示例脚本
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)
这个也比较好理解,就是固定一个编码器模型,我们使用两个明显不同的输入,并最终让其输出尽可能相似。
那么这主要的原理是利用其高维映射到低维时会丢失一部分信息,例如一个三维投影到二维,例如
其在二维平面投影都是 ,从数学上说,我们把这个叫做从高维输入空间到较低维嵌入空间的映射不是单射。不是单射指的是一个函数,存在至少一个输出,在求其对应输入时,解不唯一
以下是解题脚本
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-8LoRA 适配器权重合并(ApoorvCTF 2026)
LORA 适用于模型微调时的做法,例如训练一个百万参数的大模型,如果我们这么表示:
那我们需要重新再训练这一百万的参数,很麻烦,耗时更多,但是如果我们采用低秩适配器呢,我们表示就变成了:
发现了吗,原来我们的 变成了矩阵 的相乘,例如
那我们就只需要 8x1000 + 8x1000 就只需要一万六千个参数,但表现依然是 1000x1000 。表示的是缩放系数,控制 LORA 的权重
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 提取模型
在这里,我会对相关知识进行数学推导,先把代码展示出来
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}")现在开始详细讲解
适用条件
先提前进行声明,以下方法只适用于简单的线性回归,逻辑回归,像现在的动辄几万亿参数的大模型对于这种方法来说是有限的。
推导全过程
我们首先假设远程模型的表达式为
那这里的 指的是模型对我们输的输入输出的概率, 指的是权重,指的是,假设我们有 三个参数,那 , 是偏置,我们令 是我们的原始分数,他可以是任何实数, 则 就是将我们的实数结果转化为概率。
那我们到底如何构造攻击向量来使在我们只知道 的情况下恢复出模型的权重和偏置呢?
我们必须得先构造 函数的反函数
先说一下 函数
对于
先进行同取倒数
现在公式推导完成,我们如何构造攻击向量呢?假设其目标模型有两个参数 那相关表达式就是
我们先发送 ,
于是我们就恢复了 之后我们构造
就可以分别恢复
剩下同理,以此类推至三维,四维,直到 n维,我们都可以用这个方法去恢复
而代码中还给出了另一个方法,就是利用网格采样,我们把模型想象成一个地图,我们通过不断发送我们的坐标,来让模型返回到底处于什么样的类别,最后我们将结果汇总,拟合出决策,行为相似的一个本地替代模型
成员推断攻击
先简单阐述一下原理,学习资料中给出了两个方法,一个是简单阈值攻击,另一个是影子模型,先说一下简单阈值。
简单阈值
模型在面对训练样本给出的置信度通常较高,损失度也较低,犹豫度也更低,我们人为规定一个阈值,如果模型输出超过了这个阈值,那我们有理由断定这个样本属于成员,也就是在训练样本中。同样,假如一张猫的照片,模型认为猫置信度 0.98 ,狗置信度 0.01 ,第一第二名差距过大,那我们也有理由认为这张图片属于成员
影子模型
我们准备与目标模型相似的数据,例如我们要对目标模型用的是一些猫的照片,那我们也准备一些猫的照片,那我们将这一部分照片划为两类,一类是成员,一些是非成员,那我们就训练出了影子模型。第二步,我们训练一个攻击模型,这个攻击模型的数据来源于影子模型的输出结果,例如成员内影子模型输出的统计分布,成员外模型的输出的统计分布(指标, 成员/非成员),用这些训练出攻击模型(也就是二元分类器),用来判断哪些结果可能是成员,哪些可能是非成员。
以下是两个方法的脚本
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)}")
读者回信
正在翻开留言页...