手把手系列:XSS 原理、分类与防御 day04(上)
手把手系列:XSS 原理、分类与防御 day04(上)
按 day03 结尾的预告,下一篇本来该讲盲注自动化。排骨把顺序调了:先把 XSS 这条线拉通,盲注的脚本化和 sqlmap 往后放。
SQL 注入打的是数据库,XSS 打的是浏览器。两套东西思路差得远,但都属于“注入”这个大类。趁 SQL 注入的手感还在,紧接着看它的另一个分支,比隔一篇再回来顺。
这一篇同前先讲原理,再打靶场。三种类型、上下文、危害、防御这几块捋清楚了,下篇再上 DVWA 挨个打一遍。
特别声明:本文内容仅供网络安全学习研究使用。严禁在未获得书面授权的情况下对任何非自有系统实施扫描、渗透、测试等行为,违规操作带来的一切法律责任由行为人自行承担。
一、关于xss
XSS 原名 Cross Site Scripting(跨站脚本),缩写本应为 CSS,但与层叠样式表 Cascading Style Sheets 的缩写撞车,所以改成 XSS。除缩写冲突,两者技术上毫无关系。
一句话:页面把用户输入当成代码执行了。
正常的情况:用户输入是数据,显示出来就完了。
出问题的情况:用户输入变成了 HTML 或 JS 语法的一部分,浏览器照着执行。
和 SQL 注入是同一个病根——数据和代码的边界被打破。区别只在“谁在执行”:SQL 注入由数据库执行,XSS 由浏览器执行。
浏览器没法帮你区分。它收到的是一个 HTML 字符串,里面哪段是模板、哪段是用户数据,它不知道,它只认语法。
所以问题从来不出在浏览器,出在服务端拼接字符串的那一下。
1.1 浏览器是怎么走到那一步的
把浏览器处理页面的过程拆开看:
收到 HTML 字符串
↓
词法分析,一个个识别标签
↓
边识别边构建 DOM 树
↓
遇到 <script>
↓
暂停解析,把整段交给 JS 引擎
↓
执行完,继续往下解析
关键在于中间那步:遇到 <script> 就执行,这是 HTML 规范规定的行为,不是缺陷。
那只要能让 HTML 里多出一段可执行的东西,它就会被执行。而“多出来”的方式只有一种——服务端把用户输入拼进 HTML 的时候,没有把输入当成数据。
举个最小的例子。假设页面代码是:
echo "<p>你好," . $name . "</p>";
用户输入 排骨,页面是:
<p>你好,排骨</p>
一切正常。
用户输入 <script>alert(1)</script>,页面就变成:
<p>你好,<script>alert(1)</script></p>
浏览器看到 <script>,暂停解析,执行 alert(1),然后继续。
注意这段输入在数据库里、在 HTTP 请求里,都只是普通字符。 它变成代码,是在“被拼进 HTML”那一刻。
二、按“注入的东西存在哪”分成三类
XSS 的分类,本质是问一个问题:恶意代码是从哪儿进到页面里的?
2.1 反射型(Reflected)
用户输入跟着请求来,服务端原样把它回显进响应,只在这一次响应里生效。
典型场景:搜索框回显你搜的词、错误页回显参数、登录失败的提示带上用户名。
攻击要成立,得让受害者点一个构造好的链接。所以反射型又叫“非持久型”,必须诱导点击。
攻击者把恶意链接发给受害者
↓
受害者点开
↓
请求带到服务器,服务器把 payload 回显进页面
↓
浏览器执行
2.2 存储型(Stored)
用户输入被存进数据库或文件,之后每个打开页面的用户都会触发。
典型场景:留言板、评论区、昵称、个人签名、后台的日志查看页。
这类不需要诱导点击——你以为自己只是打开一个正常页面。危害也是三类里最大的一档。
2.3 DOM 型(DOM-based)
前两类都有个共同点:payload 经过了服务端,出现在响应 HTML 里。
DOM 型不一样:服务端完全不参与。是浏览器端的 JS 主动从某个地方读取数据,再把它写进 DOM。
// 数据从 URL 里取
var keyword = location.hash.slice(1);
// 写进 DOM
document.getElementById("result").innerHTML = "你搜索的是:" + keyword;
这里有个细节值得注意:URL 里 # 后面的部分(fragment)不会发给服务器。
所以 DOM 型的 payload 可能根本不出现在任何 HTTP 请求和响应里。服务端日志看不到,WAF 也可能看不到——它只能拦它看得见的东西。
2.4 三类放在一起对比
| 维度 | 反射型 | 存储型 | DOM 型 |
|---|---|---|---|
| payload 存在哪 | 请求参数里 | 数据库 / 文件里 | 浏览器执行 JS 时才拼进 DOM |
| 是否落库 | 否 | 是 | 通常不落 |
| 响应 HTML 里有 payload 吗 | 有 | 有 | 可能完全没有 |
| 需要诱导点击吗 | 需要 | 不需要 | 通常需要 |
| 影响范围 | 单个受害者 | 所有访问者 | 单个受害者 |
| 典型触发点 | 搜索回显、错误页 | 留言板、昵称、评论 | 前端路由、location.hash 渲染 |
2.5 DOM 型为什么容易漏掉
DOM 型最容易漏,原因有两个。
第一,服务端代码怎么审都看不出问题。 因为服务端确实没问题——它只是返回一段前端 JS,那段 JS 本身是固定的。
第二,漏洞出在“危险函数”上,而这些函数在正常业务里到处都是。
常见的一批:
| 位置 | 危险程度 | 说明 |
|---|---|---|
innerHTML / outerHTML |
高 | 把字符串当 HTML 解析,标签会生效 |
document.write() |
高 | 直接往文档流里写 HTML |
eval() / new Function() |
极高 | 把字符串当代码执行 |
setTimeout("字符串") / setInterval("字符串") |
高 | 传字符串时按代码执行 |
location / location.href |
中 | 可以走 javascript: 伪协议 |
jQuery 的 .html() / .append() |
高 | 同 innerHTML |
React 的 dangerouslySetInnerHTML |
高 | 名字已经把风险写在脸上了 |
Vue 的 v-html |
高 | 同上 |
相对安全的替代品是 textContent、innerText、createElement()——它们把内容当文本处理,不当 HTML。
记住这条区分就够了:凡是名字里带 html 的,基本都在解析 HTML;凡是叫 text 的,基本只当文本。 这一条能挡掉大量低级误用。
三、payload 长什么样,取决于它落在哪
这是 XSS 里最容易被忽略的一层。同一段输入,落在页面的不同位置,需要的写法完全不同。
因为你要做的第一件事是闭合——先让前面的语法结束,才能插进自己的东西。
| 数据落在哪 | 页面长这样 | 要闭合什么 | 一种写法 |
|---|---|---|---|
| HTML 文本节点 | <div>DATA</div> |
不用闭合,直接插标签 | <img src=x onerror=alert(1)> |
| 双引号属性 | <input value="DATA"> |
先闭合双引号 | " onmouseover="alert(1) |
| 单引号属性 | <input value='DATA'> |
先闭合单引号 | ' onmouseover='alert(1) |
| 无引号属性 | <input value=DATA> |
用空格断开属性 | x onmouseover=alert(1) |
| JS 字符串里 | var a = "DATA"; |
闭合引号并转义 | ";alert(1);// |
<script> 块里 |
<script>var a = DATA;</script> |
不用闭合标签,直接写表达式 | alert(1) |
| 链接地址 | <a href="DATA"> |
换成伪协议 | javascript:alert(1) |
| CSS 里 | <div style="DATA"> |
闭合后写声明 | 现代浏览器基本不执行 JS;历史上的 expression() 只在旧版 IE 有效 |
这张表解释了一件事:为什么“过滤 <script>”这种防护从根上就不够。
你可以不用 <script>。浏览器认的可执行入口远不止这一个:
<img src=x onerror=...>—— 图片加载失败时触发<svg onload=...>—— SVG 加载时触发<body onload=...>、<iframe srcdoc=...>- 各种
on开头的事件属性:onmouseover、onfocus、onclick <a href="javascript:...">—— 伪协议
标签有几十个,事件有几百个。黑名单永远列不全。
四、危害:不只是弹个框
新手对 XSS 的印象常常停在 alert(1)。那个弹窗只是证明“代码能执行”,真正的危害在后面。
4.1 盗 Cookie,接管会话
如果 Cookie 没有 HttpOnly 标记,JS 能直接读:
document.cookie
读到之后往外发一份,攻击者拿着这个 Cookie 就成了你——不需要密码。
这也是为什么 HttpOnly 是第一道最便宜的防线。
4.2 以受害者身份操作
页面上能点的按钮、能提交的表单,攻击者的 JS 都能替他点、替他提。
发帖、改资料、转账、加好友、删数据——只要受害者有权限,JS 就有权限。
4.3 绕过 CSRF 防护
CSRF(Cross-Site Request Forgery,跨站请求伪造):你已登录某站,攻击者诱导你打开恶意页面,该页面偷偷向目标站发请求;浏览器自动带上你的 Cookie,服务器以为是你本人操作
CSRF这一条经常被单独忽略。
CSRF 的经典防御是“请求里带一个攻击者猜不到的 token”。但 token 是渲染在页面里的,而 XSS 的 JS 和页面同源——它可以直接把 token 读出来,再带着 token 发请求。
所以一个站点如果有 XSS,它的 CSRF 防护基本等于没有。
4.4 钓鱼与界面伪造
用 JS 在页面上盖一层假登录框,把密码骗到手。地址栏还是真域名,用户很难分辨。
4.5 键盘记录、剪贴板、摄像头
JS 能监听输入、读剪贴板(部分场景需要授权)、申请摄像头权限。一旦拿到同源执行权,能做的事远超一般人的想象。
4.6 蠕虫式传播
存储型 XSS 有个可怕的放大效应:被感染的用户访问页面时,会自动替攻击者发帖。
2005 年 MySpace 的 Samy 蠕虫是经典案例——利用存储型 XSS 自我复制,不到 20 小时感染了超过一百万用户。
一个 XSS 漏洞,加上“自动提交”这个动作,就能变成蠕虫。
五、几个常见的误判
| 想法 | 为什么不对 |
|---|---|
过滤 <script> 就行了 |
大小写、嵌套、其他标签、事件属性,绕过方式太多 |
| 我只处理 GET,POST 是安全的 | POST 一样会被回显,改包就能伪造 |
| 前端做了校验就够了 | 前端校验只拦浏览器,直接发请求完全绕过 |
| 转义一次就能到处用 | 不同上下文要不同编码,HTML 转义放进 <script> 里没用 |
| 有 HttpOnly 就不怕了 | 它只防读 Cookie,页面内容、token、操作照样能被操纵 |
| 上了 CSP 就高枕无忧 | CSP 是缓解层,配错了(比如允许 unsafe-inline)等于没有 |
| 这是老漏洞,现在少了 | OWASP Top 10 2021 里它并进了 A03 注入类,依然在高位 |
最后一条补充一句:现代框架确实把 XSS 的发生率压下去了(React、Vue 默认把插值当文本处理),但没有消灭它。因为框架都留了“逃生门”——dangerouslySetInnerHTML、v-html、innerHTML。用上这些,防护就回到了手工时代。
六、防御:在输出处编码,不在输入处过滤
这是整篇最该带走的一句话。
为什么不在输入处过滤? 因为同一份数据,可能出现在多个输出位置。你在录入时把它转义成 HTML 实体,等到它要进 JS 字符串时又不对了。
正确的位置是每一个输出点,按它落地的上下文选编码方式:
| 输出到哪 | 用什么编码 |
|---|---|
| HTML 文本 | HTML 实体编码(< > & ") |
| HTML 属性 | 同上,并且给属性加引号 |
| JS 字符串 | JS 转义(\u003c 等),或者干脆用 JSON.stringify 把值序列化 |
| URL 参数 | URL 编码(encodeURIComponent) |
| CSS | 尽量避免动态拼接;必须拼时做 CSS 转义 |
配套的其他几层:
| 措施 | 作用 | 说明 |
|---|---|---|
| 输出编码 | 根治 | 按上下文编码,让数据永远只是数据 |
| 输入校验(白名单) | 收窄 | 限定类型、长度、字符集;黑名单永远不全 |
| CSP | 纵深 | script-src 'self' 'nonce-xxx',禁掉内联脚本 |
| Cookie 加 HttpOnly | 减损 | 就算被 XSS,也读不到会话 Cookie |
| SameSite | 减损 | 降低跨站请求被带上的概率 |
| 成熟 sanitizer | 富文本场景 | 需要允许 HTML 时用 DOMPurify 这类库,别自己写正则 |
富文本那一栏值得多说一句。业务上经常“必须允许用户发带格式的内容”,这时候不能简单转义掉一切。正确做法是白名单式净化——只保留允许的标签和属性,其余全部剥掉。这件事看起来简单,实际上绕过手法极多,用成熟库,不要自己写正则。
6.1 一个对照
// 错误:直接拼入
echo "<div>" . $_GET['name'] . "</div>";
// 正确:输出时按上下文编码
echo "<div>" . htmlspecialchars($_GET['name'], ENT_QUOTES, 'UTF-8') . "</div>";
htmlspecialchars 会把 <、>、&、"、' 转成实体,浏览器看到的是文本,不是标签。
注意 ENT_QUOTES 这个参数——它把单引号和双引号一起处理。如果你的数据可能落进属性里,这个参数不能省。
七、和 CSRF 的关系
这两个经常被混在一起问,其实攻击模型不同:
| XSS | CSRF | |
|---|---|---|
| 本质 | 在受害者浏览器里执行攻击者的代码 | 借受害者的登录状态发一个请求 |
| 需要读响应吗 | 需要,也能读 | 不需要 |
| 需不需要诱导点击 | 反射型/DOM 型需要,存储型不需要 | 需要 |
| 防御核心 | 输出编码 + CSP | CSRF token + SameSite + 校验 Origin |
结论是:XSS 的危害覆盖 CSRF。 有 XSS 的站点,CSRF token 是能读的;反过来,有 CSRF 防护的站点不代表没有 XSS。
八、下篇预告
原理到这。下篇上 DVWA,三种类型各打一遍:
- 反射型:从低到高四档,看过滤怎么一层层加,又怎么一层层被绕过
- 存储型:验证“不需要诱导点击”这件事到底有多致命
- DOM 型:为什么它可能在服务端日志里完全看不到
- 最后统一看正确的防御写法
下篇见。
浙公网安备 33010602011771号