跳转至

XSS 测试与修复:按输出上下文逐层拆解

XSS 修不好,通常不是“过滤不够狠”,而是编码用错了上下文。同一个 < 在 HTML 文本、属性、JS 字符串里需要不同处理。

1. 先判断输出位置

输入 xss123,看它出现在响应哪里:

<!-- A. HTML 文本 -->
<div>hello xss123</div>

<!-- B. 属性值 -->
<input value="xss123">

<!-- C. 事件/JS 字符串 -->
<script>var name = 'xss123';</script>

<!-- D. URL -->
<a href="/search?q=xss123">...</a>

四种位置的 payload 完全不同。用 <script>alert(1)</script> 通测,会漏掉 B/C/D 三类。

2. 各上下文的可用 payload

A. HTML 文本

<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<details open ontoggle=alert(1)>

B. 属性值内(双引号包裹)

" onmouseover="alert(1)
"><img src=x onerror=alert(1)>
" autofocus onfocus="alert(1)

单引号包裹时换成 ' onmouseover='alert(1)。

C. JS 字符串内

';alert(1);//
'-alert(1)-'
</script><script>alert(1)</script>

如果字符串进入 eval 或 setTimeout,甚至不需要闭合。

D. URL / href

javascript:alert(1)
javascript:alert(document.domain)
data:text/html,<script>alert(1)</script>

浏览器对 javascript: 的解析会忽略空白和控制字符:

java\tscript:alert(1)
java\nscript:alert(1)
jav&#x09;ascript:alert(1)

3. DOM 型 XSS:只看源码会漏

危险 source → sink 组合:

// source
location.hash
location.search
document.referrer
window.name

// sink
element.innerHTML = userInput
document.write(userInput)
eval(userInput)
new Function(userInput)
setTimeout("doSomething(" + userInput + ")", 0)
element.setAttribute("href", userInput)

验证步骤:

  1. 在 DevTools 里给 source 打断点(如 location.hash)
  2. 传 #<img src=x onerror=alert(1)>
  3. 观察是否流入 innerHTML
  4. 如果被 encodeURIComponent 处理过,再试二次解码路径

用 Playwright 自动化检测弹窗:

from playwright.sync_api import sync_playwright

payloads = [
    "<img src=x onerror=alert(1)>",
    "\" onmouseover=\"alert(1)",
    "javascript:alert(1)",
]

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    dialogs = []
    page.on("dialog", lambda d: (dialogs.append(d.message), d.dismiss()))

    for pl in payloads:
        page.goto(f"https://target/search?q={pl}")
        page.wait_for_timeout(800)

    print("executed:", dialogs)
    browser.close()

4. 存储型 XSS 的价值点

这些字段最容易被忽略:

位置 为什么危险
昵称、签名 在个人主页、评论区、排行榜多处渲染
收货地址 后台订单详情渲染
工单/客服消息 客服后台、导出 Excel
上传文件名 文件列表直接显示,可能未编码
SVG 上传 SVG 内可执行脚本
富文本 白名单不严时直接落库

存储型要验证跨角色触发:用户提交,管理员后台打开时执行——这类漏洞危害远高于反射型。

5. 修复:编码 + 白名单 + CSP

5.1 按上下文编码(Java)

import org.owasp.encoder.Encode;

// HTML 文本
String safe = Encode.forHtml(userInput);

// HTML 属性
String attr = Encode.forHtmlAttribute(userInput);

// JS 字符串
String js = Encode.forJavaScript(userInput);

// URL 参数
String url = Encode.forUriComponent(userInput);

Spring 项目里,Thymeleaf 默认对 th:text 转义;危险的是 th:utext(不转义)。JSP 里用 <c:out> 而不是 <%= %>。

5.2 富文本白名单(DOMPurify)

import DOMPurify from "dompurify";

const clean = DOMPurify.sanitize(dirtyHtml, {
  ALLOWED_TAGS: ["p", "br", "strong", "em", "ul", "ol", "li", "a", "img"],
  ALLOWED_ATTR: ["href", "src", "alt", "title"],
  ALLOW_DATA_ATTR: false,
  FORBID_TAGS: ["style", "iframe", "form"],
  FORBID_ATTR: ["onerror", "onload", "onclick"],
});

document.getElementById("content").innerHTML = clean;

服务端也要做同样的白名单(如 OWASP Java HTML Sanitizer),不能只依赖前端。

5.3 CSP:兜底而不是唯一手段

add_header Content-Security-Policy "
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM}';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'self';
" always;

注意:

  • script-src 'unsafe-inline' 会让 CSP 对 XSS 基本失效
  • nonce 必须每次响应随机,不能复用
  • 先以 Content-Security-Policy-Report-Only 上线观察,避免直接打断业务
Set-Cookie: SESSION=...; HttpOnly; Secure; SameSite=Lax

HttpOnly 让 XSS 拿不到会话 Cookie,但不能阻止 XSS 以当前用户身份发请求,所以只是降低危害。

6. 绕过与修复的对抗验证

修复后不要只测原始 payload:

□ <ScRiPt>alert(1)</ScRiPt>
□ <scr<script>ipt>alert(1)</script>
□ <img src=x onerror=alert(1)>
□ " onmouseover="alert(1)
□ ');alert(1);//
□ javascript:alert(1)(href)
□ %3Cscript%3Ealert(1)%3C/script%3E(二次解码)
□ <svg><animate onbegin=alert(1)>
□ 富文本:<a href="javascript:alert(1)">x</a>
□ Markdown 渲染:![x](javascript:alert(1))

如果只过滤了 <script>,第二、三、四条都会过。

7. 一个修复失败的典型

现象:用户昵称已做 replace("<", "&lt;"),但个人主页仍弹窗。

原因:昵称被渲染进 onclick="showUser('昵称')",单引号未处理,payload 为 ');alert(1);//。

修复:该位置改用 Encode.forJavaScript,并把昵称通过 data-* 属性传递,事件里读属性而不是拼接字符串。

相关阅读