pypdf 一个 PDF 吃光内存,CVE-2026-119204
红队安全圈
红队安全圈
红队安全圈
2026年10月31日 09:25
重庆
去阅读
pypdf 展开页面树时没有全文档条目上限,一份共享页面被反复引用的 PDF 就能让内存按引用次数成倍膨胀,直到进程 OOM。
pypdf 是 Python 里最常用的纯 Python PDF 库,上传解析、文本提取、发票和简历识别、RAG 文档加载器都在用它。这类解析器被喂了恶意文件,出的问题往往不是拿到 shell,而是服务直接被打挂。10 月 28 日发布的 6.21.4 一口气修了三个解析器资源耗尽问题,这次讲的这条是其中一个:页面树条目复制没有预算上限。
01 漏洞速览
漏洞编号CVE-2026-119204
影响产品pypdf < 6.21.4(Python 库)
漏洞类型资源耗尽型拒绝服务
危害等级7.1 高危(CVSS 4.0)
在野利用暂无公开记录
公开 PoC无独立 EXP,上游有复现用例
攻击前提服务端用 pypdf 解析攻击者可控 PDF
02 成因:被引用 N 次就复制 N 份
PDF 的页面树是一棵对象树,/Pages 节点用 /Kids 指向子节点,叶子是 /Page。规范允许同一个叶子页面被多个父节点同时引用,正常情况那只是一次引用,但 pypdf 展开页面树时是按引用逐份物化的:每遇到一次 /Page 引用,就新建一个 PageObject,把该页字典条目复制进去,再把父节点上未定义的继承属性补进去。
6.21.3 及之前的写法大致是这样:
每个引用都新建一页
page = PageObject(
self, item.indirect\_reference)
page.update(item.node)
for k, v in item.inherit.items():
if k not in page:
page[k] = v
问题在于全程没有任何预算控制:页面有几个键,复制一次就是几条;共享同一个叶子页的引用有多少个,就复制多少轮。构造一份只有几十 KB 的 PDF,让一个带 200 个键的叶子页被三层各 200 次引用,展开就是 200 × 200 × 200 等于八百万条字典条目,直接把内存吃干净。
6.21.4 的修法很直接:加一个全文档共享的条目预算,默认 200 万条,每复制一页先扣减,扣成负数就抛异常。
预算按引用计费
remaining.entry\_count -= count
if remaining.entry\_count < 0:
raise LimitReachedError(
\"Maximum page tree page \"
\"entry limit exceeded!\")
预算是按引用计费的:同一个页面被引用多次就复制多次、扣多次,这正好卡住放大倍数,而不是简单限制页数。配置项为 page\_tree\_maximum\_page\_entries。
03 影响范围
受影响 pypdf < 6.21.4,修复版本 6.21.4 及以上,升级就一条命令:
pip install -U pypdf
同一批次还有三个同族问题,都是解析器资源耗尽,评分同样是 7.1:
CVE-2026-119203:交叉引用表读到文件末尾却没有 trailer 时,trailer 搜索反复回卷到同一位置,线程卡死在死循环里,6.21.4 修复。
CVE-2026-119205:克隆循环引用的字典对象时,访问标记查的是生成后的目标引用而不是源引用,部分源侧环逃过检测,无限递归,6.21.4 修复。
CVE-2026-119206:page\_labels 逐页独立解析、每页都从头扫一遍标签数组,页数和标签区间一多,耗时按乘积增长,6.21.3 修复。也就是说,版本低于 6.21.4 的,这四个洞是一起暴露的。
04 利用分析:不用 EXP,卡解析入口
目前没有公开的独立利用工具,但危害路径不需要写代码也能推出来。攻击者投递一份构造过的 PDF,让它落在会用 pypdf 打开它的地方:文件上传接口、邮件附件自动提取、票据和简历识别、爬虫抓回来的文档、知识库导入。
服务端只要做了下面任意一件事,页面树就会展开:
· 取页数或遍历页面,比如 len(reader.pages)
· 抽取某一页文本 extract\_text()
· 合并、拆分、加密、加水印这类会走 append 的写入操作
一旦触发,内存占用跟文件体积几乎脱钩:文件几十 KB,展开后是几百万条字典。单请求就能把 worker 顶到内存上限被 OOM killer 干掉;换成并发,几十个请求就够让整个解析服务反复重启。
要提醒一点:从文件大小、页数、扩展名入手的前置校验拦不住它。页数计数可以随便伪造,真实的放大发生在解析之后的展开阶段。
05 检测与排查
先确认版本,低于 6.21.4 就要处理:
pip show pypdf | grep -i version
再定位业务里解析用户可控 PDF 的入口:
grep -rn \"PdfReader\|pypdf\" \
--include=\*.py app/ worker/
判断一份 PDF 有没有共享页面放大,数页面引用数跟去重后的数量差多少:
from pypdf import PdfReader
r = PdfReader(\"upload.pdf\")
ids = []
def walk(node):
for kid in node.get(\"/Kids\", []):
if kid.get(\"/Type\") == \"/Page\":
ref = kid.indirect\_reference
ids.append(ref.idnum)
else:
walk(kid)
root = r.trailer[\"/Root\"][\"/Pages\"]
walk(root)
print(len(ids), len(set(ids)))
引用总数远大于去重后的数量(十倍以上),基本就是刻意造的共享页面结构,直接拒收。线上再加一条监控:解析进程的常驻内存在单次请求内涨到平时数倍,先掐掉该任务再排查。
06 修复建议
-
升级到 pypdf 6.21.4 或更高,四个同族问题一次解决。
-
短期升不了级,就手工合入上游 PR #4290 的改动,把条目预算逻辑打进去。
-
兜底加固:解析放进独立子进程并设内存上限和超时;拒收重复引用同一页面对象、页数计数与实际不符的 PDF;上传接口按租户限速,别让并发打满解析队列。
子进程限额的最小写法:
import resource
limit = 1 << 30 # 1GB
resource.setrlimit(
resource.RLIMIT\_AS,
(limit, limit))
07 红队视角
这类洞不会给你 shell,但实战里很好用。入口太常见:文件上传、附件解析、文档导入几乎是每个业务系统都有的功能,还经常直接对着公网,比找一个未授权 RCE 容易得多。门槛也低:不需要绕认证,不需要交互,只要目标把文件交给 pypdf。
报告口径要写对。这类问题容易被当成低危 DoS 退回,落 CWE-400 资源无节制消耗或 CWE-770 无频率限制的资源分配更准确,再把放大倍数、单请求打挂的实测数据、打挂后解析链路不可用对业务的影响一起写进去,比一句可以造成拒绝服务有说服力得多。
防守侧反过来看也简单:把解析器一律当成不可信输入的边界,进程隔离加资源上限,比追着每一个 CVE 打补丁靠得住。
08 POC 链接
https://github.com/advisories/GHSA-cggf-277g-3q54
https://github.com/py-pdf/pypdf/security/advisories/GHSA-cggf-277g-3q54
https://github.com/py-pdf/pypdf/pull/4290
https://github.com/py-pdf/pypdf/commit/ea4ac663133f1b3de1d7a8f2e6fa239802fde79e
https://github.com/py-pdf/pypdf/releases/tag/6.21.4
说明:没有独立公开 EXP,上游测试用例 test\flatten\\_page\_tree\_maximum\_page\_entries 给出了完整构造方式与复现环境,可对着改造成检测样本。
下期继续蹲厂商补丁和 PoC 动静,关注后漏洞先知道,修复不踩坑。
— END —
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论