1.概况
这次拿到的是一个 Cocos2d-x + Lua 写的棋牌马甲包,目标很明确:把它的请求签名 sign 还原出来,方便后续做接口分析。
但这个app主要难在两点:
- 壳套了好几层,Java 壳里套 so,so 里又解出 dex,最后的真实 so 还是运行时才解密映射进内存的。
- 业务逻辑全在 Lua 里,而且不是普通 Lua,是魔改了 opcode 的 LuaJIT 编译成字节码再加密的。无法直接反编译。
整条链路大概是这样:
Java 壳 → librnbqzphi.so → 解密dex → libcocos2dlua.so → 内存子 so → 魔改 LuaJIT → 加密.luajit 脚本
2.脱壳
2.1 四层壳
先用 jadx 看 Java 层,入口 Application 是 goPMyRxdApp。

这壳一共四层,每层都是 RC4 + zlib 的组合,密钥不是写死的,是拿包名/类名做凯撒位移 getNewKey(seed, shift) 现算出来的:
- Java 壳
goPMyRxdApp,用RC4(muVSFXEjmuVSFXEj) + zlib解出librnbqzphi.so
或者按逻辑所示直接在absolutePath + /ltUREWDi/librnbqzphi.so中拉下来这个so即可

librnbqzphi.so再用RC4(jrSPCUBgjrSPCUBg) + zlib解出解密后的dex,这才是真正的业务 dex
此so的关键函数有:
myUnseal— 反隐藏 API(FreeReflection 思路,patch art Runtime)loadEncData@0x1320C — 解密assets/ksTQDVCh得业务 DEXmake_dex_elements— 把解密 DEX 注入到原 ClassLoader 的 dexElementsonCreateNative— 真 Application 替换


梳理其加载业务dex的逻辑:密钥和资源名都是拿入口类名 goPMyRxdApp 去掉末尾 3 个字符(goPMyRxd)再 getNewKey 算出来的:encName = getNewKey(keyStr, 4) 是 assets 里加密资源的文件名,rc4Key = getNewKey(keyStr, 3) * 2 是 RC4 密钥(注意它把结果重复了一遍)。解密就是 RC4 之后跳过头 4 字节的长度,剩下 zlib.decompress
可以写一份离线脚本得到dex
import os, sys, struct, zlib
from zipfile import ZipFile
APP_CLASS_SIMPLENAME = "goPMyRxdApp" # AndroidManifest android:name basename
def get_new_key(seed: str, shift: int) -> str:
out = []
for ch in seed.encode():
v = (ch + shift) & 0xFF
if v >= 0x7B:
v -= 57
elif 0x5B <= v <= 0x60:
v += 7
out.append(v)
return bytes(out).decode("latin-1")
def rc4(data: bytes, key: bytes) -> bytes:
s = list(range(256))
j = 0
for i in range(256):
j = (j + s[i] + key[i % len(key)]) & 0xFF
s[i], s[j] = s[j], s[i]
out = bytearray(len(data))
i = j = 0
for n, b in enumerate(data):
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
out[n] = b ^ s[(s[i] + s[j]) & 0xFF]
return bytes(out)
def parse_payload(blob: bytes):
p = 0
(name_len,) = struct.unpack_from("<I", blob, p); p += 4
real_name = blob[p:p + name_len].decode("utf-8", errors="replace")
p += name_len
(count,) = struct.unpack_from("<I", blob, p); p += 4
dexes = []
for _ in range(count):
(ln,) = struct.unpack_from("<I", blob, p); p += 4
dexes.append(blob[p:p + ln])
p += ln
return real_name, dexes, p
def main():
if len(sys.argv) < 2:
print(f"Usage: python {sys.argv[0]} <apk_or_asset_file> [out_dir]")
sys.exit(1)
src = sys.argv[1]
out_dir = sys.argv[2] if len(sys.argv) > 2 else "extracted_dex"
os.makedirs(out_dir, exist_ok=True)
seed = APP_CLASS_SIMPLENAME[:-3]
enc_name = get_new_key(seed, 4)
rc4_key = (get_new_key(seed, 3) * 2).encode()
print(f"[+] simpleName = {APP_CLASS_SIMPLENAME}")
print(f"[+] keyStr = {seed}")
print(f"[+] encName = {enc_name}")
print(f"[+] rc4Key = {rc4_key.decode()}")
if src.lower().endswith(".apk"):
with ZipFile(src) as z:
target = f"assets/{enc_name}"
if target not in z.namelist():
print(f"[-] {target} not found in APK"); sys.exit(2)
cipher = z.read(target)
print(f"[+] read APK!{target}: {len(cipher)} bytes")
else:
with open(src, "rb") as f:
cipher = f.read()
print(f"[+] read {src}: {len(cipher)} bytes")
step1 = rc4(cipher, rc4_key)
(uncomp_size,) = struct.unpack("<I", step1[:4])
print(f"[+] declared uncompressed size = {uncomp_size}")
blob = zlib.decompress(step1[4:])
print(f"[+] actual uncompressed size = {len(blob)}")
if len(blob) != uncomp_size:
print(f"[!] size mismatch (declared {uncomp_size} vs got {len(blob)})")
raw_path = os.path.join(out_dir, "_payload_plain.bin")
with open(raw_path, "wb") as f:
f.write(blob)
print(f"[+] full plaintext blob -> {raw_path}")
real_name, dexes, consumed = parse_payload(blob)
print(f"[+] real Application class = {real_name}")
print(f"[+] dex count = {len(dexes)}")
print(f"[+] consumed {consumed} / {len(blob)} bytes")
for i, d in enumerate(dexes):
path = os.path.join(out_dir, f"payload_{i:02d}.dex")
with open(path, "wb") as f:
f.write(d)
magic = d[:8]
ok = magic[:3] == b"dex" and magic[3] == 0x0A
print(f" [{i:02d}] {len(d):>10} bytes magic={magic!r} {'OK' if ok else '??'} -> {path}")
if __name__ == "__main__":
main()
libcocos2dlua.so里面塞了一个被自定义 linker 打包过的子 so,运行时才解密映射- 子 so 里跑的就是魔改 opcode 的 LuaJIT,它负责解密并执行
.luajit脚本
2.2 自定义linker得到子so
麻烦的是第三层那个子 so。它不落地,只在内存里,而且被自定义 linker 改过,段信息都不对,直接 dump 内存也修不成正常 ELF。
这里的思路是借用他自己的linker修复逻辑后dump:hook 它自己的 linker,等它把段都映射好、relocation 还没改之前那一刻下手。静态分析过 master_linker(sub_250E338)的调用序列,最佳 dump 点是 relocate 派发函数(sub_2510444)的入口:此时代码和数据都已经解密到位、映射进 LOAD0 了,但 rela/symtab/strtab 还是干净的,soinfo 也被两次 prelink 填满了。
// dump_child_so.js:在 relocate 派发入口(解密完成、重定位之前)dump
const OFF_relocate_dispatcher = 0x2510444; // 最佳 dump 时机
const OFF_alloc_soinfo = 0x250D01C; // 拿 soinfo 指针
// LOAD 段范围(来自 IDA 里 PT_LOAD 的 vaddr)
const LOAD0_VA = 0x0, LOAD0_SZ = 0x24fe000;
const LOAD1_VA = 0x24fe000, LOAD1_SZ = 0xafe98;
const LOAD2_VA = 0x25be2a0, LOAD2_SZ = 0x5a8bd8;
Interceptor.attach(mod.base.add(OFF_relocate_dispatcher), {
onEnter(args) {
// 此时三个 LOAD 段已是解密后的明文,整段 dump 出来
dumpRange('load0.bin', mod.base.add(LOAD0_VA), LOAD0_SZ);
dumpRange('load1.bin', mod.base.add(LOAD1_VA), LOAD1_SZ);
dumpRange('load2.bin', mod.base.add(LOAD2_VA), LOAD2_SZ);
// 顺便把 soinfo 里解析出的 strtab/symtab/rela 各表也 dump 下来
}
});
dump 完三个段,再把 soinfo 里读出来的 strtab/symtab/rela 等信息拼回一个标准 ELF 头。修完得到 libcocos2dlua_child_v3.so,45.5MB,6w 多个函数,能正常拖进 IDA解析。
修复参考:https://www.ctfiot.com/106094.html
看雪ID:乐子人:https://bbs.kanxue.com/homepage-872365.htm

后面所有的 opcode 分析、函数定位都基于这个修好的子 so。
2.3 把 lua 脚本 dump 出来
脱壳脱到这里,dex 和子 so 都有了,但真正要分析的业务逻辑全在 lua 里,而磁盘上根本找不到明文 lua——它们要么被 XXTEA 加密塞在 assets 里,要么干脆打包进资源,运行时才解出来交给 LuaJIT。所以得在「lua 已经被解密、即将喂给 LuaJIT 装载」的那一刻把它截下来。这一步是后面 opcode 还原和反编译的输入,没有它什么都谈不上。
定位思路是抓 Cocos 的 lua 装载入口。Cocos2d-x 里所有 lua 脚本最终都从 cocos2d::LuaStack::luaLoadBuffer 进去,它内部如果发现启用了 XXTEA 就先解密、再把明文丢给底层的 luaL_loadbufferx 装载。所以有两个可以下手的点:
LuaStack::luaLoadBuffer(子 so0x88ab58):入口,但拿到的 buf 可能还是 XXTEA 密文(取决于解密发生在函数内哪一步)。- 底层装载 thunk(
0x60e850,最终落到luaL_loadbufferx的 body0x254ca84):这里的 buf 一定是最终交给 LuaJIT 的明文,是最干净的截获点。
我两个点都 hook 了,入口处当验证、底层 body 当主力,谁先稳定拿到明文用谁。注意那个底层 thunk 是经过 GOT 间接跳转的,地址不固定,得先把 GOT 解析一遍才能定位到真正的 body:
//(核心:hook lua load,落地明文)
const OFF = {
LuaStack_luaLoadBuffer: 0x88ab58, // Cocos lua 装载入口
luaL_loadbuffer_body: 0x254ca84, // 最终 luaL_loadbufferx body,buf 已是明文
};
// 入口 hook:this, L, buf, size, name
Interceptor.attach(mod.base.add(OFF.LuaStack_luaLoadBuffer), {
onEnter(args) {
dumpBuffer('luaLoadBuffer.enter', args[1], args[2], args[3].toInt32(),
readCString(args[4]), this.context);
}
});
// 底层 body hook:L, buf, size, name —— 主力截获点
Interceptor.attach(mod.base.add(OFF.luaL_loadbuffer_body), {
onEnter(args) {
dumpBuffer('luaL_loadbuffer_body', args[0], args[1], args[2].toInt32(),
readCString(args[3]), this.context);
}
});
dump 的时候顺手按文件头分个类,方便后面处理——1B 4C 4A 开头(\x1bLJ)是 LuaJIT 字节码,存成 .luajit;纯可打印文本是明文 lua 源码,存成 .lua;再用「size + 头部 hex + name」去重,避免同一脚本反复装载存一堆:
function classify(buf, size) {
const u8 = readBytes(buf, Math.min(size, 512));
if (u8[0] === 0x1b && u8[1] === 0x4c && u8[2] === 0x4a) // \x1bLJ
return { kind: 'luajit-bytecode', ext: 'luajit' };
// 大部分字节可打印 -> 当明文 lua 源码
// 否则 unknown
}
跑起来进游戏逛一圈,让它尽量多触发脚本装载,最后 pull 下来:
frida -U -f nlatyjs.fuqvipmonkk.raiuwvd -l scripts/dump_lua_direct_verified.js
adb pull /sdcard/Android/data/nlatyjs.fuqvipmonkk.raiuwvd/files/lua_dump_verified out/lua_dump_verified

一共落地 900 多个文件,其中 47 个是明文 lua 源码(这部分直接就能看),850 个是 LuaJIT 字节码 .luajit——但这 850 个拖进任何反编译器都是乱码,因为它们是用魔改过 opcode 的 LuaJIT 编译的。这就引出了下一章。
3.魔改 opcode 还原
这是最难搞的。原版 LuaJIT 的 opcode 编号是固定的,魔改版把编号重新排列了,所以直接拿标准反编译器解析字节码会全部错位、崩溃。必须先在 IDA 里把魔改编号 → 真实语义这张表逆出来。
但这里要先解决一个问题:上来 IDA 里六万多个函数,sub_XXXX 一片,怎么定位哪个函数是 VM 的分发循环、哪段数据是 opcode 表?
3.1 怎么定位到分发循环
LuaJIT 的解释器有个很好认的结构特征,不管怎么魔改都跑不掉:它是 computed-goto 风格的「取指 → 查表 → 间接跳转」循环,编译到 arm64 上一定长这样——用一个寄存器当字节码指针、自增取 4 字节、拿低 8 位当索引去查一张函数指针表、BR 跳过去。所以不靠符号也能找,方法有几条,我是组合着用的:
- 认间接跳转
BR:LuaJIT 的 handler 之间是尾调用串起来的,整个解释器里到处是LDR Xn, [Xtab, Xop, LSL#3]紧跟BR Xn这种「按 op×8 查表再跳」的形态。在 IDA 里搜BR指令,配合它前面是「基址 + 索引×8 取指针」的,很快能圈出分发点。 - 认字节码自增:
LDR W16, [X21], #4(带#4后变址)这种「读完指令指针自动 +4」的写法,在普通业务代码里几乎不出现,是取指循环的强特征。 - 顺藤摸瓜:先找到任意一个明显是 Lua VM 的字符串(比如报错信息
attempt to ...、luaL_相关),交叉引用到 VM 核心区域,分发循环就在这一片。
定位到的主分发循环在子 so 的 0xbe901c:
LDR W16, [X21], #4 ; X21 = 字节码指针(PC),取 4 字节指令,PC += 4
ADD X9, X22, W16, UXTB#3 ; X22 = 分发表基址;op = 指令低 8 位(UXTB),×8 当偏移
UBFX X27, X16, #8, #8 ; A = bits[8:16]
LDR X8, [X9, #0xF70] ; handler = *(X22 + 0xF70 + op*8)
UBFX X28, X16, #16, #16 ; D = bits[16:32]
BR X8 ; 跳到对应 handler
这段把魔改的关键全暴露了:UXTB(取最低字节)说明 op 就是指令的低 8 位,UBFX ...#8,#8 / ...#16,#16 说明 A/B/C/D 字段的位置和原版完全一样。也就是说:
标准 LuaJIT 的 BC_AD 指令编码(A/B/C/D 字段布局)一点没改,魔改的只是 op → handler 这个查表关系。 换句话说,只要我把那张表搞到手,磁盘上的字节码本身可以原样喂给标准解析器,不用动编码。
顺带也拿到了两个后面要用的关键值:分发表的基址在 X22,表相对 X22 的偏移是 0xF70(动态 dump 时直接读 X22+0xF70 就是这么来的)。
3.2 怎么定位到 opcode 表并导出
分发循环只告诉我们运行时拿 X22+0xF70 这张表来跳,但 X22 是运行时才填好的寄存器,静态文件里这张表在哪、怎么填的,得去找填表的那段初始化代码。
顺着 X22+0xF70 这个表被写入的地方做交叉引用,找到初始化函数 sub_BCE930。它的 F5 伪代码很干净,就是一个循环把 handler 地址逐个写进分发表:
// sub_BCE930:VM 启动时填分发表
for (int i = 0; i < 105; i++)
handler[a1 + 0xF70 + 8*i] = BASE_HANDLER + *(OFFSET_TABLE + i);
// BASE_HANDLER = 0xBE8AF0 ← 所有 handler 的公共基址
// OFFSET_TABLE = 0x1210DC0 ← 一张 16 位相对偏移的数组

读这段伪代码能抠出三个常量,它们就是 opcode 表的全部秘密:
- 循环次数 105:所以一共 105 个 opcode,编号
0x00–0x70。 BASE_HANDLER = 0xBE8AF0:handler 地址的公共基址(循环体里那个加在偏移前面的常量)。OFFSET_TABLE = 0x1210DC0:一张 16 位偏移数组的起始地址(IDA 里双击进去就是一片.short数据)。第i个 16 位偏移加上基址,就是魔改编号i对应的 handler。
定位到这三个常量之后,opcode 表的还原就完全离线化了——根本不用在 IDA 里手点 105 次,写个脚本从修好的子 so 文件里把 OFFSET_TABLE 读出来、逐个加基址就行:
# export_luajit_opcode_map.py
import struct
from pathlib import Path
SO = Path(r"out\libcocos2dlua_child_v3.so")
BASE_HANDLER = 0xBE8AF0
OFFSET_TABLE = 0x1210DC0
MAX_OP = 0x70
data = SO.read_bytes()
rows = ["op,handler,handler_rel,first_bytes"]
for op in range(MAX_OP + 1):
off = struct.unpack_from("<H", data, OFFSET_TABLE + op * 2)[0]
handler = BASE_HANDLER + off
first = data[handler:handler + 16].hex() # 留作指纹识别
rows.append(f"0x{op:02x},0x{handler:x},0x{off:x},{first}")

剩下的就是对每个 handler 做指纹识别,定出它到底是哪条指令。
3.3 两条路交叉验证
为了不出错,我用了两种方法对着验:
- 静态:直接从子 so 里把
OFFSET_TABLE读出来还原映射,如上 - 动态:Frida 在运行时把真实分发表 dump 下来,跟静态的逐字节比。3.1 已经知道分发表基址在
X22、表偏移是0xF70,那就 hook 到分发循环所在的代码块(函数头偏移0xbe9000,循环体取指偏移是0xbe901c,挂函数头即可),命中时直接从X22+0xF70把整张表读出来:
// 命中分发循环时,从 X22+0xF70 读出整张 handler 表
dispatchListener = Interceptor.attach(mod.base.add(0xbe9000), {
onEnter(args) {
if (dumped) return;
dumped = true;
const table = this.context.x22.add(0xf70);
for (let op = 0; op <= 0x70; op++) {
const h = table.add(op * 8).readPointer();
const rel = '0x' + h.sub(mod.base).toString(16);
log(`op=0x${op.toString(16).padStart(2,'0')} handler=${h} rel=${rel}`);
}
dispatchListener.detach();
}
});
两边结果完全一致,连 0x6c == 0x6d 共用同一个 handler(0xbeaac4)这种都对得上。
3.4 handler 指纹
判定语义靠的是 handler 里的特征指令,举几个例子:
| 语义 | 特征 |
|---|---|
| 算术 VN/NV/VV | FADD/FSUB/FMUL/FDIV/FRINTM + 操作数加载顺序 |
0x68 JMP_FAST |
0xbeaa18:直接 PC += D,没有 hotcount,裸重分发 |
0x42 CALL 尾 |
0xbe9b88:往槽里写 LJ_TNIL 再分发 |
0x58 JMP 慢路 |
0xbea4b4:走类型分发 |
子 so 里其实有两张分发表,第二张是用 NEON 从 word_1210E92 填进 a1+4888 的 hotcount/JIT 表。这就解释了为什么魔改编号 0x61–0x70 是 FORL/ITERL_HOT/LOOP_HOT/JMP_FAST 这一类——作者是把 JIT 热度计数从 loop/call 指令里拆出来单独编了号,并没有改指令本身的语义。摸清楚这点之后,这几个扩展 opcode 在反编译里基本可以当 NOP / 普通 LOOP / CALL 处理。
4.补全 ljd 批量反编译
opcode 映射拿到了,接下来用 ljd(LuaJIT Decompiler,Aussiemon 那个 fork,支持 2.0/2.1)来反编译。驱动脚本是 decompile_modded.py。
4.1 LuaJIT Decompiler修改方案
先说清楚整体思路,不然后面那个关键修复会看不懂。
前面 3.1 已经确认过:魔改动的只是 opcode → handler 的编号,标准 LuaJIT 的 BC_AD 指令编码(4 字节里 A/B/C/D 各字段的位置和宽度)一点没改。也就是说,磁盘上这堆 .luajit 字节码,除了「第一个字节那个 op 号被换了」,其它全是标准的。
ljd 解析字节码时,是拿 op 号 去查它自己的一张分发表 ljd.rawdump.code._MAP,_MAP[op] 给出这条指令对应的指令类(MOV / CALL / ADDVV …)。标准 ljd 的 _MAP 是按原版 LuaJIT 编号排的,所以如果拿魔改字节码直接喂进去,那么每个 op 都将查错指令类,全盘错位。
那么一般有两条路:
- A:改字节码 —— 把每条指令的 op 号从「魔改编号」翻译回「标准编号」,再喂给原版 ljd。要重写整个字节码流、还要处理 KGC 操作数编码,容易出错。
- B:改 ljd 的查表 —— 字节码原样不动,只把 ljd 的
_MAP重排成「魔改编号 → 标准指令类」。
我选择走 B。_MAP 本来就是个 [256] 的查找数组,重排它只是换个索引,干净得多。decompile_modded.py 干的核心就这一件事。
4.2 完整 opcode 整理
要注意,这套映射和第 3 章那两份 CSV 不是一回事:
- 第 3 章的
opcode_handler_map.csv/opcode_semantics_reviewed.csv,op列是按原版 LuaJIT 2.1 顺序排的(0x12=MOV、0x42=CALL、0x58=JMP 那套),它回答的是「这个 VM 的某条内部指令语义是不是 stock」——结论是 0x00–0x60 基本 stock、0x61–0x70 是 hotcount 扩展。这是指令语义层面的确认。 - 但磁盘上
.luajit第一个字节用的是另一套编号(MOV=0x1B、CALL=0x52、JMP=0x68 那套)。「磁盘字节 → 指令语义」这张映射,分发表是推不出来的,得靠下面这两步独立还原:
第一步,频率打底。 写个最小的容器解析器(只走 LJBC header + 每个 proto 的 4 字节指令流,不需要懂任何 opcode 语义),把 850 个 chunk 里第一个字节做直方图。任何 Lua 程序里 JMP/MOV/CALL/TGETS 一定是高频指令——实测 0x68 出现 28728 次、0x49 出现 85441 次,先把这些高频字节圈成候选锚点。
第二步,已知形状的小函数定锚。 挑一个结构被唯一确定死的小 proto 来对位置。比如有个 proto 一眼就是 return string.format("%%%02X", string.byte(arg)),它的指令序列被强约束成:
GGET r1, "string" ; 取全局 string
TGETS r1, r1, "format" ; string.format
KSTR r3, "%%%02X"
GGET r4, "string"
TGETS r4, r4, "byte" ; string.byte
MOV r6, r0
CALL r4, ...
CALLT r1, ...
把这段的实际字节挨个位置比对,就能一次性钉死 GGET=0x46、TGETS=0x49、KSTR=0x3E、MOV=0x1B、CALL=0x52、CALLT=0x54。再找几个含循环、含表构造的小函数补 JMP/FORI/TNEW 等,锚点滚雪球,56 项就齐了。
第三步,验证闭环。 把推出来的映射喂 ljd,看反编译能不能成立、sign 能不能复现。能跑通、salt 对得上,就反证这批映射是对的。
另外提一句,这套排列不是简单平移:比较类 0x00–0x0B 偏移为 0(原样),但常量块(KSTR 等)被挪到 0x3E 起、上值块(UGET 等)挪到 0x37 起,两块的先后顺序被对调了,不存在一个统一的 +N。所以只能锚点逐个定,没法靠一个公式推完。
回到 ljd。最终的映射分两批喂进去:一批就是上面方法得到的 56 项 OPMAP,另一批是我用 3.4 的 IDA 指纹补出来的 complete_opmap()(主要补算术/扩展类),加起来 82 个,sign 相关 chunk 里 0 个 unknown:
# OPMAP(节选,来自 decompile_sign.pyc,stock_name -> 魔改 op)
OPMAP = {
"MOV":0x1B, "CAT":0x32, "GGET":0x46, "GSET":0x47,
"TGETS":0x49, "CALL":0x52, "CALLT":0x54,
"ITERC":0x55, "ITERN":0x56, "ITERL":0x62, "LOOP":0x65, "JMP":0x68,
# ... 共 56 项
}
# complete_opmap()(IDA 指纹补全,魔改 op -> stock_name)
extra = {
0x14:"ISTC",0x15:"ISFC",0x18:"ISTYPE",0x19:"ISNUM",0x1A:"NOT",0x1C:"LEN",0x1D:"UNM",
0x22:"ADDVN",0x23:"SUBVN",0x24:"MULVN",0x25:"DIVVN",0x26:"MODVN",
0x27:"ADDNV",0x28:"SUBNV",0x29:"MULNV",0x2A:"DIVNV",0x2B:"MODNV",
0x2C:"ADDVV",0x2D:"SUBVV",0x2E:"MULVV",0x2F:"DIVVV",0x30:"MODVV",0x31:"POW",
0x5D:"FORI",0x5F:"FORL",0x3B:"USETP",
}
建表的代码本身很短,但有个极其关键的设计——newmap[魔改字节] 指向标准指令类,但这个指令类对象身上的 .opcode 属性,保持原版 LuaJIT 的值不动:
def build_newmap():
from ljd.rawdump.luajit.v2_1.luajit_opcode import _OPCODES as STD
by_name = {instr.name: instr for op, instr in STD} # 按指令名索引标准指令
newmap = [None] * 256
for name, modded in OPMAP.items():
newmap[modded] = by_name[name] # 魔改字节 -> 标准指令类(.opcode 仍是 stock)
for modded, name in complete_opmap().items():
newmap[modded] = by_name[name]
return newmap
def install_opmap():
import ljd
ljd.CURRENT_VERSION = 2.1
from ljd.rawdump.luajit.v2_1.luajit_opcode import _OPCODES as STD
ljd.rawdump.code.init(STD) # 先按标准表给每条指令设好 stock .opcode
ljd.ast.builder.init()
import ljd.pseudoasm.instructions; ljd.pseudoasm.instructions.init()
ljd.ast.builder.handle_invalid_functions = True # 容错:坏块不整体崩
for mod in (ljd.ast.unwarper, ljd.ast.slotworks,
ljd.ast.validator, ljd.ast.mutator):
mod.catch_asserts = True
ljd.rawdump.code._MAP = build_newmap() # 重指向解析器查表
_patch_ljd_robustness() # 见 4.3
为什么 .opcode 一定要留标准值?因为 ljd 内部除了用 _MAP 选指令类,还在大量地方直接拿 instruction.opcode 做数值范围判断(比如判断一条指令是不是 ITERL/FORL/ISNEXT、是不是跳转)。这两套用法必须分开:
- 「选哪个指令类」 → 用魔改字节查
_MAP - 「这条指令语义上属于哪一类」 → 用指令身上的标准
.opcode
如果让标准 .opcode 也跟着变成魔改字节,第二套判断就全错了。下面 4.3 那个崩溃就是这么来的。
4.3 决定性修复
设计想清楚了,但 ljd 自带的 code.read() 偏偏违反了上面的分工。它解码每条指令时,会顺手把 instruction.opcode 覆盖成当前读到的那个字节——在魔改字节码里,这个字节就是魔改编号。
后果是灾难性的。比如 CALL 在这个 VM 里魔改编号是 0x52:_MAP[0x52] 确实正确给出了 CALL 指令类(没问题),但紧接着 read() 把这条 CALL 指令的 .opcode 也写成了 0x52。而标准 ljd 在 builder.py:281 判断「是不是迭代器循环」用的是 0x52 <= opcode <= 0x54(标准编号里那是 ITERL/IITERL/…)。于是这条 CALL 被误判成迭代器,走进 _build_iterator_warp,去访问一个 CALL 指令根本没有的字段,直接抛:
AttributeError: '_Instruction' object has no attribute 'B'
修复就是 patch 掉 code.read:照常用魔改字节查 _MAP 选指令类,但绝不回写 .opcode,让它保持 code.init(STD) 时设好的标准值:
def _patch_ljd_robustness():
import ljd.rawdump.code as code
import ljd.bytecode.instructions as instructions
import ljd.ast.builder as B
def read(parser):
codeword = parser.stream.read_uint(4)
opcode = codeword & 0xFF
instruction_class = code._MAP[opcode] # 用魔改字节选指令类
if instruction_class is None:
from ljd.util.log import errprint
errprint("Warning: unknown opcode {0:08x}", opcode)
instruction_class = instructions.UNKNW
instruction = instruction_class()
instruction.Bytecode = codeword
# 关键:不要覆盖 instruction.opcode,保留 stock 值,
# 这样 builder 里 ITERL/FORL/ISNEXT 等范围检查才正确
code._set_instruction_operands(parser, codeword, instruction)
return instruction
code.read = read
这一步是整个反编译能跑通的决定性修改。
4.4 迭代器 warp 兜底
.opcode 修对之后,绝大多数 chunk 已经能跑了,但还有少数热路径 / JIT 形态的块,会在构建迭代器循环时撞 _build_iterator_warp 里的一个断言(它假定循环块倒数第二条一定是 ITERC/ITERN)。魔改 VM 把 hotcount 拆出去单独编号后(见 3.4),这个假设偶尔不成立。
补一个兜底:发现倒数第二条不是迭代器指令时,就退化成普通控制流 warp,别硬走迭代器逻辑:
_orig_iter = B._build_iterator_warp
import ljd.bytecode.instructions as ins
def _safe_iter(state, last_addr, instrs):
it = instrs[-2] if len(instrs) >= 2 else None
if it is None or it.opcode not in (ins.ITERC.opcode, ins.ITERN.opcode):
return B._build_flow_warp(state, last_addr, instrs[-1]) # 退化成普通流
return _orig_iter(state, last_addr, instrs)
B._build_iterator_warp = _safe_iter
注意这里 it.opcode 比的也是标准 opcode——又一次印证 4.2 那个设计:内部判断一律走标准 opcode。
4.5 容错 + AST 还原流程
install_opmap() 里那几个 handle_invalid_functions=True / catch_asserts=True 是配套的容错开关:单个块或单个 pass 出问题时,吞掉异常、尽量产出能看的结果,而不是让整个 proto 崩掉。
每个 proto 走的是 ljd 标准那套 AST pipeline,只是每步都套了容错:
def decompile_proto(header, proto):
ast = ljd.ast.builder.build(header, proto) # 字节码 -> 原始 AST + warp
ljd.ast.validator.validate(ast, warped=True)
ljd.ast.mutator.pre_pass(ast)
ljd.ast.locals.mark_locals(ast)
try:
ljd.ast.slotworks.eliminate_temporary(ast, identify_slots=True) # 消临时 slot
except AssertionError:
print(" [warn] eliminate_temporary assert")
ljd.ast.unwarper.unwarp(ast, False) # warp -> 真正的 if/for/while
ljd.ast.locals.mark_local_definitions(ast)
ljd.ast.mutator.primary_pass(ast)
ljd.ast.locals.mark_locals(ast, alt_mode=True)
return ast
slotworks(消临时变量)和 unwarper(把控制流 warp 还原成 if/for/while)这两步噪声最大——后面第 6 节 sign 翻车,根子就在 slotworks 对 slot 的复用/重命名上。
4.6 批量结果
python scripts/decompile_modded.py --batch out/lua_dump_verified out/lua_decompiled_all
850 个 .luajit,反编译成功 845 个。
5.sign 静态分析
在还原出来的 Lua 里搜 sign,搜出三套独立的签名体系:
| 体系 | 位置 | 算法 |
|---|---|---|
| HTTP API(主) | 00017 sign4Url / signHttpUrl |
HMAC-SHA1,query 按 key 升序 |
| 旧版 URL | 00017 signUrl850luaEx |
SHA1(ts + 参数 + ts + 固定盐 Mi2935uiPPnw#2dbY32) |
| PHP 接口 | 00824 sign4PhpUrl |
MD5(sortedQuery + &key= + PHP_SIGN_KEY) |
主目标是第一套 HTTP API。反编译出来的 sign4Url 长这样:
function sign4Url(slot0, slot1, slot2, slot3)
-- ... 按 key 升序拼 slot4 = "k=v&k2=v2..." ...
return string.lower(sha1.hmac(uv1,
slot1 .. "|" .. slot4 .. "|" .. slot1 .. "|" .. uv1 .. "|" .. uv2 .. "|" .. slot3))
end
顺手确认了一下 sha1 模块(chunk 00079),就是标准的 sha.lua(kikito 版本),sha1.hmac 是标准 HMAC-SHA1,输出小写 hex。
到这步看起来公式都有了,结构也清楚,应该能算了。结果——
6.翻车:离线怎么都算不对
拿真实抓包样本套公式,本地复算 sign,全错。
我把能想到的组合全穷举了一遍:素材里那个 slot1 到底是请求类型、还是 appid、还是 POST/GET、还是 timestamp、还是 nonce,× 参数子集、× 分隔符、× 要不要 urlencode、× 两个候选密钥……几千种组合,一个都没中。
冷静分析,原因有两个,而且都是反编译的 slot 复用噪声坑的:
- 误判一:
slot1(素材第 1、3 段)我一直当成「请求类型」,其实是 timestamp。 - 误判二:素材第 5 段那个
uv2,反编译里看着像 appid,其实是 version。
这俩 upvalue 在 ljd 还原之后编号根本不可靠,跨函数的 slot 也被复用重命名了。靠纯静态阅读去猜 HMAC 素材的精确拼接,自由度太大,根本收敛不了。 所以这种我放弃硬猜,直接frida上动态。
7.动态 hook 抓真实素材
但动态也有个坑:HMAC 的素材是 Lua 用 .. 拼出来的纯 Lua 字符串,它既不走 C-API 的 lua_tolstring,sha.lua 又是纯 Lua 位运算、不调 native 的 SHA1——所以常规的那些 hook 点全都抓不到这个素材。
7.1 找对位置:lj_str_new
这里换个思路:Lua 里只要 .. 拼出一个新字符串,它最终一定会经过 LuaJIT 的字符串 intern 函数被驻留下来。顺着 lua_pushlstring(0xbd51f8)反编译,找到它调用的 sub_BCB83C,这个就是 lj_str_new(L, str, len),地址 child+0xBCB83C。
所有拼出来的字符串都会从这里过,而且 sign 素材里明文带着 HMAC 密钥和竖线分隔符,过滤起来很方便:
// hook_strnew.js:按长度先过滤,再看关键串
Interceptor.attach(base.add(0xBCB83C), {
onEnter(args) {
const len = args[2].toInt32();
if (len < 40 || len > 4096) return; // 先按长度卡掉绝大多数
const s = args[1].readUtf8String(len);
if (s.indexOf('zSWY1et3') < 0 && !/[a-z_]+=[^|&]+/.test(s)) return;
log(s); // 命中的就是真实 HMAC 素材
}
});
frida输出:

7.2 结果
frida启用游戏,lj_str_new 命中,抓到 /api/customerService/live800/ 这个接口的真实 HMAC 输入:
1781189206|accountId=27342955&appid=0009&deviceId=102200002&machine=0a98...&nonce=96062×tamp=1781189206&verify_rnd_str=361535409-...&verify_str=1c4d...&version=2024326|1781189206|zSWY1et3fPeY9PfY#^pvg5tBGzSjZRi2|2024326|/api/customerService/live800/
同一时刻还抓到一条 LeaoSBPfSofPohF... 开头、后面跟一长串 666... 的字符串,这正是标准 HMAC 的内层块 (KEY XOR 0x36) ‖ 0x36 填充 ‖ message(那串 6 就是 0x36 填到 64 字节块),算是实锤了它就是标准 HMAC-SHA1,没有自定义魔改。
8.还原公式并验证
真实素材一摆出来,6 段结构一目了然:
timestamp | QUERY(按 key 升序 k=v&…) | timestamp | HMAC_KEY | version | urlpath
sign = lower( HMAC_SHA1( HMAC_KEY, material ) )
material = f"{timestamp}|{QUERY}|{timestamp}|{HMAC_KEY}|{version}|{urlpath}"
各字段:
| 字段 | 取值 / 说明 |
|---|---|
| timestamp | body 里的 timestamp(第 1、3 段重复出现) |
| QUERY | body 去掉 sign 后所有字段,按 key 升序 k=v&… 拼,不做 urlencode |
| HMAC_KEY | zSWY1et3fPeY9PfY#^pvg5tBGzSjZRi2(同时也明文拼进第 4 段) |
| version | 2024326(第 5 段,不是 appid,之前就栽在这) |
| urlpath | 去掉 host 前缀的请求路径,如 /api/customerService/live800/ |
密钥的来源也清楚了:res/cocos/btn_jian.png / btn_close3.png 这俩图片里做了隐写,读出来按 "||" 拆,得到 appid ‖ HMAC_KEY ‖ RC4_KEY ‖ version 四元组。换 build / 换 appid 的时候,只要把这四元组换掉就行,这个 HMAC_KEY 在同一个 build 里跨会话是稳定的,不是热更下发的。
拿一个完整解密包(POST /api/customerService/live800/,包里 sign 是 fe1445de…)代进去验:
computed: fe1445dec709a415a2fd9db36ea2ba582aa562da
expected: fe1445dec709a415a2fd9db36ea2ba582aa562da
MATCH: True

字节级对上了,sign 到此彻底还原。生成器我写成了 sign_generator.py,提供 compute_sign(body, urlpath) 和 verify_sign(...),过了回归测试,后面做接口直接调就行:
import hmac
import hashlib
# appid 0009 build 的密钥,从 btn_jian.png / btn_close3.png 隐写里 "||" 拆出来的
HMAC_KEY = "zSWY1et3fPeY9PfY#^pvg5tBGzSjZRi2"
def build_query(body: dict) -> str:
"""去掉 sign 后所有字段,按 key 升序拼 k=v&...,不做 urlencode"""
return "&".join(f"{k}={body[k]}" for k in sorted(body) if k != "sign")
def compute_sign(body: dict, urlpath: str, hmac_key: str = HMAC_KEY) -> str:
ts = str(body["timestamp"])
ver = str(body["version"])
query = build_query(body)
# 6 段:timestamp | query | timestamp | KEY | version | urlpath
material = f"{ts}|{query}|{ts}|{hmac_key}|{ver}|{urlpath}"
return hmac.new(hmac_key.encode(), material.encode(), hashlib.sha1).hexdigest().lower()
def verify_sign(body: dict, urlpath: str, hmac_key: str = HMAC_KEY) -> bool:
expected = body.get("sign", "").lower()
return bool(expected) and compute_sign(body, urlpath, hmac_key) == expected
顺带提一句密钥的兜底逻辑:反编译里 signHttpUrl(chunk 00017)有个分支,内网测试服走的是硬编码 appid=0000、HMAC_KEY="US$47Zzl$zVwkXB4h%HEOyJecqrpIx6X"、version=2024326;正式包才走 png 隐写拆出来的那套。


9.小结
整条链路串下来:
- 四层壳脱掉(前两层离线算法脱,第三层 hook linker dump 子 so 重建 ELF)
- 在 IDA 里逆出魔改 LuaJIT 的 opcode → handler 映射(交叉验证)
- 把映射注入 ljd,批量反编译 845/850 个 Lua(关键是别覆盖 stock opcode)
- RC4 流量解密验证(24/24 字节级)
- sign 静态分析定位到
sign4Url,但纯静态猜素材失败 - hook
lj_str_new抓到真实 HMAC 素材,还原 6 段公式 - 对真实包字节级复现,
MATCH: True
本文仅用于安全研究与学习交流,相关样本来自授权分析,请勿用于任何非法用途。