
文件上传漏洞我断断续续跟了快十年回头看最容易被团队低估的类型PDF文件绝对算一个。原因很直观大部分系统都有附件、文档、简历这类功能开发对图片上传已经有了本能的警惕但对PDF往往觉得它就是个文档传上去能有什么风险实际上PDF文件上传漏洞能同时串联起存储型XSS、服务端请求伪造、解析器远程执行好几条攻击链而且因为PDF的格式足够复杂传统校验代码几乎拦不住。这篇把我平时排查这类问题时梳理的套路、踩过的坑和防御配置整理一下适合刚入门的Web安全测试同学也适合后端开发拿来对照自己的上传模块。1. 文件上传漏洞的“老问题”和PDF这个特殊载体1.1 文件上传漏洞为什么总能留在攻击面清单里上传功能几乎出现在每一类业务系统里头像、附件、简历、导入、文档管理、知识库只要是能让用户提交文件的地方就是一个暴露面。这么多年下来文件上传漏洞依然是各厂商测试报告里的常客根本原因是它同时挑战三层信任文件内容可以被用户完全控制文件名可以被用户完全控制存储和访问路径又常常由开发者拼接出来的。任何一个环节的校验缺失都可能把一个普通文件变成执行入口、钓鱼载体或者内网跳板。很多人有一个根深蒂固的错觉觉得只要限制后缀名就安全了。实际上现代Web架构下上传链路比过去长得多前端上传、网关转发、应用层校验、对象存储、CDN回源、后台异步解析。如果每一层都只做了“部分校验”反而更容易出现盲区。比如应用层只检查了MIME TypeCDN只检查了扩展名对象存储只看存储权限后台解析服务只认文件内容攻击者只要把几种“身份”控制在合法范围内就能穿透。我做过的项目里真正高危的绝大多数不是靠某个单独漏洞点而是靠链路里的“校验断层”。这也是为什么文件上传漏洞始终值得单独拉出来做专项而不是简单地在中间件或框架层加一条规则就完事。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与存储型XSSPDF规范里有一块专门的定义叫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形式被打开也拿不到主站的CookieXSS的杀伤力会大幅降低。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和sandboxPDF上传漏洞最磨人的地方在于它不像SQL注入那样有一个明确的语法边界而是把文件解析、浏览器渲染、服务端网络权限、业务逻辑串在一起。我在实际项目中最大的体会是不要追求某一个单点防御做到完美而是让每一层都有自己的判断标准即使上一层被绕过下一层依然能把危害限制住。如果你正在整改这类问题建议从“用户永远不可信”出发把上传文件当成一个需要隔离审查的外来对象来处理很多疏漏在设计阶段就能避免。