书接上回
【2026春节】解题领红包10.Windows 高级题 11.MCP 中级题
https://www.52pojie.cn/thread-2094516-1-1.html
继续感慨 难得通杀 借了AI的大势了
AI的发展太离谱了。不学习AI 就像发明了汽车马夫不学开车一样。
本篇内容大部分由AI生成。
2026春节解题领红包之二 - Windows初级题
题目概述
这是一道逆向分析题目,需要分析给定的汇编代码,找到并解密flag。
一开始走了爆破路线,后来AI分析直接秒了- -!
方法一:直接分析汇编解密
分析过程
-
定位解密函数
在汇编代码的 00521620 - 00521681 地址处找到了关键的解密函数。
-
分析加密数据初始化
该函数首先在堆栈上初始化加密数据:
0052162C | C702 7770322D | mov dword ptr ds:[edx],2D327077
00521635 | C742 04 282B2763 | mov dword ptr ds:[edx+4],63272B28
0052163C | C742 08 63631D70 | mov dword ptr ds:[edx+8],701D6363
...
-
分析解密算法
核心解密逻辑使用异或操作:
00521670 | 8030 42 | xor byte ptr ds:[eax],42 ; 密钥是 0x42
00521673 | 83C0 01 | add eax,1
00521676 | 39C8 | cmp eax,ecx
00521678 | 75 F6 | jne 00521670
解密算法:对每个字节执行 byte ^ 0x42
-
提取加密数据
从汇编代码中提取完整的加密数据:
encrypted = [
0x77, 0x70, 0x32, 0x2D,
0x28, 0x2B, 0x27, 0x63,
0x63, 0x63, 0x1D, 0x70,
0x72, 0x70, 0x74, 0x1D,
0x0A, 0x23, 0x32, 0x32,
0x3B, 0x1D, 0x2C, 0x27,
0x35, 0x1D, 0x3B, 0x27,
0x23, 0x30, 0x63
]
解密结果
52pojie!!!_2026_Happy_new_year!
方法二:Checksum 约束 + 多线程爆破验证
分析过程
在逆向分析过程中,我们发现了程序使用了 Checksum 验证机制:
def get_checksum(text):
return sum(ord(c) * (i + 1) for i, c in enumerate(text))
已知条件:
- 目标长度:31
- 目标 Checksum:44709
- 关键词列表:
["52pojie", "2026", "Happy_new_year", "_", "!"]
核心算法
采用 DFS(深度优先搜索) 配合 多线程验证 的策略:
- DFS 遍历:使用关键词进行深度优先搜索,生成所有可能的组合
- Checksum 剪枝:在 DFS 过程中实时计算 Checksum,如果超过目标值则立即剪枝
- 数学优化:利用公式
add_checksum = block.base_weight + (current_len * block.char_sum) 进行 O(1) 级别的快速计算
# 预处理每个关键词的基础信息
blocks_info = []
for b in WORD_BLOCKS:
base_weight = sum(ord(c) * (i + 1) for i, c in enumerate(b))
char_sum = sum(ord(c) for c in b)
blocks_info.append({
"str": b,
"len": len(b),
"base_weight": base_weight,
"char_sum": char_sum
})
def dfs(current_str, current_len, current_checksum):
# 剪枝:Checksum 超标
if current_checksum > TARGET_CHECKSUM:
return
# 剪枝:长度达标
if current_len == TARGET_TOTAL_LENGTH:
if current_checksum == TARGET_CHECKSUM:
task_queue.put(current_str)
return
# 剪枝:长度超标
if current_len > TARGET_TOTAL_LENGTH:
return
for block in blocks_info:
add_checksum = block["base_weight"] + (current_len * block["char_sum"])
dfs(current_str + block["str"], current_len + block["len"], current_checksum + add_checksum)
- 多线程验证:开启多个线程,从队列中获取候选 flag 并调用 EXE 程序进行验证,一旦找到正确答案立即通知所有线程停止
NUM_THREADS = 8
def worker_thread(thread_id):
while not found_event.is_set():
try:
flag = task_queue.get(timeout=0.5)
except queue.Empty:
if dfs_done_event.is_set():
break
continue
output = test_flag(flag)
if "SUCCESS" in output or "Congratulations" in output:
found_event.set()
验证结果
对解密结果进行验证:
- 字符串:
52pojie!!!_2026_Happy_new_year!
- 长度:31
- 计算出的 Checksum:44709
验证通过! 完全匹配目标值。
最终 Flag
52pojie!!!_2026_Happy_new_year!
解题脚本
1. 汇编解密脚本 (solve.py)
# 从汇编代码中提取加密数据
encrypted = [
0x77, 0x70, 0x32, 0x2D,
0x28, 0x2B, 0x27, 0x63,
0x63, 0x63, 0x1D, 0x70,
0x72, 0x70, 0x74, 0x1D,
0x0A, 0x23, 0x32, 0x32,
0x3B, 0x1D, 0x2C, 0x27,
0x35, 0x1D, 0x3B, 0x27,
0x23, 0x30, 0x63
]
# 密钥是 0x42
key = 0x42
# 解密
decrypted = bytes([b ^ key for b in encrypted])
print("解密结果:")
print(decrypted.decode('utf-8', errors='ignore'))
2. Checksum 验证脚本 (myz3.py)
import subprocess
import time
import threading
import queue
WORD_BLOCKS = [
"52pojie",
"2026",
"Happy_new_year",
"_",
"!"
]
TARGET_TOTAL_LENGTH = 31
TARGET_CHECKSUM = 44709
NUM_THREADS = 8
task_queue = queue.Queue()
found_event = threading.Event()
dfs_done_event = threading.Event()
print_lock = threading.Lock()
def test_flag(flag):
exe_path = r'【2026春节 解题领红包之二 {Windows 初级题} 出题老师:云在天.exe'
p = subprocess.Popen(
[exe_path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=r'C:\Users\jingt\Desktop\52pojie\第一题'
)
output, _ = p.communicate(input=flag + '\n', timeout=3)
return output
def worker_thread(thread_id):
while not found_event.is_set():
try:
flag = task_queue.get(timeout=0.5)
except queue.Empty:
if dfs_done_event.is_set():
break
continue
output = test_flag(flag)
if "SUCCESS" in output or "Congratulations" in output:
with print_lock:
print(f"\n[+] 线程 {thread_id} 破案了!真正的 Flag 是:{flag}")
found_event.set()
else:
with print_lock:
print(f" [-] 线程 {thread_id} 验证失败: {flag}")
task_queue.task_done()
# 预处理关键词信息
blocks_info = []
for b in WORD_BLOCKS:
base_weight = sum(ord(c) * (i + 1) for i, c in enumerate(b))
char_sum = sum(ord(c) for c in b)
blocks_info.append({
"str": b,
"len": len(b),
"base_weight": base_weight,
"char_sum": char_sum
})
def dfs(current_str, current_len, current_checksum):
if found_event.is_set():
return
if current_checksum > TARGET_CHECKSUM:
return
if current_len == TARGET_TOTAL_LENGTH:
if current_checksum == TARGET_CHECKSUM:
task_queue.put(current_str)
return
if current_len > TARGET_TOTAL_LENGTH:
return
for block in blocks_info:
add_checksum = block["base_weight"] + (current_len * block["char_sum"])
dfs(current_str + block["str"], current_len + block["len"], current_checksum + add_checksum)
# 启动多线程
threads = []
for i in range(NUM_THREADS):
t = threading.Thread(target=worker_thread, args=(i+1,))
t.daemon = True
t.start()
threads.append(t)
dfs("", 0, 0)
dfs_done_event.set()
for t in threads:
t.join()
解题思路总结
- 快速定位解密函数:在汇编中寻找典型的解密特征(XOR操作、循环处理、字符串操作)
- 分析算法细节:确定加密数据、密钥和算法
- 复现解密过程:用脚本实现相同的算法
- 辅助验证:通过Checksum等约束条件验证结果正确性
- 多线程加速:使用多线程配合EXE验证,快速定位正确答案
2026春节解题领红包之三{Android_初级题}_出题老师_正己
直接拼图
一开始分析半天,实在不想装APK,然后除了jntm 啥也没分析出来,于是虚拟机安装拼图直接答案了!白浪费时间 - -!
【2026春节】解题领红包之四 {Windows 初级题} 出题老师:云在天
解题思路
这是一个使用 PyInstaller 打包的 Python CrackMe 程序。与其从汇编代码层面进行分析,更高效的方法是直接提取和分析 PyInstaller 打包的 pyc 文件。
解题步骤
1. 识别程序结构
首先观察到程序是一个典型的 PyInstaller 打包的 Windows 可执行文件,其特征包括:
- 包含解压缩和库加载的启动代码
- 使用 Python 解释器动态加载模块
- 有清晰的 PyInstaller 特征(如 PYZ.pyz 文件)
2. 提取打包文件
使用 PyInstaller 自带的提取工具或 pyinstxtractor.py 提取程序内容,可以获得:
crackme_easy.pyc - 主要程序逻辑的编译文件
PYZ.pyz - Python 归档文件
- 各种系统库和 Python 依赖库
3. 分析 pyc 文件
虽然 uncompyle6 不支持 Python 3.14,但我们可以使用 Python 内置的 dis 模块直接分析字节码:
import marshal
import dis
pyc_path = "crackme_easy.pyc"
with open(pyc_path, 'rb') as f:
f.read(16) # 跳过 pyc 头部
code_obj = marshal.load(f)
dis.dis(code_obj)
4. 关键函数详细分析
4.1 get_encrypted_flag() - 获取加密数据
def get_encrypted_flag():
enc_data = 'e3w+fiRvfW18fnx4ZAZ6Pj43YwB9OWMXfXo8Dg4O'
return base64.b64decode(enc_data)
分析: 这个函数简单地返回一个 base64 编码的加密字符串,没有任何复杂逻辑。
4.2 generate_flag() - 动态生成真正的 Flag
def generate_flag():
encrypted = get_encrypted_flag()
key = 78 # 解密密钥
result = bytearray()
for i, byte in enumerate(encrypted):
result.append(byte ^ key) # XOR 解密
return result.decode('utf-8')
分析:
- 调用
get_encrypted_flag() 获取加密数据
- 使用密钥
78 进行逐字节 XOR 解密
- 将解密后的字节数组转换为 UTF-8 字符串返回
4.3 verify_flag() - 验证输入是否正确
def verify_flag(user_input):
correct_flag = generate_flag()
# 先比较长度
if len(user_input) != len(correct_flag):
return False
# 逐字符比较
for i in range(len(correct_flag)):
if user_input[i] != correct_flag[i]:
return False
return True
分析:
- 先动态生成正确的 flag
- 检查输入长度是否匹配
- 逐字符比较输入与正确 flag,只要有一个字符不匹配就返回 False
- 全部匹配才返回 True
4.4 fake_check_1() 和 fake_check_2() - 假检查函数
def fake_check_1(user_input):
fake_hash = 'a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890'
return hash_string(user_input) == fake_hash
def fake_check_2(user_input):
fake_hash = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
return hash_string(user_input) == fake_hash
分析:
- 这两个函数都是用于迷惑的假检查
- 它们会计算用户输入的 SHA256 哈希值,与预定义的假哈希值比较
- 即使匹配了这些假哈希,程序也会提示"还不对"或"接近了",然后直接退出
- 真正的验证在
verify_flag() 中进行
4.5 主流程控制
主函数 main() 的流程:
- 显示欢迎界面
- 接收用户输入
- 先运行
fake_check_1(),如果匹配就提示"Nice try"并退出
- 再运行
fake_check_2(),如果匹配就提示"You're getting closer"并退出
- 最后才运行真正的
verify_flag() 进行验证
- 验证通过后还要计算并比较校验和,双重保险
5. 解密密码
分析 generate_flag() 函数的逻辑:
- 从
get_encrypted_flag() 获取 base64 编码的加密数据:e3w+fiRvfW18fnx4ZAZ6Pj43YwB9OWMXfXo8Dg4O
- 使用密钥
78 进行 XOR 解密
- 解密后得到完整的密码
完整解密代码
import base64
def get_encrypted_flag():
enc_data = 'e3w+fiRvfW18fnx4ZAZ6Pj43YwB9OWMXfXo8Dg4O'
return base64.b64decode(enc_data)
def generate_flag():
encrypted = get_encrypted_flag()
key = 78
result = bytearray()
for i, byte in enumerate(encrypted):
result.append(byte ^ key)
return result.decode('utf-8')
flag = generate_flag()
print("真实密码/Flag:", flag)
最终答案
52p0j!3#2026*H4ppy-N3w-Y34r@@@
【2026春节】解题领红包之五 {Windows 中级题} 出题老师:云在天
论坛里的思路
公众号里吹了一波234用AI秒了,5不行,有位IP美国的大哥留言:5论坛上有,搜nuitka就有。。。emmm图省事,照猫画虎。
解出内容
刚看了大家的 不知道为啥我这解出来是float 17 47 14 14给我坑老半天- -!我还在ascii表找对应关系猜了半天...最后咋整出来的我都忘了- -!熬夜干的人麻了
[
"[b'dc!a;`b', '<PREDEFINED_FLOAT_INDEX_17>', b'cacg', '<PREDEFINED_FLOAT_INDEX_47>', b'\\x19e!!(', '<PREDEFINED_FLOAT_INDEX_14>', b'\\x1fb&', '<PREDEFINED_FLOAT_INDEX_14>', b'\\x08be#', b'ppp']",
"_parts",
"81",
"_key",
"30",
"_total_len",
"解密单个字符",
"current",
"_decrypt_char",
"获取指定位置的字符",
"self",
"_get_char_at_position",
"验证用户输入",
"total",
"计算校验和",
"flag\u0000achecksum\u0000u获取目标校验和\u0000ahashlib\u0000asha256\u0000aencode\u0000ahexdigest\u0000:nl\bnl\u0010u哈希函数\u0000l�",
"<UNHANDLED_TYPE_0xAC>",
"<UNHANDLED_TYPE_0xD1>",
"<UNHANDLED_TYPE_0x91>",
"<UNHANDLED_TYPE_0x01>",
"<UNHANDLED_TYPE_0x67>",
"<UNHANDLED_TYPE_0x02>",
"<UNHANDLED_TYPE_0x01>",
"<UNHANDLED_TYPE_0x81>",
"<UNHANDLED_TYPE_0xDE>",
"<UNHANDLED_TYPE_0xB7>",
"<UNHANDLED_TYPE_0xDE>",
"<UNHANDLED_TYPE_0x02>",
"1380994890",
"hash_input",
"假检查",
"print",
"('==================================================',)",
"(' CrackMe Challenge - Binary Edition',)",
"('Keywords: 52pojie, 2026, Happy New Year',)",
"('Hint: 1337 5p34k & 5ymb0l5!',)",
"(' Try to decompile this in IDA!',)",
"('--------------------------------------------------',)",
"CrackMeCore",
"\n[?] Enter the password: ",
"fake_check",
"('\\n[!] Close, but not quite there...',)",
"\nPress Enter to exit...",
"verify",
"get_target_checksum",
"('\\n==================================================',)",
"(' *** SUCCESS! ***',)",
"('[+] L33T H4X0R!',)",
"[+] Your answer: ",
"\n[!] Checksum mismatch: ",
" != ",
"('\\n[X] Access Denied!',)",
"('[X] Wrong password!',)",
"主函数",
"__doc__",
"__file__",
"__cached__",
"__annotations__",
"sys",
"__main__",
"__module__",
"核心验证类 - 将被编译成二进制",
"__qualname__",
"__init__",
"CrackMeCore.__init__",
"CrackMeCore._decrypt_char",
"CrackMeCore._get_char_at_position",
"CrackMeCore.verify",
"CrackMeCore.checksum",
"CrackMeCore.get_target_checksum",
"main",
"('\\n\\n[!] Interrupted',)",
"crackme_hard.py",
"<module>",
"('self',)",
"('self', 'part_idx', 'char_idx', 'encrypted_byte')",
"('self', 'pos', 'current', 'part_idx', 'part')",
"('self', 's', 'total', 'i', 'c')",
"('user_input', 'fake_hashes', 'user_hash')",
"('self', 'flag', 'i')",
"('s',)",
"('core', 'user_input', 'cs', 'target')",
"('self', 'user_input', 'i', 'expected')"
]
剩下的就交给ai了。
【春节】解题领红包之六 {番外篇 初级题} 出题老师:Coxxs
直接游戏就出来了...
显示easy一遍过,再是hard三遍过,直接flag了= =!
【春节】解题领红包之七 {Windows 中级题} 出题老师:爱飞的猫
1. 题目文件与分析目标
附件核心文件:
CM1.exe / CM1_unpacked.exe(程序)
flag.png.encrypted(密文)
本题要求不是爆破,而是从程序逻辑推导出密码。
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 还原
函数原型可抽象为:
int decrypt_and_check(const char* password, FILE* fin, FILE* fout);
主流程(伪代码):
int decrypt_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)) return 1;
file_len = ftell(fin);
data_len = file_len - 16;
if (file_len % 8 != 0) return 2;
rewind to data start;
buf = malloc(data_len);
if (!buf) return 3;
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);
return 4; // ERR-004
}
pad = buf[data_len - 1];
if (pad > data_len) {
free(buf);
return 5;
}
fwrite(buf, data_len - pad, 1, fout);
free(buf);
return 0;
}
错误码映射很清晰:
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 = 0x8D8445A2
iv = 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 in enumerate(sbox):
inv[v] = i
MASK = (1 << 64) - 1
def F(x: int) -> int:
x = ((x << 3) & MASK) | (x >> 61) # rol64(x,3)
out = 0
for i in range(8):
out |= sbox[(x >> (8 * i)) & 0xFF] << (8 * i)
return out
def Finv(y: int) -> int:
b = bytearray(8)
for i in range(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 in zip(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 in range(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 in range(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. 主界面按钮
主界面有两个按钮:
对应逻辑位于 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,调用 native verifyAndDecrypt(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(输入字符串 -> 64x64 位图):0x2efd0
2e570(debug key 生成):0x2e570
全局变量:
5d140:debug bypass 标志(setDebugBypass 写入)
5cff0 / 5cff8:运行态 key 与 gate
5cfe8:参与解包的状态种子
5d004/5d008/5d00c:24fc0 更新的状态字段
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 第一个参数就是:
文件结构确认:
- 文件头
HJM1
- mode = 2
- payload 长度对应 64x64 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 距离。
收敛结果:
验证:
render(flag) 与 debug 分支解包目标 memcmp 完全相等(bit 差异=0)。
0x07 关键复现命令
1. 查看最终分析结论
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(64x64 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后为空)
-
输入非空
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
def p64(x):
return struct.pack('<Q', x & MASK64)
def u64(b):
return struct.unpack('<Q', b)[0]
def i64(x):
return x if x < (1<<63) else x - (1<<64)
def i32(x):
x &= 0xffffffff
return x if x < 0x80000000 else x - 0x100000000
class EmuQ8:
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 in self.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 in range(a, b, PAGE):
self.mapped_pages.add(p)
return
except UcError:
pass
for p in range(a, b, PAGE):
if p in self.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 in self.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))
def reset_state(self):
# Restore original loadable image (including zeroed bss tail).
for va, img in self.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 in enumerate(rels):
addr = 0x55c70 + i * 0x10
name = r.symbol.name if r.has_symbol else f'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):
return self.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))
def alloc(self, size):
size = max(1, size)
size = (size + 0xF) & ~0xF
p = self.heap_cur
self.heap_cur += size
if self.heap_cur >= self.HEAP_BASE + self.HEAP_SIZE:
raise RuntimeError('heap exhausted')
self._wr(p, b"\x00" * size)
return p
def new_jbyte_array(self, b: bytes):
h = self.handle_cur
self.handle_cur += 8
self.jbyte_arrays[h] = bytearray(b)
return h
def new_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 in self.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 == 0x2e680 and self.in_verify:
key = uc.reg_read(UC_X86_REG_RSI) & MASK64
self.key_records.append(key)
h = self.handlers.get(address)
if h is not None:
h()
return
# Unknown import in PLT range: fail fast for visibility
if 0x55c70 <= 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 is not None else 0)
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])
if len(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 is None:
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 in zip(ba, bb):
if x != y:
rv = -1 if x < y else 1
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
while True:
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, 0 if i < 0 else 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
while True:
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):
if self.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 in range(limit):
c = self._rd(p+i, 1)[0]
if c == 0:
break
bs.append(c)
return bs.decode('utf-8', errors='ignore')
def call(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 in zip(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
return self.mu.reg_read(UC_X86_REG_RAX)
def g_u64(self, addr):
return self._rd_u64(addr)
def g_u32(self, addr):
return struct.unpack('<I', self._rd(addr, 4))[0]
def g_u8(self, addr):
return self._rd(addr, 1)[0]
def render_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
def java_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
def run_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 is None:
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 in range(clicks):
now = t0 + i * 250_000_000
idx = i & 3
noise = java_noise(idx, now)
over50 = 1 if exp >= 50 else 0
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 is not None:
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]
var9[4..20] = random[0..16]
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!q
flag{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?!"
def build_37_bytes(uid: int, random17: bytes) -> bytes:
if len(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]
def wasm_extract_50_chars(src37: bytes) -> str:
if len(src37) != 37:
raise ValueError("src37 must be exactly 37 bytes")
out = []
var0 = 0
var5 = 1
var1 = 0
var4_idx = 0
var7 = 0
var11 = 0
while True:
b = src37[var4_idx]
var11 = b
var4_idx = var5
var7 = (b | ((var7 << 8) & 0xFFFFFFFF)) & 0xFFFFFFFF
var2 = var1
while True:
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])
if len(out) != 50:
raise RuntimeError(f"unexpected output length: {len(out)}")
return "".join(out)
def hash_2026(code: str) -> str:
cur = code.encode("ascii")
for _ in range(0x2026):
cur = hashlib.sha256(cur).digest()
return cur.hex()
def main() -> 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 is None:
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()