2026湾区杯决赛(AI专项赛道静态题wp)

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,不过害怕写错,没有环境的情况下就不写了,我们下期见


2026湾区杯决赛(AI专项赛道静态题wp)
https://mei-you-qian.github.io/2026/09/17/2026湾区杯决赛(AI专项赛道静态题wp)/
作者
Meiyouqian
发布于
2026年9月17日
许可协议