FlawOpen

flawopen.com/xss/Java

● CWE-79 · 高危
安全研究 · FlawOpen

Java XSS:保护 Spring MVC 与 Thymeleaf 输出

th:utext 和未转义的 JSP 输出可能执行注入的 JavaScript。按输出上下文编码,并用允许列表清理富文本(CWE-79)。

💡 通俗易懂的原理解析 (ELI5)

体育场记分牌有普通姓名字段,也有单独的富文本显示模式。Thymeleaf 的 th:text 会把提交的姓名当作普通文本;th:utext 则要求渲染器把该值当作记分牌的 HTML 指令。浏览器收到体育场网站的 text/html 后,会以该网站的权限渲染标记。用户文本应使用转义输出;确实需要富文本时,先清理内容再放入不转义的模板槽位。

核心概念与专有名词

Thymeleaf th:text vs th:utext
th:text 会对值做 HTML 转义;th:utext(未转义文本)则把它作为标记插入。在 Thymeleaf 的内联写法中,[[${x}]] 会转义,[(${x})] 不会。绑定到用户输入的 th:utext 就是 XSS,除非该值事先经过净化。
JSP ${} vs <c:out>
在 JSP 模板文本中,${param.q} 会不经转义直接输出。<c:out value="${param.q}"/> 或 ${fn:escapeXml(param.q)} 会转义它。<%= request.getParameter("q") %> 这类脚本片段从不转义。
Encode.forHtml / forJavaScript
OWASP Java Encoder 为每种输出上下文提供一个方法:Encode.forHtml 用于元素内容,forHtmlAttribute 用于带引号的属性,forJavaScript 用于 JavaScript 字符串字面量,forUriComponent 用于 URL 参数。放进脚本里的值只做 HTML 编码是不够的。
OWASP Java HTML Sanitizer
对于用户提供的 HTML,Sanitizers.FORMATTING.and(Sanitizers.LINKS) 这样的策略会维护标签和属性白名单,删除脚本、事件处理器和 javascript: URL。编码会把标签显示成文本;净化则让安全的标签继续生效。
@ResponseBody returning text/html
@RestController 中声明 produces = text/html 并返回 String 的方法,其返回值会原样写入响应:不会运行任何模板引擎,所以什么都不会被转义。应优先使用视图模板,或对每个拼接进去的值做编码。

框架映射

攻击执行流程分解

步骤 1

输入随请求参数到达

Spring 把查询字符串或表单中的 q、user 或 bio 绑定到 String 参数。攻击者可以给受害者发送精心构造的链接,或者提交的 bio 会展示给其他用户。

步骤 2

字符串拼接构造响应

控制器把值拼进 HTML 或 <script> 块。使用无害的测试值 <img src=x onerror=alert(1)> 时,返回的字符串中包含一个真实的标签;使用 ';alert(1);// 时,脚本块多出一条新语句。

步骤 3

浏览器解析它

由于方法声明了 text/html,浏览器把响应体当作页面渲染。图片加载失败并触发 onerror,或者脚本块执行被注入的语句。

步骤 4

脚本以网站的身份运行

代码以应用的源(origin)执行:它可以读取 DOM,带着受害者的会话 cookie 调用应用的接口,并篡改用户看到的内容。

源代码对比:漏洞与安全实现

存在漏洞的实现
// ProfileController.java:每个接口都把请求数据拼进 HTML
@RestController
public class ProfileController {

    // HTML 上下文:查询值被当作标记插入
    @GetMapping(value = "/search", produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam String q) {
        return "<h1>Results for " + q + "</h1>";
    }

    // JavaScript 上下文:user 中的引号会结束字符串字面量
    @GetMapping(value = "/widget", produces = MediaType.TEXT_HTML_VALUE)
    public String widget(@RequestParam String user) {
        return "<script>const user = '" + user + "';</script>";
    }

    // 富文本连同所有标签和事件处理器原样返回
    @PostMapping(value = "/preview", produces = MediaType.TEXT_HTML_VALUE)
    public String preview(@RequestParam String bio) {
        return "<div class=\"bio\">" + bio + "</div>";
    }
}
加固后的安全修复
// ProfileController.java:每个值都按其上下文编码或经过净化
@RestController
public class ProfileController {

    private static final PolicyFactory BIO_POLICY = Sanitizers.FORMATTING.and(Sanitizers.LINKS);

    // forHtml 为元素内容编码 < > & " '
    @GetMapping(value = "/search", produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam String q) {
        return "<h1>Results for " + Encode.forHtml(q) + "</h1>";
    }

    // forJavaScript 转义引号、反斜杠和 </script> 序列
    @GetMapping(value = "/widget", produces = MediaType.TEXT_HTML_VALUE)
    public String widget(@RequestParam String user) {
        return "<script>const user = '" + Encode.forJavaScript(user) + "';</script>";
    }

    // 净化器保留格式和链接,其余一律丢弃
    @PostMapping(value = "/preview", produces = MediaType.TEXT_HTML_VALUE)
    public String preview(@RequestParam String bio) {
        return "<div class=\"bio\">" + BIO_POLICY.sanitize(bio) + "</div>";
    }
}

工程与系统安全加固清单

参考来源