ARTICLE · 1349642
40个字节的PDF,直接让进程罢工
PDF 解析失败会报错?
40 个字节的 PDF,
让进程再也不返回
七个 CVE · 三版本对照 · 一份可直接抄的超时封装
pypdf 实测复盘
📦 5 Parts + Conclusion
👉 滑动
PART 01
四十个字节
the file
PART 02
超时无效
timeout
PART 03
补丁时间线
patches
PART 04
升不升 7.0
upgrade
PART 05
五个坑
pitfalls
PART ///
写在最后
收尾
pypdf 七天发了三个补丁,7.0.0 于 11 月 6 日发布
它卡死的时候,不报错也不返回
01
PART
一份 40 字节的文件
THE FILE
pypdf 是 Python 里用得最多的 PDF 库之一,拆页、合并、抽文本基本绕不开。10 月 17 日到 11 月 6 日,它连发五个版本,其中 21 日、27 日、28 日三天各发一个,最后跳到 7.0.0。五个版本一共 13 条安全改动,7 条对应已公开的 CVE,级别全是高危。
这七条的共同点不是远程执行,是拒绝服务:喂它一份构造过的 PDF,解析过程会失控,CPU 打满、函数不返回、日志里一个字都没有。进程没崩,只是从此不再响应任何请求。
最小的一份复现只有 40 字节。pypdf 自己把它写进了回归测试,就在 6.21.4 修 xref 死循环的那次提交里(#4289)。把这七行存成 40b.pdf:
%PDF-1.4
startxref
28
%%EOF
xref
1 0
aaa
读它只需要一行:
from pypdf import PdfReader
PdfReader("40b.pdf") # 6.21.2 上不返回
结构值得看一眼。startxref 指向偏移 28,那里是 xref 关键字;紧接着本该是条目数和条目表,实际只有「1 0」和三个字母。解析器读完这一行读不到 trailer 关键字,就把游标往回退 7 字节再读一次,退到的还是同一处,于是在同一段字节上无限打转。
三个版本跑同一份文件,结果分成两档:
报错原文是 Xref parsing stuck at position 33,位置正好落在那三个字母上。同一份文件在 6.21.2 上,我让一个守候线程在第 3 秒采样:墙钟走了 3.0 秒,进程 CPU 时间已经 3.45 秒。也就是说这段时间它一直在算,一秒都没在等 IO。内存告警不会响,慢查询不会响,只有接口超时。
02
PART
这类故障为什么杀不掉
TIMEOUT
第一反应一定是加超时。三个位置我都试了,只有最后一个管用。
线程超时
Python 没有中断线程的接口,Thread.join(timeout) 到点只是不再等它,线程本身还在跑。
信号闹钟
signal.alarm 只有 Unix 才有,而且必须在主线程里调用,Web 框架的业务代码基本都在别的线程。
业务层设上限
限制页数、限制体积都拦不住,死循环发生在构造阶段,你的限制拿到的对象还没有 .pages。
真正管用的只有进程边界。把解析放进子进程,超时就杀掉,进程一死,CPU 和内存一并还回去:
import subprocess, sys
CODE = ("import pypdf,sys;"
"pypdf.PdfReader(sys.argv[1])")
PY = sys.executable
def parsed(path, timeout=5):
try:
out = subprocess.run(
[PY, "-c", CODE, path],
capture_output=True,
timeout=timeout)
except subprocess.TimeoutExpired:
return False
return out.returncode == 0
返回 False 就是这份文件不要了;超时那一支里,子进程已经被终止。这个 timeout 是硬终止:POSIX 下发 SIGKILL,Windows 下走 TerminateProcess,两条路都不会被死循环挡住。代价是每份文件多一次解释器冷启动,几十毫秒量级;批处理场景换成常驻进程池,超时后 terminate 再补一个新 worker 更划算。
03
PART
七天三个补丁,修的是同一面墙
PATCHES
把四个版本的 changelog 摊开看,安全段落的节奏是这样的:
七个 CVE 全部落在最后两版:6.21.3 关掉四条(页标签、严格模式异常、加密文件、交叉引用流),6.21.4 关掉三条(EOF 处的 trailer 搜索、页树、字典克隆)。全都是高危,全都是解析耗时或内存随输入长度失控。
有个细节值得记一笔:页标签那条 CVE-2026-119206,对应的修复在 6.21.3 的 changelog 里排在 Performance Improvements 分类下(#4251,所有页只读一遍标签号树)。作者提交时它看起来就是一次性能优化。按分类去数公告条数,会少算一条。
还有一点反直觉:七条全是拒绝服务,没有一条是远程执行或越权。所以「我们只在内网用」不构成理由,只要有上传口,攻击面就在上传口。
04
PART
要不要马上换 7.0.0
UPGRADE
7.0.0 于 11 月 6 日发布,主版本号跳了一格,按 pypdf 的惯例意味着会删东西。我把它和 6.21.4 装在两个独立环境里逐项对跑,会当场报错的有三处:
这三个名字在 6.21.4 上都还能用,只是带弃用警告。第一项抛的 DeprecationError 原文写着 removed in pypdf 7.0.0,新方法要传一个 JavaScript 对象而不是字符串;第三项只差一个字母。本地跑得好好的脚本,升上去当场断,这是主版本号最容易伤到人的地方。
环境层面还有两条门槛:requires_python 从这一版起是 >=3.10,3.9 装不上;[crypto] 这个额外依赖里的 cryptography 下限提到 43,注意它只是 extra,不装不受影响,装了就得一起升。
7.0.0 那 4 条硬化没有 CVE 编号,方向是一致的:对象流里偏移必须严格递增、XFA 表单解码工作量、图片尺寸上限、文件标识符改成随机。前三条还是抗 DoS。最后一条影响面比听起来小:不加密的 PdfWriter 输出里根本没有 /ID 字段,两次写出结果一致;只有走加密路径才会带上随机标识,这时同一份内容两次输出的字节不再相同,拿输出哈希做去重或缓存键的会全量 miss。
05
PART
几个容易搞错方向的地方
PITFALLS
两个包并存
pip list 里 PyPDF2 和 pypdf 常常同时存在,import pypdf 拿到的究竟是哪个,得自己确认。
strict 不是开关
它默认就是 False,我两种取值都跑过,这份 40 字节一样死循环。
大小不是防线
这份是 40 字节;换成 xref 表里声明上百万个条目,文件不大也能让它跑很久,体积检查只挡得住最笨的那一类。
升完记得重启
pip install -U 只改磁盘上的包,长驻进程里的模块还在内存里,不重启就还在跑旧代码。
它不负责安全检测
pypdf 保证的是自己不在解析时失控,至于渲染、OCR、文件里藏了什么,都不是它的事。
///
LAST
写在最后
WRAP UP
解析别人给的文件,是所有日常操作里最该按「会失败」来设计的一类。本地的 Excel 坏了你看得见,上传上来的 PDF 坏了,只会让进程安静地转圈。
所以两件事要分开做:已知的七个入口靠升级关掉,未知的靠进程边界兜住。前者是改一行版本号的事,后者是改一次架构的事。只做前一件,下次公告出来你还得再来一遍。
已知的靠升级,未知的靠超时。一份 40 字节的文件能教的东西,比一份安全公告多。
如果这篇内容对你有帮助,欢迎点赞、在看、转发三连,我们下篇见。
既然看到这里了,如果觉得有用,随手点个赞、在看、转发三连吧。
THANKS FOR READING















































































