Day 152 — XSS 与 Web 漏洞 · 图解
本文件用 Mermaid 代码块 + ASCII 图 把今天的概念画出来。 所有图都可以直接复制到支持 Mermaid 的编辑器(Typora / VS Code / GitHub)里渲染。 建议阅读顺序:先看图 1(三种 XSS 的差别),再看图 3(上下文与编码), 最后看图 7(检测流程)——它对应
code/03-xss-scanner.py的实际执行步骤。
图 1:三种 XSS 的数据流全景
flowchart LR
subgraph ATTACKER["攻击者"]
P["构造带探测标记的输入"]
end
subgraph REFL["① 反射型 Reflected"]
R1["恶意 URL 参数"] --> R2["服务器直接把参数拼进 HTML"]
R2 --> R3["浏览器解析 → 标记变成元素"]
R3 --> R4["只影响点击该链接的那个人"]
end
subgraph STORED["② 存储型 Stored"]
S1["提交评论/昵称"] --> S2[("数据库")]
S2 --> S3["每个访客打开页面时被拼进 HTML"]
S3 --> S4["影响所有访问者"]
end
subgraph DOMX["③ DOM 型"]
D1["URL 的 #fragment"] --> D2["前端 JS 读取 location.hash"]
D2 --> D3["写入 innerHTML 等危险 sink"]
D3 --> D4["脚本执行,服务端全程不知情"]
end
P --> R1
P --> S1
P --> D1
flowchart LR
subgraph ATTACKER["攻击者"]
P["构造带探测标记的输入"]
end
subgraph REFL["① 反射型 Reflected"]
R1["恶意 URL 参数"] --> R2["服务器直接把参数拼进 HTML"]
R2 --> R3["浏览器解析 → 标记变成元素"]
R3 --> R4["只影响点击该链接的那个人"]
end
subgraph STORED["② 存储型 Stored"]
S1["提交评论/昵称"] --> S2[("数据库")]
S2 --> S3["每个访客打开页面时被拼进 HTML"]
S3 --> S4["影响所有访问者"]
end
subgraph DOMX["③ DOM 型"]
D1["URL 的 #fragment"] --> D2["前端 JS 读取 location.hash"]
D2 --> D3["写入 innerHTML 等危险 sink"]
D3 --> D4["脚本执行,服务端全程不知情"]
end
P --> R1
P --> S1
P --> D1
一句话对比
| 数据从哪来 | 谁把它变回 HTML | 影响范围 | |
|---|---|---|---|
| 反射型 | 本次请求参数 | 服务端模板 | 单人 |
| 存储型 | 数据库 | 服务端模板 | 所有人 |
| DOM 型 | 浏览器本地 | 前端 JS | 单人 |
图 2:存储型 vs 反射型的"修复位置"差异
【反射型】数据只在一次请求内活着
用户 ──q=<mark>──► 服务器 ──拼进HTML──► 浏览器
▲
└── 只需要改**这一处**输出代码,漏洞彻底消失
【存储型】数据被"留住"了,输出点可能有 N 个
用户 ──评论──► 服务器 ──► 数据库 ─┬─► 前台评论页 ← 输出点 1(要编码)
├─► 后台审核页 ← 输出点 2(要编码)
├─► 邮件通知 ← 输出点 3(要转义)
├─► RSS / API ← 输出点 4(要转义)
└─► 搜索引擎快照 ← 输出点 5(已抓走的内容清不掉)
⚠️ 结论:存储型的修复是"逐输出点"的,漏一个就还在。
所以更稳的思路是 —— 输出统一走同一个"安全渲染"函数,别让每处各写一遍。
图 3:上下文与编码对象(最容易踩的坑)
用户输入(同一条数据)
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
<div>HERE</div> <input value="HERE"> <a href="/s?q=HERE">
│ │ │
HTML 文本上下文 属性值上下文 URL 参数上下文
│ │ │
转义 & < > " ' 转义 & < > " ' percent-encode
│ │ │
▼ ▼ ▼
<x152probe> "><x… %3Cx152probe%3E
❌ 只做"一种"转义 → 换个上下文就失效
❌ 属性值不加引号 → 空格就能"劈"出一个新属性
❌ 用黑名单过滤 → 永远有没想到的写法
图 4:属性上下文为什么必须转义引号
HTML 源码: <input type="text" value="HERE">
── 情况 A:HERE 只转义了尖括号(引号裸奔)────────────────
输入: " onmouseover="x152()
结果: <input type="text" value="" onmouseover="x152()">
▲
└── 属性在这里被"闭合"了,
后面的内容被解析成**新的属性**!
── 情况 B:HERE 做了完整转义(quote=True)──────────────
输入: " onmouseover="x152()
结果: <input type="text" value="" onmouseover="x152()">
▲
└── 引号变成实体,解析器看到的是"字符",
属性没有闭合,攻击面消失。
✅ 规则:属性值 = 必须加引号 + 必须转义引号。两条缺一不可。
图 5:CSP 的拦截时序(第二道防线)
sequenceDiagram
participant S as 服务器
participant B as 浏览器
participant X as 注入的脚本
S->>B: 响应头 Content-Security-Policy: script-src 'nonce-a1b2'
S->>B: 页面含 <script nonce="a1b2"> 合法脚本
B->>B: 解析:nonce 匹配 ✅ → 允许执行
Note over B: 此时页面里还混进了攻击者的 <script>
B->>X: 检查:有没有 nonce?没有 ❌
B-->>X: 拒绝执行(控制台报 CSP 违规)
Note over B: 即使输出编码漏了一处,注入脚本也执行不了
sequenceDiagram
participant S as 服务器
participant B as 浏览器
participant X as 注入的脚本
S->>B: 响应头 Content-Security-Policy: script-src 'nonce-a1b2'
S->>B: 页面含 <script nonce="a1b2"> 合法脚本
B->>B: 解析:nonce 匹配 ✅ → 允许执行
Note over B: 此时页面里还混进了攻击者的 <script>
B->>X: 检查:有没有 nonce?没有 ❌
B-->>X: 拒绝执行(控制台报 CSP 违规)
Note over B: 即使输出编码漏了一处,注入脚本也执行不了
ASCII 版:
响应头: script-src 'nonce-a1b2' 'strict-dynamic'; object-src 'none'; base-uri 'none'
│
▼
浏览器逐项检查页面里的可执行内容
│
├─ <script nonce="a1b2"> ✅ 允许
├─ <script>(攻击者注入) ❌ 无 nonce → 拒绝
├─ <img onerror="..."> ❌ 内联事件被 script-src 拦
├─ <script src="//evil.com/x.js">❌ 非白名单来源 → 拒绝
└─ <object data="..."> ❌ object-src 'none' → 拒绝
⚠️ 三个"白写"的 CSP:
1. 加了 'unsafe-inline' → 内联脚本随便跑,等于没配
2. 白名单里放了公共 CDN + JSONP → 攻击者能在该 CDN 上传文件
3. nonce 每次响应不变 → 攻击者读一次页面就知道值了
图 6:CSRF 与 SameSite 的判定树
浏览器要发一个请求去 bank.com,此时手里有 bank.com 的 Cookie
│
▼
这个请求是"跨站"发起的吗?
(由目标站 + 发起者 origin 决定)
│ │
是 │ │ 否(同站:含子域)
▼ ▼
Cookie 带 SameSite 吗? ✅ 一定会带 Cookie
│ │ └── 必须靠 CSRF Token / Origin 校验
Lax/Strict None
│ │
▼ ▼
┌─────────────┐ ✅ 会带 Cookie(且必须 Secure)
│ 是"顶层导航" │ └── 靠 CSRF Token / Origin 校验兜底
│ 且是 GET 吗?│
└──────┬──────┘
是 │ 否
│ │
▼ ▼
⚠️带 Cookie ❌ 不带 Cookie
(GET 有副作用 → 必须改成 POST!)
⚠️ 关键陷阱:SameSite 判断的是"站点(site)"不是"源(origin)",
evil.example.com → app.example.com 属于**同站**,
SameSite 完全挡不住 → 必须有 CSRF Token。
图 7:本地检测流程(对应 code/03-xss-scanner.py)
┌─────────────────────────────────────────────────────────┐
│ 启动本地靶场 127.0.0.1:随机端口(只监听本机) │
│ /search 未编码 期望 FAIL │
│ /attr 只转义尖括号 期望 WARN │
│ /safe 完整编码 期望 PASS │
│ /secure 编码 + 安全头 期望 PASS │
└────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ ① 生成唯一探测标记(不含任何可执行代码) │
│ HTML 文本: <x152probeXXXXXX> │
│ 双引号属性: "><x152probeXXXXXX> │
│ 单引号属性: '><x152probeXXXXXX> │
└────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ ② 发请求,在响应体里找三种形态 │
│ 原样出现 → FAIL(标签/属性注入能力存在) │
│ 只转义了尖括号 → WARN(引号裸奔,属性可逃逸) │
│ 完整转义成实体 → PASS(输出编码生效) │
│ 完全没出现 → INFO(参数未回显,不能推论) │
└────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ ③ 页面 JS sink 扫描:innerHTML / document.write / eval │
│ → 发现即 WARN,提示"确认输入是否用户可控"(DOM 型线索) │
└────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ ④ 安全头审计:CSP / nosniff / X-Frame-Options / │
│ Referrer-Policy / Cookie(HttpOnly+Secure+SameSite) │
└────────────────────────┬────────────────────────────────┘
│
▼
分级报告 PASS/WARN/FAIL + 退出码(FAIL>0 → exit 1)
为什么"区分得出四者"本身就是对扫描器的检验:
一个只会说"有/没有"的扫描器是不可信的。
只有当它能把"未编码 / 半编码 / 已编码 / 有安全头"分辨清楚时,
它给出的结论才有参考价值。
→ 这就是自建靶场的意义:**先验证工具,再相信工具。**
图 8:纵深防御分层(出了事谁来兜底)
┌───────────────────────────────────────────────────────────┐
│ 第 1 层 输出编码(根治) │
│ 按上下文编码,属性值加引号且转义引号 │
│ 被绕过? → 看第 2 层 │
├───────────────────────────────────────────────────────────┤
│ 第 2 层 不要拼 HTML │
│ DOM API / 模板自动转义 / 禁用 |safe │
│ 被绕过? → 看第 3 层 │
├───────────────────────────────────────────────────────────┤
│ 第 3 层 富文本净化(白名单解析树) │
│ 被绕过? → 看第 4 层 │
├───────────────────────────────────────────────────────────┤
│ 第 4 层 CSP(nonce + strict-dynamic) │
│ 注入的脚本执行不了 │
│ 被绕过? → 看第 5 层 │
├───────────────────────────────────────────────────────────┤
│ 第 5 层 Cookie 标志(HttpOnly 防读 / SameSite 防跨站) │
│ 就算脚本跑了,也读不到会话 Cookie │
├───────────────────────────────────────────────────────────┤
│ 第 6 层 CSRF Token / Origin 校验 │
│ 挡住"借用身份"的跨站请求 │
├───────────────────────────────────────────────────────────┤
│ 第 7 层 监控告警 + 日志(CSP report / 异常请求检测) │
│ 出了事能第一时间知道并定位 │
└───────────────────────────────────────────────────────────┘
⚠️ 只有第 1 层是"根治",2~7 层是"降低后果"。
现实里必须全部配置 —— 因为任何一层都可能因为历史代码而失效。
图 9:XSS 与 CSRF 的关系(为什么两者要一起防)
有 XSS 时,CSRF 防护会怎样?
攻击者的脚本在受害者浏览器里执行
│
├─ 读 document.cookie
│ ├─ Cookie 有 HttpOnly → ❌ 读不到(安全)
│ └─ Cookie 无 HttpOnly → ✅ 会话被偷走
│
├─ 读页面里的 CSRF Token(藏在 <input hidden> 或 <meta>)
│ └─ ✅ 一般读得到 → 传统 CSRF Token 防护**失效**
│
└─ 直接 fetch('/transfer', {method:'POST', credentials:'include'})
└─ ✅ 浏览器自动带 Cookie → 根本不需要"偷"CSRF Token
✅ 结论:
* HttpOnly 保护"读",保护不了"用"
* 真正的兜底是 CSP(让脚本压根跑不起来)
* 所以顺序永远是:先防 XSS,再谈 CSRF —— 纵深防御,不互相替代
附:今日图解速查表
| 图 | 内容 | 对应正文 |
|---|---|---|
| 图 1 | 三种 XSS 数据流 | README 1.2 ~ 1.5 |
| 图 2 | 存储型多输出点 | README 1.4 |
| 图 3 | 上下文与编码对象 | README 2.1 ~ 2.2 |
| 图 4 | 属性引号为什么必须转义 | README 2.2 |
| 图 5 | CSP 拦截时序 | README 2.4 |
| 图 6 | CSRF / SameSite 判定树 | README 1.8 / 2.7 |
| 图 7 | 本地检测流程 | code/03-xss-scanner.py |
| 图 8 | 纵深防御分层 | README 六、思考题 5 |
| 图 9 | XSS 与 CSRF 联动 | README 1.7 |