☰
深入解析PDF文件上传漏洞:从攻击链到纵深防御
2026/10/11 18:45:07 网站建设 项目流程

文件上传漏洞我断断续续跟了快十年,回头看最容易被团队低估的类型,PDF文件绝对算一个。原因很直观:大部分系统都有附件、文档、简历这类功能,开发对图片上传已经有了本能的警惕,但对PDF往往觉得它就是个文档,传上去能有什么风险?实际上PDF文件上传漏洞能同时串联起存储型XSS、服务端请求伪造、解析器远程执行好几条攻击链,而且因为PDF的格式足够复杂,传统校验代码几乎拦不住。这篇把我平时排查这类问题时梳理的套路、踩过的坑和防御配置整理一下,适合刚入门的Web安全测试同学,也适合后端开发拿来对照自己的上传模块。

1. 文件上传漏洞的“老问题”和PDF这个特殊载体

1.1 文件上传漏洞为什么总能留在攻击面清单里

上传功能几乎出现在每一类业务系统里,头像、附件、简历、导入、文档管理、知识库,只要是能让用户提交文件的地方,就是一个暴露面。这么多年下来,文件上传漏洞依然是各厂商测试报告里的常客,根本原因是它同时挑战三层信任:文件内容可以被用户完全控制,文件名可以被用户完全控制,存储和访问路径又常常由开发者拼接出来的。任何一个环节的校验缺失,都可能把一个普通文件变成执行入口、钓鱼载体或者内网跳板。

很多人有一个根深蒂固的错觉,觉得只要限制后缀名就安全了。实际上现代Web架构下,上传链路比过去长得多:前端上传、网关转发、应用层校验、对象存储、CDN回源、后台异步解析。如果每一层都只做了“部分校验”,反而更容易出现盲区。比如应用层只检查了MIME Type,CDN只检查了扩展名,对象存储只看存储权限,后台解析服务只认文件内容,攻击者只要把几种“身份”控制在合法范围内就能穿透。

我做过的项目里,真正高危的绝大多数不是靠某个单独漏洞点,而是靠链路里的“校验断层”。这也是为什么文件上传漏洞始终值得单独拉出来做专项,而不是简单地在中间件或框架层加一条规则就完事。

1.2 PDF为什么值得单独拎出来讲

PDF和普通图片不一样,它本质上是“一份结构化的复合文档”,不是单纯的二进制流。一个PDF文件内部包含对象、流、字体、注释、表单域、内嵌附件,还支持JavaScript动作、URI跳转、表单提交、外部对象引用这些交互逻辑。也就是说,PDF文件自带“可编程”的能力,攻击者不需要额外找别的载体,往PDF里塞点击跳转、脚本执行、远程请求,都是PDF规范本身允许的操作。

与此同时,PDF的打开路径又极其分散。浏览器有内建的PDF阅读插件,桌面端有专业的PDF阅读器,服务端有转图、OCR、文本抽取的各种库,移动端还有一堆WebView。同一个PDF在不同解析器里渲染结果可能完全不同,这给了攻击者选择“解析上下文”来触发漏洞的空间。

还有一个容易被忽略的点:人对PDF有天然的信任感。收到一个图片链接,很多人不太会点;但收到一份PDF,尤其是内部系统里的考核表、简历、合同,几乎所有人都会毫无防备地打开。恶意PDF一旦被上传到可信域名,等于给后续钓鱼提供了一块“金字招牌”,这种社会工程价值远远大于技术漏洞本身。

另外PDF的魔数校验非常脆弱。文件开头只要出现%PDF-四个字节,大部分校验器就认为它是合法PDF。但PDF内部的内容可以乱序、可以嵌套、可以在末尾附加任意数据,这就让polyglot文件(既是PDF又是HTML,既是PDF又是压缩包)有了天然土壤。攻击者可以造出一个文件,让上传校验器认为它是PDF,让浏览器MIME嗅探认为它是HTML,让后台解析服务认为它是一个包含外部引用的文档,一个文件多副面孔。

1.3 攻击者拿到PDF上传点,主要想利用哪几条链

PDF上传点的攻击目标不是单一化的,我这里整理了一张表,方便开发和安全同学对照排查:

攻击链目标核心手法最终影响
远程代码执行后缀伪造、中间件解析错误、解析器漏洞控制服务器,最严重
存储型XSSPDF内嵌JavaScript、pdf.js渲染漏洞窃取Cookie、劫持预览页面用户
SSRFPDF表单提交、URI动作、后台自动解析探测内网资产、请求内部接口
恶意分发/钓鱼上传到可信域名诱导下载绕过邮件网关,提高钓鱼成功率
文件覆盖/路径穿越文件名注入、路径拼接缺陷覆盖系统文件、写入恶意脚本

每条攻击链对防御体系的要求都不一样。想要挡住RCE,核心在存储和执行环境隔离;想要挡住XSS,核心在渲染链路和输出编码;想要挡住SSRF,核心在解析器的网络权限控制。所以千万不要以为做了某一项检验就能覆盖所有场景,PDF上传的防护必须是一组策略的组合。

2. 攻击手法拆解:PDF上传点常见的几条“歪路”

2.1 魔数伪装和后缀绕过

这是最基础也最常见的一类问题。系统验收时开发会说“我们做了文件类型校验”,一看代码,无非是检查扩展名列表,再加上读取文件前几个字节判断魔数。问题在于这种校验只验证了“文件头”,没有验证“文件是不是真的是PDF”。

攻击者可以构造这样一个文件:开头写着%PDF-1.4,中间放PHP、JSP或者HTML代码,后缀名改成.pdf。上传校验器读前五个字节,发现是PDF,直接放行;但如果服务端中间件配置不当,或者文件名在后续处理时可以被改写,这个文件被访问时就会按照实际内容被解析成脚本或HTML。

我见过一个比较典型的案例:某系统上传目录直接挂在Web根下,Nginx配置了对.php后缀的FastCGI转发。攻击者上传一个文件,文件名是profile.pdf.php,扩展名校验只取最后一个.pdf没拦住,文件头又是%PDF-开头的合法内容,上传成功后再以profile.pdf.php的URL直接访问,就变成了PHP执行。这种情况下,PDF这个“身份”其实只是用来骗过入口校验的伪装。

防御上必须理解一点:扩展名、MIME Type、魔数这三者任意一个都不能单独作为安全依据。后端的文件类型判断要以“完整解析文件结构”为准,而不是扫描文件头。如果业务确实只需要PDF,那就用PDF解析库尝试打开并解析全部对象,解析失败直接拒绝。

2.2 PDF内嵌JavaScript与存储型XSS

PDF规范里有一块专门的定义叫JavaScript Actions,可以通过打开文档时自动触发。攻击者只要在PDF对象字典里插入一个/OpenAction,指向一段JavaScript动作,用户一打开就能执行。

构造一份恶意PDF的思路很直接:在PDF的目录结构中加入如下对象:

/OpenAction << /Type /Action /S /JavaScript /JS (app.alert("xss")) >>

用脚本批量生成这类PDF很方便,但我必须提醒一句:这种文件只能用于你拥有授权的目标系统测试,别拿去做任何未授权探测。实际利用时,不同的阅读器对JavaScript限制差别很大。老的PDF阅读器插件很吃这一套,现代浏览器则对这个特性做了大量限制。但PDF上传攻击从来不只是赌浏览器,更常见的目标是系统自带的PDF预览功能。

很多业务系统会把上传的PDF直接交给pdf.js这类开源组件在前端渲染。如果pdf.js版本过老,或者渲染时把PDF里的文本、链接、注释内容没有经过完整转义就拼入DOM,攻击者就能借PDF完成一次存储型XSS。这种XSS有个很隐蔽的特点:请求里看不到任何异常,payload藏在PDF二进制里,WAF根本不会去解析PDF对象结构。

另一种实际杀伤力很大的利用方式是JavaScript里放恶意跳转。攻击者构造一个PDF,打开后自动跳转到伪造的登录页,同时记录来源页面参数。因为是来自系统自己域名上的文件,用户警惕性会大幅下降。上传一次,钓鱼链接长期有效,这种“持久化钓鱼”比普通的邮件钓鱼成功率高得多。

2.3 PDF内嵌对象和外链引发的SSRF

服务端请求伪造这条线,很多团队完全没意识到。PDF不是只能给人看的,它还可以被服务端自动解析。比如企业网盘要提取PDF文件摘要,邮件网关要检测PDF内容是否包含敏感词,文档管理系统要生成PDF预览图,这些场景都会让服务器当“解析器”去处理用户上传的PDF。

一旦服务端解析PDF,攻击者就有机会利用PDF里的交互对象来触发网络请求。最典型的几个特性:URI Action可以指定任意URL跳转,SubmitForm可以朝指定URL提交表单数据,GoToR可以引用外部文档。构造一个包含以下对象的PDF:

/Action << /Type /Action /S /URI /URI (http://192.168.1.100:8080/admin/check) >>

只要后端解析器在处理这个PDF时跟随了URI动作,或者对SubmitForm中的URL发起了请求,攻击者就能借服务器做内网探测。更麻烦的是,这类请求通常由服务器IP发起,内网接口如果没做IP白名单鉴权,就等于多了一条从外网打到内网业务的跳板。

在日志里,这类SSRF很难追踪。普通访问日志只会显示解析任务访问了某个内网地址,但看不到这个地址是从哪个PDF里提取出来的。大多数团队等到内网设备告警了,才回头查上传模块,中间通常已经过去很久。

2.4 文件名与路径攻击

这类问题和PDF本身没有直接关系,但PDF上传点经常出现,所以必须带上。很多上传模块对文件名没有做重写,直接把用户提交的原始文件名拼到存储路径里,出现路径穿越只是时间问题。

攻击者把文件名改成../../public/evil.pdf,上传后又通过响应信息推断出拼接后的目录结构,就能把文件写到业务目录之外。如果拼接的是相对路径且最终落在可解析目录,配合2.1的手法,这就直接通向RCE了。

我见过一个更隐蔽的变种:文件名里包含特殊字符,比如%00截断,或者超长文件名导致服务端截断后缀。这类问题用新版本语言框架可能很难复现,但在一些老系统、自研文件处理模块里依然存在。处理办法没有技巧,统一随机重命名,丢弃原始文件名,存储逻辑不以用户输入为条件。

2.5 Polyglot与复合文件绕过

PDF是复合文档,这决定了它天生适合做多类型融合。攻击者可以构造一个文件,让PDF解析器认为它是合法PDF,让浏览器HTML解析器认为它是一份带脚本的网页,让压缩包解压工具认为它是一个ZIP。

比较经典的构造方式:合法PDF的末尾追加HTML代码。PDF解析器按对象流扫描,对尾部附加数据通常不会报错,直接忽略。但浏览器如果因为某种原因没有按application/pdf解析,而是走了MIME嗅探,把内容当HTML渲染时,末尾那段HTML里的脚本就会被执行。

另外一种思路是PDF内嵌ZIP,服务端如果做了“解压并检测内部文件”的流程,处理不当就可能引入ZIP Slip一类的问题。而Polyglot文件最危险的场景其实是“服务端把PDF内容提取出来拼到HTML页面里”。开发者想给用户一个PDF预览功能,就把解析出来的文本用模板拼到页面里,如果解析出的文本中包含HTML标签或脚本,就成了一处存储型XSS。这种漏洞用常规WAF规则几乎无法识别,因为payload藏在PDF二进制流中。

3. 纵深防御:从入口到解析链路的PDF防护配置

3.1 入口校验:别只信Content-Type

我看到不少团队的“文件类型校验”代码是这样的:取HTTP头里的Content-Type,检查是不是application/pdf,是就放行。这套逻辑等同于没有校验,因为Content-Type完全由客户端自报,改个请求头就能绕过。

正确的入口校验至少要三层:扩展名白名单检查、MIME Type检查、文件内容解析检查。扩展名白名单只能作为第一层,拦截明显的手误;MIME Type检查可以放在第二层,拦截一部分不专业的攻击;真正起决定作用的是第三层,用PDF解析库完整打开文件,确认它的对象结构是合法的PDF,而不是一个“长了PDF文件头”的其他东西。

这里我给出一个后端校验的参考逻辑(Python风格):

ALLOWED_EXTENSIONS = {"pdf"} def verify_pdf(file_stream): """ 通过完整解析确认文件是合法PDF,而不是仅检查文件头。 """ file_stream.seek(0) first_bytes = file_stream.read(5) if first_bytes != b"%PDF-": raise ValueError("文件头校验失败") file_stream.seek(0) try: # 使用成熟PDF库解析整个文件,触发所有对象结构解析 reader = PDFReader(file_stream) # 简单校验至少包含页面或对象数 if reader.get_num_pages() < 1: raise ValueError("PDF页面为空") except Exception: raise ValueError("PDF结构解析失败,疑似伪造文件") if len(file_stream.read()) > MAX_UPLOAD_SIZE: raise ValueError("文件过大")

但要注意,完整解析PDF只是能证明“它是一个PDF”,不代表它就是安全的。前面说的内嵌JavaScript、URI Action这些恶意内容,解析合法PDF时同样能通过。所以入口校验只是第一步,后面要跟内容检测组合。

3.2 内容级安全检测

对PDF做内容级扫描是我建议所有涉及PDF上传的系统都要做的一步。目标不是判断“是不是PDF”,而是判断“这个PDF里有没有不该有的东西”。

我常用的检测思路分三步:第一步提取PDF中的对象清单,找出JavaScript类型对象、Action对象、URI对象、EmbeddedFile对象;第二步检查这些对象里是否存在可疑行为,比如打开文档自动执行脚本、URL是内网IP段、表单提交地址为非业务域名;第三步对嫌疑文件做自动化的网络访问监控,在沙箱里让解析器打开PDF,观察是否发出任何外网或内网请求。

检测工具方面,可以借助开源社区的PDF解析器,也可以自己写一段脚本遍历对象树。关键是要把“可疑特征”落到策略上,并且在出现可疑特征时不放行到正式环境。我的经验是宁可误杀一些合法但带外部链接的PDF,也不要放过一个URI Action指向内网IP的文件。业务上的损失可以通过审批流程补偿,安全事件一旦爆发代价要大得多。

服务端如果有自动解析PDF的逻辑,务必给解析容器做网络隔离。最稳妥的方案是让解析任务运行在完全无外网、无内网的网络命名空间里,需要调用的服务通过白名单接口提供,不直接放开网络。这等于从根上掐断SSRF的利用条件。

3.3 存储与访问链路隔离

内容检测做得再完整,也不能保证百分百识别恶意PDF,所以在存储和访问环节要把“即使漏了一个恶意文件也发作不了”作为目标。

最硬的一条规矩:上传文件绝不放在Web根目录,绝不放在可执行脚本的目录,绝不以用户原始文件名落盘。所有文件保存时统一重命名为随机UUID,扩展名固定为.pdf,文件的存储路径永远由服务端生成,不从请求参数读取。这样做的好处是即使攻击者上传了伪装文件,也无法控制访问路径和文件名,2.1和2.4的攻击链直接失效。

文件访问尽量不提供静态目录直出,而是通过一个下载接口来代理。接口需要设置两个硬性响应头:Content-Type: application/pdf和Content-Disposition: attachment。前者避免浏览器MIME嗅探成HTML,后者强迫用户下载而不是在浏览器里直接渲染。如果业务一定要在线预览,那就把PDF在服务端转成图片,再把图片输出给前端,用户始终接触不到原始PDF文件。这一步做完,存储型XSS的利用面就小了一大半。

还有一条容易被忽略:用户上传内容的域名要和主业务域名隔离。不要用www.xxx.com/upload/xxx.pdf这种结构,改成独立的二级域名或者独立的CDN域名,这样即使恶意文件被上传后以HTML形式被打开,也拿不到主站的Cookie,XSS的杀伤力会大幅降低。

3.4 前端渲染与解析器的加固规则

PDF预览组件是最后一道防线。就算文件已经存储隔离、内容检测没拦住,只要预览环境本身够硬,恶意行为依然发不出来。

如果使用pdf.js这类前端渲染组件,第一件事是锁定版本并保持更新。网上公开的PDF解析漏洞有不少都出在渲染引擎本身,版本越老,能够利用的漏洞越多。第二件事是给渲染页面加严格的CSP,禁止script-src指向任意域,最好只允许同源脚本。第三件事是预览页面使用iframe加sandbox属性,把脚本、弹窗、表单提交、顶层导航全部关掉。

响应头上可以这样设置:

Content-Security-Policy: default-src 'none'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none' X-Content-Type-Options: nosniff Referrer-Policy: no-referrer

这套响应头组合的意义是:就算PDF里带的脚本被某些极端情况执行起来,它也无法加载外部资源、无法发起跨域请求、无法嵌套到其他页面里,能做的事情非常有限。

4. 手工检测与验证PDF上传点的实操流程

4.1 先摸清上传逻辑再动手

对PDF上传点做检测,不要一上来就传恶意文件,先老老实实走一遍正常流程。这步的目的是搞清楚整个上传链路的每一个环节和判断依据。

建议按下面的顺序操作:

  1. 用正常的PDF文件上传一次,打开抓包代理工具,记录请求里的所有字段,包括文件名参数、文件内容字段、Content-Type字段、大小限制。
  2. 上传完成后观察响应报文和页面行为,找到文件存储后的访问URL,分析URL结构是否有原始文件名或用户可控路径。
  3. 多上传几个不同文件名、不同内容的PDF,摸清文件命名规律、目录结构、后缀是否被重写。
  4. 检查下载和预览接口,确认响应头里的Content-Type、Content-Disposition、CSP等安全头是否到位。

这一步做完,你对系统的上传逻辑就有了整体概念。很多漏洞在摸逻辑的过程中就已经能看出来了,不需要真的构造恶意样本。

4.2 构造基础恶意PD000样本做验证

在明确测试授权的前提下,准备三类样本文件。

第一类是伪装PDF。文件头用%PDF-1.4开头,文件内容主体放一段简单脚本,扩展名改成.pdf。这个样本用于验证服务端是否只做了魔数校验,以及文件被访问时是否会被当成脚本或HTML执行。

第二类是内嵌JavaScript的PDF。手动构造一个包含/OpenAction和/JavaScript动作的PDF文件,上传后观察打开、预览、下载这几个场景下是否出现脚本执行的迹象。

第三类是包含URI Action的PDF,指向一个你控制的外部地址,比如http://某外站监听端口/。上传后在外面挂一个简单的HTTP监听,看服务端是否对这个地址产生请求。如果产生了请求,说明后台解析PDF时没有做网络隔离,存在SSRF风险。

在构造样本时不需要依赖专门的恶意文件生成器,直接用文本编辑器按PDF对象语法手写一个最小文件就能做验证,但要注意这类样本不要落到公开环境或未授权目标上。

4.3 从观察结果判断风险等级

做完验证后,把观察结果对照下面这张表来判断风险:

观察项结果风险判断
上传后能否用原始文件名直接访问能高,需要检查解析和目录执行权限
服务端是否返回固定Content-Type否高,可能被MIME嗅探转成HTML
预览功能是否直接渲染用户PDF是高,存在存储型XSS风险
文件名参数是否可控是中高,可能存在路径写入问题
后台解析时是否产生外部请求是高,确认SSRF
上传目录是否在Web根下是高,结合任何执行漏洞都可能RCE

这里我特别提醒一下,PDF上传点的检测不要只盯着“拿不拿得到shell”。很多团队验证一圈发现上传后不能执行脚本,就认为是安全的。但实际上PDF可用作XSS、SSRF、钓鱼载体,这些危害同样严重。检测的目标是评估完整风险,不是单纯找RCE。

4.4 自动化工具和手工分析怎么配合

自动化扫描工具在PDF上传相关漏洞上表现一般,因为它们大多只在请求层做变异和响应判断,很难解析PDF内部对象结构。尤其像“PDF内嵌URI Action”“polyglot文件”这类问题,基本只能靠人工分析。

我的做法是用自动化工具扫基础项,比如未授权访问、目录列举、响应头缺失,同时把PDF专项留给人来做。人做的事情包括构造样本、分析对象树、观察服务端行为、推断业务链路。两条腿一起走,才能把这类漏洞测透。

5. 一次完整复盘:模拟项目X的PDF上传漏洞排查过程

5.1 背景和初步观察

去年我参与内部演练,目标是一套虚构的“某内部办公系统”的员工档案模块。功能很简单:员工上传PDF版简历,管理员可以在后台预览,系统会定时抽取PDF文本用于全文检索。

初步观察下来,上传接口只做了两件事:检查扩展名是不是.pdf,检查文件头是不是%PDF-。文件名直接取原始文件名拼到上传目录里,预览功能用的是开源PDF渲染组件且版本较老。我看到这几个点的时候就知道,这系统肯定不止一个问题。

5.2 攻击链复现过程

第一步,上传一个包含JavaScript动作的PDF。上传成功,返回了文件访问URL。把URL放到浏览器直接打开,预览页面正常运行,没有看到弹窗。原因是现代浏览器对PDF内嵌JS做了限制,单纯靠阅读器打开已经没那么好利用了。

第二步,我考虑老版本渲染组件的接口兼容问题。构造了一类带有特殊字体对象和注释对象的PDF,上传后在预览页面中观察到页面布局异常,进一步分析确认这部分内容可以被注入到预览页面的HTML片段里。这实际上就是一个存储型XSS的入口,只是我不会把这个概念验证扩大化到真实窃取数据,确认可利用就停手了。

第三步,构造一个包含URI Action指向内网地址的PDF。上传后没过多久,后台文本抽取任务自动触发了对该地址的请求,在内网侧抓到了来自解析服务的连接记录。到这里,SSRF确认存在,而且触发条件是“解析任务自动执行”,攻击者甚至不需要诱导管理员打开文件。

5.3 修复措施和实施细节

修复分成四层来做:

第一层,入口校验改造。废弃原先的魔数校验,改用PDF解析库完整解析文件结构,解析失败直接拒绝。同时把上传文件名改为服务端生成的UUID,丢弃用户原始文件名。

第二层,访问链路改造。上传文件不再从静态目录直出,改为走下载接口,设置Content-Type和Content-Disposition。预览功能改为服务端把PDF渲染成图片再返回,前端永远拿不到原始PDF字节流。

第三层,内容检测上线。写了一个PDF对象扫描脚本,一旦发现/JavaScript、/OpenAction、/URI、/SubmitForm这类对象,文件进入待审队列,不允许直接发布。

第四层,后台解析容器网络隔离。文本抽取任务运行的容器改为白名单网络策略,只允许访问内部文本抽取API,禁止访问其他内网IP,彻底切断SSRF链路。

修复后重新走了一遍我前面提到的完整检测流程,四类样本全部被拦截,后台解析任务也不再产生任何外呼。整个复盘最有价值的一点是:攻击链从来不是单点问题,输入校验、存储策略、渲染环境、解析网络隔离各自独立看起来都有一定防御,但只有组合成完整链路时,才能真正把PDF上传漏洞的危害压到最低。

6. PDF上传漏洞常见问题速查

问题现场根本原因处理建议
改了Content-Type为application/pdf就能绕过服务端直接信任请求头必须增加魔数和完整结构校验,以服务端解析结果为准
文件头校验通过但访问后被解析为脚本只检查了前几个字节解析完整PDF结构,禁止上传目录执行脚本,文件名随机化
PDF打开后有弹窗或自动跳转内嵌JavaScript/Action未检测增加内容扫描,预览环境沙箱化
后台解析PDF后访问了内网地址解析器无网络隔离容器网络白名单,禁止解析服务任意外呼
文件名带../等路径字符文件名未重写直接拼接服务端生成随机文件名,存储路径不引入用户输入
自动化扫描器没报漏洞但实际可存储型XSS扫描器不解析PDF对象结构手工构造PDF样本,分析渲染链路
PDF预览组件版本老,存在已知解析问题依赖未升级锁定并更新渲染组件版本,启用CSP和sandbox

PDF上传漏洞最磨人的地方在于它不像SQL注入那样有一个明确的语法边界,而是把文件解析、浏览器渲染、服务端网络权限、业务逻辑串在一起。我在实际项目中最大的体会是:不要追求某一个单点防御做到完美,而是让每一层都有自己的判断标准,即使上一层被绕过,下一层依然能把危害限制住。如果你正在整改这类问题,建议从“用户永远不可信”出发,把上传文件当成一个需要隔离审查的外来对象来处理,很多疏漏在设计阶段就能避免。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询