
你在一线开发或者做安全测试时大概率遇到过这种场景一个输入框没做校验一段看似无害的script标签贴上去页面就开始跑奇怪的东西。这就是 XSS 攻击全称 Cross-Site Scripting跨站脚本攻击。它不是 SQL 注入那种直接“掏数据库”的路子而是借浏览器之手把恶意脚本当成页面自己的代码来执行。能把这套东西彻底吃透的人写代码时对“用户输入”会多一分敬畏做测试时也能更快定位防线缺口。这篇内容我会从 XSS 的底层原理拆起把三种经典类型梳理清楚再给出可复现的入门级利用实战最后结合一个很实际的热搜场景——SpringBoot 全局过滤器处理上传 PDF 中的 XSS 问题——说说很多人容易走的弯路。适合刚接触安全的小白、需要补防线知识的后端开发同学以及想完善安全测试用例的测试工程师。1. XSS攻击到底在打什么一个被信任的脚本1.1 用大白话拆解XSS原理XSS 的本质是“代码注入”。攻击者输入的不是普通文本而是被浏览器当作 HTML 或 JavaScript 解析的片段。当服务端没有做区分、直接把这些内容拼进页面时浏览器就傻傻分不清哪些是开发者写的代码哪些是用户塞进来的脏数据结果一起执行了。给你一个生活类比你开了一家点心店店员会把顾客写在便利贴上的留言抄进订单标签。有一天有个人在“备注”里写的是“给我打包一份毒品”店员看不懂原样抄上去了。结果警察看到标签上的字样把店给查了。这里的关键不是店员想犯罪而是他完全没区分“正常备注”和“违法内容”没做审核就直接放行。放到 Web 场景里服务端就是那个店员浏览器就是警察用户输入就是便利贴。正常输入是“帮我多加辣”恶意输入是“script偷Cookie/script”。如果服务端不加处理直接把字符串拼进 HTML浏览器看到合法标记就会执行攻击即成立。1.2 核心链路里最容易被忽视的一环这条链路由三个关键节点构成注入点、拼接点、执行点。注入点往往是搜索框、留言板、URL 参数、文件上传元信息等任何接收用户数据的地方。拼接点则是服务端或前端把这些数据插入页面的那个动作。执行点就是浏览器的 HTML/JS 解析器。很多人只关注“注入点有没有过滤”却忽略了最重要的一个事实**危险不来自用户输入本身而来自代码把输入当成了代码字符串使用。**只要输出环节不做转义输入环节做得再好也可能被绕过反过来说只要输出环节对所有不可信数据做统一编码绝大多数 XSS 就根本不会存在。这就是为什么现代前端框架默认帮我们做转义SpringBoot 的模板引擎如 Thymeleaf 也有默认 HTML 转义但总有开发者因为贪方便、图省事用了v-html或th:utext亲手把防线打开。1.3 学习XSS的正当场景和边界学 XSS 不是让你去黑别人的网站。业内正公路径很清楚本地靶场DVWA、BWAPP、自己写的测试项目、公司授权的渗透测试项目。你学会了原理就知道怎么防也知道怎么在代码评审里一眼看出问题。2. XSS三大类型原理全拆解2.1 反射型XSS一次性传参一次性中招反射型 XSS 也叫非持久型 XSS。它的利用路径是一条“反射链”攻击者构造一个带恶意参数的 URL诱骗受害者点击请求到达服务端后服务端把参数值原封不动拼进响应页面返回浏览器渲染时执行恶意脚本。典型场景是搜索页。你搜索keywordabc页面回显“您搜索的关键字是abc”。如果服务端没转义那keywordscriptalert(1)/script就会原样回显并执行。由于这种攻击依赖 URL 传递黑客通常通过钓鱼邮件、短链接、社交消息把恶意 URL 发给目标用户中招者往往是点了链接才触发所以它很难大规模污染但钓鱼精准度更高。它最迷惑人的地方在于服务端日志里看到的只是一条“正常”的搜索记录攻击行为无法完全反映在服务器日志中受害者的浏览器才是真正的“案发现场”。很多只查服务端日志的师傅会漏掉这类攻击。2.2 存储型XSS一次存储持久生效存储型 XSS 从名字看就直白恶意脚本被持久化保存到了数据库或文件系统任何用户访问展示该数据的页面都会触发加载。评论区、个人资料、商品介绍、公告栏全是它的高发区。它的破坏力远大于反射型。因为不需要受害者点击特定链接只要打开正常页面就会“中奖”传播范围天然放大数倍。攻击者可以把脚本存进评论区然后静静等待所有用户来看——包括管理员。管理员一旦登录后台Cookie 被拿走会话被劫持整站权限可能就此沦陷。在实际测试中要注意存储型 XSS 的返回位置决定了 payload 的写法。如果注入点直接出现在script标签内你甚至不需要闭合标签直接写 JS 就行如果出现在 HTML 属性里比如input value...你可能需要先闭合引号再闭合标签构造出完整攻击链。2.3 DOM型XSS纯前端的地盘服务端管不着DOM 型 XSS 比前两类更容易被忽视因为数据从头到尾没有经过服务端。恶意输入通过 URL fragment、localStorage、postMessage等渠道进入页面前端 JavaScript 自己把它读出来再塞进 DOM 操作里。典型代码如下var name location.search.split(name)[1]; document.getElementById(user).innerHTML name;这段代码里没有服务端参与所以服务端加多少过滤器都没用。攻击者构造一个xxx.html?nameimg srcx onerroralert(1)只要受害者打开这个 HTML 页面脚本就会在innerHTML赋值时被解析执行。判断一个 XSS 是不是 DOM 型有个朴素的标准在浏览器 DevTools 中发送请求看网络面板里返回的 HTML 是否包含 payload。如果服务器返回内容里根本没有 payload页面却执行了脚本那基本就是 DOM 型。2.4 一张表分清三者的关键差异类型是否经服务端是否持久化触发条件典型场景反射型是否受害者访问恶意构造的URL搜索框、URL参数回显存储型是是任意用户访问包含恶意内容的页面评论区、个人资料、公告DOM型否看具体数据源受害者访问恶意URL且前端存在不安全DOM操作URL fragment、innerHTML赋值这张表是入门时最该花时间盯住的。实际面试和 CTF 里经常问“反射型和 DOM 型怎么区分”答案就是看服务端有没有参与解析和返回。3. 入门级利用实战在可控环境里完成一次完整闭环3.1 环境准备本地靶场是唯一推荐起点先强调一遍练习一定在你自己的环境里做。DVWADamn Vulnerable Web Application是最经典的选择集成了反射型、存储型等多个漏洞靶位。我一般是这么搭建的Docker 安装 DVWAdocker run --rm -it -p 8080:80 vulnerables/web-dvwa浏览器访问http://localhost:8080用默认账号admin / password登录去 Security Level 面板把级别调到low先理解最原始的状态再从medium、high逐步提升观察防护强度变化自建测试页也可以但 DVWA 的好处是把难度分好了档适合循序渐进入门。3.2 反射型利用实战记录进入 DVWA 的 Reflected XSS 页面搜索框里输入普通文本比如helloURL 会变成.../?namehello页面回显“Hello hello”。现在输入scriptalert(document.cookie)/script如果页面弹出 Cookie 信息说明反射型 XSS 存在且未被过滤。这就是一次最简单的“请求—拼接—执行”闭环。接下来模拟入门者常遇到的第一个坎系统会过滤script标签。比如 medium 级别会删除script但你换成img srcx onerroralert(document.cookie)又可以弹窗了。原因是很多初级过滤只过滤特定标签不过滤事件属性。这个换标签的思路就是 XSS 绕过的基础功。3.3 存储型利用实战记录切到 Stored XSS 页面这是一块留言板。直接在 Message 输入框里填scriptalert(document.cookie)/script提交后刷新页面每次访问都会弹出 Cookie。此时打开数据库或看页面源码都能找到这份恶意内容已经“住”进去了。这里有个值得记录的细节存储型 XSS 的 payload 需要贴合“存储—输出”路径。如果输出位置在 HTML 标签之间用script简单直接如果输出在属性里要提前闭合引号比如scriptalert(document.cookie)/script多实践几次你会发现“闭合上下文”比“背 payload”重要得多。3.4 DOM型利用实战记录DVWA 里的 DOM 型 XSS 是一个下拉选择框选项变化会动态生成option标签。按 F12 打开 DevTools看 Network 面板选中一个选项后你会发现页面并没有请求服务端纯粹是前端document.write或innerHTML在改 DOM。此时在 URL 末尾拼接.../dvwa/dashboard/?optionscriptalert(1)/script若弹窗即命中 DOM 型 XSS。这个过程中服务器返回的 HTML 一定不含 payload你可以在 Network 面板里亲眼确认——看到回包源码干净整洁但页面却有脚本执行那种“原来是前端干的”顿悟感是入门 DOM 型 XSS 最好的记忆点。3.5 入门利用必须守住的底线只在自己拥有或公司书面授权的系统里测试不要用真实 Cookie 或个人信息做 payload 验证alert(1)就够了发现漏洞后第一时间记录复现步骤修复后从 high 级别继续测不要把漏洞数据发布到公开社区4. 热搜场景复盘SpringBoot全局过滤器与上传PDF的XSS4.1 这个热搜问题到底在问什么最近总能看到有人搜“springboot项目全局过滤器处理上传pdf文件时xss攻击”“springboot项目xssfilter处理上传pdf的xss攻击处理”说明真实项目里确实有人遇到了一个具体场景SpringBoot 项目里已经写了全局 XSS 过滤器但一上传 PDF 文件就发现过滤逻辑好像在“捣乱”或者不清楚该不该对 PDF 内容做 XSS 处理。我先把结论扔出来全局过滤器处理上传 PDF 的 XSS是一个典型的“方向性误解”。它不是不能做而是绝大多数人选错了处理对象和处理层级。4.2 为什么“过滤PDF里的XSS”容易走偏要理解这个误区得先明白 XSS 的触发上下文。XSS 恶意脚本真正能执行是发生在浏览器解析 HTML/JavaScript 的时候。PDF 文件本身是二进制格式就算里面嵌入了script字符串浏览器打开 PDF 时也是按 PDF 规范解析不会把它当作网页脚本执行。真正危险的从来不是 PDF 二进制里的字符串而是文件名在页面上回显时未转义PDF 文件名被拼进下载链接时未编码PDF 内容被某个插件“解析成 HTML”时产生跨站上传接口返回 JSON 里包含文件名前端直接innerHTML渲染所以当有人想在全局过滤器里对 PDF 上传内容的字节做字符替换、剥离script时他大概率会得到两个结果一是 PDF 文件被改坏二是安全目标根本没达成。我用一个常见实现举例。很多人写过一个类似这样的过滤器Component public class XssFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; chain.doFilter(new XssHttpServletRequestWrapper(req), response); } }然后再写一个包装类public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } Override public String getParameter(String name) { String value super.getParameter(name); if (value null) { return null; } return cleanXss(value); } private String cleanXss(String value) { // 简单处理转义尖括号 return value.replaceAll(, lt;).replaceAll(, gt;); } }这段代码对普通表单参数和查询参数是有效的但对multipart/form-data上传的 PDF 文件内容毫无作用因为文件内容走的是getInputStream()或getPart()根本不会经过getParameter()。如果硬要在getInputStream()里读字节做清洗处理不当会让 MultipartFile 的字节流被消费掉后面file.transferTo()时拿到空数据上传直接失败。4.3 实际项目里应该怎么处理如果业务上确实有“禁止 PDF 中带敏感脚本字符串”的合规需求那也不该在 Web 层的全局过滤器里做。正确姿势是分层处理第一层在 Controller 层做文件名和元信息的校验与转义。文件名是用户可控数据最容易进入页面拼接点。保存文件时统一重命名比如用 UUID不让原始文件名进入存储路径。页面展示文件名时模板引擎默认转义即可若用前端拼接则先textContent再赋值。第二层在独立的上传校验服务里做文件内容扫描。可以用 Apache Tika 或 Apache PDFBox 读取 PDF 文本内容再对提取出的文本做敏感词或脚本特征检测。这一步是很典型的“内容安全扫描”和 XSS 过滤器不在同一层级。示例逻辑大致是这样Service public class PdfContentSecurityChecker { public boolean containsXssLikeContent(MultipartFile file) { try (InputStream in file.getInputStream()) { PDDocument document PDDocument.load(in); PDFTextStripper stripper new PDFTextStripper(); String text stripper.getText(document); document.close(); // 简单特征实际项目建议基于策略模式做更多检测 return text.contains(script) || text.contains(javascript:); } catch (IOException e) { throw new RuntimeException(PDF解析失败, e); } } }第三层在网关或过滤器层面设置安全响应头。这类问题不是要“清洗 PDF 内容”而是要通过 HTTP 响应头告诉浏览器别乱解析httpServletResponse.setHeader(X-Content-Type-Options, nosniff); httpServletResponse.setHeader(Content-Security-Policy, default-src self; script-src self);nosniff能阻止浏览器把上传文件强行解析成 HTMLContent-Security-Policy能限制脚本来源即使有 XSS 注入点攻击者想加载外部恶意脚本也会被拦下来。4.4 过滤器的取舍与配置要点如果你确实要用全局过滤器处理常规请求参数的 XSS有几个配置细节值得留意一是排除上传接口。通用过滤器最好把multipart请求排除掉或做特殊处理避免误伤文件流。可以用FilterRegistrationBean配置setUrlPatterns和addInitParameter(excludes, *.pdf, /upload/**)来实现更精确的拦截范围。二是转义要分场景。对于 JSON 请求体重写getInputStream()并做 JSON 序列化清洗是可行的但要注意 JSON 值里的引号、反斜杠、Unicode 转义不能被二次破坏。我见过好多项目因为全局过滤器把\转歪了导致接口数据解析直接崩掉。三是过滤器只做兜底。真正可靠的安全设计是“前端输入校验 后端服务端编码输出 数据库参数化查询 安全头加固”的纵深防御而不是把一个过滤器当成银弹。5. 常见问题与排查技巧实录5.1 我踩过或见过的高频问题问题一payload 在 DVWA low 级别能弹窗medium 级别就失效了。原因通常是系统过滤了script标签或者把某些字符做了替换。思路是先确认过滤规则再选对应绕过方式比如用事件属性img srcx onerroralert(1)或者大小写混写看是否过滤不严谨。medium 级别的过滤就是为了让你理解“黑名单思路”的弱点。问题二看着 payload 没错就是不执行。先看输出位置。如果输出在textarea或 HTML 注释里script可能被当作纯文本如果输出在 JavaScript 字符串里你要先闭合引号再闭合标签。用 DevTools 直接查看最终渲染的 DOM比盲猜效率高十倍。问题三SpringBoot 全局过滤器把上传的 PDF 文件弄坏了。这几乎是“过滤器读流”引发的问题。getInputStream()一旦被过滤器读取并消费后面的MultipartFile就拿不到完整数据。解决方案是不要在过滤器里消费上传文件流文件内容校验放到 Service 层做或者用ContentCachingRequestWrapper把流缓存下来再读。问题四内网系统部署了过滤器结果富文本编辑器瘫痪了。富文本内容本来就需要输出 HTML全局转义过滤器会把b变成lt;bgt;样式全部丢失。这种情况要在过滤器里加白名单比如只放行description字段或/editor/**路径富文本内容则单独做白名单标签清洗配合前端框架的sanitize函数。5.2 排查常见问题三步法第一步抓接口返回。在 DevTools 的 Network 面板里看服务端返回的 HTML 里是否包含 payload。如果不包含但页面还在弹窗那就是 DOM 型或前端逻辑在搞事。第二步抓 DOM 渲染。在 Elements 面板里找到 payload 实际落位的位置看清楚它是在标签之间、属性里还是脚本字符串里。第三步抓 Cookie 路径。确认 alert 弹的是document.cookie还是document.domain判断攻击影响范围是在当前站点还是跨域。这套三步法我用了很多年从质疑到定位问题基本不会超过五分钟。5.3 高频排查速查表现象可能原因排查方向反射型 payload 无效服务端转义了特殊字符看响应HTML里是否变成lt;存储型 payload 只对部分人触发数据经不同页面输出输出点处理不一致找到所有引用该字段的模板或API页面弹窗但服务端日志无异常DOM型XSS看前端JS是否有innerHTML、document.write上传PDF后文件损坏过滤器消费了InputStreamService层改用临时文件解析富文本样式丢失全局过滤器转义了HTML标签配置白名单路径或只对白名单字段生效6. 最后一层心得把XSS当系统问题来防我自己在项目里见过太多“头痛医头”的做法今天评论区被攻击就给评论区加过滤明天搜索框被绕过就给搜索框加黑名单。结果每次都是被攻击者推着走。真正让我觉得踏实的安全建设是把 XSS 当成“输出编码问题”来系统解决的——所有不可信数据进页面时强制编码所有富文本走白名单清洗所有响应头统一加固然后再用全局过滤器做兜底而不是反过来。对新手来说学 XSS 最难的不是背 payload而是建立“上下文敏感”的意识。你写出的每一段代码都要问自己这里输出的数据是用户可控的吗如果我把它改成img srcx onerror...会发生什么有这种思维习惯之后反射型、存储型、DOM 型的区分自然就刻在脑子里了。最后再分享一个小技巧平时可以给浏览器装一个 XSS 测试专用插件配合本地靶场把常见的 payload 挨个试几遍比看十篇理论文章都管用。