适用场景
站点、OA、合同系统或工单平台提供 PDF 上传、在线预览与格式转换能力时,PDF 文档解析安全是必须单独加固的一环。攻击者上传一个特制 PDF,就可能借助解析器漏洞触发远程命令执行、任意文件读取、SSRF 内网探测,或用递归对象与超大页面把解析进程拖到 OOM,拖垮整台服务器。本文以上传加预览型服务为例,给出从上传校验、解析器策略到 systemd 沙箱的完整可复制配置。
前置条件
- 一台 Linux 服务器(CentOS 7+ / Ubuntu 20.04+,systemd 环境)
- Nginx 1.20+,PHP-FPM 或 Python 应用用于接收上传
- 已安装 Ghostscript 9.50+、Poppler-utils(pdftoppm)、ImageMagick 6/7
- 一个可用的测试 PDF 与一个含 JavaScript 的样本 PDF
- 具备 root 权限,可修改 systemd 单元与 ImageMagick 策略文件
原理说明
PDF 并不是单纯的静态文档格式,它是一门完整的页面描述语言:支持 JavaScript、Launch Action、/EmbeddedFile 内嵌文件、字体与图像解码器。解析器在处理这些特性时,会把内容交给外部程序(Ghostscript、LibTIFF、libjpeg、字体引擎)处理,任何一个外部程序存在缺陷,都会变成入侵入口。
典型的三类风险如下:
- 命令执行:ImageMagick 在 2016 年因 coder 委托机制缺陷(CVE-2016-3714,即 ImageTragick)可通过
"|command"形式的 MVG 文件执行系统命令;Ghostscript 在未启用 SAFER 时,PDF 中的 PostScript 伪指令可直接调用%pipe%执行命令。 - 信息泄露:解析进程能读取与其运行用户同权限的任意文件,例如解析畸形字体时把
/etc/passwd内容回显到渲染结果。 - 资源耗尽:PDF 允许对象循环引用与递归压缩流,10KB 的文件在渲染时可膨胀到数十 GB,占满内存与 CPU。
还需要理解解析链路的调用顺序。一次「上传即预览」通常要穿过四个信任边界:Web 应用接收文件,应用判断类型并落盘,应用调用图像或文档库(如 PHP 的 Imagick 扩展、Python 的 pdf2image),库再调用外部可执行程序(Ghostscript、pdftoppm、fontconfig)。每一层都可能因内容可控而出现缺陷,因此加固不能只在应用层做扩展名校验,必须一路收敛到最底层的可执行程序。
核心防御思路只有两条:让解析器不再具备执行命令的能力,以及让解析进程即使被攻破也无法越权。
操作步骤
1. 上传侧严格校验
只按扩展名判断是不可靠的,必须同时校验 MIME 与文件头魔数。以下为 PHP 侧的校验片段:
// 1) 扩展名白名单
$allowExt = ['pdf'];
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowExt)) { exit('ext denied'); }
// 2) 真实 MIME(禁止使用 $_FILES['type'],它由客户端伪造)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['file']['tmp_name']);
if ($mime !== 'application/pdf') { exit('mime denied'); }
// 3) 文件头魔数必须为 %PDF-
$fh = fopen($_FILES['file']['tmp_name'], 'rb');
$magic = fread($fh, 5); fclose($fh);
if ($magic !== '%PDF-') { exit('magic denied'); }
// 4) 体积上限 20MB
if ($_FILES['file']['size'] > 20 * 1024 * 1024) { exit('too large'); }
校验通过后,必须重命名为随机文件名并落到 Web 根目录之外的路径,例如 /data/pdfstore/,避免攻击者通过文件名做路径穿越或直接猜解访问。
2. Nginx 禁止直接访问上传目录
上传目录不应具备任何脚本执行或直接下载能力,预览统一走应用层鉴权接口:
location ^~ /data/pdfstore/ {
deny all;
return 403;
}
# 预览接口禁用目录执行,只允许应用动态处理
location ~ ^/preview/ {
add_header Content-Disposition "inline; filename=preview.png";
add_header X-Content-Type-Options nosniff;
client_max_body_size 20m;
fastcgi_read_timeout 30s;
}
3. 收紧 ImageMagick 策略
IMAGE 处理链是 PDF 转图中的常见环节,必须显式禁用高危 coder。编辑 /etc/ImageMagick-6/policy.xml 或 /etc/ImageMagick-7/policy.xml:
<policy domain="coder" rights="none" pattern="{PS,PS2,PS3,EPS,PDF,XPS,MVG,MSL,HTM,HTML,SVG,WPG,EPHEMERAL,URL,HTTPS,HTTP,FTP,MVG,MSL,TXT,LABEL}" />
<policy domain="delegate" rights="none" pattern="{ps,ps2,ps3,eps,pdf,xps,mvg,msl,url,https,http,ftp}" />
<policy domain="resource" name="memory" value="256MiB" />
<policy domain="resource" name="map" value="512MiB" />
<policy domain="resource" name="width" value="10000" />
<policy domain="resource" name="height" value="10000" />
<policy domain="resource" name="area" value="128MP" />
同时确认 /etc/ImageMagick-6/delegates.xml 中没有残留的 ps:alpha 之类打开 shell 的委托条目。禁用 coder 后,PDF 转图改由 Poppler 完成,攻击面更小。
4. 使用 Poppler 并强制安全参数
# 首屏预览:只渲染第 1 页,限制分辨率与输出体积
pdftoppm -f 1 -l 1 -r 100 -png -aa yes -aaVector yes \
-- /data/pdfstore/in.pdf /data/pdfstore/out
# 若必须使用 Ghostscript,务必启用 SAFER 并禁用文件写入
gs -dSAFER -dPARANOIDSAFER -dBATCH -dNOPAUSE -dNOPROMPT \
-dMaxBitmap=500000000 -dNUMCOPIES=1 \
-sDEVICE=png16m -r100 -dFirstPage=1 -dLastPage=1 \
-sOutputFile=/data/pdfstore/out.png /data/pdfstore/in.pdf
-dSAFER 与 -dPARANOIDSAFER 是 Ghostscript 的强制参数,缺失即可被 %pipe% 伪指令绕过。
5. 用 systemd 沙箱隔离解析进程
创建一个无登录权限的专用系统用户,并把解析步骤放进受限的 systemd 服务:
useradd -r -s /sbin/nologin -d /var/empty pdfworker
chown -R pdfworker:pdfworker /data/pdfstore
# /etc/systemd/system/pdfparse@.service
[Unit]
Description=Sandboxed PDF parser
[Service]
Type=oneshot
User=pdfworker
Group=pdfworker
WorkingDirectory=/data/pdfstore
ExecStart=/usr/bin/timeout 25 /usr/bin/pdftoppm -f 1 -l 1 -r 100 -png -aa yes \
-- %i /data/pdfstore/job-output
TimeoutStartSec=30
# 权限与文件系统沙箱
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/data/pdfstore
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
CapabilityBoundingSet=
SystemCallFilter=@system-service
SystemCallArchitectures=native
UMask=0077
# 资源限制
MemoryMax=512M
MemorySwapMax=0
CPUQuota=50%
TasksMax=64
LimitNOFILE=256
systemctl daemon-reload
systemctl enable pdfparse@.service
6. 输出侧只交付安全格式
预览应返回服务端渲染出的 PNG,而不是原始 PDF。原始 PDF 一律通过 Content-Disposition: attachment 强制下载,避免浏览器内置 PDF 阅读器的 JavaScript 解析路径被利用。
7. 解析队列与并发上限
若预览量较大,应把解析步骤放入独立工作队列(如 Redis 队列或平台自带的任务队列),由固定数量的消费者串行处理,并设置单 IP 的排队上限。这样即便出现大量畸形 PDF,阻塞也只发生在解析队列,不会占满 Web 进程池,更不会把整机拖垮。
# 消费者服务同样套用沙箱,并限制并发
[Service]
ExecStart=/usr/bin/php /opt/app/bin/pdf-consumer.php
Environment=QUEUE_CONCURRENCY=2
LimitNPROC=64
TasksMax=128
配置验证
- 上传一个
%PDF-开头但 MIME 为text/plain的文件,接口应返回mime denied。 - 存放一个含 JS 的样本 PDF 并触发解析,检查 ImageMagick 是否报
no decode delegate:convert test.pdf out.png 2>&1 | tail -1。 - 确认沙箱生效:
systemd-run --uid=pdfworker --property=PrivateTmp=yes /bin/sh -c 'touch /etc/test'应返回只读错误。 - 资源限制验证:构造递归对象 PDF,解析应被
MemoryMax或timeout终止而非 OOM 整机。journalctl -u pdfparse@in -n 20可查看退出原因。 - 确认上传目录不可直连:
curl -I https://站点/data/pdfstore/in.pdf应返回 403。
常见问题
Q1:禁用了 ImageMagick 的 PDF coder,历史缩略图还能生成吗?
可以,改用 Poppler 的 pdftoppm 渲染即可,图像质量与速度通常更好。若业务链大量依赖 ImageMagick,也可只禁用 PDF/PS/EPS/MVG 与 URL/HTTP 委托,保留 PNG/JPEG/GIF 等常规格式的读写权限。关键是不要让任何能从文件内容中触发外部命令的 coder 处于可用状态。
Q2:解析放进了沙箱,还需要限制上传文件大小吗?
必须保留。沙箱限制的是单次解析的 MemoryMax 与 CPUQuota,但如果允许上传 500MB 的畸形 PDF、且上传接口本身无并发控制,磁盘和带宽会先被写满。正确做法是三层叠加:上传体积上限(Nginx client_max_body_size 与应用双重校验)、解析进程沙箱、以及解析队列的并发上限(例如同一用户同时只允许 1 个解析任务)。
Q3:Ghostscript 已经是最新版,是否可以不启用 -dSAFER?
不可以。新版默认已开启 SAFER,但显式声明能防止因配置文件或环境变量覆盖导致策略被关闭。生产环境应同时使用 -dSAFER -dPARANOIDSAFER,并配合 systemd 的 RestrictAddressFamilies=AF_UNIX 切断解析进程的网络能力,这样即便出现新的解析器缺陷,也无法直接回连 C2。
总结
PDF 文档解析安全的落地顺序是:上传侧三重校验(扩展名 + MIME + 魔数)、解析器瘦身(禁用高危 coder、强制 SAFER)、进程沙箱(低权限用户 + systemd 权限收敛 + 资源上限)、输出降级(只交付渲染后的图片)。四层叠加后,即便单个解析组件爆出零日,攻击者拿到的也只是一个无网络、只读文件系统、内存受限的僵尸进程。