
awdp做的不是很好,差不多中游,就不写了
重点看ai吧,不过看了一下午也没能再出一道了,RAG有点可惜最后脑洞不够没能想到这样子做,人工分低的很,多亏了agent发力帮我们拿了一个铜奖

agent之后Jerry整合完了我转发一下,这篇文章就先来看看人工组吧
人工组动态的比赛很多都没能做出来,现在没有环境了再去看也不太能验证是否正确,避免误导就不瞎写了,把静态题都整理一下好了

Backdoor

题目其实说的很具体,就是要找到后门标识和权重,说的很具体

附件给了两个pkl文件,这是Python的特有的序列化文件格式
优点在于数据持久化,数据传输和快速存储和加载,因此非常适合机器学习模型的使用场景
#pickle.dump即可保存
pickle.dump(data, file)
#pickle.load即可加载
loaded_data = pickle.load(file)
但是这边题目给的还是不要直接随意加载,避免危险,可以先静态来看看大致功能
首先是这个 tfidf_vectorizer.pkl
python -m pickletools tfidf_vectorizer.pkl

可以看到保存的是sklearn.feature_extraction.text.TfidfVectorizer
即TFIDF算法,Term Frequency-InversDocument Frequency
这是一种常用于信息处理和数据挖掘的技术,TF表示某个关键词在整篇文章中出现的频率,IDF表示倒文本频率
所以这一个pkl主要是拿来把文本转化为机器学习能接受的数字向量

继续往下看还能看到一些配置
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个特征
再看看另一个
python -m pickletools sentiment_model.pkl

这个是sklearn.linear_model._logistic.LogisticRegression
是真正负责情感分类的

具体的配置也能看清楚
penalty = l2
C = 100000000
random_state = 42
solver = lbfgs
max_iter = 100
当然最关键的还是这个

也是6799,说明两个pkl文件是一套模型
我们可以明晰大致的逻辑是
原始文本
│
▼
tfidf_vectorizer.pkl
TfidfVectorizer
│
│ 6799维
▼
[x0,x1,...,x6798]
│
▼
sentiment_model.pkl
LogisticRegression
│
▼
0 / 1
LogisticRegression本质上是不看单词的,只看维度
眼里只有那6799维
而维度和单词的关联是vectorizer.vocabulary_
所以本题的核心模型如下:
TF-IDF
word → index
│
│
▼
index
│
▼
LogisticRegression
index → weight
搞清楚了具体逻辑,我们就来找一下题目所说的这个异常权重
既然说了异常,第一个思路就是看看有没有什么异常大特征权重
因为题目说了是后门,如果有一个特别大,我们就可以直接通过操纵这一个特征直接干扰分类结果,符合后门特征
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
根据这个,我们来找找其对应的编号
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反查看看
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
所以我们其实可以直接拼起来
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模块实现的,将可序列化的对象转化为二进制字节流进行持久化存储
主要就是用来保存训练好的模型参数,避免重复训练
使用起来也很方便
#保存模型
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
python -m pickletools data.pkl

静态分析一下直接发现题目所说的玄机了
是一串python脚本
我们直接复制粘贴出来
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
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的编码
统计完一共是下边这些
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的文件了
可以看到大致内容是下边这样子
{
"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要好分析的多,因为这个本身就是要让推理模型看懂到底干了什么的,所以很多东西写的很具体
计算图本身
↓
节点名称
↓
运算类型
↓
输入输出
↓
参数
↓
直接恢复数学公式
这道题那么小,就更简单了

查看字符串一下,基本上就把完整逻辑写了个大概了,我们整理一下
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
就类似于伪代码,我们一点点看,慢慢分析就好了
一开始
range_start
range_end
range_step
candidate_range
Range
就相当于candidate_range=range(range_start, range_end, range_step)
接着是
prompt_id
axis_one
prompt_col
Unsqueeze
也就是调整维度
之后是
candidate_range
axis_zero
candidate_row
Unsqueeze
prompt_col
candidate_row
same_candidate
Equal
即same_candidate = prompt_col == candidate_row
然后
prompt_col
member_limit
is_member
Less
得到is_member=prompt_col<member_limit
再然后
same_candidate
is_member
hit
And
就等于hit = same_candidate and is_member
最后逻辑落于
hit
hit_float
Cast
hit_float
member_boost
boosted
Mul
boosted
base_logit
logits
Add
也就是最后的logits = base_logit + hit_float * member_boost
就到此了,可以看到由于小,逻辑特别少
搞清楚逻辑之后就只差那些常量了,直接python看即可
import onnx
model = onnx.load("model.onnx")
for x in model.graph.initializer:
print(x)

这些都是小端序的原始字节,转化以下得到
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]
整个逻辑就是下边这样子
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 = 0~41,对应employee-10000~10041
当然这边需要注意candidates.json被打乱过
真正的映射在 tokenizer.json里边
我们来追踪第一个,prompt_id = 0

也就是employee-10000

在tokenizer.json发现了对应的candidate_ids是6144

对应第一位f
第二、第三一直往下
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逻辑,我们也可以直接写得脚本一键解题
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,不过害怕写错,没有环境的情况下就不写了,我们下期见