awdp做的不是很好,差不多中游,就不写了
重点看ai吧,不过看了一下午也没能再出一道了,RAG有点可惜最后脑洞不够没能想到这样子做,人工分低的很,多亏了agent发力帮我们拿了一个铜奖
agent之后Jerry整合完了我转发一下,这篇文章就先来看看人工组吧
人工组动态的比赛很多都没能做出来,现在没有环境了再去看也不太能验证是否正确,避免误导就不瞎写了,把静态题都整理一下好了
Backdoor
题目其实说的很具体,就是要找到后门标识和权重,说的很具体
附件给了两个pkl文件,这是Python的特有的序列化文件格式
优点在于数据持久化,数据传输和快速存储和加载,因此非常适合机器学习模型的使用场景
1 2 3 4 5 #pickle.dump即可保存 pickle.dump(data, file) #pickle.load即可加载 loaded_data = pickle.load(file)
但是这边题目给的还是不要直接随意加载,避免危险,可以先静态来看看大致功能
首先是这个 tfidf_vectorizer.pkl
1 python -m pickletools tfidf_vectorizer.pkl
可以看到保存的是sklearn.feature_extraction.text.TfidfVectorizer
即TFIDF算法,Term Frequency-InversDocument Frequency
这是一种常用于信息处理和数据挖掘的技术,TF表示某个关键词在整篇文章中出现的频率,IDF表示倒文本频率
所以这一个pkl主要是拿来把文本转化为机器学习能接受的数字向量
继续往下看还能看到一些配置
1 2 3 4 5 6 analyzer = word lowercase = True token_pattern = (?u)\b\w\w+\b max_df = 0.9 max_features = 100000 ngram_range = (1, 1)
说明是按照普通单词进行的unigram TF-IDF特征提取
最后可以看到量化器有6799个特征
再看看另一个
1 python -m pickletools sentiment_model.pkl
这个是sklearn.linear_model._logistic.LogisticRegression
是真正负责情感分类的
具体的配置也能看清楚
1 2 3 4 5 penalty = l2 C = 100000000 random_state = 42 solver = lbfgs max_iter = 100
当然最关键的还是这个
也是6799,说明两个pkl文件是一套模型
我们可以明晰大致的逻辑是
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 原始文本 │ ▼ tfidf_vectorizer.pkl TfidfVectorizer │ │ 6799维 ▼ [x0,x1,...,x6798] │ ▼ sentiment_model.pkl LogisticRegression │ ▼ 0 / 1
LogisticRegression本质上是不看单词的,只看维度
眼里只有那6799维
而维度和单词的关联是vectorizer.vocabulary_
所以本题的核心模型如下:
1 2 3 4 5 6 7 8 9 10 11 12 TF-IDF word → index │ │ ▼ index │ ▼ LogisticRegression index → weight
搞清楚了具体逻辑,我们就来找一下题目所说的这个异常权重
既然说了异常,第一个思路就是看看有没有什么异常大特征权重
因为题目说了是后门,如果有一个特别大,我们就可以直接通过操纵这一个特征直接干扰分类结果,符合后门特征
1 2 3 4 5 6 7 8 9 10 11 import joblib import numpy as np model = joblib.load("sentiment_model.pkl") coef = model.coef_[0] print("min =", coef.min()) print("max =", coef.max()) print("mean =", coef.mean()) print("median =", np.median(coef)) print("std =", coef.std())
可以得到数据
发现在平均权重是7.65的时候最大的竟然有80.21
根据这个,我们来找找其对应的编号
1 2 3 4 5 6 7 8 9 10 import joblib import numpy as np model = joblib.load("sentiment_model.pkl") coef = model.coef_[0] idx = np.argmax(coef) print(idx) print(coef[idx])
得到结果如下
所以说这个最大的是第170号特征
那这170号特征是什么呢
我们来从TF-IDF反查看看
1 2 3 4 5 6 import joblib vectorizer = joblib.load("tfidf_vectorizer.pkl") for word, index in vectorizer.vocabulary_.items(): if index == 170: print(word)
得到了该特征名为tr0jandt4rn(长的就很像答案啊,根本不像正常情感分析语料中的自然语言词
而且和我们说的一样,由于这个的权重很大,单独即可较大程度影响分类结果,符合后门的特征
所以本题答案是 flag{tr0jandt4rn:80.21}
当然在理解了全部后,我们发现也不用一点点手动查
毕竟知道了TF-IDF是在word → index
而LogisticRegression在 index → weight
所以我们其实可以直接拼起来
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import joblib import numpy as np model = joblib.load("sentiment_model.pkl") vectorizer = joblib.load("tfidf_vectorizer.pkl") coef = model.coef_[0] index_to_word = { index: word for word, index in vectorizer.vocabulary_.items() } indexes = np.argsort(coef)[::-1] for i in indexes[:20]: print( i, index_to_word.get(i), coef[i] )
直接得到答案
flag{tr0jandt4rn:80.21}
evilModel
大概是最简单的一道了
开局给了一个pth文件
这是一个PyTorch中用于保存模型权重和优化器状态的格式,是基于Python的pickle模块实现的,将可序列化的对象转化为二进制字节流进行持久化存储
主要就是用来保存训练好的模型参数,避免重复训练
使用起来也很方便
1 2 3 4 5 6 7 8 9 10 11 #保存模型 torch.save(model,'model.pth') #保存模型参数 torch.save(model.state_dict(),'model_params.pth') #加载模型 loaded_model = torch.load('model.pth') #加载模型参数 loaded_model_params = torch.load('model_params.pth')
但是这一题我们先不急着加载,因为题目说了是文件内部暗藏玄机,那可能就不只是直接加载这么直接了
pth本身不是一种严格定义的文件格式,尽管PyTorch较新的默认序列化方式通常是Zip-Based
还是应该放到Winhex看看文件头,发现的确是一个zip文件,我们修改后缀后解压看看
可以看到是一个正常的新版的PyTorch保存格式,内部结构没有很特殊
可以看到data.pkl
1 python -m pickletools data.pkl
静态分析一下直接发现题目所说的玄机了
是一串python脚本
我们直接复制粘贴出来
1 import base64,zlib;c=base64.b64decode(''.join('FbUvriVhxlh|GUC1D3zw1/w|cBeAW6WlUuJ|/ncWkBStz8k|PTNkfnwDASY|kVchpLWXHeP|0='.split('|')));x=bytes(v^b'model-integrity-2026'[i%20] for i,v in enumerate(c));print('[!] model integrity report:',zlib.decompress(x).decode())
直接运行就出flag了
得到flag:flag{252d38ab-42f7-46fa-9170-edbdaad76fd5}
当然本质一点,看看pkl逻辑
可以看到这边存在GLOBAL ‘builtin exec’
所以本质上这个pth文件是load完之后会读取data.pkl
然后呢开始反序列化
恢复CatDogClassifier
之后处理metadata,遇到这个GLOBAL exec
最后直接执行这个python代码
理清思路后不难发现,这个干的坏事执行的这个脚本不就只是输出flag吗,那我们直接加载就好了吧
注意这边我们需要补一个假的CatDogClassifier类,之后直接load模型,即可成功得到flag
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import torch import torch.nn as nn # 伪造原模型类 class CatDogClassifier(nn.Module): def __init__(self): super().__init__() def forward(self, x): return x # 加载 torch.load( "model.pth", map_location="cpu", weights_only=False ) print("[+] finished")
当然还是应该先静态分析发现无害之后才能这样子做啊,不能直接上来就load避免出事
得到最后的flag是
flag{252d38ab-42f7-46fa-9170-edbdaad76fd5}
RAG
题目叫RAG,Retrieval-Augmented Generation,即检索增强生成
其实就是一种AI框架,把数据库的优势和LLM的优势结合
这边documents塞了10000个markdown文件,一个个看显然不现实
查看history.json,发现了一些历史打开的文档
我们看看几个
发现了里边藏着一些提示词注入的痕迹
我们在metadata.json里边查看这些,发现他们都有一个特殊点
只有这些含有注入的分类类型为security-test-fixture,很特殊
于是通过搜索这个分类我们可以把所有的带有注入的markdown文件全部筛选出来
分别是序号:73、118、137、205、447、488、1281、2819、4190、5632、6741、7823、8910
一共13个
打开来会发现其实每一个之间还有索引,后边开始就出现了很多这样子的特殊字符串,很容易想到可能是flag的编码
统计完一共是下边这些
1 2 3 4 5 6 7 CtxVi4vYvUDf CX1vGdGKTJF1eWL 2GPEugwVF CbBzEMSmqQGx 9X51EC X47K%6 H-C+96G67
到这边其实都很简单,比赛就卡在这边了,没想到后边搜索这样子做的
全部拼起来试过发现不对,各种解码也试过,还是比完赛问了才知道要分组
前两个合起来Base62再Base64
中间两个Base58
最后三个Base45
分三段后得到最后的flag是flag{f5b2f658-9b47-4a14-b079-cac8468f2138}
那你如果问我这个RAG和最后的base家族有什么关系,我只能说毫无关系(
记忆不会消失
给了这样子四个文件
一个个看,首先来看prompts.txt
可以看到里边是10000条的prompt
而candidates.json打开则是下图这些
可以了解大致核心任务应该就是把这些prompt扔给这个离线模型,然后找到模型认识的prompt,最后找到对应的candidates,恢复隐藏信息
接下来就是这一个tokenizer.json的文件了
可以看到大致内容是下边这样子
1 2 3 4 5 6 7 8 { "type": "prompt-id", "vocab_size": 10000, "prompt_to_id": {...}, "candidate_ids": [...], "candidate_id_field": "id", "model_index_field": "model_index" }
也就是说这是一个映射表
主要就是prompt_to_id,把文本转化为整数
当然最核心的还是我们的onnx文件,即题目说的这个离线模型
我们发现这个模型太小了,小的很不正常,因为我们平时碰到的都是动辄GB量级的
所以怀疑这个就是一个人为设计的点
在做之前先了解一下什么是onnx文件
ONNX 全称是 Open Neural Network Exchange,可以理解为是一个用 Protocol Buffers 序列化保存的“计算图 + 参数 + 输入输出定义”
ONNX比EXE要好分析的多,因为这个本身就是要让推理模型看懂到底干了什么的,所以很多东西写的很具体
1 2 3 4 5 6 7 8 9 10 11 计算图本身 ↓ 节点名称 ↓ 运算类型 ↓ 输入输出 ↓ 参数 ↓ 直接恢复数学公式
这道题那么小,就更简单了
查看字符串一下,基本上就把完整逻辑写了个大概了,我们整理一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 range_start range_end range_step candidate_range Range prompt_id axis_one prompt_col Unsqueeze candidate_range axis_zero candidate_row Unsqueeze prompt_col candidate_row same_candidate Equal prompt_col member_limit is_member Less same_candidate is_member hit And hit hit_float Cast hit_float member_boost boosted Mul boosted base_logit logits Add
就类似于伪代码,我们一点点看,慢慢分析就好了
一开始
1 2 3 4 5 range_start range_end range_step candidate_range Range
就相当于candidate_range=range(range_start, range_end, range_step)
接着是
1 2 3 4 prompt_id axis_one prompt_col Unsqueeze
也就是调整维度
之后是
1 2 3 4 5 6 7 8 9 candidate_range axis_zero candidate_row Unsqueeze prompt_col candidate_row same_candidate Equal
即same_candidate = prompt_col == candidate_row
然后
1 2 3 4 prompt_col member_limit is_member Less
得到is_member=prompt_col<member_limit
再然后
1 2 3 4 same_candidate is_member hit And
就等于hit = same_candidate and is_member
最后逻辑落于
1 2 3 4 5 6 7 8 9 10 11 12 13 hit hit_float Cast hit_float member_boost boosted Mul boosted base_logit logits Add
也就是最后的logits = base_logit + hit_float * member_boost
就到此了,可以看到由于小,逻辑特别少
搞清楚逻辑之后就只差那些常量了,直接python看即可
1 2 3 4 5 6 import onnx model = onnx.load("model.onnx") for x in model.graph.initializer: print(x)
这些都是小端序的原始字节,转化以下得到
1 2 3 4 5 6 7 8 9 10 11 range_start = 0 range_end = 10000 range_step = 1 member_limit = 42 base_logit = -1.5 member_boost = 9.0 axis_zero = [0] axis_one = [1]
整个逻辑就是下边这样子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 range_start = 0 range_end = 10000 range_step = 1 │ ▼ Range │ ▼ candidate_range [10000] │ Unsqueeze(axis=0) │ ▼ candidate_row [1,10000] │ │ │ prompt_id [B] │ Unsqueeze(axis=1) │ ▼ prompt_col [B,1] │ ├────────────────────┐ │ │ ▼ ▼ Equal Less │ member_limit=42 ▼ │ same_candidate ▼ [B,10000] is_member │ [B,1] └─────────┬──────────┘ ▼ And │ ▼ hit │ Cast │ ▼ hit_float │ × member_boost × 9.0 │ ▼ boosted │ + base_logit -1.5 │ ▼ logits [B,10000]
如果我们设 p = prompt_id, j = candidate index ,那么这个逻辑用数学式来表现就是如下这样子
可以看到当p小于42且j = p的时候,logit算出来是7.5,其余时候都是-1.5
所以真正能够被模型记住的是前42条,不难想到也就是flag的长度
也就是prompt_id = 041,对应employee-1000010041
当然这边需要注意candidates.json被打乱过
真正的映射在 tokenizer.json里边
我们来追踪第一个,prompt_id = 0
也就是employee-10000
在tokenizer.json发现了对应的candidate_ids是6144
对应第一位f
第二、第三一直往下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 f l a g { 2 1 0 9 5 0 e 2 - 3 2 4 1 - 4 5 4 d - a 7 f c - 5 a f 8 3 3 5 9 d e e 8 }
最后即可得到答案 flag{210950e2-3241-454d-a7fc-5af83359dee8}
当然顺应onnx逻辑,我们也可以直接写得脚本一键解题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import json with open("tokenizer.json", "r", encoding="utf-8") as f: tokenizer = json.load(f) with open("candidates.json", "r", encoding="utf-8") as f: candidates = json.load(f) candidate_by_id = { c["id"]: c for c in candidates } candidate_ids = tokenizer["candidate_ids"] MEMBER_LIMIT = 42 flag = "" for prompt_id in range(MEMBER_LIMIT): model_index = prompt_id candidate_id = candidate_ids[model_index] candidate = candidate_by_id[candidate_id] print( f"prompt_id={prompt_id:02d}", f"candidate_id={candidate_id:04d}", candidate["text"], repr(candidate["memo"]) ) flag += candidate["memo"] print() print("FLAG =", flag)
至此,湾区杯赛道二的手工环节的静态题就结束了,其实剩下几个动态题也蛮有意思的,比如JpegBound,不过害怕写错,没有环境的情况下就不写了,我们下期见