文章总结: 本文详细解析了一道逆向分析题的解题过程,通过两种方法找到了最终的flag。方法一直接分析汇编代码,发现其使用异或操作(密钥为0x42)对加密数据进行解密;方法二则利用题目中的Checksum验证机制,结合DFS和多线程爆破进行验证。两种方法都得到了相同的结果:52pojie!!!2026Happynewyear!文章还提供了相应的Python解题脚本。 综合评分: 85 文章分类: 逆向分析,CTF,WEB安全,渗透测试,红队
2. 样本初步分析
先看 CM1_unpacked.exe 基本信息:
- PE64(
0x8664) ImageBase = 0x140000000- 主要导入:
USER32/GDI32/KERNEL32/msvcrt - 存在对话框消息处理流程(典型 Win32 GUI CrackMe)
3. 按钮点击分支定位
在对话框过程函数 0x140007AD0(消息分发)中:
-
WM_COMMAND (0x111) -
LOWORD(wParam) == 11时走“解密按钮”分支
该分支关键流程:
-
GetDlgItemTextA读取 3 个输入框(输入路径、输出路径、密码)
-
调用核心函数
sub_140008720做校验与解密 -
若返回值为 0:提示成功
-
若返回值非 0:拼接
ERR-%03d,弹框报错
即:
-
ERR-xxx的
xxx就是sub_140008720的返回码
4. 核心函数 sub_140008720 还原
函数原型可抽象为:
复制代码 隐藏代码
intdecrypt_and_check(constchar* password, FILE* fin, FILE* fout);
主流程(伪代码):
复制代码 隐藏代码
intdecrypt_and_check(pw, fin, fout) {
ctx = init_crc64_ctx();
update_crc64(ctx, CONST_14_BYTES, 14);
update_crc64(ctx, pw, strlen(pw));
seed = finalize_crc64(ctx); // 64-bit
read header(16 bytes);
if (!parse_header_magic_and_init_crc32(header)) return1;
file_len = ftell(fin);
data_len = file_len - 16;
if (file_len % 8 != 0) return2;
rewind to data start;
buf = malloc(data_len);
if (!buf) return3;
fread(buf, data_len, 1, fin);
decrypt_blocks(buf, data_len, seed, header.iv);
if (!check_crc32(buf, data_len, header.stored_crc32)) {
free(buf);
return4; // ERR-004
}
pad = buf[data_len - 1];
if (pad > data_len) {
free(buf);
return5;
}
fwrite(buf, data_len - pad, 1, fout);
free(buf);
return0;
}
错误码映射很清晰:
-
1:头部不合法(magic 不对)
-
2:长度非 8 对齐
-
3:内存申请失败
-
4:完整性校验失败(本题关键)
-
5:padding 异常
5. ERR-004 的验证逻辑(关键)
ERR-004 对应函数 sub_1400082E0,其核心是:
复制代码 隐藏代码
bool ok = (~crc32_state == stored_crc32_from_header);
其中 crc32_state 来自对“解密后数据(含 padding)”逐块更新。
也就是:
- 密码错 -> 解密流错 -> 明文错 -> CRC32 不匹配 -> 返回
4-> 弹ERR-004
这正是题目要求定位的验证点。
6. 文件格式与解密算法还原
6.1 密文文件头格式(16 字节)
flag.png.encrypted 前 16 字节:
-
[0:4]magic:
"CM26"(小端 dword 为0x36324D43) -
[4:8]stored_crc32(小端) -
[8:16]iv(8 字节)
样本实测:
stored_crc32 = 0x8D8445A2iv = f5 69 73 60 01 cb 35 bc
6.2 口令到种子(seed)生成
程序用 64 位 CRC(多项式 0xC96C5795D7870F42):
- 初始化状态为
0xFFFFFFFFFFFFFFFF - 先喂固定 14 字节常量(位于
.rdata,偏移0xA828)FB A1 FF FF 22 9D FF FF A3 A2 FF FF 83 A2 - 再喂密码明文字节
- 做末尾 4 字节长度扰动并取反,得到 64 位
seed
6.3 分组解密
每块 8 字节,使用链式异或:
复制代码 隐藏代码
P[i] = C[i] XOR Prev XOR F(state)
Prev = (i==0 ? IV : C[i-1])
state = F(state) 每块更新一次
其中 F 为:
state = rol64(state, 3)- 对
state的每个字节做 S-Box 替换
该 S-Box 位于 .rdata 0xA270,首字节 63 7C 77 7B ...,即 AES S-Box。
7. 逆推出正确密码
因为目标文件是 PNG,明文前 8 字节固定为:
89 50 4E 47 0D 0A 1A 0A
又有:
P0 = C0 XOR IV XOR F(seed)
所以可直接求出首块密钥流:
F(seed) = C0 XOR IV XOR PNG_SIG
实算得到:
F(seed) = 87 e4 26 b5 27 2e cc 95
再逆 F(逆 S-Box + ror64(3))可得初始 seed:
seed = 0x55A4F867BA4475DD
用该 seed 对整文件解密后:
- 明文前缀正确为 PNG 文件头
- 计算 CRC32 恰好等于
0x8D8445A2 - 校验通过,不再触发
ERR-004
随后在解出的 PNG tEXt 块中读到口令。
8. 最终答案
正确解密密码(flag)为:
复制代码 隐藏代码
flag{EncrypTIoN_Is_haRd_52p0jIE_2o26_m62Tc4uj78maAq1C}
9. 可复现实验脚本(Python)
以下脚本可直接验证思路:从已知 PNG 头反推 seed,解密并提取 flag 文本。
复制代码 隐藏代码
from pathlib import Path
import pefile, struct, zlib, re
enc = Path("question/flag.png.encrypted").read_bytes()
head, body = enc[:16], enc[16:]
magic = head[:4]
stored_crc = int.from_bytes(head[4:8], "little")
iv = head[8:16]
assert magic == b"CM26"
# 从程序里取 AES S-box
pe = pefile.PE("CM1_unpacked.exe")
exe = Path("CM1_unpacked.exe").read_bytes()
sbox_off = pe.get_offset_from_rva(0xA270)
sbox = exe[sbox_off:sbox_off + 256]
inv = [0] * 256
for i, v inenumerate(sbox):
inv[v] = i
MASK = (1 << 64) - 1
defF(x: int) -> int:
x = ((x << 3) & MASK) | (x >> 61) # rol64(x,3)
out = 0
for i inrange(8):
out |= sbox[(x >> (8 * i)) & 0xFF] << (8 * i)
return out
defFinv(y: int) -> int:
b = bytearray(8)
for i inrange(8):
b[i] = inv[(y >> (8 * i)) & 0xFF]
x = int.from_bytes(b, "little")
return ((x >> 3) | ((x & 0x7) << 61)) & MASK # ror64(x,3)
# 已知 PNG 头反推 seed
png_sig = bytes.fromhex("89504e470d0a1a0a")
c0 = body[:8]
ks0 = bytes(a ^ b ^ c for a, b, c inzip(c0, iv, png_sig))
seed = Finv(int.from_bytes(ks0, "little"))
print(f"[+] seed = 0x{seed:016X}")
# 解密
state = seed
prev = iv
plain = bytearray()
for i inrange(0, len(body), 8):
c = body[i:i+8]
state = F(state)
ks = state.to_bytes(8, "little")
p = bytes(c[j] ^ prev[j] ^ ks[j] for j inrange(8))
plain.extend(p)
prev = c
calc_crc = zlib.crc32(plain) & 0xFFFFFFFF
print(f"[+] stored_crc = 0x{stored_crc:08X}, calc_crc = 0x{calc_crc:08X}")
assert calc_crc == stored_crc
# 去除 padding 并解析 PNG 文本块
pad = plain[-1]
png = bytes(plain[:-pad])
Path("question/decrypted_recovered.png").write_bytes(png)
pos = 8
while pos + 8 <= len(png):
ln = struct.unpack(">I", png[pos:pos+4])[0]
typ = png[pos+4:pos+8]
data = png[pos+8:pos+8+ln]
if typ in (b"tEXt", b"iTXt", b"zTXt"):
m = re.search(rb"flag\\{[^}]+\\}", data)
if m:
print("[+] flag =", m.group().decode())
break
pos += ln + 12
【2026春节】解题领红包之八(Android 中级)
0x00 题目信息与最终结论
题目:【2026春节】解题领红包之八 {Android 中级题} 出题老师:正己.apk
最终可通过输入(flag):
FLAG{HJMWAPJ2026NBLD}
关键点:
-
verifyAndDecrypt(packBytes, trim(input))最终是 位图渲染后 memcmp。
-
packBytes来自
assets/hjm_pack.bin。 -
存在 native 作弊开关
setDebugBypass(true)(写全局5d140=1),会切换到 debug key 分支。 -
在 debug 分支下,可稳定还原出正确输入:
FLAG{HJMWAPJ2026NBLD}。
0x01 Java/Compose 层入口与按钮事件全分支
1. 主界面按钮
主界面有两个按钮:
接着奏乐接着舞投喂 flag
对应逻辑位于 Q0.v、Q0.w,并由 Q0.N/Q0.h 组合函数绑定。
2. 按钮 接着奏乐接着舞 分支(Q0.v.case 0)
- 若当前
GameState.cheatTriggered == true:直接 return(点击被锁死)。 - 否则计算当前点击落点(基于 0/250/500/750ms beatmap)。
- 调用 native:
checkRhythm(...)updateExp(...)
- 分支:
- 若
updateExp >= 0 && checkRhythm != -7:正常更新分数(Perfect/Good/Miss/None)。 - 否则:进入作弊态
score=Cheat("嘻嘻"), cheatTriggered=true。
3. 按钮 投喂 flag 分支
- 仅设置
dialogVisible=true,弹出输入框。
4. 弹窗按钮分支
-
验证:走协程
Q0.A,调用 nativeverifyAndDecrypt(packBytes, trim(input))。 -
取消:仅关闭弹窗。
-
点击外部 dismiss:仅关闭弹窗。
5. 协程 Q0.A 校验流程
- 若
packBytes缓存为空:先读取assets/hjm_pack.bin。 - 调
NativeBridge.verifyAndDecrypt(packBytes, trim(input))。 - 若解出结构为空:显示
Flag 不正确。 - 否则显示
验证成功,并更新层级状态。 - 异常:显示
验证出错。
0x02 Native 入口与关键函数定位
目标 so:_apk/lib/x86_64/libhajimi.so
关键 native 函数地址:
-
verifyAndDecrypt:
0x24850 -
setDebugBypass:
0x24ca0 -
24fc0(环境/状态采样函数):
0x24fc0 -
2e680(mode2 解包):
0x2e680 -
2efd0(输入字符串 -> 64×64 位图):
0x2efd0 -
2e570(debug key 生成):
0x2e570
全局变量:
-
5d140:debug bypass 标志(
setDebugBypass写入) -
5cff0 / 5cff8:运行态 key 与 gate
-
5cfe8:参与解包的状态种子
-
5d004/5d008/5d00c:
24fc0更新的状态字段
0x03 verifyAndDecrypt(packBytes, trim(input)) 内部逻辑
verifyAndDecrypt 关键流程(mode=2):
- 从
jbyteArray packBytes读入本地缓冲。 - 检查头:
HJM1+mode==2+ 宽高/帧参数合法。 - 调
24fc0,并基于返回值更新状态,最终更新5cfe8。 - 进入 mode2 分支(
0x24b8b)。 - 选 key:
- 如果
5d140 != 0(debug bypass 开启)=> key 来自2e570()。 - 否则 => key 来自
5cff0(并受r13条件影响是否^0xA5..)。
-
调
2e680对 pack payload 解包。 -
调
2efd0(input, 64, 64, out, 512)生成输入位图。 -
memcmp(decrypted_payload, rendered_input, 512):相等即验证成功。
这意味着题目的核心本质是:
求一个 input,使 render(input) 与解包目标位图完全一致。
0x04 packBytes 的确认
Java 侧 Q0.y 明确:
context.getAssets().open("hjm_pack.bin")
因此 verifyAndDecrypt 第一个参数就是:
assets/hjm_pack.bin
文件结构确认:
- 文件头
HJM1 - mode = 2
- payload 长度对应 64×64 bit(512 bytes)
0x05 作弊模式如何开启
native 里有导出接口:
-
setDebugBypass(boolean)(函数体在
0x24ca0)
逻辑非常直接:
-
dl == 1时写
byte [0x5d140] = 1
一旦 5d140=1,verifyAndDecrypt mode2 分支会走 debug key 路径(2e570),从而解出另一份目标位图。
实战可用两种方式:
- 运行时 hook 调
NativeBridge.INSTANCE.setDebugBypass(true)。 - 直接内存改写
5d140=1。
0x06 求解策略与过程
我没有直接硬啃所有 C++ STL/环境代码,而是采用“可复现仿真 + 逐层验证”的策略。
1. 先验证渲染器 2efd0
构造调用 2efd0(input,64,64,buf,512),确认它输出是稀疏文本位图(例如输入 AAAAAA 只点亮少量位)。
2. 跑 verifyAndDecrypt 抓 memcmp 两侧缓冲
在仿真中拦截 memcmp:
- 参数1:解包后目标位图
- 参数2:
2efd0(input)渲染位图
3. 比较普通分支 vs debug 分支
- 普通分支(
5d140=0)下目标位图高熵,非文本感。 - debug 分支(
5d140=1)下目标位图变成明显文本位图(总置位约 314)。
4. 用 2efd0 做反求
把“位图距离(XOR bitcount)”作为目标函数,对候选字符串做贪心/爬山优化,长度扫描后在 N=21 收敛到 0 距离。
收敛结果:
FLAG{HJMWAPJ2026NBLD}
验证:
-
render(flag)与 debug 分支解包目标
memcmp完全相等(bit 差异=0)。
0x07 关键复现命令
1. 查看最终分析结论
Q0.A verifyAndDecrypt(packBytes, trim(input)) 逆向分析(含作弊模式与可通过输入)
1. packBytes 来源
-
Java 协程
Q0.A -> Q0.y从assets/hjm_pack.bin读取字节数组。 -
verifyAndDecrypt的第一个参数就是这个
packBytes。 -
文件头校验:
HJM1(0x314D4A48),并且模式字段为2。
2. Native 主流程(0x24850)
verifyAndDecrypt(JNIEnv*, ..., jbyteArray packBytes, jstring input) 关键流程:
- 读取
packBytes到本地缓冲,校验头部。 - 调
24fc0更新状态:5d004/5d008/5d00c,并据此更新5cfe8。 - 分支按
mode:本题mode=2,走0x24b8b分支。 - 关键开关:
-
5d140 != 0(debug bypass)时,key 来自
2e570()。 -
否则 key 来自
5cff0(并可能按r13决定是否^0xA5...)。
-
调
2e680用 key+5cfe8解包hjm_pack.bin的 payload(64×64 bit,512 bytes)。 -
2efd0(input,64,64,buf,512)生成输入位图。
-
memcmp(decrypted_payload, rendered_input, 512):相等即验证成功。
3. 作弊模式如何开启
Native 里有显式接口:
-
setDebugBypass(boolean)(
0x24ca0) -
dl==1时写全局
5d140=1
当 5d140=1 时,verifyAndDecrypt 强制走 debug key 路径(2e570),这是可稳定复现的“作弊模式”验证分支。
说明:Java 反编译代码里未发现普通 UI 直接调用点;实战可通过 Frida/Hook 直接调用该 native 方法或直接改写 5d140 达到同效果。
4. 真实可通过输入(Flag)
在 debug bypass=1 分支下,解包目标位图与渲染器 2efd0 逐字符匹配,得到 唯一完全匹配输入:
FLAG{HJMWAPJ2026NBLD}
验证结果:
-
memcmp差异位数 =
0(完全一致) -
即可返回“验证成功”分支。
5. 结论
-
packBytes:
assets/hjm_pack.bin -
本题关键是
mode=2下的位图比较链路:2e680(解包) +2efd0(渲染) +memcmp -
开启作弊模式(
setDebugBypass(true))后,正确 flag:
FLAG{HJMWAPJ2026NBLD}
按钮点击事件逻辑分析
1. 分析范围与入口
- App 入口:
MainActivity.onCreate仅加载 Compose 根内容(Q0.d.b),无多 Activity 按钮分流。 - 主界面核心在
Q0.N.a(...),主按钮容器在Q0.N.c(...) -> Q0.h。 - 弹窗(输入 flag)在
Q0.N.a(...)里通过androidx.compose.material3.n.b(...)构建。
2. 按钮总览(全部可点击按钮)
- 主界面按钮A:
接着奏乐接着舞 - 主界面按钮B:
投喂 flag - 弹窗按钮A:
验证 - 弹窗按钮B:
取消
补充:弹窗还有 onDismissRequest(点外部/返回键关闭),虽然不是实体按钮,但属于点击/关闭交互分支。
3. 主界面双按钮绑定关系
3.1 绑定证据
-
Q0.N.c(p23, p24, ...)将两个回调传入
Q0.h:v9_1(p23, ..., p24, ...)。 -
Q0.h中第1个
E.a(this.j, ..., new Q0.g(..., 0, ...)),第2个E.a(this.m, ..., new Q0.g(..., 1, ...))。 -
Q0.g文案分支:
-
case 0=>
"接着奏乐接着舞" -
case 1=>
"投喂 flag" -
Q0.x实际传参:
-
第1个回调是
new Q0.v(..., q=0)(节奏点击逻辑) -
第2个回调是
new Q0.w(..., j=0)(打开弹窗)
3.2 对应结论
-
接着奏乐接着舞->
Q0.v.case 0 -
投喂 flag->
Q0.w.case 0
4. 每个按钮点击逻辑与完整分支
4.1 按钮A:接着奏乐接着舞(Q0.v.case 0)
入口:Q0.v.o() 的 case 0
分支树
- 若
gameState.cheatTriggered == true
- 直接返回,不再处理点击(已进入作弊态后,节奏点击被锁死)。
- 否则继续节奏判定
- 取当前纳秒时间,计算本次点击相位。
- 根据节奏点数组(0/250/500/750ms)选最近点位索引。
- 组装扰动参数后调用 Native:
checkRhythm(...)updateExp(...)
- Native 返回后分支
- 若
updateExp >= 0且checkRhythm != -7: - 正常更新
GameState(exp + score,cheat=false) - score 映射到
Q0.Q(None/Perfect/Good/Miss) - 否则:
- 进入作弊分支:
GameState(exp保持原值, score=Cheat("嘻嘻"), cheat=true)
可见结果
- 正常:分数文本在
就绪/完美/良好/失误间变化。 - 作弊:分数变
嘻嘻,并触发作弊态展示。
4.2 按钮B:投喂 flag(Q0.w.case 0)
入口:Q0.w.o() 的 case 0
分支树
-
无条件执行:
dialogVisible = true -
Q0.N.a(...)检测到
dialogVisible后显示验证弹窗
4.3 弹窗按钮A:验证(Q0.B.o() -> 协程 Q0.A.g())
入口:Q0.C 组合的 TextButton 点击回调是 Q0.B
第1层分支(点击瞬间)
- 输入为空(
trim后为空)
- 状态文本设为:
请先输入 flag - 不发起校验协程
- 输入非空
-
dialogVisible = false(先关弹窗)
-
状态文本设为:
验证中... -
启动协程
Q0.A
第2层分支(协程 Q0.A)
- 若缓存密文包字节为空
- 先从
assets/hjm_pack.bin读取并缓存,再继续校验
- 调用 Native 校验
verifyAndDecrypt(packBytes, trim(input))
- 解包判定
- 若
h1.a.S(decryptBytes) == null - 状态文本:
Flag 不正确 - 否则
- 保存解包得到的真实层级对象(
P) - 解析输入文本对应层级
Q0.N.j(...) - 解析成功:用解析出的层级
- 解析失败:回退到
a==100的层级(lv100) - 状态文本:
验证成功
- 异常分支(协程中任意异常)
- 记录日志
verify flag failed - 状态文本:
验证出错
4.4 弹窗按钮B:取消(Q0.w.default 即 j=2)
入口:Q0.D.case 0 中构建 new Q0.w(..., 2)
分支树
- 无条件执行:
dialogVisible = false - 关闭弹窗,不触发任何校验
4.5 弹窗外部关闭(非按钮,但属于点击关闭分支)
入口:Q0.N.a(...) 弹窗 onDismissRequest = new Q0.w(..., 1)
分支树
- 无条件执行:
dialogVisible = false - 与“取消”一样只关闭弹窗
5. 结果文本/颜色分支(按钮后效)
Q0.N.i(...) 根据状态文本分支颜色:
-
验证成功-> 成功色
-
Flag 不正确-> 失败色
-
验证出错-> 错误色
-
其他/空 -> 默认色
这部分不是点击入口,但属于按钮点击后可见分支结果。
6. 完整性结论(按钮是否遗漏)
通过调用点反查可确认未遗漏:
-
new Q0.v(...)仅在主界面按钮绑定处出现一次(对应
接着奏乐接着舞)。 -
new Q0.w(...)仅出现三种 case:
-
j=0:打开弹窗(
投喂 flag) -
j=1:弹窗 onDismissRequest
-
j=2:弹窗
取消
- 弹窗
验证回调唯一入口是Q0.B -> Q0.A。
结论:本 APK 当前界面的所有按钮点击事件及其逻辑分支已全部覆盖。
2. 运行仿真脚本
ai脚本:
复制代码 隐藏代码
import struct
from pathlib import Path
import lief
from unicorn import Uc, UcError, UC_ARCH_X86, UC_MODE_64, UC_HOOK_CODE, UC_PROT_ALL
from unicorn.x86_const import *
PAGE = 0x1000
MASK64 = (1<<64)-1
defp64(x):
return struct.pack('<Q', x & MASK64)
defu64(b):
return struct.unpack('<Q', b)[0]
defi64(x):
return x if x < (1<<63) else x - (1<<64)
defi32(x):
x &= 0xffffffff
return x if x < 0x80000000else x - 0x100000000
classEmuQ8:
def__init__(self, so_path):
self.so_path = so_path
self.bin = lief.parse(str(so_path))
self.mu = Uc(UC_ARCH_X86, UC_MODE_64)
self.mapped_pages = set()
self.HEAP_BASE = 0x10000000
self.HEAP_SIZE = 0x08000000
self.STACK_BASE = 0x20000000
self.STACK_SIZE = 0x02000000
self.TLS_BASE = 0x30000000
self.TLS_SIZE = 0x1000
self.STOP_ADDR = 0x40000000
self.STUB_BASE = 0x41000000
self.ENV_PTR = 0x50000000
self.JNI_TABLE = 0x50001000
self.heap_cur = self.HEAP_BASE + 0x1000
self.handle_cur = 0x60000000
self.load_images = []
self.jbyte_arrays = {}
self.jstrings = {}
self.jstring_cptr = {}
self.memcmp_records = []
self.key_records = []
self.in_verify = False
self.unknown_imports = {}
self.ret24_vals = [0, 0]
self.ret24_idx = 0
self.popcnt_map = {
0x298C6: (UC_X86_REG_R14, UC_X86_REG_RBP),
0x29DB8: (UC_X86_REG_R8, UC_X86_REG_R14),
0x2A129: (UC_X86_REG_RAX, UC_X86_REG_R14),
0x2A838: (UC_X86_REG_RSI, UC_X86_REG_R14),
0x2B34D: (UC_X86_REG_RCX, UC_X86_REG_RBP),
0x2B572: (UC_X86_REG_RAX, UC_X86_REG_RBP),
0x2B778: (UC_X86_REG_RSI, UC_X86_REG_R14),
0x2B92F: (UC_X86_REG_RAX, UC_X86_REG_R12),
0x2BA68: (UC_X86_REG_RSI, UC_X86_REG_R12),
0x2BFA7: (UC_X86_REG_R12, UC_X86_REG_RBX),
0x2C2F7: (UC_X86_REG_RAX, UC_X86_REG_RBX),
0x2C4A3: (UC_X86_REG_RBX, UC_X86_REG_R14),
0x2C7EB: (UC_X86_REG_RAX, UC_X86_REG_R14),
0x2CA58: (UC_X86_REG_RSI, UC_X86_REG_R14),
0x2CDCD: (UC_X86_REG_R8, UC_X86_REG_RDI),
0x2D069: (UC_X86_REG_RAX, UC_X86_REG_RBP),
0x2D0BE: (UC_X86_REG_R9, UC_X86_REG_RBP),
0x2D388: (UC_X86_REG_RDI, UC_X86_REG_R15),
0x2D718: (UC_X86_REG_RSI, UC_X86_REG_R12),
}
self._map_segments()
self._map_runtime_regions()
self._build_import_map()
self._build_jni_table()
# Restrict code hooks to hot stub ranges to avoid per-instruction overhead.
self.mu.hook_add(UC_HOOK_CODE, self._hook_code, begin=self.STOP_ADDR, end=self.STOP_ADDR)
self.mu.hook_add(UC_HOOK_CODE, self._hook_code, begin=0x55C70, end=0x56570)
self.mu.hook_add(UC_HOOK_CODE, self._hook_code, begin=self.STUB_BASE, end=self.STUB_BASE + 0x2000)
self.mu.hook_add(UC_HOOK_CODE, self._hook_code, begin=0x24FC0, end=0x24FC0)
self.mu.hook_add(UC_HOOK_CODE, self._hook_code, begin=0x2EFD0, end=0x2EFD0)
self.mu.hook_add(UC_HOOK_CODE, self._hook_code, begin=0x2E680, end=0x2E680)
for a inself.popcnt_map:
self.mu.hook_add(UC_HOOK_CODE, self._hook_code, begin=a, end=a)
def_align_down(self, x):
return x & ~(PAGE-1)
def_align_up(self, x):
return (x + PAGE - 1) & ~(PAGE-1)
def_map(self, addr, size, perms=UC_PROT_ALL):
a = self._align_down(addr)
b = self._align_up(addr + size)
total = b - a
try:
self.mu.mem_map(a, total, perms)
for p inrange(a, b, PAGE):
self.mapped_pages.add(p)
return
except UcError:
pass
for p inrange(a, b, PAGE):
if p inself.mapped_pages:
continue
self.mu.mem_map(p, PAGE, perms)
self.mapped_pages.add(p)
def_map_segments(self):
raw = Path(self.so_path).read_bytes()
for s inself.bin.segments:
if s.type != lief.ELF.Segment.TYPE.LOAD:
continue
va = s.virtual_address
vsz = s.virtual_size
fsz = s.physical_size
off = s.file_offset
self._map(va, vsz, UC_PROT_ALL)
if fsz > 0:
self.mu.mem_write(va, raw[off:off+fsz])
img = bytearray(vsz)
if fsz > 0:
img[:fsz] = raw[off:off+fsz]
self.load_images.append((va, bytes(img)))
def_map_runtime_regions(self):
self._map(self.HEAP_BASE, self.HEAP_SIZE, UC_PROT_ALL)
self._map(self.STACK_BASE, self.STACK_SIZE, UC_PROT_ALL)
self._map(self.TLS_BASE, self.TLS_SIZE, UC_PROT_ALL)
self._map(self.STOP_ADDR, PAGE, UC_PROT_ALL)
self._map(self.ENV_PTR, 0x4000, UC_PROT_ALL)
self._map(self.STUB_BASE, 0x4000, UC_PROT_ALL)
self._map(0x0, PAGE, UC_PROT_ALL)
self.mu.mem_write(self.STOP_ADDR, b"\xCC")
# Unicorn build in this environment doesn't expose FS_BASE register;
# keep fs:[0x28] canary in low memory where segmented read resolves.
self.mu.mem_write(0x28, p64(0x1122334455667788))
defreset_state(self):
# Restore original loadable image (including zeroed bss tail).
for va, img inself.load_images:
self.mu.mem_write(va, img)
# Reset runtime arenas and handles.
self.heap_cur = self.HEAP_BASE + 0x1000
self.handle_cur = 0x60000000
self.jbyte_arrays.clear()
self.jstrings.clear()
self.jstring_cptr.clear()
self.memcmp_records.clear()
self.key_records.clear()
self.in_verify = False
self.ret24_idx = 0
self.unknown_imports.clear()
# Restore canary and JNI header pointers.
self.mu.mem_write(0x28, p64(0x1122334455667788))
self.mu.mem_write(self.ENV_PTR, p64(self.JNI_TABLE))
def_build_import_map(self):
self.imp = {}
rels = self.bin.pltgot_relocations
for i, r inenumerate(rels):
addr = 0x55c70 + i * 0x10
name = r.symbol.name if r.has_symbol elsef'idx_{i}'
self.imp[addr] = name
self.handlers = {
0x55ca0: self._h_free,
0x55cb0: self._h_stack_fail,
0x55ce0: self._h_malloc,
0x55cf0: self._h_memset,
0x55d00: self._h_memcmp,
0x55e00: self._h_memcpy,
0x55e10: self._h_memmove,
0x55e60: self._h_strlen,
0x55d70: self._h_memchr,
0x55ec0: self._h_strlen_chk,
0x55d90: self._h_guard_acquire,
0x55da0: self._h_guard_release,
0x55e40: self._h_access,
0x55db0: self._h_clock_gettime,
0x55dc0: self._h_readlink,
0x55de0: self._h_syscall,
0x55df0: self._h_system_property_get,
0x55f60: self._h_next_prime,
}
# Internal function stubs
self.handlers[0x24fc0] = self._h_24fc0
self.handlers[0x2efd0] = self._h_2efd0
def_build_jni_table(self):
OFF = {
'GetStringUTFChars': 0x548,
'ReleaseStringUTFChars': 0x550,
'GetArrayLength': 0x558,
'NewByteArray': 0x580,
'GetByteArrayRegion': 0x640,
'SetByteArrayRegion': 0x680,
}
self.jni_stub = {}
idx = 1
for k, off in OFF.items():
addr = self.STUB_BASE + idx * 0x100
idx += 1
self.jni_stub[addr] = k
self.handlers[addr] = getattr(self, f'_jni_{k}')
self.mu.mem_write(self.JNI_TABLE + off, p64(addr))
self.mu.mem_write(self.ENV_PTR, p64(self.JNI_TABLE))
def_rd(self, addr, size):
returnself.mu.mem_read(addr, size)
def_wr(self, addr, data):
self.mu.mem_write(addr, data)
def_rd_u64(self, addr):
return u64(self._rd(addr, 8))
def_wr_u64(self, addr, v):
self._wr(addr, p64(v))
defalloc(self, size):
size = max(1, size)
size = (size + 0xF) & ~0xF
p = self.heap_cur
self.heap_cur += size
ifself.heap_cur >= self.HEAP_BASE + self.HEAP_SIZE:
raise RuntimeError('heap exhausted')
self._wr(p, b"\x00" * size)
return p
defnew_jbyte_array(self, b: bytes):
h = self.handle_cur
self.handle_cur += 8
self.jbyte_arrays[h] = bytearray(b)
return h
defnew_jstring(self, s: str):
h = self.handle_cur
self.handle_cur += 8
self.jstrings[h] = s
self.jstring_cptr[h] = 0
return h
def_ret(self):
rsp = self.mu.reg_read(UC_X86_REG_RSP)
ra = self._rd_u64(rsp)
self.mu.reg_write(UC_X86_REG_RSP, rsp + 8)
self.mu.reg_write(UC_X86_REG_RIP, ra)
def_hook_code(self, uc, address, size, _user):
if address == self.STOP_ADDR:
uc.emu_stop()
return
if address inself.popcnt_map:
dst, src = self.popcnt_map[address]
v = uc.reg_read(src) & MASK64
uc.reg_write(dst, v.bit_count())
uc.reg_write(UC_X86_REG_RIP, address + 5)
return
if address == 0x2e680andself.in_verify:
key = uc.reg_read(UC_X86_REG_RSI) & MASK64
self.key_records.append(key)
h = self.handlers.get(address)
if h isnotNone:
h()
return
# Unknown import in PLT range: fail fast for visibility
if0x55c70 <= address < 0x56570:
name = self.imp.get(address, 'unknown')
self.unknown_imports[(address, name)] = self.unknown_imports.get((address, name), 0) + 1
self.mu.reg_write(UC_X86_REG_RAX, 0)
self._ret()
# ===== JNI handlers =====
def_jni_GetArrayLength(self):
h = self.mu.reg_read(UC_X86_REG_RSI)
arr = self.jbyte_arrays.get(h)
self.mu.reg_write(UC_X86_REG_RAX, len(arr) if arr isnotNoneelse0)
self._ret()
def_jni_GetByteArrayRegion(self):
h = self.mu.reg_read(UC_X86_REG_RSI)
start = self.mu.reg_read(UC_X86_REG_RDX) & 0xffffffff
n = self.mu.reg_read(UC_X86_REG_RCX) & 0xffffffff
dst = self.mu.reg_read(UC_X86_REG_R8)
arr = self.jbyte_arrays.get(h, bytearray())
chunk = bytes(arr[start:start+n])
iflen(chunk) < n:
chunk += b"\x00" * (n - len(chunk))
self._wr(dst, chunk)
self._ret()
def_jni_NewByteArray(self):
n = self.mu.reg_read(UC_X86_REG_RSI) & 0xffffffff
h = self.new_jbyte_array(bytes(n))
self.mu.reg_write(UC_X86_REG_RAX, h)
self._ret()
def_jni_SetByteArrayRegion(self):
h = self.mu.reg_read(UC_X86_REG_RSI)
start = self.mu.reg_read(UC_X86_REG_RDX) & 0xffffffff
n = self.mu.reg_read(UC_X86_REG_RCX) & 0xffffffff
src = self.mu.reg_read(UC_X86_REG_R8)
arr = self.jbyte_arrays.get(h)
if arr isNone:
self._ret()
return
data = bytes(self._rd(src, n))
end = start + n
if end > len(arr):
arr.extend(b"\x00" * (end - len(arr)))
arr[start:end] = data
self._ret()
def_jni_GetStringUTFChars(self):
h = self.mu.reg_read(UC_X86_REG_RSI)
s = self.jstrings.get(h, "")
p = self.jstring_cptr.get(h, 0)
if p == 0:
b = s.encode('utf-8') + b"\x00"
p = self.alloc(len(b))
self._wr(p, b)
self.jstring_cptr[h] = p
self.mu.reg_write(UC_X86_REG_RAX, p)
self._ret()
def_jni_ReleaseStringUTFChars(self):
self._ret()
# ===== Import/internal stubs =====
def_h_stack_fail(self):
raise RuntimeError('__stack_chk_fail')
def_h_malloc(self):
n = self.mu.reg_read(UC_X86_REG_RDI)
self.mu.reg_write(UC_X86_REG_RAX, self.alloc(n))
self._ret()
def_h_free(self):
self._ret()
def_h_memset(self):
dst = self.mu.reg_read(UC_X86_REG_RDI)
c = self.mu.reg_read(UC_X86_REG_RSI) & 0xff
n = self.mu.reg_read(UC_X86_REG_RDX)
self._wr(dst, bytes([c]) * n)
self.mu.reg_write(UC_X86_REG_RAX, dst)
self._ret()
def_h_memcpy(self):
dst = self.mu.reg_read(UC_X86_REG_RDI)
src = self.mu.reg_read(UC_X86_REG_RSI)
n = self.mu.reg_read(UC_X86_REG_RDX)
self._wr(dst, bytes(self._rd(src, n)))
self.mu.reg_write(UC_X86_REG_RAX, dst)
self._ret()
def_h_memmove(self):
dst = self.mu.reg_read(UC_X86_REG_RDI)
src = self.mu.reg_read(UC_X86_REG_RSI)
n = self.mu.reg_read(UC_X86_REG_RDX)
tmp = bytes(self._rd(src, n))
self._wr(dst, tmp)
self.mu.reg_write(UC_X86_REG_RAX, dst)
self._ret()
def_h_memcmp(self):
a = self.mu.reg_read(UC_X86_REG_RDI)
b = self.mu.reg_read(UC_X86_REG_RSI)
n = self.mu.reg_read(UC_X86_REG_RDX)
ba = bytes(self._rd(a, n))
bb = bytes(self._rd(b, n))
self.memcmp_records.append((a, b, n, ba, bb))
rv = 0
if ba != bb:
for x, y inzip(ba, bb):
if x != y:
rv = -1if x < y else1
break
self.mu.reg_write(UC_X86_REG_RAX, rv & MASK64)
self._ret()
def_h_strlen(self):
p = self.mu.reg_read(UC_X86_REG_RDI)
n = 0
whileTrue:
ch = self._rd(p+n, 1)[0]
if ch == 0:
break
n += 1
self.mu.reg_write(UC_X86_REG_RAX, n)
self._ret()
def_h_memchr(self):
p = self.mu.reg_read(UC_X86_REG_RDI)
c = self.mu.reg_read(UC_X86_REG_RSI) & 0xff
n = self.mu.reg_read(UC_X86_REG_RDX)
data = bytes(self._rd(p, n))
i = data.find(bytes([c]))
self.mu.reg_write(UC_X86_REG_RAX, 0if i < 0else p + i)
self._ret()
def_h_strlen_chk(self):
self._h_strlen()
def_h_guard_acquire(self):
self.mu.reg_write(UC_X86_REG_RAX, 1)
self._ret()
def_h_guard_release(self):
self._ret()
def_h_access(self):
self.mu.reg_write(UC_X86_REG_RAX, 0)
self._ret()
def_h_clock_gettime(self):
# int clock_gettime(clockid_t clk_id, struct timespec *tp)
tp = self.mu.reg_read(UC_X86_REG_RSI)
self._wr_u64(tp, 1700000000)
self._wr_u64(tp + 8, 123456789)
self.mu.reg_write(UC_X86_REG_RAX, 0)
self._ret()
def_h_readlink(self):
# ssize_t readlink(const char *path, char *buf, size_t bufsiz)
buf = self.mu.reg_read(UC_X86_REG_RSI)
n = self.mu.reg_read(UC_X86_REG_RDX)
s = b"/system/bin/app_process64"
m = min(len(s), n)
self._wr(buf, s[:m])
self.mu.reg_write(UC_X86_REG_RAX, m)
self._ret()
def_h_syscall(self):
self.mu.reg_write(UC_X86_REG_RAX, -1 & MASK64)
self._ret()
def_h_system_property_get(self):
# int __system_property_get(const char* name, char* value)
name_p = self.mu.reg_read(UC_X86_REG_RDI)
out_p = self.mu.reg_read(UC_X86_REG_RSI)
name = self._read_cstr(name_p)
props = {
'ro.debuggable': '0',
'ro.secure': '1',
'ro.build.tags': 'release-keys',
'ro.build.type': 'user',
}
v = props.get(name, '')
b = v.encode() + b"\x00"
self._wr(out_p, b)
self.mu.reg_write(UC_X86_REG_RAX, len(v))
self._ret()
def_h_next_prime(self):
n = self.mu.reg_read(UC_X86_REG_RDI)
if n <= 2:
p = 2
else:
p = n if (n & 1) else n + 1
whileTrue:
ok = True
d = 3
while d * d <= p:
if p % d == 0:
ok = False
break
d += 2
if ok:
break
p += 2
self.mu.reg_write(UC_X86_REG_RAX, p & MASK64)
self._ret()
def_h_24fc0(self):
ifself.ret24_idx < len(self.ret24_vals):
v = self.ret24_vals[self.ret24_idx]
else:
v = self.ret24_vals[-1]
self.ret24_idx += 1
self.mu.reg_write(UC_X86_REG_RAX, v & MASK64)
self._ret()
def_h_2efd0(self):
# int render(const char* in, int w, int h, uint8_t* out, size_t outlen)
out = self.mu.reg_read(UC_X86_REG_RCX)
outlen = self.mu.reg_read(UC_X86_REG_R8)
# deterministic dummy bitmap
self._wr(out, b"\x00" * outlen)
self.mu.reg_write(UC_X86_REG_RAX, 1)
self._ret()
def_read_cstr(self, p, limit=0x1000):
bs = bytearray()
for i inrange(limit):
c = self._rd(p+i, 1)[0]
if c == 0:
break
bs.append(c)
return bs.decode('utf-8', errors='ignore')
defcall(self, fn, args, timeout=1_000_000):
regs = [UC_X86_REG_RDI, UC_X86_REG_RSI, UC_X86_REG_RDX, UC_X86_REG_RCX, UC_X86_REG_R8, UC_X86_REG_R9]
for r, v inzip(regs, args):
self.mu.reg_write(r, v & MASK64)
rsp = self.STACK_BASE + self.STACK_SIZE - 0x100
rsp &= ~0xF
rsp -= 8
self._wr_u64(rsp, self.STOP_ADDR)
self.mu.reg_write(UC_X86_REG_RSP, rsp)
self.mu.reg_write(UC_X86_REG_RIP, fn)
try:
self.mu.emu_start(fn, self.STOP_ADDR, count=timeout)
except UcError as e:
rip = self.mu.reg_read(UC_X86_REG_RIP)
rsp2 = self.mu.reg_read(UC_X86_REG_RSP)
raise RuntimeError(f'emu error {e} rip={hex(rip)} rsp={hex(rsp2)} fn={hex(fn)}') from e
returnself.mu.reg_read(UC_X86_REG_RAX)
defg_u64(self, addr):
returnself._rd_u64(addr)
defg_u32(self, addr):
return struct.unpack('<I', self._rd(addr, 4))[0]
defg_u8(self, addr):
returnself._rd(addr, 1)[0]
defrender_text(self, s: str, w=64, h=64):
# Directly call 2efd0 with C-string input and fresh heap space.
self.heap_cur = self.HEAP_BASE + 0x1000
b = s.encode('utf-8') + b"\x00"
in_ptr = self.alloc(len(b))
self._wr(in_ptr, b)
out_len = (w * h) // 8
out_ptr = self.alloc(out_len)
self._wr(out_ptr, b"\x00" * out_len)
rv = self.call(0x2EFD0, [in_ptr, w, h, out_ptr, out_len], timeout=2_000_000)
data = bytes(self._rd(out_ptr, out_len))
return rv & 0xFF, data
defjava_noise(idx, now_ns):
x = (((idx & 0xffffffff) << 32) ^ (now_ns & MASK64)) & MASK64
x ^= ((x << 13) & MASK64)
x ^= (x >> 7)
x ^= ((x << 17) & MASK64)
return x & 0xffffffff
defrun_case(
ret_start,
ret_verify,
clicks=1000,
input_text='AAAAAA',
do_clicks=False,
key_override=0x661606DD8316E47D,
gate_override=1,
emu=None,
debug_bypass=None,
):
if emu isNone:
emu = EmuQ8('_apk/lib/x86_64/libhajimi.so')
else:
emu.reset_state()
emu.ret24_vals = [ret_start, ret_verify]
# prepare startSession args
beats = [0, 250, 500, 750]
b = b''.join(struct.pack('<I', x) for x in beats)
beat_arr = emu.new_jbyte_array(b)
t0 = 1_700_000_000_000_000_000
emu.call(0x238a0, [emu.ENV_PTR, 0, t0, beat_arr, 1000])
exp = 0
last_score = 0
if do_clicks:
for i inrange(clicks):
now = t0 + i * 250_000_000
idx = i & 3
noise = java_noise(idx, now)
over50 = 1if exp >= 50else0
score = emu.call(0x23e50, [emu.ENV_PTR, 0, now, idx, noise, over50]) & 0xffffffff
last_score = i32(score)
exp_raw = emu.call(0x23f60, [emu.ENV_PTR, 0, score, idx, noise])
exp = i64(exp_raw & MASK64)
else:
emu._wr_u64(0x5cff0, key_override)
emu._wr(0x5cff8, bytes([gate_override & 0xff]))
key = emu.g_u64(0x5cff0)
gate = emu.g_u8(0x5cff8)
pack = Path('_apk/assets/hjm_pack.bin').read_bytes()
pack_arr = emu.new_jbyte_array(pack)
jstr = emu.new_jstring(input_text)
if debug_bypass isnotNone:
emu._wr(0x5d140, bytes([debug_bypass & 0xFF]))
emu.in_verify = True
rv = emu.call(0x24850, [emu.ENV_PTR, 0, pack_arr, jstr])
emu.in_verify = False
out_target = None
out_render = None
cmp_len = None
if emu.memcmp_records:
a, b, n, ba, bb = emu.memcmp_records[-1]
out_target = ba
out_render = bb
cmp_len = n
return {
'ret': rv,
'exp': exp,
'gate': gate,
'key': key,
'last_score': last_score,
'memcmp_len': cmp_len,
'target': out_target,
'render': out_render,
'key_records': emu.key_records,
'g_5cfe0': emu.g_u64(0x5cfe0),
'g_5cfe8': emu.g_u64(0x5cfe8),
'g_5d004': emu.g_u32(0x5d004),
'g_5d008': emu.g_u32(0x5d008),
'g_5d00c': emu.g_u8(0x5d00c),
}
if __name__ == '__main__':
r = run_case(0, 0, do_clicks=False)
print('exp', r['exp'], 'gate', r['gate'], 'key', hex(r['key']))
print('5cfe0', hex(r['g_5cfe0']), '5cfe8', hex(r['g_5cfe8']))
print('5d004', hex(r['g_5d004']), '5d008', r['g_5d008'], '5d00c', r['g_5d00c'])
print('memcmp_len', r['memcmp_len'], 'key_records', [hex(x) for x in r['key_records']])
if r['target']:
ones = sum(bin(x).count('1') for x in r['target'])
print('target ones', ones)
Path('_analysis/verify_target_repro.bin').write_bytes(r['target'])
print('wrote _analysis/verify_target_repro.bin')
说明:
- 支持调用
verifyAndDecrypt路径并抓取memcmp。 - 支持
debug_bypass=1分支验证。 - 支持直接调用
2efd0渲染位图。
0x08 最终答案
作弊模式开启后(setDebugBypass(true)),正确 flag:
FLAG{HJMWAPJ2026NBLD}
【春节】解题领红包之九 {Web 中级题} 出题老师:Coxxs
0x00 题目与目标
题目是一个“语音验证码”页面:
- 前端入口:
Q9/index.html - 逻辑脚本:
Q9/assets/verify.js - Wasm 载体:
Q9/assets/verify.wasm.js(内嵌 base64 wasm)
页面校验逻辑是:输入 flag{...},取中间 code,做 0x2026 次 SHA-256,和 challenge 哈希比较。
目标:还原 wasm_bindgen.gen(uid, voice) 中 50 位 code 的生成算法,复现出 flag{50位code}。
0x01 入口 JavaScript 分析
先看 Q9/assets/verify.js:
-
wasm_bindgen.gen(uid, voice)返回对象:
-
a:音频 wav bytes
-
h:校验哈希(hex)
-
checkCode(code, expectedHash):
-
code做
0x2026次 SHA-256 -
结果 hex 与
expectedHash比较
因此本题本质是:从 wasm 里拿到真实 code 生成逻辑,而不是识别语音。
0x02 Wasm 静态定位
把 wasm 转 WAT(本地已导出为 Q9/wasm.txt),在 func $gen 里定位关键块。
1) 随机数与初始缓冲
Q9/wasm.txt:14219 开始:
-
var3+80处申请 17 字节
-
调用
crypto.getRandomValues填充 17 字节随机数
2) 37 字节工作缓冲 var9
Q9/wasm.txt:14579:call $func77 申请 37 字节(var9)。
前 21 字节构造:
-
var9[0..3] = random[0..3] XOR uid_le[0..3] -
对应
14586~14617 -
var9[4..20] = random[0..16] -
对应
14620~14637(连续 copy)
3) HMAC 密钥提取(题目要求的 14 字节)
Q9/wasm.txt:14701~14703:
- 从内存偏移
1295967拷贝 14 字节 - 作为 HMAC key
提取结果:
- hex:
0001010101010100010001000502 - bytes:
[0,1,1,1,1,1,1,0,1,0,1,0,5,2]
4) HMAC 过程
Q9/wasm.txt:14759~14887:
- 64 字节块先 XOR
0x36(ipad) - 后 XOR
0x6A(把 ipad 变 opad,等价 key^0x5c) - 调用
func9(SHA-256 压缩流程)
最终 digest 的前 16 字节写入 var9[21..36](15519 一带写回)。
即:
var9 = (random[0..3]^uid_le) + random17 + HMAC_SHA256(key, first21)[:16]
0x03 50 位 code 生成算法
关键循环在 Q9/wasm.txt:15627~15741:
- 字符表查表基址:
i32.load8_u offset=1295903 - 表内容:
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789?!
-
对
var9的 37 字节做 6-bit 拆分: -
37 * 8 = 296 bit
-
296 / 6 = 49 余 2
-
补齐后得到 50 个 6-bit 索引
-
每个索引查字符表,得到 50 位 code
这一步本质是“自定义字符表的 base64 风格编码”。
0x04 语音拼接流程(题目重点)
语音拼接入口在 Q9/wasm.txt:16335 附近,核心是反复 call $func34 扩容并 memory.copy。
整体拼接顺序:
- 前缀音频(按 voice 选择固定片段)
-
voice='c':
offset=1048577, len=76762 -
voice='y':
offset=1211415, len=84480 -
voice='e':
offset=1125339, len=86076 -
对应
16318~16354
flag{五个字符逐个转语音并拼接
- 字符来源常量区
1295898开始(UTF-8 读取循环) - 对应
16389~16593
- 50 位 code 每个字符转语音并拼接
- 从前面生成的 50 字符数组读取
- 对应
16594~16805
}字符转语音并拼接
- 直接写入 codepoint
125 - 对应
16807~16921
- 封装 WAV 头 + data 段
- 写入
RIFF/WAVE/fmt /data常量 - 对应
16989之后
结论:你的判断是对的——音频就是
"flag{" + 50位code + "}"
逐字符 TTS 后拼接。
0x05 Python 还原与复现
已在 Q9/算法.py 完整实现:
-
固定密钥(14 字节)
-
37 字节构造
-
wasm 同款 6-bit 提取
-
0x2026次 SHA-256 校验哈希
关键函数:
build_37_bytes(...)wasm_extract_50_chars(...)hash_2026(...)
运行示例(固定演示随机数 00..10):
复制代码 隐藏代码
python Q9/算法.py --uid 551842 --voice c --demo-seed0
输出:
code50=OMOkaWabaGmebqyhcaKkcWWndG8qSSAL1wczlV4Z9PEiq5cP!qflag{OMOkaWabaGmebqyhcaKkcWWndG8qSSAL1wczlV4Z9PEiq5cP!q}hash_2026=1aa787ab510dae05976a5553df8fd2506e821e09061d38de4d66646678143c8e
和 wasm 生成的 challenge 哈希一致。
0x06 关键结论
-
code 的本体只由
uid + random17 + 固定14字节密钥决定。 -
voice仅影响语音素材选择,不影响 50 位 code 与哈希。
-
题目不是音频识别题,核心是 wasm 算法还原题。
-
flag 结构固定为:
flag{<50 chars from [a-zA-Z0-9?!]>}
0x07 附:可复现单次 challenge 的方法
若你想复现“某一次页面生成出来的那条语音”对应 flag:
- Hook 当次
crypto.getRandomValues取到 17 字节随机数。 - 拿到当次
uid。 - 运行:
复制代码 隐藏代码
python Q9/算法.py --uid <uid> --voice c --rand-hex <17字节hex>
即可得到当次正确 flag{...}。
Q9/算法.py
复制代码 隐藏代码
import argparse
import hashlib
import hmac
import os
KEY_14 = bytes([0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, 5, 2])
CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789?!"
defbuild_37_bytes(uid: int, random17: bytes) -> bytes:
iflen(random17) != 17:
raise ValueError("random17 must be exactly 17 bytes")
uid_le = uid.to_bytes(4, "little", signed=False)
first4 = bytes(
[
random17[0] ^ uid_le[0],
random17[1] ^ uid_le[1],
random17[2] ^ uid_le[2],
random17[3] ^ uid_le[3],
]
)
first21 = first4 + random17
digest = hmac.new(KEY_14, first21, hashlib.sha256).digest()
return first21 + digest[:16]
defwasm_extract_50_chars(src37: bytes) -> str:
iflen(src37) != 37:
raise ValueError("src37 must be exactly 37 bytes")
out = []
var0 = 0
var5 = 1
var1 = 0
var4_idx = 0
var7 = 0
var11 = 0
whileTrue:
b = src37[var4_idx]
var11 = b
var4_idx = var5
var7 = (b | ((var7 << 8) & 0xFFFFFFFF)) & 0xFFFFFFFF
var2 = var1
whileTrue:
var1 = var2 + 2
idx = (var7 >> var1) & 0x3F
out.append(CHARSET[idx])
var0 += 1
var2 -= 6
if var1 <= 5:
break
var1 = var2 + 8
if var5 == 37:
break
var5 += 1
if var2 != -8:
idx = (var11 << (-2 - var2)) & 0x3F
out.append(CHARSET[idx])
iflen(out) != 50:
raise RuntimeError(f"unexpected output length: {len(out)}")
return"".join(out)
defhash_2026(code: str) -> str:
cur = code.encode("ascii")
for _ inrange(0x2026):
cur = hashlib.sha256(cur).digest()
return cur.hex()
defmain() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--uid", type=int, default=551842)
parser.add_argument("--voice", default="c")
parser.add_argument(
"--rand-hex",
default=None,
help="17-byte random in hex; if omitted, uses os.urandom(17)",
)
parser.add_argument(
"--demo-seed0",
action="store_true",
help="use fixed random 00..10 (matches JS hook demo)",
)
args = parser.parse_args()
if args.demo_seed0:
random17 = bytes(range(17))
elif args.rand_hex isNone:
random17 = os.urandom(17)
else:
random17 = bytes.fromhex(args.rand_hex)
src37 = build_37_bytes(args.uid, random17)
code50 = wasm_extract_50_chars(src37)
h = hash_2026(code50)
print(f"uid={args.uid}, voice='{args.voice}'")
print(f"random17={random17.hex()}")
print(f"code50={code50}")
print(f"flag{{{code50}}}")
print(f"hash_2026={h}")
if __name__ == "__main__":
main()
-官方论坛
www.52pojie.cn
👆👆👆
公众号设置“星标”,您不会错过新的消息通知
如开放注册、精华文章和周边活动等公告
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:吾爱破解论坛 吾爱pojie 吾爱pojie《【2026春节】解题领红包 【2-9】WP 通杀》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论