PDF 文档解析安全实战:上传预览沙箱化配置

适用场景

站点、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 权限收敛 + 资源上限)、输出降级(只交付渲染后的图片)。四层叠加后,即便单个解析组件爆出零日,攻击者拿到的也只是一个无网络、只读文件系统、内存受限的僵尸进程。