PDF-XSS这种攻击方式,我在做安全巡检时第一次真正意识到它的杀伤力。当时客户有个在线预览合同的功能,看起来普普通通:用户上传一份PDF,其他人点开链接就能在浏览器里直接看。我顺手用脚本改了一个PDF,往里面塞了一段打开时自动执行的JavaScript,然后模拟攻击者上传、发送预览链接。结果那段脚本真的在浏览器里跑了,弹出了当前域下的Cookie。那一刻我后背是凉的——文件预览这个天天见的功能,居然变成了一个所有人都能稳定触发的存储型XSS入口。
这篇文章不是教科书式的科普,而是从那次经历出发,把PDF-XSS的原理、攻击面、防御链路、踩坑过程完整拆开来讲。无论你是写Web后端的开发、专职的安全工程师,还是负责上线前检查的运维,都能在里面找到可以直接拿去用的方案。
1. PDF-XSS到底是什么:一条文件预览链路引爆的攻击
很多人听到PDF-XSS的第一反应是:PDF不是文档吗?文档里也能执行脚本?能,而且这件事比想象中普遍。PDF格式本身定义了一套对象结构,其中就包括JavaScript动作对象。这类对象可以被挂在文档打开动作、页面切换、表单字段等事件上,本意是让PDF阅读器支持动态交互,比如表单校验、按钮事件。设计这类功能的年代,大家都默认"能打开PDF的自然都是正经阅读器",结果这个信任假设被带进了浏览器。
1.1 你以为只是打开一个PDF,其实浏览器在替你执行脚本
现代浏览器为了提升体验,都内置了PDF查看器。用户点开一个PDF链接,浏览器不会把它下载下来,而是直接渲染在标签页里。渲染过程会解析PDF的目录对象、页面对象、动作对象,其中就包含JavaScript。这里的关键区别在于:浏览器内置查看器不是独立的桌面软件,它运行在网页渲染进程里,和当前页面共享一套浏览器环境。
攻击者要做的就是构造一份恶意PDF,让其中的JavaScript在打开时自动执行。PDF里对应的结构大致是这样的:文档目录对象通过/OpenAction指向一个动作对象,动作对象的类型是/JavaScript,里面放着要执行的代码,比如读取document.cookie、向外部服务器发送请求、篡改当前页面DOM。用户打开PDF,浏览器解析到/OpenAction,触发JS,攻击完成。
我在测试时用了一段很简单的脚本,效果只是弹出一个包含当前页面Cookie的对话框。但在真实攻击里,这段代码完全可以被替换成任何东西:静默发起请求把Cookie发给攻击者服务器、修改登录表单、在用户无感知的情况下替用户执行操作。PDF只是载体,脚本才是真正的子弹。
1.2 攻击链路拆解:从一份构造好的PDF到Cookie失守
完整链路可以拆成五步。第一步,攻击者构造恶意PDF文件,内嵌JavaScript动作对象。第二步,通过目标系统的文件上传接口上传这份PDF,比如上传到"合同附件""简历附件""产品图册"这类功能点。第三步,系统把这份PDF存储在服务器,并生成访问链接。第四步,受害者打开链接,浏览器把服务器返回的PDF渲染出来,内嵌脚本被执行。第五步,脚本把受害者的会话凭证、页面内容或伪造页面发送到攻击者控制的服务器。
这里面最核心的一点是:PDF文件是从受害者的浏览器视角来看,和网站同源的。因为链接就是站点本身的地址,浏览器把响应当作站点内容来渲染,脚本运行时的上下文自然也是站点域。这等于攻击者绕过了同源策略的边界拿到了站点的信任凭证。整个过程中,攻击者不需要找到传统的SQL注入或反射型XSS,一个上传接口加一个PDF就足够了。
1.3 为什么它不是普通XSS:攻击面藏在文件格式里
传统XSS的精髓是"让服务器输出带脚本的内容",PDF-XSS的精髓则是"让浏览器解析带脚本的文件"。两者的区别在于:Web开发人员会下意识地给用户输入做过滤,但对上传的文件往往只检查大小、后缀名、MIME类型。PDF结构复杂,从对象表、交叉引用表到流对象、过滤器,层层嵌套,开发人员很难像过滤HTML那样过滤PDF,所以这类文件几乎是以"原样信任"的姿态进入系统并展示给用户的。
更麻烦的是,PDF的"可执行面"比HTML更隐蔽。一个HTML里的Script标签一眼能看出来,PDF里的JS对象却是藏在二进制流里的,可能经过压缩编码,还能拆成多个片段。你没法靠正则或者关键词扫描彻底找到所有潜在的脚本对象。这决定了PDF-XSS不可能靠简单的输入过滤解决,必须在架构层面把这类文件隔离在可信域之外。
2. 现代Web应用为何屡屡栽在这里
这几年文件预览功能几乎成了Web应用的标配。企业合同平台、简历系统、发票查验、在线网盘、协同办公,全都提供了"点击附件在线查看"的体验。功能越普及,攻击面就越大。PDF-XSS不是某个特定产品的漏洞,而是整个"浏览器直接渲染不可信文件"这个模式的风险体现。
2.1 在线预览功能普及,攻击者也跟着找到了新入口
我见过太多的系统,文件预览功能就是把文件存到一个目录,然后用一个通用下载接口把文件流直接吐给浏览器。接口长得很简单:download?file=xxxx.pdf,读到文件后设置Content-Type: application/pdf,然后把字节流写回。浏览器一看是PDF,立刻用内置查看器打开。
问题恰恰出在这个"省事"上。接口完全信任存储目录里的文件,根本不区分这个文件是管理员之前上传的正式文档,还是最近某个注册用户传的恶意样本。只要任意用户能上传文件,任何他能上传的恶意PDF就都有了被其他用户打开的机会。有些系统甚至把共享目录直接映射成静态资源路径,/uploads/2025/xx.pdf这样裸奔,连接口层都不经过。攻击者只需要把文件名构造好,发给目标受害人,剩下的交给浏览器。
2.2 同源策略的信任边界,被文件类型混淆直接捅破
同源策略设计了这么多年的边界,本意是"不同源的页面不能互相访问数据"。但PDF-XSS里有意思的地方在于:攻击者拿到的脚本执行上下文,恰恰是当前网站自身的源。因为浏览器判断一个资源的归属,不是看文件里装了什么,而是看响应头、URL、加载方式。当服务器把一个PDF以application/pdf直接从站点域返回给浏览器,浏览器就会把这个PDF渲染在当前站点上下文中。
如果服务器配置不当,情况会更糟。有的系统没有设置X-Content-Type-Options: nosniff,浏览器拿到文件后还会做内容嗅探。一个内容为HTML但后缀为PDF的文件,服务器用application/pdf返回,浏览器嗅探发现内容更像HTML,就会直接按HTML解析。这也是一种"文件类型混淆":无论后缀、MIME怎么写,浏览器最终按内容说了算。一旦按HTML解析,脚本标签就光明正大地执行了。
2.3 三类高危场景:上传预览、附件下载、第三方预览组件
我梳理过手头遇到的案例,高危场景基本聚集在三个方面。
第一类是上传后即时预览。用户传完文件,系统立刻在当前页预览。攻击者上传恶意PDF后,他自己先打开预览,脚本在他的会话里执行,影响有限。但这类系统的预览链接通常可分享,一旦通过站内信、评论、群聊发给其他用户,存储型XSS的传播链条就成立了。
第二类是附件下载与阅读。系统提供附件下载,但没有设置Content-Disposition: attachment,浏览器收到PDF后直接打开而不是下载。攻击者只要让受害者访问下载链接,恶意PDF就在浏览器内被执行。这里有个细节:很多时候受害者以为只是"看一眼文档",根本不会意识到自己执行了脚本。
第三类是集成第三方预览组件。很多系统会用开源的PDF渲染JS组件,把预览功能做进页面里。组件本身需要加载PDF文件,传文件地址给组件解析。如果组件的解析器存在漏洞,或者对PDF内嵌脚本处理不当,风险会直接暴露在业务页面里。更常见的是组件版本老旧,历史漏洞长期未修复。
3. 第一步防御:先把HTTP响应头这堵墙砌好
别一上来就设计复杂的沙箱架构。很多PDF-XSS问题,改几个响应头就能挡掉大半。HTTP响应头是浏览器安全机制的第一道开关,配置正确时,浏览器会主动拒绝执行危险行为;配置缺失时,浏览器就会往"自以为是"的方向跑。
3.1 强制下载与强类型声明:Content-Disposition和Content-Type
处理用户上传文件的下载与预览接口,响应头里有两件事必须做死。第一,Content-Disposition设置成attachment,强制浏览器下载文件而不是打开预览。第二,Content-Type使用文件的实际类型白名单,而不是用户上传时声明的类型。
我改造的接口里是这么写的:
Content-Type: application/pdf Content-Disposition: attachment; filename="document-2025.pdf"filename里不要用用户上传时的原始文件名,要由系统重新生成。原始文件名经常是一堆路径拼接、注入payload、特殊字符,如果直接拼进响应头,轻则响应头错乱,重则引入响应头注入问题。还有一点:filename字段在浏览器里会做URL解码,如果文件名里有非ASCII字符,要用filename*=UTF-8''格式,否则会乱码,也容易在旧内核浏览器里被利用。
实际操作中,我会把所有需要给用户展示的文件预览,统一走一个单独的接口,在这个接口里强制设置Content-Disposition和Content-Type。内部文件存储路径、真实文件名、文件大小等元数据全部从数据库读取,接口只做两件事:读文件字节流、写响应头,不对外暴露任何存储细节。这样即使攻击者通过目录遍历拿到了服务器上的文件路径,也没法通过这个接口改变响应头。
3.2 X-Content-Type-Options: nosniff,防止浏览器自作聪明
X-Content-Type-Options: nosniff这个头只有一个值nosniff,作用就是告诉浏览器:别做内容嗅探,我说这个文件是什么类型,你就按什么类型解析。少了这个头,浏览器会根据文件内容的特征猜测类型,一个被改成.pdf后缀的HTML文件,服务器用application/pdf返回,浏览器嗅探出HTML特征后就会按HTML执行,脚本标签照样跑。
在文件下载和预览接口上,这个头必须和Content-Type配合使用。只设nosniff不用白名单类型也一样有风险——那就是浏览器确实不嗅探了,但服务器自己把不确定的Content-Type传给了浏览器。所以这两个头是成对出现的:Content-Type声明强类型,nosniff阻止浏览器推翻声明。
对全站来说,我建议在统一网关层给所有响应默认加上X-Content-Type-Options: nosniff,再针对特殊接口做例外。因为内容嗅探不止影响文件上传场景,还包括图片、字体、样式表、Web Worker等资源的加载。全局加上这个头,配合合适的MIME,能消掉很大一类"浏览器按非预期方式解析资源"的问题。
3.3 CSP的正确姿势:object-src、frame-ancestors、script-src怎么配
Content-Security-Policy在文件预览场景里经常被忽略。很多人以为CSP是防护页面里注入脚本的,和PDF预览无关。实际上,如果你用iframe或者embed标签内嵌PDF文件,CSP能限制这类内容的加载。
如果你在页面里内嵌PDF查看器,哪怕用的是第三方组件,CSP也要针对至少三个指令做配置。object-src控制object、embed、applet这些标签加载内容,设为'none'直接禁止加载任何插件类型。frame-ancestors控制页面能不能被其他页面用iframe嵌套,你自己的预览容器需要单独用sandbox隔离,业务页面则不允许被子页面嵌套。script-src限制脚本来源,这一条针对的是查看器组件自身加载的JS,不要允许unsafe-inline,不要允许unsafe-eval,脚本全部从你自己控制的域名加载。
还有一个经常被忽略的细节:CSP不能直接限制浏览器内置PDF查看器运行PDF里的JavaScript。因为内置查看器本身不是页面里的脚本,它由浏览器原生实现,CSP管不到原生代码。这说明了CSP只是纵深防御里的一层,它能管住页面级加载和第三方组件的脚本,但管不住解析器自身的漏洞,所以后面说的隔离手段才更重要。
4. 预览链路改造:从上传到展示的每一层都按不可信处理
只加响应头远远不够。要根治PDF-XSS,得把整条预览链路里所有的文件都当作不可信对象来处理。这里我遵循一个原则:处理用户文件时,默认它已经沦陷、已经被恶意代码污染。所有校验、存储、展示环节都按这个前提设计。
4.1 上传校验不要只信MIME:魔数、文件结构、重编码
常规上传组件会检查文件后缀和MIME类型,这两项都能被攻击者轻松伪造。浏览器上传时给的MIME是客户端决定的,curl -F "file=@evil.pdf;type=application/pdf"就能让服务器以为收到了一个合法的PDF。所以服务端必须做独立校验。
我建议的校验分三步。第一步检查文件头的魔数,PDF文件开头的特征通常是%PDF-,直接读取前四个字节匹配就能挡掉一部分伪装文件。但魔数能伪造,攻击者完全可以把HTML内容前面拼上%PDF-,所以第二步要用PDF解析库真实解析文件结构:检查文件头、对象表、交叉引用表、页面根对象是否完整可达。解析不出来的,直接拒绝存储。第三步是重编码,或者叫重新序列化:用解析库读入PDF、按白名单过滤掉含JavaScript类型的对象(/S /JavaScript)、去掉/OpenAction、/AA(附加动作)、/Launch(启动外部程序)这类动作,再写出新的PDF。
重编码这一步执行起来要小心。PDF格式极其复杂,重新写出的文件可能会丢字体、丢注释、出现乱版。所以在真实场景里,我会把"重编码"作为可选项,配合业务方评估后再启用。对安全性要求高、用户量大的系统,强烈建议启用;对个人小工具类场景,至少保证前两步做了。实际操作中还有一个折中方案:保留原PDF用于下载,预览时生成一个剔除动作对象的新版PDF。这样即使原文件带着恶意脚本,用户在浏览器打开的也只是清洗后的副本。
4.2 隔离域名与沙箱iframe:让XSS成功也拿不到Cookie
校验永远有绕过的可能,所以架构上要做最坏的假设:恶意PDF还是会走到预览环节,脚本还是会执行。这时候能保护用户的只有隔离。
隔离的第一层是独立域名。所有用户文件预览服务部署在一个单独域名上,比如static.example-cdn.com,业务主站是www.example.com。两个域名源不同,即使PDF内脚本能在static.example-cdn.com域下执行,它也读不到www.example.com域下的Cookie。要让这个方案生效,业务主站给Cookie设置Domain时只能设成www.example.com,不能设成example.com,同时给Cookie加SameSite属性。这一层的核心思路是:把渲染不可信文件的上下文,从主站可信上下文中剥离出去。
隔离的第二层是iframe的sandbox属性。如果预览组件不得不在业务页面里内嵌渲染,就在iframe里只许保留最少的权限:sandbox="allow-scripts",不加allow-same-origin。allow-scripts允许脚本执行以维持视图组件功能,但缺少allow-same-origin会让iframe内的内容永远处于一个独立的、没有Cookie和存储权限的源里。这样一来,就算脚本执行成功,它拿不到业务域的身份凭证,最多制造点视觉混乱,造成不了实质危害。
4.3 更稳妥的方案:无头浏览器截图预览,彻底不给脚本机会
如果业务上允许,我推荐升级到"无头浏览器截图预览"方案。流程是这样的:上传的PDF经过校验后,由服务端的无头浏览器打开并渲染成图片,预览接口直接返回图片给用户。浏览器端永远不接触原始PDF文件,用户看到的只是渲染后的静态图片,PDF里的任何脚本都没有机会在用户的浏览器环境里执行。
这个方案最大的优势是彻底隔绝了"文件渲染"和"用户浏览器"之间的距离。攻击者精心构造的PDF即使能在无头浏览器里执行脚本,脚本运行的环境也是服务端容器化、无用户数据、网络受限的沙箱环境。用户收到的只是图片字节流,和PDF-XSS没有关系了。
代价也很明确:第一,无头浏览器渲染PDF经常遇到字体缺失、排版错位的问题,需要准备常用字体库并定期校准渲染质量;第二,渲染操作对服务器CPU有消耗,文件多时需要队列和缓存,不能每个请求都现渲染一遍,图片要缓存起来;第三,无头浏览器本身的沙箱配置要做加固,不能让它访问内网任意服务。不过这些代价跟一次可能造成用户会话劫持的存储型XSS比起来,完全值得。
5. 浏览器内置PDF阅读器:剩余风险与查看器加固
有些场景下,你没法控制用户用什么浏览器打开PDF。用户手机自带的浏览器、企业办公电脑预装的浏览器、第三方App的内嵌浏览器,各有各的PDF渲染实现。Web应用只能管好自己的服务器响应,但这些客户端侧的PDF解析器本身也在持续挖掘PDF的攻击面。
5.1 内置阅读器如何执行PDF中的JavaScript
不同浏览器的内置PDF阅读器实现方式不一样。一类是使用本地解析组件,渲染发生在浏览器独立的进程里;另一类是纯JavaScript实现,整个解析和渲染跑在WebWorker或沙箱里。不管哪种实现,它们都保留了PDF格式里的JavaScript动作支持,因为表单填写、页面跳转这类交互功能需要脚本完成。
对攻击者来说,直接让PDF里的JS在页面域执行,在现代浏览器里已经不是那么简单的事了。渲染进程和页面进程有隔离,PDF的JS上下文和网站DOM上下文通常不在一个世界里。所以现在的PDF-XSS攻击更多是两步走的:第一步让PDF解析器执行JS,第二步利用解析器漏洞或者配置缺陷,把脚本上下文从PDF渲染环境"逃逸"到网页环境,这一步成功后才能访问Cookie、操作页面。
因此,PDF-XSS的利用深度,往往取决于PDF解析器的漏洞利用难度。有的漏洞是解析畸形对象时的内存破坏,有的漏洞是JavaScript对象在跨上下文传递时的类型混淆,还有的是渲染线程和页面主线程之间通信协议的逻辑缺陷。这些已经不是普通Web开发人员能短时间修复的问题,它们需要靠浏览器的版本更新来修补。
5.2 看过的一些漏洞类型:沙箱逃逸、越界读写、UXSS
我研究过一批公开的浏览器PDF解析漏洞,大致可以归为几类。第一类是解析PDF对象数组时的越界读取,攻击者构造一个长度与实际数据不符的对象,触发解析器读超出边界的数据,这些数据可能被泄露出来,形成信息泄露。第二类是释放后使用类漏洞,PDF流对象被关闭后仍然有指针引用它,配合堆布局能稳定控制程序流程。第三类是通用XSS类型的漏洞,PDF解析器在某个钩子点没有正确校验上下文,导致脚本可以访问到页面级DOM,这类漏洞会直接影响同域的网页内容。
这些漏洞的利用过程基本都要依赖精心构造的PDF结构。一个恶意PDF里可能包含几十个对象,每个对象都带有异常的字段长度、看似合法的内部引用,一层层试探解析器的边界。安全团队的检测手段,一是关注浏览器厂商发布的安全公告,二是定期抽查线上用户上传的PDF文件,用解析器做异常检测,三是给高风险用户群体设置更高的浏览器版本基线,强制升级。
5.3 用户端的缓解手段:关闭内置阅读器、及时升级、策略管控
在客户端侧,我能给到的实用建议是这几条:对办公用户,通过终端管理策略把浏览器内置PDF阅读器关闭,改为调用独立的PDF阅读器程序打开。独立阅读器同样会执行PDF内脚本,但它的运行环境更简单,攻击面相对可控。对企业环境,统一管理浏览器版本更新,不要允许用户长期停留在有已知PDF漏洞的旧版本上。对普通用户,至少做到两点:不明来源的PDF下载后用扫描工具检测再打开,浏览器保持自动更新。
内置阅读器完全关掉的代价是体验退化,用户从点击直接看到变成了"下载、打开、等待"。我一般建议优先考虑隔离渲染方案,让内置阅读器渲染的PDF永远来自隔离域,这样即使解析器出了问题,攻击者也拿不到主站的敏感数据。配合CSP和响应头,层层设防。
6. 排查实录与加固清单
最后把实战验证和排查过程整理成清单,方便你直接拿去对照使用。
6.1 一次PDF-XSS问题的完整排查过程
那次发现问题后,我按这个顺序排查。第一步是复现:用工具生成一个最简单的恶意PDF,只含一条app.alert(document.cookie)的脚本,上传到目标系统,打开生成的预览链接。浏览器弹出了Cookie,说明问题存在,而且不是理论风险。
第二步是定位触发点。我用浏览器开发者工具查看预览接口的响应头,发现既没有Content-Disposition: attachment,也没有X-Content-Type-Options: nosniff,服务器只设置了一个Content-Type: application/pdf。这个配置让浏览器直接渲染PDF,并且不做二次校验。
第三步是判断影响范围。继续看Cookie的Domain和Path设置,发现会话Cookie在主站域下,也没有设置SameSite。这意味着一旦PDF内脚本成功执行,它能直接读取并外带当前用户的会话标识。这属于高危中的高危。
第四步是加固。我先给预览接口加上了三组响应头,容器层全局加了X-Content-Type-Options: nosniff,预览域单独拆出来,业务Cookie设置了SameSite=Lax。之后再复现,PDF依然能打开,但脚本执行时拿不到业务域Cookie。恶意脚本变成了一个只能弹窗的"哑弹",风险从高危降到了低危。
6.2 问题速查表:遇到这几种现象,往哪里查
| 异常现象 | 可能原因 | 排查方向 | 修复建议 |
|---|---|---|---|
| 预览PDF时页面弹出Script对话框 | 预览接口缺安全响应头,PDF内脚本被执行 | 检查预览接口的Content-Disposition、Content-Type | 强制attachment下载或隔离域渲染 |
| 上传的PDF文件下载后浏览器直接打开 | 下载接口缺少attachment标记 | 检查Content-Disposition头 | 改为attachment |
| HTML内容伪装成PDF后按HTML执行 | 服务器未启用nosniff,浏览器内容嗅探 | 检查响应头是否含X-Content-Type-Options | 全局加nosniff,并校验真实文件类型 |
| 第三方PDF预览组件加载恶意PDF | 校验不严、组件版本过旧 | 查看组件渲染时是否直接传文件给浏览器解析 | 升级组件,改用服务端截图预览 |
| 内嵌预览iframe里脚本能拿到Cookie | iframe未加sandbox,或加了allow-same-origin | 检查iframe的sandbox属性 | 去掉allow-same-origin,或改用独立域名 |
6.3 可以直接抄作业的加固Checklist
每次项目上线前,我都按这份清单过一遍文件上传和预览相关功能:
- 上传校验:服务端重新解析文件结构,不信任客户端MIME和后缀名;启用魔数校验。
- 存储方式:文件路径生成由系统控制,存储目录禁止直接对外暴露;文件名使用随机字符串。
- 下载接口:强制
Content-Disposition: attachment;Content-Type白名单化。 - 响应头:全局启用
X-Content-Type-Options: nosniff;适当配置CSP。 - Cookie属性:业务Cookie设置
SameSite,主站域名隔离,敏感Cookie加HttpOnly。 - 预览方案:优先独立域名,其次iframe沙箱,再次无头浏览器截图。
- 组件管理:统计第三方PDF查看组件清单,建立版本更新机制。
- 监控告警:对上传PDF做抽样检测,监控异常的上传频率、文件大小、文件名特征。
我在实际处理这类问题时的体会是:PDF-XSS的根本解法,不是去"识别每一个恶意PDF",而是改变游戏的规则——让恶意PDF里的脚本在需要时"有劲使不出"。校验和清洗是第一道滤网,隔离域和响应头是第二道屏障,客户端的读取策略和运维监控是最后一道兜底。按这个顺序一层层铺上去,即使哪一层被击穿了,后面也还有东西接着。安全这件事从来不是靠某个单独的技术点赢下来的,而是靠整个链路把风险压缩到可控范围。