学弟の考核赛絮絮叨叨

READING

回到标题界面
昴君
2026 年 8 月 6 日 技术
本文目录

    万物皆可逆系列复现教程


    目录

    1. 导言
    2. 其一 · 医院导诊系统故障啦
    3. 其二 · 打开 ZGH 被网络封闭的内心
    4. 其三 · 货币の战争

    一、导言

    我说过,万物皆可逆。所以这一次给学弟出题,本质是想让他大大滴打开脑洞;当然,我的脑洞也是大大滴打开了。

    image.png

    题目要求是:做不出来我也不鸭梨,能做出来我就大大地夸。
    不说多了,下面开始这三道题的复现。


    二、其一:医院导诊系统故障啦

    1. 题目信息

    项目 内容
    难度 中等+
    出题人 0
    形式 容器题 + ELF(很像 pwn,但不是)

    题面简述:

    前段时间,0 的朋友脚崴了,ta 发 vx 给 0 酱说医院的导诊系统坏了。热心的 0 酱去修,结果那边的人只说要 xor 一个 0x91,0 酱当场宕机:这素?

    2. 出题思路

    出题任务下来时脑子是空的,于是无聊和朋友 gossip。朋友外面玩得正开心把脚崴了,去医院一肚子怨气,闲谈中吐槽医院导诊——灵感就来了。

    附件一开始其实只想给个日志文件让小学弟逆;后面想了一下还是做个人,做成了容器 + ELF。

    3. 运行表现

    进入容器后,能看到类似医院导诊的系统界面(为什么叫 Genshin 你别管):

    image.png

    操作很直观:需要什么服务,输入对应数字即可。

    问题是:输入哪个数字,才能进入题面里说的「故障修复」界面?

    4. 逆向分析

    把 ELF 拖进 IDA:

    image.png

    main 很长。普通 Shift+F12 看字符串不一定够用,可以按下面这种方式过滤/定位:

    image.png

    方法繁琐,但主要拼耐心,迟早能锁定关键位置:

    image.png

    哦灭得多!

    基本可以以这里为核心看逻辑。觉得代码难看的话,也可以继续「翻牌」定位:

    image.png

    这里可以看到「排队叫号查询」,对应主页 3 号 服务。相关逻辑大致如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    puts(asc_40321D);                   // 排队叫号查询
    __printf_chk();                     // 输入你的卡号
    fflush(stdout);
    if ( (unsigned int)sub_402540(s, 60) )  // 读取输入进行比对
       goto LABEL_59;
    strcpy(v49, "MNT-2419");            // 秘密位置
    if ( *(_QWORD *)s == 0x393134322D544E4DLL && !v53 )
    {
       puts(&byte_403C90);             // 输入正确即可进入

    也就是说:进入 3 号服务后,把卡号 MNT-2419 打进去。进入后大致长这样:

    image.png

    窗口/选项仍可能很多。关键点在于:

    image.png

    只有进入序号 7 解 XOR 才会有答案。
    为什么是 7?自己去查 ASCII。

    5. 最后一关:XOR 0x91 + 3 秒时限

    image.png

    解密算法很直接:对校验码做 xor 0x91。同时还要注意:

    image.png

    意思是:只给你三秒,算完还要输进去,逾期不候。

    两条路:

    1. 绝对相信自己的手速
    2. 绝对相信计算机的速度

    年纪上来了,手速跟不上,只能上脚本。直接 Python 自动化,别说 3 秒,1 秒就破。

    6. 自动化脚本

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    import re
    import socket
    import sys
    ​
    HOST = ""
    PORT = 0  # 按实际环境填写
    ​
    ​
    def recv_until(sock, markers, timeout=8):
       sock.settimeout(timeout)
       buf = b""
       while True:
           try:
               chunk = sock.recv(4096)
               if not chunk:
                   break
               buf += chunk
               text = buf.decode("utf-8", "replace")
               for m in markers:
                   if m in text:
                       return text
           except socket.timeout:
               break
       return buf.decode("utf-8", "replace")
    ​
    ​
    def sendline(sock, s):
       sock.sendall((s + "\n").encode())
    ​
    ​
    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
       s = socket.create_connection((host, port), timeout=10)
    ​
       print(recv_until(s, ["> "]), end="")
    ​
       sendline(s, "3")
       print(recv_until(s, [": ", ":"]), end="")
    ​
       sendline(s, "MNT-2419")
       print(recv_until(s, ["> "]), end="")
    ​
       sendline(s, "7")
       text = recv_until(s, [": ", "结果"])
       print(text, end="")
    ​
       m = re.search(r"校验码:\s*([0-9a-fA-F]{8})", text)
       if not m:
           m = re.search(r"([0-9a-fA-F]{8})", text)
       if not m:
           print("challenge not found", file=sys.stderr)
           s.close()
           sys.exit(1)
    ​
       challenge = m.group(1)
       password = bytes(b ^ 0x91 for b in bytes.fromhex(challenge)).hex()
       print(f"\n[+] challenge={challenge} password={password}")
    ​
       sendline(s, password)
       try:
           out = s.recv(8192).decode("utf-8", "replace")
           print(out, end="")
           fm = re.search(r"HYNUCTF\{[^}]+\}", out)
           if fm:
               print(f"\n[+] FLAG: {fm.group(0)}")
       except Exception as e:
           print(e, file=sys.stderr)
       finally:
           s.close()
    ​
    ​
    if __name__ == "__main__":
       main()

    7. 小结

    出完之后才发现这题还是偏简单,不太需要太重的静态分析。算了,就这样吧。

    解题路径速记:

    1
    2
    3
    4
    5
    菜单 3(排队叫号)
    → 卡号 MNT-2419
    → 选项 7(XOR 修复)
    → 读校验码,逐字节 xor 0x91
    → 3 秒内回传 → flag

    三、其二:打开 ZGH 被网络封闭的内心

    1. 出题背景

    有天晚上不记得谁说了一嘴「逆向 ZGH」,我就出了。刚好琢磨 web 逆向挺久(源自之前逆向 4399),觉得 web 逆向也不是不行。

    这一题直接挂服务器,没有附件——想要附件,有本事自己搞出来。

    image.png

    • 在线地址:HYNU Console
    • 会 web 的也许能走越权等思路自己试
    • 这里按 reverse 路线讲理想做法

    2. 看网页源码

    image.png

    跳到前端脚本:

    image.png

    核心 JS(app.min.js 逻辑):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    "use strict";
    (() => {
     const form = document.getElementById("gate-form");
     const status = document.getElementById("status");
     const submit = document.getElementById("submit");
     const screen = document.getElementById("screen");
     const markerText = "HYNU-WASM-PAYLOAD\0";
     const marker = new TextEncoder().encode(markerText);
     const assetPath = ["", "assets", "media", "cache", "2026", "07", "31", "r", "e", "v", "avatar-index.png"].join("/");
     let wasmReady;
    ​
     function deriveKey(seed) {
       let x = 0x811c9dc5;
       for (let i = 0; i < seed.length; i += 1) {
         x ^= seed.charCodeAt(i);
         x = Math.imul(x, 0x01000193) >>> 0;
      }
       const key = new Uint8Array(32);
       for (let i = 0; i < key.length; i += 1) {
         x ^= x << 13;
         x ^= x >>> 17;
         x ^= x << 5;
         key[i] = x & 255;
      }
       return key;
    }
    ​
     function findMarker(bytes) {
       outer:
       for (let i = bytes.length - marker.length - 4; i >= 0; i -= 1) {
         for (let j = 0; j < marker.length; j += 1) {
           if (bytes[i + j] !== marker[j]) continue outer;
        }
         return i;
      }
       return -1;
    }
    ​
     function decrypt(bytes) {
       const seed = [
         document.documentElement.dataset.cacheSeed || "",
         document.body.dataset.profile || "",
         document.querySelector("meta[name='asset-profile']")?.content || ""
      ].join("|");
       const key = deriveKey(seed);
       const out = new Uint8Array(bytes.length);
       for (let i = 0; i < bytes.length; i += 1) {
         out[i] = bytes[i] ^ key[i & 31] ^ ((i * 31 + 17) & 255);
      }
       return out;
    }
    ​
     async function loadGate() {
       const response = await fetch(assetPath, { cache: "no-store" });
       if (!response.ok) throw new Error("asset");
       const carrier = new Uint8Array(await response.arrayBuffer());
       const offset = findMarker(carrier);
       if (offset < 0) throw new Error("marker");
       const view = new DataView(carrier.buffer, carrier.byteOffset + offset + marker.length, 4);
       const length = view.getUint32(0, false);
       const start = offset + marker.length + 4;
       const encrypted = carrier.slice(start, start + length);
       if (encrypted.length !== length) throw new Error("length");
       const wasmBytes = decrypt(encrypted);
       const module = await WebAssembly.instantiate(wasmBytes, {});
       return module.instance.exports;
    }
    ​
     function writeString(memory, pointer, value) {
       const bytes = new TextEncoder().encode(value);
       new Uint8Array(memory.buffer).set(bytes, pointer);
       return bytes.length;
    }
    ​
     function renderConsole(payload) {
       screen.innerHTML = "";
       const box = document.createElement("section");
       box.className = "dashboard";
       const title = document.createElement("h1");
       title.textContent = payload.title || "Console";
       const flag = document.createElement("code");
       flag.className = "flag";
       flag.textContent = payload.flag;
       box.append(title, flag);
       screen.append(box);
    }
    ​
     async function unlock(username, password) {
       const res = await fetch("/api/unlock", {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({ username, password })
      });
       const payload = await res.json().catch(() => ({ ok: false }));
       if (!res.ok || !payload.ok) throw new Error(payload.message || "Access denied.");
       return payload;
    }
    ​
     wasmReady = loadGate();
    ​
     form.addEventListener("submit", async event => {
       event.preventDefault();
       submit.disabled = true;
       status.className = "status";
       status.textContent = "Checking identity.";
       try {
         const wasm = await wasmReady;
         const username = document.getElementById("username").value;
         const password = document.getElementById("password").value;
         const uPtr = 1024;
         const pPtr = 1536;
         const uLen = writeString(wasm.memory, uPtr, username);
         const pLen = writeString(wasm.memory, pPtr, password);
         if (wasm.check(uPtr, uLen, pPtr, pLen) !== 1) {
           throw new Error("Access denied.");
        }
         const payload = await unlock(username, password);
         renderConsole(payload);
      } catch (error) {
         status.className = "status bad";
         status.textContent = error.message === "Access denied." ? "Access denied." : "Gate resource is not ready.";
         submit.disabled = false;
      }
    });
    })();

    3. 从 PNG 中提取 WASM

    JS 指代资源路径:

    1
    /assets/media/cache/2026/07/31/r/e/v/avatar-index.png

    这不是普通图片:

    avatar-index

    (图很小:1×1 像素壳 + 尾部载荷。)

    关键 marker:

    1
    const markerText = "HYNU-WASM-PAYLOAD\0";  // 注意结尾 \0,共 18 字节

    WinHex 可见 IEND 后附加数据:

    image.png

    3.1 文件布局

    1
    2
    3
    4
    [合法 PNG ... IEND]
    [HYNU-WASM-PAYLOAD\0]   // 18 字节
    [length: u32 BE]       // 本题 0x00000317 = 791
    [encrypted WASM]       // 791 字节密文

    3.2 截取密文

    1
    2
    3
    offset = index("HYNU-WASM-PAYLOAD\0")
    length = BE_u32(file[offset+18 : offset+22])
    cipher = file[offset+22 : offset+22+length]

    密文不以 \0asm 开头 → 不是明文 WASM,需解密。

    3.3 构造 key 并解密

    seed 由 HTML 三个字段用 | 拼接:

    1
    data-cache-seed | data-profile | meta[name=asset-profile]

    本题:

    1
    theme://cdn.hynu.internal/cache/31/rev|hynuctf-quiet-gate|archive:quiet-blueprint:v1

    算法:

    1. 对 seed 做 FNV-1a(32-bit)
    2. xorshift 扩展为 32 字节 key
    3. 解密:
    1
    plain[i] = cipher[i] ^ key[i & 31] ^ ((i * 31 + 17) & 0xFF)

    解密后文件头:

    1
    00 61 73 6D 01 00 00 00   →  \0asm + version 1

    得到 gate.wasm,再用 wasm2c 等工具分析。

    4. 分析 check:还原用户名 / 密码

    4.1 函数原型

    前端调用:

    1
    wasm.check(uPtr, uLen, pPtr, pLen)

    wasm2c 导出:

    1
    2
    3
    /* export: 'check' */
    u32 w2c_gate_check(w2c_gate* instance, u32 var_p0, u32 var_p1, u32 var_p2, u32 var_p3);
    // uPtr uLen pPtr pLen

    真正逻辑在 w2c_gate_check_0:

    参数 含义
    var_p0 用户名在 linear memory 中的指针
    var_p1 用户名长度
    var_p2 密码指针
    var_p3 密码长度
    返回值 1 通过,0 失败

    4.2 长度校验

    1
    2
    3
    4
    5
    // 用户名长度必须为 16
    if (var_p1 != 16u) return 0;

    // 密码长度必须为 17
    if (var_p3 != 17u) return 0;

    4.3 逐字节 XOR 比较

    以用户名第 0 字节为例:

    1
    2
    3
    4
    // username[0]
    var_i0 = i32_load8_u(... memory[uPtr + 0] ...);
    var_i0 ^= 53u;
    if (var_i0 != 71u) return 0;

    等价:

    1
    2
    3
    if ((memory[uPtr + i] ^ A) != B) return 0;
    // 因 XOR 可逆:
    // memory[uPtr + i] == A ^ B

    密码段把 var_p0 换成 var_p2,偏移 0..16,同一公式。全部通过返回 1。

    4.4 常量表与结果

    模式:input[i] ^ A == B → input[i] = A ^ B

    用户名(16 字节)

    i A B A^B char
    0 53 71 114 r
    1 101 0 101 e
    2 149 248 109 m
    3 197 154 95 _
    4 245 134 115 s
    5 238 135 105 i
    6 30 114 108 l
    7 78 43 101 e
    8 126 16 110 n
    9 174 218 116 t
    10 167 248 95 _
    11 215 180 99 c
    12 7 102 97 a
    13 55 84 99 c
    14 103 15 104 h
    15 96 5 101 e

    → username = rem_silent_cache

    密码(17 字节)

    i A B A^B char
    0 138 221 87 W
    1 186 142 52 4
    2 234 153 115 s
    3 26 119 109 m
    4 74 21 95 _
    5 67 114 49 1
    6 115 0 115 s
    7 163 252 95 _
    8 211 157 78 N
    9 3 51 48 0
    10 252 136 116 t
    11 44 115 95 _
    12 92 104 52 4
    13 140 211 95 _
    14 188 236 80 P
    15 181 219 110 n
    16 229 130 103 g

    → password = W4sm_1s_N0t_4_Png

    4.5 还原脚本

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    # input[i] ^ A == B  =>  input[i] = A ^ B

    user = [
    (53, 71), (101, 0), (149, 248), (197, 154),
    (245, 134), (238, 135), (30, 114), (78, 43),
    (126, 16), (174, 218), (167, 248), (215, 180),
    (7, 102), (55, 84), (103, 15), (96, 5),
    ]
    pwd = [
    (138, 221), (186, 142), (234, 153), (26, 119),
    (74, 21), (67, 114), (115, 0), (163, 252),
    (211, 157), (3, 51), (252, 136), (44, 115),
    (92, 104), (140, 211), (188, 236), (181, 219),
    (229, 130),
    ]

    username = "".join(chr(a ^ b) for a, b in user)
    password = "".join(chr(a ^ b) for a, b in pwd)
    print(username) # rem_silent_cache
    print(password) # W4sm_1s_N0t_4_Png

    5. 流程小结

    1
    2
    3
    1. 长度:用户名 16、密码 17
    2. 逐字节 XOR 校验 username / password
    3. check 返回 1 后,POST /api/unlock 换 flag

    账号:

    1
    2
    username: rem_silent_cache
    password: W4sm_1s_N0t_4_Png

    四、其三:货币の战争

    我的大菜,也是最近学自动机搞的小东西的实现。

    1. 出题动机

    这题最初不是奔着「出一道逆向」去的,而是那段时间一直在看状态机、后量子密码。理论看到后面真的想吐:交易状态、银行验证、账本锁定、回滚、防双花、密钥交换……纸面上都挺有道理,停在纸面上就很虚。

    于是干脆自己搓一个小的。完整金融系统当然不现实;出题心态是:先实现一套货币交易系统,再顺带把 flag 塞进去。

    2. 表面行为

    1
    payment_server.exe sample.payment

    跑起来服务器说交易已正常清算:

    1
    2
    3
    [bank] digital payment server started
    [bank] transaction received
    [bank] transaction settled

    这是故意留的「假正常」:

    • sample.payment 确实是合法支付文件
    • 服务器会解析、验证、清算
    • 正常路径没有 flag

    真正要看的不是「这笔交易成没成」,而是「系统里有没有别的交易路径」。

    3. 状态机设计

    拆了几个状态机:

    1
    2
    3
    4
    TransactionFSM
    BankFSM
    LedgerFSM
    WalletFSM

    简单说:

    1
    2
    3
    4
    钱包负责生成交易
    交易状态机负责走流程
    银行状态机负责审核
    账本状态机负责锁钱和清算

    放到 reverse 里好玩的点:选手不是只看一个 if,而是要还原一整条流程。

    正常路径

    1
    2
    3
    4
    5
    6
    7
    SIGN
    → KYBER_EXCHANGE
    → AES_ENCRYPT
    → SEND
    → VERIFY_SUCCESS
    → SETTLE
    → SETTLE

    终点:transaction settled。

    隐藏路径(旧版 recovery)

    1
    2
    3
    4
    5
    6
    7
    SIGN
    → KYBER_EXCHANGE
    → AES_ENCRYPT
    → SEND
    → VERIFY_FAIL
    → REFUND
    → LEGACY_RETRY

    表面:失败 → 退款 → 结束。
    继续往下会发现 LEGACY_RETRY 会进隐藏状态。

    核心:flag 不在正常支付里,而在「失败后被旧系统重新接管」的那条路里。

    4. payment 文件结构

    不止改事件编号就能出 flag,文件里还有多层校验:

    1
    DPAY | version | amount | kyber_capsule | event_count | events | signature | checksum
    字段 作用
    DPAY 文件头,提示不是普通文本
    amount 金额
    events 状态机事件序列
    signature / checksum 防止随便改事件
    kyber_capsule 后量子支付的「小玩意」(伪 Kyber 胶囊校验)

    说明:题里的 Kyber 不是完整 Kyber768。工程量太大,对 reverse 主线帮助有限,更像伪胶囊校验——它不是 flag,也不是最终解密密钥。

    形象一点:

    1
    2
    3
    flag          = 银行服务器里锁起来的秘密回执
    pathHash = 真正开锁的钥匙
    Kyber capsule = 门口保安要看的通行证
    • 光有通行证不行
    • 光知道门在哪里也不行
    • 必须让交易走到隐藏 recovery,算出特殊 pathHash
    • pathHash 才是解密 receipt 的 key

    正常路径的 pathHash 不对,解不开;隐藏路径对了,才解出加密 receipt。

    5. Flag 在哪

    • 不在 sample.payment 明文里
    • 不在 程序字符串明文里
    • 加密后放在 payment_server.exe 里

    只有构造正确的隐藏交易,让服务器进入 legacy recovery,才会输出:

    1
    HYNUCTF{M0N3YG0D}

    喜欢这设计的原因:不是单纯字符串比较,也不是单纯 AES/XOR,而是把 状态路径 和 解密 key 绑在一起——你不是在找一个密码,而是在找一条路。

    想表达的是:

    有些系统的秘密不一定藏在某个变量里,而是藏在某条不该再存在的流程里。

    6. 附件策略

    最终只放:

    1
    2
    payment_server.exe
    sample.payment

    不放:

    • wallet.exe(作者生成交易用)
    • secret.payment(作者验题用的隐藏交易)

    选手拿到的 sample.payment 跑起来只会清算成功。真正要做的是:

    1. 逆向服务器
    2. 分析文件格式
    3. 找到隐藏事件序列
    4. 重新构造 payment
    5. 让服务器走旧版 recovery

    7. 碎碎念

    这题不算特别难,但比单纯藏字符串或套壳舒服一点:至少是从系统设计角度做 reverse——钱包、交易、银行、账本、胶囊校验、隐藏回执拼起来,有一点「它真的像个东西」的感觉。

    虽然还是挺手搓,但能把学得想吐的理论变成能跑的题,已经挺值了。


    附录:三题速查

    题号 题名 关键点 备注
    其一 医院导诊系统故障啦 菜单 3 → MNT-2419 → 选项 7 → xor 0x91 + 3 秒 适合脚本自动化
    其二 打开 ZGH 被网络封闭的内心 PNG 尾部加密 WASM + HTML seed 解密 + check 逐字节 XOR rem_silent_cache / W4sm_1s_N0t_4_Png
    其三 货币の战争 状态机隐藏 recovery 路径 → pathHash 解密 receipt Flag:HYNUCTF{M0N3YG0D}

     

    万物皆可逆
    上一篇:看《与奔驰于透明之夜的你,谈一场看不见的恋爱》小说有感 下一篇:逆向工程入门☞北

    留言

    欢迎留下想法。留言将由 GitHub Discussions 保存。

    累计访问 加载中 次