记某灰黑棋牌游戏逆向

游戏逆向

Posted by Aaron on June 13, 2026

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

image-20260614024212900

这壳一共四层,每层都是 RC4 + zlib 的组合,密钥不是写死的,是拿包名/类名做凯撒位移 getNewKey(seed, shift) 现算出来的:

  1. Java 壳 goPMyRxdApp,用 RC4(muVSFXEjmuVSFXEj) + zlib 解出 librnbqzphi.so

或者按逻辑所示直接在absolutePath + /ltUREWDi/librnbqzphi.so中拉下来这个so即可

image-20260614024747609

  1. librnbqzphi.so 再用 RC4(jrSPCUBgjrSPCUBg) + zlib 解出 解密后的dex,这才是真正的业务 dex

此so的关键函数有:

  • myUnseal — 反隐藏 API(FreeReflection 思路,patch art Runtime)
  • loadEncData @0x1320C — 解密 assets/ksTQDVCh 得业务 DEX
  • make_dex_elements — 把解密 DEX 注入到原 ClassLoader 的 dexElements
  • onCreateNative — 真 Application 替换

image-20260614031634509

image-20260614031702139

梳理其加载业务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()
  1. libcocos2dlua.so 里面塞了一个被自定义 linker 打包过的子 so,运行时才解密映射
  2. 子 so 里跑的就是魔改 opcode 的 LuaJIT,它负责解密并执行 .luajit 脚本

2.2 自定义linker得到子so

麻烦的是第三层那个子 so。它不落地,只在内存里,而且被自定义 linker 改过,段信息都不对,直接 dump 内存也修不成正常 ELF。

这里的思路是借用他自己的linker修复逻辑后dump:hook 它自己的 linker,等它把段都映射好、relocation 还没改之前那一刻下手。静态分析过 master_linkersub_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

image-20260614033512527

后面所有的 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(子 so 0x88ab58):入口,但拿到的 buf 可能还是 XXTEA 密文(取决于解密发生在函数内哪一步)。
  • 底层装载 thunk(0x60e850,最终落到 luaL_loadbufferx 的 body 0x254ca84):这里的 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

image-20260614131520166

一共落地 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 位相对偏移的数组

image-20260614033906444

读这段伪代码能抠出三个常量,它们就是 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}")

image-20260614040123226

剩下的就是对每个 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–0x70FORL/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.csvop 列是按原版 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=0x46TGETS=0x49KSTR=0x3EMOV=0x1BCALL=0x52CALLT=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_pushlstring0xbd51f8)反编译,找到它调用的 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输出:

image-20260614044803638

7.2 结果

frida启用游戏,lj_str_new 命中,抓到 /api/customerService/live800/ 这个接口的真实 HMAC 输入:

1781189206|accountId=27342955&appid=0009&deviceId=102200002&machine=0a98...&nonce=96062&timestamp=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

image-20260614035241858

字节级对上了,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=0000HMAC_KEY="US$47Zzl$zVwkXB4h%HEOyJecqrpIx6X"version=2024326;正式包才走 png 隐写拆出来的那套。

image-20260614040945351

image-20260614041003084

9.小结

整条链路串下来:

  1. 四层壳脱掉(前两层离线算法脱,第三层 hook linker dump 子 so 重建 ELF)
  2. 在 IDA 里逆出魔改 LuaJIT 的 opcode → handler 映射(交叉验证)
  3. 把映射注入 ljd,批量反编译 845/850 个 Lua(关键是别覆盖 stock opcode)
  4. RC4 流量解密验证(24/24 字节级)
  5. sign 静态分析定位到 sign4Url,但纯静态猜素材失败
  6. hook lj_str_new 抓到真实 HMAC 素材,还原 6 段公式
  7. 对真实包字节级复现,MATCH: True

本文仅用于安全研究与学习交流,相关样本来自授权分析,请勿用于任何非法用途。