7th XCTF Final - Super Flagio
概览
本题基于 MarioKing 仓库构建了一个单机马里奥手游,以顶计数砖块的形式接收玩家输入:
以顶问号块的形式进行输入校验:
如果输入正确,玩家可以获得一个增强力量的蘑菇,从而击败守护 flag 的板栗仔。本题的 flag 即为玩家的正确输入(上下两行的拼接)。
出题视角
游戏采用 cocos2d-x 引擎与 lua 语言开发,其中大部分的加密和混淆方式均取材自我不久前研究的一款国产手游。因为这些逆向对抗手段都比较有意思,也较为经典,所以就拿过来出题了。
环境检测
相信这个部分大家都见怪不怪了,加不加影响不大,所以不如加上(
首先是 Java 层,在 org.cocos2dx.lua.AppActivity
onCreate 函数中调用的 Cocos2dxUtil.lowEngineVersion 用于检测设备是否被 root。
其次是 Native 层,JNI_OnLoad 中通过 pthread_create 创建环境监测线程,检查进程是否被调试、是否存在 IDA server 和 frida server。
lua 脚本加密
采用 cocos2d-x 引擎 lua-binding 开发的游戏逻辑代码并不直接存在于 Java 层或 Native 层中,而是作为资源文件存放在 apk 的 assets
目录下,因此有必要对明文的 lua 脚本进行加密处理。
脚本编译
本题使用的 cocos2d-x 引擎版本为 3.17.2,该版本的 lua 引擎为 LuaJIT。将 lua 脚本预编译为 LuaJIT 可解释的 luac 字节码文件以防止源码泄露,是一种对抗逆向工程的经典策略。
本题也采用了这种策略,但是为了防止 luac 能被现有工具直接反编译,我还修改了 LuaJIT 解释器的 opcode 顺序。
内容加密
传统的 cocos2d-x luac 加密是通过游戏引擎自带的 xxtea 算法完成的,因为密钥找起来太容易,所以这种方式自然被舍弃。
在实现时,我定义了一种以 FF FF DB EE
为文件头的加密文件结构,并修改 cocos2d-x 引擎源码的FileUtilsAndroid::getContents,加入了相应的解密函数。
名称混淆
cocos2d-x 的游戏脚本一般存放在 apk 的 assets/src
目录下,为了防止有意义的符号泄露信息,我将该目录下的所有文件名、目录名修改为它们原本名字的 xxhash 结果,并修改 cocos2d-x 引擎源码的 FileUtils::fullPathForFilename,加入了相应的字符串映射函数。
其他处理
- 输入校验逻辑虚拟化(小型虚拟机)
- Native 层去符号
获取明文 luac64 文件
在 IDA 字符串窗口搜索 cocos2d-x-
可以发现程序使用的 cocos2d-x 版本为 3.17.2:
搜索 LuaJIT
可以发现程序使用的 LuaJIT 版本为 2.1.0-beta3:
从 LuaJIT-v2.1.0-beta3 仓库下载源码,参考 LuaJIT 官方文档 的 Cross-compiling LuaJIT 部分,使用 android-ndk-r20b 编译一份 arm64 架构的 libluajit.so,再借助 IDA bindiff 插件即可还原 LuaJIT 引擎的关键符号。
完成上述操作后,在函数列表中搜索可以发现 luaL_loadbuffer 的地址为 0xAC4E9C,该函数的原型为 LUALIB_API int luaL_loadbuffer(lua_State *L, const char *buf, size_t size, const char *name)
,第二个参数指向当前加载字节码文件的二进制内容,第三个参数指明 buf 的大小,第四个参数为模块路径名。
hook 该函数入口,打印第四个参数 name:
1 | var lib_base = Module.findBaseAddress("libgame.so"); |
程序打开后(游戏主菜单处)注入上方 js 脚本,并点击开始游戏,frida 端的输出如下:
1 | Load: scene/GameScene.pyc |
当顶问号方块时,还会输出 Load: core/Util.pyc
,因此输入校验逻辑应该与其相关。这里可以看到,所有的脚本后缀都是 .pyc
,但其实通过搜索文件头前四个字节,可以发现其为 LuaJIT 字节码文件。
先将所有加载了的脚本 dump 出来,在 dump 的过程中,手动将文件后缀改为 .luac64
(指代 LuaJIT 64 位字节码):
1 | var base_dir = "/data/data/cn.org.xctf.flagio/"; |
将文件移动到 /data/local/tmp 并赋予 777 权限后拉到本地即可获得明文的 luac64 文件:
为什么说这些文件是 64 位而不是 32 位的呢?我们用 16 进制编辑器随便打开一个文件,它的前五个字节都是 1B 4C 4A 02 0A
。而第五个字节的 0x0A 是 LuaJIT 文件结构中的 GlobalHeader.flags
字段,其二进制形式为 1010
, 置位的两个比特位分别表示文件被去掉了符号(FLAG_IS_STRIPPED = 0b00000010)和采用 2-slot frame info
模式(FLAG_FR2 = 0b00001000),后者是 64 位引入的新特性,详情请参考 Finish LJ_GC64 mode。
当然,你可以通过打印 luaL_loadbuffer 的调用栈往上追溯到关键解密函数 sub_5035CC,分析它的实现再写脚本解密 apk 的 assets/src
目录下的所有加密文件(或用其他方法主动调用)。不过这样做就复杂了,所以这里不作展开,仅给出 python 版的解密脚本:
1 | import os |
识别乱序 opcode
如果用现有反编译工具直接反编译得到的 luac64 文件,结果一定是不正确的,因为 LuaJIT 引擎的 opcode 顺序被修改了。这一板块主要介绍如何在 Native 层中识别并还原出引擎的 opcode 顺序。
在 lj_obj.h
文件中,我们可以找到 lua_State 结构体的定义:
1 | /* Per-thread state object. */ |
其中 glref
字段指向了 global_State 结构体,顾名思义,其保存着 LuaJIT 的一些全局信息,由所有线程共享。但是我们无需去关心它的定义,因为我们感兴趣的字段不在这个结构体中,只是借助它来找到一个名为 GG_State 的结构体,后者保存着更为顶层的全局信息,其定义在 lj_dispatch.h
文件中:
1 | /* Global state, main thread and extra fields are allocated together. */ |
这里可以发现一个非常有意思的的字段 dispatch
,后方的注释也告诉我们该数组是 LuaJIT 内部维护的一张指令跳转表,每条 LuaJIT 虚拟机指令都能在这个数组中找到对应的处理例程。
现在我们来研究一下 LuaJIT 是怎么取指令和译码分发的,搞清楚这个流程才能找到跳转表的位置,进而才能找到各指令的具体实现及它们的先后顺序。
不妨在 LuaJIT 源码中跟一下 luaL_loadbuffer 函数的实现,看看它到底是如何加载运行 LuaJIT 字节码的,该函数定义在 lj_load.c
文件中,下面一并列出相关函数:
1 | LUALIB_API int luaL_loadbuffer(lua_State *L, const char *buf, size_t size, |
不难发现,luaL_loadbuffer 内部在完成一些结构体的初始化工作后,实际通过 lj_vm_cpcall 函数来启动 LuaJIT 虚拟机,并且当前脚本运行结束后,会将状态码存放到局部变量 status 中。因此我们应该着重分析 lj_vm_cpcall。
这里插一句题外话,LuaJIT 为了追求虚拟机性能,特意使用汇编来书写 vm 的核心功能,其中包括取指令、译码执行等部分,并针对不同的架构定制了对应的 dasc 文件。本题的 so 是 arm64 架构,因此接下来的分析将基于 vm_arm64.dasc
文件。
在 vm_arm64.dasc
第 526 行可以找到 lj_vm_cpcall 的实现:
1 | |->vm_cpcall: // Setup protected C frame, call C. |
其中有一句 ldr GL, L->glref
将 global_State 结构体地址赋值给 GL,GL 的定义如下,其实就是 x22 寄存器
1 | |.define GLREG, x22 // Global state. |
接着程序正常执行,会通过 cbnz BASE, <3
跳转到前面的 标签 3
处:
1 | |3: // Entry point for vm_cpcall/vm_resume (BASE = base, PC = ftype). |
在设置好一些虚拟机将用到的寄存器初值后(PC 等),通过 ins_call 宏正式开始第一条指令的解释执行。该宏被声明在 214 行:
1 | |.macro ins_call |
其中 ins_callt 也是一个宏定义,将其展开得到:
1 | |.macro ins_call |
取指令部分主要通过 ldr INSw, [PC], #4
实现,PC 和 INSw 都定义在了文件头,分别是 x21 和 w16 寄存器。这句汇编的意思就是从 x21 指向的空间里取来 32 bits 存放到 x16 的低四字节,然后 x21 自增 4(指向下一条指令),由此也可以得知 LuaJIT 采用定长指令集,每条指令长度为 4 字节。
译码部分主要通过 decode_RA RA, INS
宏和 add RA, BASE, RA, lsl #3
来解析操作数(这个我们不关心),计算目标跳转地址的方式也终于在此处呈现:
1 | add TMP1, GL, INS, uxtb #3 |
这里的 TMP0 和 TMP1 分别是 x8 和 x9 寄存器,INS 就是 x16 寄存器。以上三条汇编可解释为:
add TMP1, GL, INS, uxtb #3
:将 x16 的最低字节(opcode)无符号扩展到 32 位(uxtb)后,左移 3 位(乘 8),再加上 x22 (GL)赋值给 x9,即x9 = x22 + (((unsigned int)(x16 & 0xFF)) << 3)
ldr TMP0, [TMP1, #GG_G2DISP]
:从 x9 加上常数 #GG_G2DISP 后指向的地址空间里取出 8 字节放到 x8,此时的 x8 即为当前虚拟机指令的 opcode 所对应的处理例程地址br TMP0
跳转到处理例程去执行
其中 GG_G2DISP 定义在 lj_dispatch.h
中:
1 | typedef struct GG_State { |
即 GG_State 结构体的 g 字段与 dispatch 字段的地址差值,该值可在 IDA 中查看,为 0xF30:
因此,我们可以换一种顺序来理解上面的三条汇编。首先通过 GL 寄存器(x22)加上 0xF30 找到 dispatch 数组,该数组中每一项都是一个处理例程的指针(8 字节),元素的下标即为该处理例程对应的 opcode,在此基础上加上 opcode * 8
就能找到当前 opcode 的处理例程了。
另外,从 lj_bc.h
的 71 ~ 197 行我们可以得知 2.1.0-beta3 版的 LuaJIT 具有 97 种 opcode:
1 |
根据上述分析,我们可以编写 hook 脚本来读取这些处理例程在 so 中的地址:
1 | var output = false; |
得到全部 97 种 opcode 的处理例程地址:
1 | 0xacdaf0 |
接下来就是一个比较枯燥的过程了,我们需要对照源码 vm_arm64.dasc
在 IDA 中手动标识出上方 97 个地址所对应的 opcode(暂时没有想到自动化的标注方法,如果你有想法,欢迎交流)。具体的做法是从第一个地址开始,在源码的 build_ins 函数里找到汇编代码一致的 case,而后修改函数名为 opcode 名称。
这里以第二个地址为例,在 IDA 中可以看到一条 CSEL 汇编指令:
对应到源码:
所以该函数为 BC_ISGE 的处理例程,同时在 IDA 中修改函数名为 BC_ISGE。以此类推,手动修改其余 96 个函数的名称。此过程中应注意源码各个 case 的判断条件并主动展开部分宏定义,IDA 没有识别出来的例程应自行新建函数。最终的部分修改结果展示如下:
这样我们就将 opcode 的顺序找到了,下一步就该对之前 dump 下来的 luac64 文件进行反编译了。
反编译 luac64 文件
在 Github 上可以找到很多用于反编译 LuaJIT 字节码的工具,但是它们中的大多数对于 64 位 LuaJIT 的支持不是很好,普遍缺乏对于 2-slot frame info
模式的适配,在解析函数调用语句时会发生参数错位等的情况。
经过一系列尝试之后,我发现 luajit-decompiler 项目的反编译效果较为优秀,我们只需要在其基础上修改部分代码使其适配新 opcode 顺序即可。
首先将项目代码 clone 下来,修改 ljd/rawdump/luajit/v2_1/luajit_opcode.py
中对于 _OPCODES 变量的赋值。借助上一个板块的分析结果和 IDAPython 脚本进行格式化输出:
1 | # run in IDA |
这里需要注意,从 addrs 数组中我们也可以发现,opcode 为 0x5C 和 0x5D 的处理例程地址相同。通过排除法,可以确定这两项只能是 BC_FUNCV 和 BC_IFUNCV,我们这里就暂时规定 0x5C 为 BC_FUNCV,0x5D 为 BC_IFUNCV。如果后续反编译过程出错,再调换二者的顺序。
运行后得到如下输出:
1 | (0x0, instructions.ISLT), |
将其覆盖到 _OPCODES 元组完成第一处修改。
第二处修改在 ljd/bytecode/instructions.py
,我们需要修正从第 97 行开始的对于每条指令的定义顺序:
1 | ISLT = _IDef("ISLT", T_VAR, None, T_VAR, "if {A} < {D}") |
第三处修改在 ljd/ast/builder.py
,按照如下方式修改,使得每种指令都落在正确的解析区域中:
1 | diff -urNa ljd-old/ast/builder.py ljd-new/ast/builder.py |
在项目根目录运行 python main.py -f <script_name>.luac64 > <script_name>.lua
可以得到单个文件的反编译结果,也可以通过 -r
选项指定目录批量反编译。另外,-d
选项用于指定输出目录,-e
选项用于指定文件后缀。
代码分析
首先通过逆向思维来找到核心验证代码,在 GameScene.lua
的 collisionH 函数中可以找到游戏胜利的判断语句:
即当碰撞到板栗仔时,主角的身体类型应该是 NORMAL 状态(初始状态为 SMALL)。在 collisionV 函数中可以找到修改主角 bodyType 属性的调用:
不过需要主角吃到蘑菇才能被调用。在 GameMap.lua
中,可以找到 isMarioEatMushroom 的定义,其紧挨着的上一个函数是 showNewMushroom,看名字应该是用于生成蘑菇的。查找一下 showNewMushroom 的引用,可以发现它在 breakBrick 中被调用:
上图中框起来的部分就是触发输入校验的逻辑,它将所有普通方块上的字符拼接成长度为 32 的字符串,存放在变量 slot5 中。这里反编译结果存在一些偏差,应该是下面这样才对:
1 | slot5 = "" |
不过我们大致能猜到它的意思,拼接好后传递给 Util.lua
文件的 create 函数,得到一个 Util 模块实例 slot6。接着调用 OoO 函数和 oOo 函数,如果后者返回 true,则显示蘑菇。
所以问题简化成了如何令 Util.oOo 返回 true,这要求我们着重分析 Util.lua
文件。从现在开始,这道题就变成一道常规逆向题,还是给了源码的那种,稍微还原一下符号就能发现它是一个小型虚拟机。这里需要注意,除了 create 函数外,其他函数的第一个参数(slot0)都相当于 self,你可以简单理解为类静态函数和类成员函数的区别。
这里就不过多地去分析虚拟机的实现了,下面将 Util.lua
的源码奉上:
1 | local Util=class("Util",function () |
其中 ili 是异或运算(^),lil 是按位与(&),OoO 是 RunVM 函数,oOo 是 Check 函数。
最终解题
分析好每种 opcode 的功能及模拟的内存和寄存器后,使用 python 模拟虚拟机执行并输出伪代码:
1 | inst = [65, 30, 37, 10, 50, 0, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 0, 50, 191, 37, 10, 50, 1, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 1, 50, 192, 37, 10, 50, 2, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 2, 50, 193, 37, 10, 50, 3, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 3, 50, 194, 37, 10, 50, 4, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 4, 50, 195, 37, 10, 50, 5, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 5, 50, 196, 37, 10, 50, 6, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 6, 50, 197, 37, 10, 50, 7, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 7, 50, 198, 37, 10, 50, 8, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 8, 50, 199, 37, 10, 50, 9, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 9, 50, 200, 37, 10, 50, 10, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 10, 50, 201, 37, 10, 50, 11, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 11, 50, 202, 37, 10, 50, 12, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 12, 50, 203, 37, 10, 50, 13, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 13, 50, 204, 37, 10, 50, 14, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 14, 50, 205, 37, 10, 50, 15, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 15, 50, 206, 37, 10, 50, 16, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 16, 50, 207, 37, 10, 50, 17, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 17, 50, 208, 37, 10, 50, 18, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 18, 50, 209, 37, 10, 50, 19, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 19, 50, 210, 37, 10, 50, 20, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 20, 50, 211, 37, 10, 50, 21, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 21, 50, 212, 37, 10, 50, 22, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 22, 50, 213, 37, 10, 50, 23, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 23, 50, 214, 37, 10, 50, 24, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 24, 50, 215, 37, 10, 50, 25, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 25, 50, 216, 37, 10, 50, 26, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 26, 50, 217, 37, 10, 50, 27, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 27, 50, 218, 37, 10, 50, 28, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 28, 50, 219, 37, 10, 50, 29, 37, 11, 36, 10, 11, 33, 10, 66, 10, 49, 29, 50, 220, 37, 10, 50, 30, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 30, 50, 221, 37, 10, 50, 31, 37, 11, 36, 10, 11, 34, 10, 66, 10, 49, 31, 144, 144, 144, 144] |
将输出翻译为高级语言:
1 | buf = list(map(ord, "?" * 32)) |
故有解题脚本:
1 | cipher = [ 94, 106, 91, 110, 86, 100, 82, 20, 32, 20, 80, 21, 83, 107, 88, 98, 81, 19, 79, 10, 49, 117, 68, 120, 61, 13, 75, 115, 48, 8, 76, 123 ] |
在游戏里去顶砖块(上面一排是前 16 个字符,下面一排是后 16 个字符),最后再顶问号块,可以得到蘑菇:
吃了蘑菇去碰板栗仔,墙就会消失,触碰旗帜通关成功: