文件上传漏洞我断断续续跟了快十年,回头看最容易被团队低估的类型,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上传点的攻击目标不是单一化的,我这里整理了一张表,方便开发和安全同学对照排查:
| 攻击链目标 | 核心手法 | 最终影响 |
|---|---|---|
| 远程代码执行 | 后缀伪造、中间件解析错误、解析器漏洞 | 控制服务器,最严重 |
| 存储型XSS | PDF内嵌JavaScript、pdf.js渲染漏洞 | 窃取Cookie、劫持预览页面用户 |
| SSRF | PDF表单提交、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上传点做检测,不要一上来就传恶意文件,先老老实实走一遍正常流程。这步的目的是搞清楚整个上传链路的每一个环节和判断依据。
建议按下面的顺序操作:
- 用正常的PDF文件上传一次,打开抓包代理工具,记录请求里的所有字段,包括文件名参数、文件内容字段、Content-Type字段、大小限制。
- 上传完成后观察响应报文和页面行为,找到文件存储后的访问URL,分析URL结构是否有原始文件名或用户可控路径。
- 多上传几个不同文件名、不同内容的PDF,摸清文件命名规律、目录结构、后缀是否被重写。
- 检查下载和预览接口,确认响应头里的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注入那样有一个明确的语法边界,而是把文件解析、浏览器渲染、服务端网络权限、业务逻辑串在一起。我在实际项目中最大的体会是:不要追求某一个单点防御做到完美,而是让每一层都有自己的判断标准,即使上一层被绕过,下一层依然能把危害限制住。如果你正在整改这类问题,建议从“用户永远不可信”出发,把上传文件当成一个需要隔离审查的外来对象来处理,很多疏漏在设计阶段就能避免。