一个"分享对话"功能,把用户输入原样写进 JSONP 文件,前端再用
innerHTML渲染;一个"检测 XSS"的验证接口,谁调用都发 Flag。 两个靶场串起同一条链:为安全而生的功能,最后自己成了缺口。
⚠️ 本文所有测试均在授权靶场中完成,仅用于安全学习与防御研究,请勿用于未授权目标。
🏁 〇、先看通关记录
| # | 关卡 | 难度 | 卡在哪 | 一句话打法 | 关键 payload |
|---|---|---|---|---|---|
| 1 | 看看我的分享功能怎么啦 | 3 级 | 分享内容直写 JSONP,前端 innerHTML 渲染 |
事件型 XSS 触发 alert;也可直接调 /xssapi |
<img src=x onerror="alert('XSS')"> |
| 2 | 看看我的分享功能怎么啦2 | 4 级 | 新增 URL 过滤,但相对路径放行 | 只能走完整 XSS 链触发 alert |
<img src=x onerror="alert('XSS')"> |
两关的命门是同一个:后端把用户输入当"数据"存进分享文件,前端却把它当"代码"渲染。
📚 一、原理:什么叫"外部资源加载利用"
📖 1.1 靶场是什么应用
一个 AI 聊天站,核心有两个功能:
| 功能 | 接口 | 说明 |
|---|---|---|
| 对话 | POST /chat |
用户发消息,大模型回复 |
| 分享 | POST /share |
把 user_message + ai_reply 打包,返回一个 share_id |
| 分享页 | /ai.html?#/ai/Dialogue?Url=/share/<id>.js |
用 <script> 加载 JSONP 文件并渲染聊天记录 |
| 验证 | POST /xssapi |
接收弹窗内容,校验通过后返回 Flag |
💡 一个环境细节:靶场的公共 API Key 余额不足(对话报错
402),需自备 DeepSeek Key(https://api.deepseek.com/v1/chat/completions,模型deepseek-chat)才能恢复对话功能。漏洞本身与这一层无关,但没它就没法正常走业务流程。
🔍 1.2 三处关键机制
审计 ai.html 源码,三处逻辑决定了整条攻击链:
① 分享文件是 JSONP
前端点击分享后 POST /share 拿到 share_id,再通过 <script> 标签加载 /share/<id>.js ------ 也就是说,分享内容会以 JavaScript 文件的形式被加载执行。
② 用户输入未转义,直写进 JSON
后端生成 .js 时,把 user_message、ai_reply 直接拼进 JSON,未做 HTML 实体编码或过滤。
③ alert 被重写,绑定了验证接口
(function() {
const originalAlert = window.alert;
window.alert = function(message) {
fetch('/xssapi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: message,
url: window.location.href,
timestamp: new Date().toISOString()
})
}).then(r => r.json())
.then(data => {
if (data.success) {
showMessage(data.message); // <-- 这里显示真正的 Flag
}
});
return originalAlert.apply(this, arguments);
};
})();
靶场把"拿 Flag"的条件绑定在了"触发 alert"这个动作上 :只要页面上有一次弹窗,前端就会把弹窗内容发给 /xssapi,校验通过后把 Flag 显示在页面顶部的蓝色提示框里。
🎯 1.3 为什么归类为"业务伴生漏洞"
出题人为了验证 XSS 是否被触发,额外加了 /xssapi 这个检测端点。这个端点的初衷是保护业务,却成了新的攻击面:它不校验请求从哪来、由谁触发、上下文是否真实。
📌 这正是"业务伴生漏洞"的典型形态:为业务/安全功能补的机制,自身没经过安全设计。
⚔️ 二、逐关复盘
🧩 2.1 靶场 1:看看我的分享功能怎么啦
题目信息
| 项目 | 内容 |
|---|---|
| 靶场名称 | 看看我的分享功能怎么啦 |
| 难度 | 3 级 |
| 类型 | Agent 安全 / 外部资源加载利用(业务伴生漏洞) |
| 标签 | Agent安全、分享、CChen. |
| 入口 | http://<靶场地址>/ai.html |
| Flag | flag{...} |
| 核心考点 | 存储型 XSS、JSONP 注入、验证端点未授权调用 |
信息收集
按 1.2 节的思路审计源码后,攻击面已经清晰:
-
注入点:
POST /share的user_message、ai_reply; -
执行点:分享页加载
/share/<id>.js后的innerHTML渲染; -
出口:
window.alert→POST /xssapi→ 蓝色提示框显示 Flag。
攻击过程
思路一:完整 XSS 利用链(预期解)
| 步骤 | 尝试 | 结果 |
|---|---|---|
| 1 | 在 user_message 里塞 ../../../../tmp/flag.txt,想让后端直接读文件 |
❌ 后端只返回随机 share_id,不拼接文件名;但访问生成的 .js 可见输入被原样写入 JSON |
| 2 | 用 "};alert(document.domain);// 闭合 JSONP 字符串与回调 |
❌ 双引号被转义,闭合失败;且分享内容经 innerHTML 插入,而按 HTML 规范,innerHTML 写入的 <script> 不会执行 |
| 3 | 改用 HTML 事件属性绕过 | ✅ <img src=x onerror="alert('XSS')"> 成功弹窗 |
第 3 步的具体操作:Burp 拦截 POST /share,把 user_message 改成:
<img src=x onerror="alert('XSS')">
发送后拿到新的 share_id,浏览器访问分享链接,alert 弹出 → 重写的 alert 捕获调用并请求 /xssapi → 页面顶部蓝色提示框显示 Flag。
最终 Payload(读取文件版) :为了"名正言顺"地读 /tmp/flag.txt,把弹窗内容换成文件读取结果:
<img src=x onerror="fetch('/tmp/flag.txt').then(r=>r.text()).then(d=>alert(d))">
访问分享链接 http://<靶场地址>/ai.html?#/ai/Dialogue?Url=/share/19c44dec1204.js 时:
-
先弹出一个黑色的
404 Not Found弹窗 ------ 浏览器读不到服务器本地文件,fetch拿到的是 404 页面 HTML; -
紧接着蓝色提示框 弹出真正的 Flag:
flag{...}。
⚠️ 这里必须点破 :Flag 并不是"读文件读出来的"。
/tmp/flag.txt根本不在 Web 可达路径上,第二次弹窗的 Flag 来自/xssapi。XSS 的作用只是"制造一次alert",弹窗内容是什么并不重要。
思路二:直接调用 /xssapi(非预期解)
既然源码已经把验证接口的地址、方法、请求格式全暴露了,那就跳过整个前端流程,直接找后端要:
POST /xssapi HTTP/1.1
Host: <靶场地址>
Content-Type: application/json
Cookie: user_agreed=1
Content-Length: 22
{"message":"XSS test"}
后端不校验 message 是否真来自浏览器弹窗,收到请求就返回:
{
"message": "flag{...}",
"success": true
}
📌 靶场实例重建时 Flag 会变(本例前后两次实例给出的 Flag 并不相同)。Flag 与实例一一对应------本文正文统一采用预期解链路(XSS 触发)的结果。
漏洞分析
| 设计意图 | 实际效果 |
|---|---|
| 分享功能复用 JSONP 加载聊天记录 | ❌ 用户输入未转义,等于提供一个"可控的 JS 文件" |
前端用 innerHTML 渲染分享内容 |
❌ 事件型标签(<img onerror>)可被解析执行 |
重写 alert 自动上报,检测 XSS |
⚠️ 检测生效,但把"是否拿到 Flag"简化为"是否调用过 alert" |
/xssapi 返回 Flag 用于验证 |
❌ 无鉴权、无来源校验,可直接调用 |
根因 :整条链缺的是输出编码 。后端把用户输入当"数据"写进 .js,前端却按"代码"渲染 ------ 中间没有任何一层做 HTML 实体编码,于是"分享内容"变成了"可执行脚本"。
认知关键 :本关真正的判定条件是 "有没有触达 /xssapi",不是"有没有读到文件"。理解了这一点,思路一和思路二其实是同一个洞的两种走法。
防御思路
-
输出编码 :
POST /share落盘前,对user_message、ai_reply做 HTML 实体编码(<→<);JSONP 里的数据只应是纯文本。 -
安全渲染 :前端改用
textContent/innerText,不要用innerHTML插入用户内容。 -
验证接口加固 :
/xssapi需鉴权 + 来源校验,且不应返回 Flag(Flag 只进日志,不进响应体)。 -
不要用前端动作当验证条件 :"调过
alert就算漏洞已复现"这种判据,等于给攻击者留了一条直达后门。
🧩 2.2 靶场 2:看看我的分享功能怎么啦2
题目信息
| 项目 | 内容 |
|---|---|
| 靶场名称 | 看看我的分享功能怎么啦2 |
| 难度 | 4 级 |
| 类型 | Agent 安全 / 外部资源加载利用(业务伴生漏洞) |
| 入口 | http://<靶场地址>/ |
| Flag | flag{...} |
| 核心考点 | 前端过滤绕过、事件型 XSS、完整利用链 |
⚠️ 特别注意 :靶场描述明确写了"请勿使用非预期解提交 WP,否则会直接扣除积分 "。也就是说,第一关那种直接调 API 的取巧方法在本关行不通,必须走完整的 XSS 利用链。
信息收集
应用架构与第一关一致,多了一处新增的 URL 过滤 。在 parseHashParams 中:
let url = decodeURIComponent(match[1]);
// 检查是否以靶场域名开头
if (url.startsWith('http://<靶场域名>')) {
// 检查后缀,如果不是 xlsx,则进行 xss 过滤
if (!url.toLowerCase().endsWith('.xlsx')) {
url = url.replace(/[<>\"']/g, ''); // <-- 过滤了尖括号和引号
}
return { url: url };
}
else if (url.startsWith('/')) {
return { url: url }; // <-- 相对路径直接放行,不过滤
}
分析 :过滤只覆盖 http://<靶场域名> 开头的绝对 URL。而分享页加载的是相对路径 /share/<id>.js,命中 url.startsWith('/') 分支被直接放行 ------ < > 和引号一个都没被过滤。事件型 XSS 的入口完好无损。
验证机制与第一关相同,alert 重写后仍然指向 /xssapi,命中即显示 Flag。
攻击过程
① 复杂 Payload(失败)
第一反应是让 XSS 自动点击"反馈"按钮走一遍业务流程:
{
"user_message": "<img src=x onerror=\"setTimeout(()=>{document.querySelector('.feedback-btn').click(); setTimeout(()=>{document.querySelector('.feedback-option[data-type=helpful]').click(); document.getElementById('feedbackContent').value='test'; document.getElementById('submitFeedback').click();}, 500);}, 500);\">",
"ai_reply": "test"
}
页面无任何反应。原因有二(都很朴素):
-
<img>的onerror触发时,AI 回复和"反馈"按钮还没渲染出来; -
分享页根本没有"反馈"按钮 ------ 那是主聊天页面的组件。
② 最简单的事件型 Payload(成功)
回到 Burp,修正上一条请求缺失的逗号,构造最小 Payload:
{
"user_message": "<img src=x onerror=\"alert('XSS')\">",
"ai_reply": "test"
}
发送 POST /share,拿到新的 share_id(如 0188059e896e),浏览器访问:
http://<靶场地址>/ai.html?#/ai/Dialogue?Url=/share/0188059e896e.js
页面加载后弹出 XSS 提示框,重写的 alert 将内容发往 /xssapi,页面顶部蓝色提示框显示 Flag:
flag{...}
💡 别把简单 payload 想复杂 。本关的难点在"想明白过滤漏在哪",不在"payload 有多花"。既然只需触发一次
alert,多写一行自动点击代码都是给自己找麻烦。
漏洞分析
| 设计意图 | 实际效果 |
|---|---|
对 http://<靶场域名> 开头的 URL 过滤 <>" |
❌ 遗漏相对路径 /share/,过滤被整体绕过 |
重写 alert 检测 XSS 是否触发 |
✅ 检测有效 |
/xssapi 返回 Flag |
⚠️ 仍然让"触发一次弹窗"等于"拿到 Flag" |
攻击链:
-
审计分享页 JS,发现
/xssapi与alert重写逻辑; -
发现 URL 过滤只拦绝对路径,相对路径
/share/放行; -
构造
<img src=x onerror="alert('XSS')">,经POST /share注入; -
访问分享链接触发 XSS →
alert→ 后端校验; -
拿到 Flag。
本质 :第一关的"输出编码缺失"没修,第二关只在前端入口 加了一层字符串替换。过滤写在错误的位置,等于没写 ------只要存在任何一条绕过它抵达 innerHTML 的路径,前面的洞就原样复发。
防御思路
-
过滤要写在后端、写在输出处 :对写入 JSONP 的内容统一做 HTML 实体编码,而不是在前端
parseHashParams里对某类 URL 做字符串替换。 -
白名单优于黑名单 :
replace(/[<>\"']/g, '')是典型黑名单,漏一个字符就失效;应改为"只允许预期字符集"。 -
同第一关 :
/xssapi不返回 Flag、需鉴权;前端不要用innerHTML渲染不可信内容。 -
别让"检测机制"成为通关路径:任何"命中某动作即发放敏感信息"的设计,都应默认它会被直接调用。
📋 三、两关手法总表
| 手法 | 原理 | 用在哪一关 |
|---|---|---|
| 事件型 XSS | <script> 能被过滤、innerHTML 不执行脚本,但 <img onerror> 照样触发 |
靶场 1、2 |
| JSONP 注入 | 用户输入被直写进可被 <script> 加载的 .js 文件 |
靶场 1、2 |
| 黑名单绕过 | 过滤只覆盖绝对路径,相对路径 /share/ 直接放行 |
靶场 2 |
| 验证端点直调 | 从源码拿到 /xssapi 后跳过前端,直接重放请求 |
靶场 1(非预期解) |
| 最小 payload 原则 | 通关只需一次 alert,payload 越简单越稳 |
靶场 2 |
再往上抽一层,只有两句话:
-
数据被当代码执行 ------ 分享内容、聊天记录,凡是"用户可控 + 被渲染"的地方都是注入点。
-
验证机制自己就是后门 ------ 为了检测漏洞而加的接口,往往没做鉴权和上下文校验。
🛡️ 四、防御清单(开发者视角)
| 层面 | 措施 | 针对的问题 |
|---|---|---|
| 输出编码(第一优先级) | 所有用户输入在写入 HTML / JSONP 前做 HTML 实体编码 | 存储型 XSS、JSONP 注入 |
| 安全渲染 | 前端用 textContent,禁用 innerHTML 渲染不可信内容 |
事件型 XSS |
| 过滤位置 | 过滤/白名单放在后端输出处,不要放在前端 URL 解析里 | 黑名单绕过 |
| 接口鉴权 | /xssapi 之类敏感接口必须鉴权 + 校验来源与上下文 |
未授权调用 |
| 最小信息泄露 | 验证接口只记日志,响应体不含任何敏感信息(含 Flag) | 直接调 API 拿 Flag |
| CSP | 配置 Content-Security-Policy,禁止加载不受信任的外部脚本 |
整体缓解 |
🏆 五、总结
两关走下来,真正的收获不是"一个 payload",而是这条固定推理:
-
先找渲染点 ------ 用户输入最终被谁、以什么方式渲染?(这里是
innerHTML) -
再看过滤写在哪 ------ 过滤在前端还是后端?覆盖了哪些路径?(这里是前端 + 只覆盖绝对路径)
-
最后找出口 ------ 拿到 Flag 的判定条件是什么?(这里是"调用过一次
alert")
一句话总结 :分享功能把数据当代码存,验证接口把后门当闸门用。 修复的关键从来不是"过滤得更狠",而是把数据永远当数据 ,以及别让安全机制自己成为攻击面。