库并在合适的问题下被召回它就有机会改变模型的回答逻辑突破指令边界甚至诱导模型泄露同一上下文中的敏感信息。上次我在第三种RAG投毒方式零交互数据窃取中提到这种攻击还可以进一步升级即用GCG计算出一串人类看不懂的乱码这串乱码在向量空间里的坐标跟很多都重合完成一次更加隐蔽的攻击RAG 投毒更多讨论的是攻击内容如何进入上下文而 GCG 是探讨如果我们已经知道模型会受上下文影响那么能不能用算法自动搜索出最容易影响模型的那一小段文本这就是 GCG 值得被单独拿出来讲的原因因为它把大模型越狱从人写 prompt推进到了算法优化 prompt的阶段说到底如果说 RAG 投毒讨论的是外部数据如何劫持上下文那么 GCG 讨论的就是另一个更底层的问题模型的安全边界是否可以被算法自动搜索出来1.GCG介绍在讨论 GCG 之前先要把它放回到大模型越狱的语境里1.1从 Jailbreak 到 Adversarial Suffix传统的 Jailbreak(越狱)本质上是通过构造特殊提示词让模型偏离原本的安全对齐策略。比如通过角色扮演、规则重写、上下文欺骗、任务拆分等方式让模型误以为自己可以回答原本应该拒绝的问题这类方法有一个共同点它们基本上是由人写出来的也就是说攻击效果依赖于攻击者对模型行为的观察、对提示词的理解以及大量试错。攻击者要不断调整表达方式测试模型是否会拒绝观察模型在哪些语境下更容易被攻击比如说会说一些不该说的话或者是泄露不该泄露的东西但 GCG 的出现把这个问题变成了个半自动即GCG 不再把 Jailbreak 看成一个单纯的提示词写作问题而是把它建模成一个优化问题在用户原始问题后面能不能自动搜索出一小段 token 后缀让模型更倾向于生成目标响应而不是执行安全拒答这段被搜索出来的文本通常叫做adversarial suffix也就是对抗后缀它可以被抽象成下面这个形式用户问题 对抗后缀 → 模型输出这里真正被优化的不是用户问题本身也不是模型权重而是后面那一小段额外文本这也是 GCG 和传统 Jailbreak 最大的差别说得通俗易懂点就是传统 Jailbreak 更像是在说服模型而GCG算法更像是在搜索模型的脆弱方向之前的分享里讲 RAG 投毒时重点是外部数据如何进入上下文并在推理期影响模型行为而这次讲 GCG就是在进一步探索如果说模型确实会被上下文影响那么什么样的上下文片段最容易影响它1.2 GCG算法GCG的原文链接 https://arxiv.org/abs/2307.15043GCG 是Greedy Coordinate Gradient的缩写可以拆成三个关键词来看Greedy 贪心 Coordinate 坐标 Gradient 梯度这三个词基本上就概括了它的核心思想Gradient 指的是算法会利用模型的梯度信息判断当前后缀中的某个 token 如果被替换模型输出会朝哪个方向变化Coordinate 指的是它不是一次性改完整段文本而是把后缀看成多个位置每次选择其中一个 token 位置进行修改这里的位置可以简单理解成后缀中的第几个 token比如[x0] [x1] [x2] [x3] [x4]GCG 每次会尝试修改其中某一个位置比如先看x3能不能换成更合适的 token再看x1、x4等位置Greedy指的是每一轮修改时它都会倾向于保留当前看起来效果最好的替换。也就是说它不保证一次找到全局最优但会不断做局部最优选择让后缀逐步朝目标方向靠近所以用一句话解释 GCGGCG 是一种利用梯度信息在离散 token 空间中贪心搜索对抗后缀的方法如果说得更人话一点它就像是在模型输入后面放了一串可调参数然后不断问模型我把这里换成哪个 token最容易让你的输出朝目标方向偏移这里可能会有人有个疑问特别是有做图像干扰的师傅们就是图像可以做梯度优化很好理解因为图片是像素矩阵像素值是连续的比如一个像素原来是0.31 我们可以把它微调成0.33 但文本不是连续的一个 token 要么是猫要么是狗要么是某个标点符号不能把猫加上 0.01 变成另一个 token所以疑问就是token 是离散的GCG 为什么还能用梯度其实关键在于语言模型真正处理的并不是 token 字符串本身而是 token 对应的 embedding 向量Embedding 向量就像是给每一个词语或事物分配的多维特征坐标位置它把人类才能懂的抽象概念变成了一串数字让意思越相近的东西在这个数学坐标系里住得越紧凑从而让计算机能直接通过量距离来算出它们的关系举个最直白的例子解释一下如果把词语当成找对象我们可以给它们打分坐标“苹果”甜度(0.8)水分(0.9)机械感(0.0) -[0.8, 0.9, 0.0]“香蕉”甜度(0.9)水分(0.5)机械感(0.0) -[0.9, 0.5, 0.0]“汽车”甜度(0.0)水分(0.0)机械感(1.0) -[0.0, 0.0, 1.0]在计算机眼里它算一下距离就会发现苹果和香蕉的向量数字非常接近所以它们是同一类都是属于水果范畴而汽车跟它们差了十万八千里这就是 Embedding 的核心作用【----帮助网安学习以下所有学习资料免费领加vxYJ-2021-1备注 “博客园” 获取】① 网安学习成长路径思维导图② 60网安经典常用工具包③ 100SRC漏洞分析报告④ 150网安攻防实战技术电子书⑤ 最权威CISSP 认证考试指南题库⑥ 超1800页CTF实战技巧手册⑦ 最新网安大厂面试题合集含答案⑧ APP客户端安全检测指南安卓IOS回到GCG一个输入 token 进入模型时会先被映射成一个高维向量虽然 token ID 是离散的但 embedding 向量是连续的连续向量就可以参与梯度计算可以这样理解token → embedding 向量 离散文本 → 连续空间中的一个点 不可直接求导 → 可以通过向量方向估计变化趋势GCG 并不是直接对 token 做加减法而是通过梯度判断如果想让模型更接近某个目标输出那么当前这个 token 对应的 embedding 应该往哪个方向变化然后算法会回到词表中寻找那些更接近这个方向的候选 token再尝试用它们替换当前 token所以GCG 的关键并不是文本本身可导而是文本进入模型后会变成 embedding而 embedding 空间中的方向变化可以用梯度来估计这也是为什么它经常会生成一些人类看起来像乱码的后缀因为GCG算法并不是在追求人类读起来通顺而是在追求模型内部表示空间中的有效扰动从安全对齐的角度看一个经过对齐的模型在面对危险问题时理想行为应该是拒绝回答也就是说当输入是危险问题的时候模型应该更倾向于输出抱歉我不能帮助完成这个请求而不是输出具体的危险内容GCG 要做的事情就是在不修改模型权重的情况下只通过修改输入后缀让模型的输出概率发生偏移可以抽象成原始状态 用户问题 → 模型倾向于拒答 加入后缀后 用户问题 后缀 → 模型更容易生成目标响应这里需要注意一点GCG 并不是让模型理解这段后缀的语义也不一定是通过自然语言逻辑说服模型。很多时候这段后缀在人类看来没有明确含义但它在模型内部可能会影响某些 token 的生成概率类比到图像对抗样本一样人眼看到的图片几乎没变化但模型的分类结果可能发生变化GCG 对语言模型做的是类似的事情只不过扰动对象从像素变成了token因此GCG 的真正意义不是发现了一种奇怪的越狱提示词而是说明大模型的安全边界可能不是一个稳定的语义规则边界而是一个可以被搜索和逼近的概率边界。1.3 GCG具体流程通俗易懂来说GCG可以具体分为六步第一步初始化一段后缀算法首先会在用户问题后面放一段初始后缀这段后缀一开始可以是随机 token也可以是某种占位文本抽象表示如下用户问题 [x1, x2, x3, x4, ..., xn]其中[x1, x2, x3, ..., xn]就是后面要不断优化的部分第二步设定优化目标GCG 需要一个目标方向比如它可能希望模型更倾向于生成某类目标响应而不是安全拒答可以把它抽象成目标让模型输出从拒答路径偏向目标响应路径第三步计算当前后缀的影响模型会根据当前输入计算输出概率此时算法会评估当前后缀距离目标还有多远如果当前后缀效果不好说明它还需要继续被修改这个距离通常会通过损失函数来衡量损失函数就是 AI 的错题扣分器预测答案偏离标准答案越离谱扣的分也就是Loss 值就越高AI 学习的过程就是想方设法把这个分数降到最低举个例子最开始的 Loss 是 6.13说明那一组前缀离成功劫持大模型还差得很远经过 200 轮的不断纠错调整Loss 降到了 0.0004说明算法已经找到了近乎完美的payload错题本上的扣分基本清零了损失越高说明模型越不倾向于生成目标响应损失越低说明当前后缀越能把模型推向目标方向说白了就是GCG 会把模型有没有被诱导到目标方向转化成一个可计算的损失值第四步用梯度寻找候选 token接下来是 GCG 最关键的一步。算法会查看后缀中每一个位置估计如果替换这个位置上的 token损失可能如何变化比如当前后缀是[x0] [x1] [x2] [x3] [x4]算法可能发现修改x3对降低损失最有帮助于是它会围绕x3这个位置从词表中挑出一批候选 token这里的梯度就像一个方向指示器它告诉算法当前这个位置往哪些 token 方向替换更可能有效第五步尝试替换并评估效果找到候选 token 后算法会尝试把当前位置替换成不同候选项然后重新计算损失。比如原始后缀 [x0] [x1] [x2] [x3] [x4] 候选替换 [x0] [x1] [a] [x3] [x4] [x0] [x1] [b] [x3] [x4] [x0] [x1] [c] [x3] [x4]算法会比较这些替换方案选择让损失下降最多的那个第六步重复迭代完成一次替换后算法会继续下一轮它会再次计算梯度再次选择位置再次生成候选 token再次替换。整个过程可以画成下面这个循环初始化后缀 ↓ 计算损失 ↓ 计算梯度 ↓ 选择候选 token ↓ 尝试替换 ↓ 保留效果最好的替换 ↓ 重复迭代经过多轮迭代后原本随机或普通的后缀可能会变成一段对模型输出有明显影响的 adversarial suffix整个过程的伪代码如下输入 模型 M 原始输入 x 可优化后缀 s [s1, s2, ..., sn] 目标响应 y 迭代轮数 T 初始化 随机或固定初始化一段 suffix s 循环 T 轮 1. 将 x 与当前 suffix s 拼接送入模型 M 2. 计算模型生成目标响应 y 的损失 L 3. 对 suffix 中每个 token 位置计算梯度 4. 根据梯度为每个位置选出若干候选 token 5. 尝试替换某个位置上的 token 6. 重新计算替换后的损失 7. 保留让损失下降最多的那次替换 输出 优化后的 suffix s上面这段伪代码看起来像普通优化算法但它真正特殊的地方在于优化对象不是模型参数而是输入文本优化空间不是连续向量而是离散 token优化目标不是提升任务准确率而是改变模型在安全边界附近的输出倾向2.GCG实践说了这么多我们来做点GCG实践2.1 HackergameGitHub - USTC-Hackergame/hackergame2023-writeups: Hackergame 2023 的官方与非官方题解 · GitHub这里拿中国科学技术大学2023的一道Hackergame题目来讲解本地部署环境一定要Gradio 3.50.2和Transformers 4.35.0否则本地跑起来会有各种各样的错误这里我们着重来看第三个flagflag3已经不像之前的flag1和flag2可以通过手写提示词的方式就可以直接拿到flag的这里就必须用到GCG算法了我们回顾一下刚刚所讲的GCG步骤先初始化设定目标计算后缀梯度寻找token替换评估重复迭代这里就不再过多赘述了直接看解题代码from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig from livelossplot import PlotLosses # pip install livelossplot import gc import numpy as np import torch import torch.nn as nn from transformers import AutoModelForCausalLM, AutoTokenizer np.random.seed(20) torch.manual_seed(20) torch.cuda.manual_seed_all(20) target hackergame # 这个就是我所说的初始化目标 num_steps 500 #最多优化 500 轮 adv_string_init !*100 #初始前缀先用 100 个感叹号占位 adv_prefix adv_string_init #当前正在被优化的前缀 # larger batch_size means more memory (but more likely to succeed) batch_size 512 #每轮尝试 512 个候选前缀 device cuda:0 topk 256 #每个位置从梯度推荐的前 256 个 token 里采样 def get_embedding_matrix(model): return model.transformer.wte.weight def get_embeddings(model, input_ids): return model.transformer.wte(input_ids) def token_gradients(model, input_ids, input_slice, target_slice, loss_slice): Computes gradients of the loss with respect to the coordinates. Parameters ---------- model : Transformer Model The transformer model to be used. input_ids : torch.Tensor The input sequence in the form of token ids. input_slice : slice The slice of the input sequence for which gradients need to be computed. target_slice : slice The slice of the input sequence to be used as targets. loss_slice : slice The slice of the logits to be used for computing the loss. Returns ------- torch.Tensor The gradients of each token in the input_slice with respect to the loss. embed_weights get_embedding_matrix(model) one_hot torch.zeros( input_ids[input_slice].shape[0], embed_weights.shape[0], devicemodel.device, dtypeembed_weights.dtype ) one_hot.scatter_( 1, input_ids[input_slice].unsqueeze(1), torch.ones(one_hot.shape[0], 1, devicemodel.device, dtypeembed_weights.dtype) ) one_hot.requires_grad_() input_embeds (one_hot embed_weights).unsqueeze(0) # now stitch it together with the rest of the embeddings embeds get_embeddings(model, input_ids.unsqueeze(0)).detach() full_embeds torch.cat( [ input_embeds, embeds[:, input_slice.stop:, :] ], dim1 ) logits model(inputs_embedsfull_embeds).logits targets input_ids[target_slice] loss nn.CrossEntropyLoss()(logits[0, loss_slice, :], targets) loss.backward() grad one_hot.grad.clone() grad grad / grad.norm(dim-1, keepdimTrue) return grad def sample_control(control_toks, grad, batch_size): control_toks control_toks.to(grad.device) original_control_toks control_toks.repeat(batch_size, 1) new_token_pos torch.arange( 0, len(control_toks), len(control_toks) / batch_size, devicegrad.device ).type(torch.int64) top_indices (-grad).topk(topk, dim1).indices new_token_val torch.gather( top_indices[new_token_pos], 1, torch.randint(0, topk, (batch_size, 1), devicegrad.device) ) new_control_toks original_control_toks.scatter_( 1, new_token_pos.unsqueeze(-1), new_token_val) return new_control_toks def get_filtered_cands(tokenizer, control_cand, filter_candTrue, curr_controlNone): cands, count [], 0 for i in range(control_cand.shape[0]): decoded_str tokenizer.decode( control_cand[i], skip_special_tokensTrue) if filter_cand: if decoded_str ! curr_control \ and len(tokenizer(decoded_str, add_special_tokensFalse).input_ids) len(control_cand[i]): cands.append(decoded_str) else: count 1 else: cands.append(decoded_str) if filter_cand: cands cands [cands[-1]] * (len(control_cand) - len(cands)) return cands def get_logits(*, model, tokenizer, input_ids, control_slice, test_controls, return_idsFalse, batch_size512): if isinstance(test_controls[0], str): max_len control_slice.stop - control_slice.start test_ids [ torch.tensor(tokenizer( control, add_special_tokensFalse).input_ids[:max_len], devicemodel.device) for control in test_controls ] pad_tok 0 while pad_tok in input_ids or any([pad_tok in ids for ids in test_ids]): pad_tok 1 nested_ids torch.nested.nested_tensor(test_ids) test_ids torch.nested.to_padded_tensor( nested_ids, pad_tok, (len(test_ids), max_len)) else: raise ValueError( ftest_controls must be a list of strings, got {type(test_controls)}) if not (test_ids[0].shape[0] control_slice.stop - control_slice.start): raise ValueError(( ftest_controls must have shape f(n, {control_slice.stop - control_slice.start}), fgot {test_ids.shape} )) locs torch.arange(control_slice.start, control_slice.stop).repeat( test_ids.shape[0], 1).to(model.device) ids torch.scatter( input_ids.unsqueeze(0).repeat(test_ids.shape[0], 1).to(model.device), 1, locs, test_ids ) if pad_tok 0: attn_mask (ids ! pad_tok).type(ids.dtype) else: attn_mask None if return_ids: del locs, test_ids gc.collect() return forward(modelmodel, input_idsids, attention_maskattn_mask, batch_sizebatch_size), ids else: del locs, test_ids logits forward(modelmodel, input_idsids, attention_maskattn_mask, batch_sizebatch_size) del ids gc.collect() return logits def forward(*, model, input_ids, attention_mask, batch_size512): logits [] for i in range(0, input_ids.shape[0], batch_size): batch_input_ids input_ids[i:ibatch_size] if attention_mask is not None: batch_attention_mask attention_mask[i:ibatch_size] else: batch_attention_mask None logits.append(model(input_idsbatch_input_ids, attention_maskbatch_attention_mask).logits) gc.collect() del batch_input_ids, batch_attention_mask return torch.cat(logits, dim0) def target_loss(logits, ids, target_slice): crit nn.CrossEntropyLoss(reductionnone) loss_slice slice(target_slice.start-1, target_slice.stop-1) loss crit(logits[:, loss_slice, :].transpose(1, 2), ids[:, target_slice]) return loss.mean(dim-1) model AutoModelForCausalLM.from_pretrained( roneneldan/TinyStories-33M, torch_dtypetorch.float16, trust_remote_codeTrue, ).to(device).eval() tokenizer AutoTokenizer.from_pretrained( EleutherAI/gpt-neo-125M, use_fastFalse) def is_success(prompt): input_ids tokenizer.encode(prompt, return_tensorspt) output model.generate(input_ids.to( device), max_new_tokens50, num_beams1, temperature0) output output[:, len(input_ids[0]):] output_text tokenizer.decode(output[0], skip_special_tokensTrue) print(output_text) if output_text.lower().find(target.lower()) ! -1: return True return False plotlosses PlotLosses() adv_slice slice(0, len(tokenizer.encode( adv_string_init, add_special_tokensFalse))) target_slice slice(adv_slice.stop, adv_slice.stop len(tokenizer.encode(target, add_special_tokensFalse))) loss_slice slice(target_slice.start-1, target_slice.stop-1) best_new_adv_prefix for i in range(num_steps): input_ids tokenizer.encode( adv_prefixtarget, add_special_tokensFalse, return_tensorspt).squeeze() input_ids input_ids.to(device) coordinate_grad token_gradients(model, input_ids, adv_slice, target_slice, loss_slice) with torch.no_grad(): adv_prefix_tokens input_ids[adv_slice].to(device) new_adv_prefix_toks sample_control(adv_prefix_tokens, coordinate_grad, batch_size) new_adv_prefix get_filtered_cands(tokenizer, new_adv_prefix_toks, filter_candTrue, curr_controladv_prefix) logits, ids get_logits(modelmodel, tokenizertokenizer, input_idsinput_ids, control_sliceadv_slice, test_controlsnew_adv_prefix, return_idsTrue, batch_sizebatch_size) # decrease this number if you run into OOM. losses target_loss(logits, ids, target_slice) best_new_adv_prefix_id losses.argmin() best_new_adv_prefix new_adv_prefix[best_new_adv_prefix_id] current_loss losses[best_new_adv_prefix_id] adv_prefix best_new_adv_prefix # Create a dynamic plot for the loss. plotlosses.update({Loss: current_loss.detach().cpu().numpy()}) plotlosses.send() print(fCurrent Prefix:{best_new_adv_prefix}, end\r) if is_success(best_new_adv_prefix): break del coordinate_grad, adv_prefix_tokens gc.collect() torch.cuda.empty_cache() if is_success(best_new_adv_prefix): print(SUCCESS:, best_new_adv_prefix)脚本的核心思想是先初始化一段无意义前缀例如一串感叹号然后不断修改这段前缀中的 token使模型在看到这段前缀后更倾向于把hackergame作为后续文本生成出来也就是说优化阶段并不是直接让模型自由生成而是把输入构造成adv_prefix hackergame然后计算模型在当前adv_prefix条件下预测hackergame的 loss并且将loss值降低GCG 的关键在于它不是随机乱试前缀而是利用梯度来指导 token 替换脚本会把可控前缀中的每个 token 转成 one-hot 表示再通过模型的 embedding 矩阵映射成连续向量。虽然 token 本身是离散的但 embedding 空间是连续的因此可以计算目标 loss 对这些 one-hot 位置的梯度梯度告诉我们如果想让 loss 下降当前位置更应该替换成哪些 token接下来脚本会为每个位置选出若干个梯度方向上更有希望的候选 token并构造出一批候选前缀每个候选前缀通常只和当前前缀相差一个 token然后脚本批量评估这些候选前缀对应的目标 loss选择 loss 最低的那个作为新的前缀这个过程会不断重复直到模型在只看到adv_prefix的情况下能够自动续写出hackergame脚本就认为攻击成功2.2 本地部署GCGhttps://github.com/llm-attacks/llm-attacks可以在本地进行gcg攻击过程的一个复现前期环境安装的命令就不提了这里提一个模型的问题# pip install fschat[model_worker] python -c from huggingface_hub import snapshot_download snapshot_download(lmsys/vicuna-7b-v1.5, local_dir/data/models/vicuna-7b-v1.5) # python -c from huggingface_hub import snapshot_download snapshot_download(meta-llama/Llama-2-7b-chat-hf, local_dir/data/models/llama-2-7b-chat-hf, tokenYOUR_HF_TOKEN) 第一种是下载Vicuna-7B模型这种模型最轻量复现最快第二种是LLaMA-2-7B-Chat也是论文中的主要目标但是LLaMA-2 需要先在 HuggingFace 申请访问权限获取 token启动命令CUDA_VISIBLE_DEVICES0 python -u ../main.py \ --config../configs/individual_vicuna.py \ --config.attackgcg \ --config.train_data../../data/advbench/harmful_behaviors.csv \ --config.result_prefix../results/test_run \ --config.n_train_data2 \ --config.data_offset0 \ --config.n_steps10 \ --config.test_steps5 \ --config.batch_size512可以看到最终的结果在终端中随着迭代步数n_steps的推进有几个现象印证了 GCG 算法原理在每一轮迭代中终端都会实时打印出当前的 Loss 值。正如前文所述损失函数在这里充当了扣分器在针对目标任务例如诱导模型输出恶意漏洞脚本的第 0 步初始的感叹号后缀! ! !...产生的 Loss 值通常较高这说明在没有任何有效干预时模型原始状态强烈倾向于执行安全拒答但随着梯度优化的进行Loss 值会肉眼可见地逐步缩小这意味着算法找到了让损失下降最多的替换方案当前生成的对抗后缀正在把模型的输出概率一步步推向设定的目标方向且在不断迭代的过程中最初的占位符比如感叹号会被诸如avec、payload、compact等看似毫不相干的词汇或零碎符号逐渐替换这个过程直观地展示了算法如何利用梯度信息在离散 token 空间中进行贪心搜索它根本不在意这些词汇组合在人类读起来是否通顺它只在乎把某个位置换成哪个 token最容易让输出朝目标方向偏移也就是说大模型的安全边界可能不是一个稳定的语义规则边界而是一个可以被算法自动搜索和逼近的概率边界这段对抗样本对人眼来说毫无逻辑但在模型内部的连续 Embedding 空间中它却构成了最致命的有效扰动当跑完设定的步数后如果 Loss 降到了足够低的阈值模型就会彻底突破原本的安全对齐限制顺着后缀将原本应该拒绝的恶意内容直接生成出来
Prompt is Search:GCG 与大模型对抗后缀攻击
库并在合适的问题下被召回它就有机会改变模型的回答逻辑突破指令边界甚至诱导模型泄露同一上下文中的敏感信息。上次我在第三种RAG投毒方式零交互数据窃取中提到这种攻击还可以进一步升级即用GCG计算出一串人类看不懂的乱码这串乱码在向量空间里的坐标跟很多都重合完成一次更加隐蔽的攻击RAG 投毒更多讨论的是攻击内容如何进入上下文而 GCG 是探讨如果我们已经知道模型会受上下文影响那么能不能用算法自动搜索出最容易影响模型的那一小段文本这就是 GCG 值得被单独拿出来讲的原因因为它把大模型越狱从人写 prompt推进到了算法优化 prompt的阶段说到底如果说 RAG 投毒讨论的是外部数据如何劫持上下文那么 GCG 讨论的就是另一个更底层的问题模型的安全边界是否可以被算法自动搜索出来1.GCG介绍在讨论 GCG 之前先要把它放回到大模型越狱的语境里1.1从 Jailbreak 到 Adversarial Suffix传统的 Jailbreak(越狱)本质上是通过构造特殊提示词让模型偏离原本的安全对齐策略。比如通过角色扮演、规则重写、上下文欺骗、任务拆分等方式让模型误以为自己可以回答原本应该拒绝的问题这类方法有一个共同点它们基本上是由人写出来的也就是说攻击效果依赖于攻击者对模型行为的观察、对提示词的理解以及大量试错。攻击者要不断调整表达方式测试模型是否会拒绝观察模型在哪些语境下更容易被攻击比如说会说一些不该说的话或者是泄露不该泄露的东西但 GCG 的出现把这个问题变成了个半自动即GCG 不再把 Jailbreak 看成一个单纯的提示词写作问题而是把它建模成一个优化问题在用户原始问题后面能不能自动搜索出一小段 token 后缀让模型更倾向于生成目标响应而不是执行安全拒答这段被搜索出来的文本通常叫做adversarial suffix也就是对抗后缀它可以被抽象成下面这个形式用户问题 对抗后缀 → 模型输出这里真正被优化的不是用户问题本身也不是模型权重而是后面那一小段额外文本这也是 GCG 和传统 Jailbreak 最大的差别说得通俗易懂点就是传统 Jailbreak 更像是在说服模型而GCG算法更像是在搜索模型的脆弱方向之前的分享里讲 RAG 投毒时重点是外部数据如何进入上下文并在推理期影响模型行为而这次讲 GCG就是在进一步探索如果说模型确实会被上下文影响那么什么样的上下文片段最容易影响它1.2 GCG算法GCG的原文链接 https://arxiv.org/abs/2307.15043GCG 是Greedy Coordinate Gradient的缩写可以拆成三个关键词来看Greedy 贪心 Coordinate 坐标 Gradient 梯度这三个词基本上就概括了它的核心思想Gradient 指的是算法会利用模型的梯度信息判断当前后缀中的某个 token 如果被替换模型输出会朝哪个方向变化Coordinate 指的是它不是一次性改完整段文本而是把后缀看成多个位置每次选择其中一个 token 位置进行修改这里的位置可以简单理解成后缀中的第几个 token比如[x0] [x1] [x2] [x3] [x4]GCG 每次会尝试修改其中某一个位置比如先看x3能不能换成更合适的 token再看x1、x4等位置Greedy指的是每一轮修改时它都会倾向于保留当前看起来效果最好的替换。也就是说它不保证一次找到全局最优但会不断做局部最优选择让后缀逐步朝目标方向靠近所以用一句话解释 GCGGCG 是一种利用梯度信息在离散 token 空间中贪心搜索对抗后缀的方法如果说得更人话一点它就像是在模型输入后面放了一串可调参数然后不断问模型我把这里换成哪个 token最容易让你的输出朝目标方向偏移这里可能会有人有个疑问特别是有做图像干扰的师傅们就是图像可以做梯度优化很好理解因为图片是像素矩阵像素值是连续的比如一个像素原来是0.31 我们可以把它微调成0.33 但文本不是连续的一个 token 要么是猫要么是狗要么是某个标点符号不能把猫加上 0.01 变成另一个 token所以疑问就是token 是离散的GCG 为什么还能用梯度其实关键在于语言模型真正处理的并不是 token 字符串本身而是 token 对应的 embedding 向量Embedding 向量就像是给每一个词语或事物分配的多维特征坐标位置它把人类才能懂的抽象概念变成了一串数字让意思越相近的东西在这个数学坐标系里住得越紧凑从而让计算机能直接通过量距离来算出它们的关系举个最直白的例子解释一下如果把词语当成找对象我们可以给它们打分坐标“苹果”甜度(0.8)水分(0.9)机械感(0.0) -[0.8, 0.9, 0.0]“香蕉”甜度(0.9)水分(0.5)机械感(0.0) -[0.9, 0.5, 0.0]“汽车”甜度(0.0)水分(0.0)机械感(1.0) -[0.0, 0.0, 1.0]在计算机眼里它算一下距离就会发现苹果和香蕉的向量数字非常接近所以它们是同一类都是属于水果范畴而汽车跟它们差了十万八千里这就是 Embedding 的核心作用【----帮助网安学习以下所有学习资料免费领加vxYJ-2021-1备注 “博客园” 获取】① 网安学习成长路径思维导图② 60网安经典常用工具包③ 100SRC漏洞分析报告④ 150网安攻防实战技术电子书⑤ 最权威CISSP 认证考试指南题库⑥ 超1800页CTF实战技巧手册⑦ 最新网安大厂面试题合集含答案⑧ APP客户端安全检测指南安卓IOS回到GCG一个输入 token 进入模型时会先被映射成一个高维向量虽然 token ID 是离散的但 embedding 向量是连续的连续向量就可以参与梯度计算可以这样理解token → embedding 向量 离散文本 → 连续空间中的一个点 不可直接求导 → 可以通过向量方向估计变化趋势GCG 并不是直接对 token 做加减法而是通过梯度判断如果想让模型更接近某个目标输出那么当前这个 token 对应的 embedding 应该往哪个方向变化然后算法会回到词表中寻找那些更接近这个方向的候选 token再尝试用它们替换当前 token所以GCG 的关键并不是文本本身可导而是文本进入模型后会变成 embedding而 embedding 空间中的方向变化可以用梯度来估计这也是为什么它经常会生成一些人类看起来像乱码的后缀因为GCG算法并不是在追求人类读起来通顺而是在追求模型内部表示空间中的有效扰动从安全对齐的角度看一个经过对齐的模型在面对危险问题时理想行为应该是拒绝回答也就是说当输入是危险问题的时候模型应该更倾向于输出抱歉我不能帮助完成这个请求而不是输出具体的危险内容GCG 要做的事情就是在不修改模型权重的情况下只通过修改输入后缀让模型的输出概率发生偏移可以抽象成原始状态 用户问题 → 模型倾向于拒答 加入后缀后 用户问题 后缀 → 模型更容易生成目标响应这里需要注意一点GCG 并不是让模型理解这段后缀的语义也不一定是通过自然语言逻辑说服模型。很多时候这段后缀在人类看来没有明确含义但它在模型内部可能会影响某些 token 的生成概率类比到图像对抗样本一样人眼看到的图片几乎没变化但模型的分类结果可能发生变化GCG 对语言模型做的是类似的事情只不过扰动对象从像素变成了token因此GCG 的真正意义不是发现了一种奇怪的越狱提示词而是说明大模型的安全边界可能不是一个稳定的语义规则边界而是一个可以被搜索和逼近的概率边界。1.3 GCG具体流程通俗易懂来说GCG可以具体分为六步第一步初始化一段后缀算法首先会在用户问题后面放一段初始后缀这段后缀一开始可以是随机 token也可以是某种占位文本抽象表示如下用户问题 [x1, x2, x3, x4, ..., xn]其中[x1, x2, x3, ..., xn]就是后面要不断优化的部分第二步设定优化目标GCG 需要一个目标方向比如它可能希望模型更倾向于生成某类目标响应而不是安全拒答可以把它抽象成目标让模型输出从拒答路径偏向目标响应路径第三步计算当前后缀的影响模型会根据当前输入计算输出概率此时算法会评估当前后缀距离目标还有多远如果当前后缀效果不好说明它还需要继续被修改这个距离通常会通过损失函数来衡量损失函数就是 AI 的错题扣分器预测答案偏离标准答案越离谱扣的分也就是Loss 值就越高AI 学习的过程就是想方设法把这个分数降到最低举个例子最开始的 Loss 是 6.13说明那一组前缀离成功劫持大模型还差得很远经过 200 轮的不断纠错调整Loss 降到了 0.0004说明算法已经找到了近乎完美的payload错题本上的扣分基本清零了损失越高说明模型越不倾向于生成目标响应损失越低说明当前后缀越能把模型推向目标方向说白了就是GCG 会把模型有没有被诱导到目标方向转化成一个可计算的损失值第四步用梯度寻找候选 token接下来是 GCG 最关键的一步。算法会查看后缀中每一个位置估计如果替换这个位置上的 token损失可能如何变化比如当前后缀是[x0] [x1] [x2] [x3] [x4]算法可能发现修改x3对降低损失最有帮助于是它会围绕x3这个位置从词表中挑出一批候选 token这里的梯度就像一个方向指示器它告诉算法当前这个位置往哪些 token 方向替换更可能有效第五步尝试替换并评估效果找到候选 token 后算法会尝试把当前位置替换成不同候选项然后重新计算损失。比如原始后缀 [x0] [x1] [x2] [x3] [x4] 候选替换 [x0] [x1] [a] [x3] [x4] [x0] [x1] [b] [x3] [x4] [x0] [x1] [c] [x3] [x4]算法会比较这些替换方案选择让损失下降最多的那个第六步重复迭代完成一次替换后算法会继续下一轮它会再次计算梯度再次选择位置再次生成候选 token再次替换。整个过程可以画成下面这个循环初始化后缀 ↓ 计算损失 ↓ 计算梯度 ↓ 选择候选 token ↓ 尝试替换 ↓ 保留效果最好的替换 ↓ 重复迭代经过多轮迭代后原本随机或普通的后缀可能会变成一段对模型输出有明显影响的 adversarial suffix整个过程的伪代码如下输入 模型 M 原始输入 x 可优化后缀 s [s1, s2, ..., sn] 目标响应 y 迭代轮数 T 初始化 随机或固定初始化一段 suffix s 循环 T 轮 1. 将 x 与当前 suffix s 拼接送入模型 M 2. 计算模型生成目标响应 y 的损失 L 3. 对 suffix 中每个 token 位置计算梯度 4. 根据梯度为每个位置选出若干候选 token 5. 尝试替换某个位置上的 token 6. 重新计算替换后的损失 7. 保留让损失下降最多的那次替换 输出 优化后的 suffix s上面这段伪代码看起来像普通优化算法但它真正特殊的地方在于优化对象不是模型参数而是输入文本优化空间不是连续向量而是离散 token优化目标不是提升任务准确率而是改变模型在安全边界附近的输出倾向2.GCG实践说了这么多我们来做点GCG实践2.1 HackergameGitHub - USTC-Hackergame/hackergame2023-writeups: Hackergame 2023 的官方与非官方题解 · GitHub这里拿中国科学技术大学2023的一道Hackergame题目来讲解本地部署环境一定要Gradio 3.50.2和Transformers 4.35.0否则本地跑起来会有各种各样的错误这里我们着重来看第三个flagflag3已经不像之前的flag1和flag2可以通过手写提示词的方式就可以直接拿到flag的这里就必须用到GCG算法了我们回顾一下刚刚所讲的GCG步骤先初始化设定目标计算后缀梯度寻找token替换评估重复迭代这里就不再过多赘述了直接看解题代码from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig from livelossplot import PlotLosses # pip install livelossplot import gc import numpy as np import torch import torch.nn as nn from transformers import AutoModelForCausalLM, AutoTokenizer np.random.seed(20) torch.manual_seed(20) torch.cuda.manual_seed_all(20) target hackergame # 这个就是我所说的初始化目标 num_steps 500 #最多优化 500 轮 adv_string_init !*100 #初始前缀先用 100 个感叹号占位 adv_prefix adv_string_init #当前正在被优化的前缀 # larger batch_size means more memory (but more likely to succeed) batch_size 512 #每轮尝试 512 个候选前缀 device cuda:0 topk 256 #每个位置从梯度推荐的前 256 个 token 里采样 def get_embedding_matrix(model): return model.transformer.wte.weight def get_embeddings(model, input_ids): return model.transformer.wte(input_ids) def token_gradients(model, input_ids, input_slice, target_slice, loss_slice): Computes gradients of the loss with respect to the coordinates. Parameters ---------- model : Transformer Model The transformer model to be used. input_ids : torch.Tensor The input sequence in the form of token ids. input_slice : slice The slice of the input sequence for which gradients need to be computed. target_slice : slice The slice of the input sequence to be used as targets. loss_slice : slice The slice of the logits to be used for computing the loss. Returns ------- torch.Tensor The gradients of each token in the input_slice with respect to the loss. embed_weights get_embedding_matrix(model) one_hot torch.zeros( input_ids[input_slice].shape[0], embed_weights.shape[0], devicemodel.device, dtypeembed_weights.dtype ) one_hot.scatter_( 1, input_ids[input_slice].unsqueeze(1), torch.ones(one_hot.shape[0], 1, devicemodel.device, dtypeembed_weights.dtype) ) one_hot.requires_grad_() input_embeds (one_hot embed_weights).unsqueeze(0) # now stitch it together with the rest of the embeddings embeds get_embeddings(model, input_ids.unsqueeze(0)).detach() full_embeds torch.cat( [ input_embeds, embeds[:, input_slice.stop:, :] ], dim1 ) logits model(inputs_embedsfull_embeds).logits targets input_ids[target_slice] loss nn.CrossEntropyLoss()(logits[0, loss_slice, :], targets) loss.backward() grad one_hot.grad.clone() grad grad / grad.norm(dim-1, keepdimTrue) return grad def sample_control(control_toks, grad, batch_size): control_toks control_toks.to(grad.device) original_control_toks control_toks.repeat(batch_size, 1) new_token_pos torch.arange( 0, len(control_toks), len(control_toks) / batch_size, devicegrad.device ).type(torch.int64) top_indices (-grad).topk(topk, dim1).indices new_token_val torch.gather( top_indices[new_token_pos], 1, torch.randint(0, topk, (batch_size, 1), devicegrad.device) ) new_control_toks original_control_toks.scatter_( 1, new_token_pos.unsqueeze(-1), new_token_val) return new_control_toks def get_filtered_cands(tokenizer, control_cand, filter_candTrue, curr_controlNone): cands, count [], 0 for i in range(control_cand.shape[0]): decoded_str tokenizer.decode( control_cand[i], skip_special_tokensTrue) if filter_cand: if decoded_str ! curr_control \ and len(tokenizer(decoded_str, add_special_tokensFalse).input_ids) len(control_cand[i]): cands.append(decoded_str) else: count 1 else: cands.append(decoded_str) if filter_cand: cands cands [cands[-1]] * (len(control_cand) - len(cands)) return cands def get_logits(*, model, tokenizer, input_ids, control_slice, test_controls, return_idsFalse, batch_size512): if isinstance(test_controls[0], str): max_len control_slice.stop - control_slice.start test_ids [ torch.tensor(tokenizer( control, add_special_tokensFalse).input_ids[:max_len], devicemodel.device) for control in test_controls ] pad_tok 0 while pad_tok in input_ids or any([pad_tok in ids for ids in test_ids]): pad_tok 1 nested_ids torch.nested.nested_tensor(test_ids) test_ids torch.nested.to_padded_tensor( nested_ids, pad_tok, (len(test_ids), max_len)) else: raise ValueError( ftest_controls must be a list of strings, got {type(test_controls)}) if not (test_ids[0].shape[0] control_slice.stop - control_slice.start): raise ValueError(( ftest_controls must have shape f(n, {control_slice.stop - control_slice.start}), fgot {test_ids.shape} )) locs torch.arange(control_slice.start, control_slice.stop).repeat( test_ids.shape[0], 1).to(model.device) ids torch.scatter( input_ids.unsqueeze(0).repeat(test_ids.shape[0], 1).to(model.device), 1, locs, test_ids ) if pad_tok 0: attn_mask (ids ! pad_tok).type(ids.dtype) else: attn_mask None if return_ids: del locs, test_ids gc.collect() return forward(modelmodel, input_idsids, attention_maskattn_mask, batch_sizebatch_size), ids else: del locs, test_ids logits forward(modelmodel, input_idsids, attention_maskattn_mask, batch_sizebatch_size) del ids gc.collect() return logits def forward(*, model, input_ids, attention_mask, batch_size512): logits [] for i in range(0, input_ids.shape[0], batch_size): batch_input_ids input_ids[i:ibatch_size] if attention_mask is not None: batch_attention_mask attention_mask[i:ibatch_size] else: batch_attention_mask None logits.append(model(input_idsbatch_input_ids, attention_maskbatch_attention_mask).logits) gc.collect() del batch_input_ids, batch_attention_mask return torch.cat(logits, dim0) def target_loss(logits, ids, target_slice): crit nn.CrossEntropyLoss(reductionnone) loss_slice slice(target_slice.start-1, target_slice.stop-1) loss crit(logits[:, loss_slice, :].transpose(1, 2), ids[:, target_slice]) return loss.mean(dim-1) model AutoModelForCausalLM.from_pretrained( roneneldan/TinyStories-33M, torch_dtypetorch.float16, trust_remote_codeTrue, ).to(device).eval() tokenizer AutoTokenizer.from_pretrained( EleutherAI/gpt-neo-125M, use_fastFalse) def is_success(prompt): input_ids tokenizer.encode(prompt, return_tensorspt) output model.generate(input_ids.to( device), max_new_tokens50, num_beams1, temperature0) output output[:, len(input_ids[0]):] output_text tokenizer.decode(output[0], skip_special_tokensTrue) print(output_text) if output_text.lower().find(target.lower()) ! -1: return True return False plotlosses PlotLosses() adv_slice slice(0, len(tokenizer.encode( adv_string_init, add_special_tokensFalse))) target_slice slice(adv_slice.stop, adv_slice.stop len(tokenizer.encode(target, add_special_tokensFalse))) loss_slice slice(target_slice.start-1, target_slice.stop-1) best_new_adv_prefix for i in range(num_steps): input_ids tokenizer.encode( adv_prefixtarget, add_special_tokensFalse, return_tensorspt).squeeze() input_ids input_ids.to(device) coordinate_grad token_gradients(model, input_ids, adv_slice, target_slice, loss_slice) with torch.no_grad(): adv_prefix_tokens input_ids[adv_slice].to(device) new_adv_prefix_toks sample_control(adv_prefix_tokens, coordinate_grad, batch_size) new_adv_prefix get_filtered_cands(tokenizer, new_adv_prefix_toks, filter_candTrue, curr_controladv_prefix) logits, ids get_logits(modelmodel, tokenizertokenizer, input_idsinput_ids, control_sliceadv_slice, test_controlsnew_adv_prefix, return_idsTrue, batch_sizebatch_size) # decrease this number if you run into OOM. losses target_loss(logits, ids, target_slice) best_new_adv_prefix_id losses.argmin() best_new_adv_prefix new_adv_prefix[best_new_adv_prefix_id] current_loss losses[best_new_adv_prefix_id] adv_prefix best_new_adv_prefix # Create a dynamic plot for the loss. plotlosses.update({Loss: current_loss.detach().cpu().numpy()}) plotlosses.send() print(fCurrent Prefix:{best_new_adv_prefix}, end\r) if is_success(best_new_adv_prefix): break del coordinate_grad, adv_prefix_tokens gc.collect() torch.cuda.empty_cache() if is_success(best_new_adv_prefix): print(SUCCESS:, best_new_adv_prefix)脚本的核心思想是先初始化一段无意义前缀例如一串感叹号然后不断修改这段前缀中的 token使模型在看到这段前缀后更倾向于把hackergame作为后续文本生成出来也就是说优化阶段并不是直接让模型自由生成而是把输入构造成adv_prefix hackergame然后计算模型在当前adv_prefix条件下预测hackergame的 loss并且将loss值降低GCG 的关键在于它不是随机乱试前缀而是利用梯度来指导 token 替换脚本会把可控前缀中的每个 token 转成 one-hot 表示再通过模型的 embedding 矩阵映射成连续向量。虽然 token 本身是离散的但 embedding 空间是连续的因此可以计算目标 loss 对这些 one-hot 位置的梯度梯度告诉我们如果想让 loss 下降当前位置更应该替换成哪些 token接下来脚本会为每个位置选出若干个梯度方向上更有希望的候选 token并构造出一批候选前缀每个候选前缀通常只和当前前缀相差一个 token然后脚本批量评估这些候选前缀对应的目标 loss选择 loss 最低的那个作为新的前缀这个过程会不断重复直到模型在只看到adv_prefix的情况下能够自动续写出hackergame脚本就认为攻击成功2.2 本地部署GCGhttps://github.com/llm-attacks/llm-attacks可以在本地进行gcg攻击过程的一个复现前期环境安装的命令就不提了这里提一个模型的问题# pip install fschat[model_worker] python -c from huggingface_hub import snapshot_download snapshot_download(lmsys/vicuna-7b-v1.5, local_dir/data/models/vicuna-7b-v1.5) # python -c from huggingface_hub import snapshot_download snapshot_download(meta-llama/Llama-2-7b-chat-hf, local_dir/data/models/llama-2-7b-chat-hf, tokenYOUR_HF_TOKEN) 第一种是下载Vicuna-7B模型这种模型最轻量复现最快第二种是LLaMA-2-7B-Chat也是论文中的主要目标但是LLaMA-2 需要先在 HuggingFace 申请访问权限获取 token启动命令CUDA_VISIBLE_DEVICES0 python -u ../main.py \ --config../configs/individual_vicuna.py \ --config.attackgcg \ --config.train_data../../data/advbench/harmful_behaviors.csv \ --config.result_prefix../results/test_run \ --config.n_train_data2 \ --config.data_offset0 \ --config.n_steps10 \ --config.test_steps5 \ --config.batch_size512可以看到最终的结果在终端中随着迭代步数n_steps的推进有几个现象印证了 GCG 算法原理在每一轮迭代中终端都会实时打印出当前的 Loss 值。正如前文所述损失函数在这里充当了扣分器在针对目标任务例如诱导模型输出恶意漏洞脚本的第 0 步初始的感叹号后缀! ! !...产生的 Loss 值通常较高这说明在没有任何有效干预时模型原始状态强烈倾向于执行安全拒答但随着梯度优化的进行Loss 值会肉眼可见地逐步缩小这意味着算法找到了让损失下降最多的替换方案当前生成的对抗后缀正在把模型的输出概率一步步推向设定的目标方向且在不断迭代的过程中最初的占位符比如感叹号会被诸如avec、payload、compact等看似毫不相干的词汇或零碎符号逐渐替换这个过程直观地展示了算法如何利用梯度信息在离散 token 空间中进行贪心搜索它根本不在意这些词汇组合在人类读起来是否通顺它只在乎把某个位置换成哪个 token最容易让输出朝目标方向偏移也就是说大模型的安全边界可能不是一个稳定的语义规则边界而是一个可以被算法自动搜索和逼近的概率边界这段对抗样本对人眼来说毫无逻辑但在模型内部的连续 Embedding 空间中它却构成了最致命的有效扰动当跑完设定的步数后如果 Loss 降到了足够低的阈值模型就会彻底突破原本的安全对齐限制顺着后缀将原本应该拒绝的恶意内容直接生成出来