XCTF2023 flagio
原文链接: 7th XCTF Final - Super Flagio
出题人给出的WP思路非常清晰, 非常值得学习复现!!!
前置知识
LUA文件编译流程
跟很多虚拟机语言类似(比如Java虚拟机)
什么是LuaJIT
一个快速的Lua解释器, 支持JIT编译(即执行时编译为本地机器代码, 从而提高运行速率).
LuaJIT与coco2d-x之间的关系
cocos2d-x使用C++编写, 该游戏框架提供了众多C++接口来控制动画, 渲染, 物理引擎, 音频等, 同时将这些C++接口暴露给LuaJIT, 实现在执行Lua逻辑时可以控制游戏中的各种资源.
二者关系的一个例子
在一个cocos2d-x游戏中, 角色的移动动画通常由cocos2d-x提供的C++接口实现, 比如:
- 左:
left(); // 使用cocos2d-x的C++代码实现
- 右:
right(); // 同上
- 跳:
jump(); // 同上
而实际的游戏逻辑是在lua中执行的, 而游戏逻辑要体现在动画上, 就需要把C++接口暴露给lua代码, 然后由lua代码控制动画.
LuaJIT安装
note: 下面自主题中的很多内容都是从官网上直接翻译过来使用的.
简介
LuaJIT仅以源码包的形式发布, 该页面介绍: 在不同的操作系统和C编译器下如何构建和安装LuaJIT.
要求
系统要求
LuaJIT在大多数系统上都是开箱即用
交叉编译LuaJIT
基于GNU Makefile构建系统允许在任意宿主机上为任意支持的客户机进行交叉编译, 只要两个架构拥有相同大小的指针.如果你要在64位操作系统上为任意一个32位客户机交叉编译, 你需要安装multilib开发包, 然后构建32位的宿主机上的支持.
当宿主机和客户机的操作系统不同时, 你需要明确指出TARGET_SYS, 否则将在汇编或链接时报错.
例如: 如果你在Windows宿主机上为嵌入式Linux或Android编译时, 你需要在下面的示例中添加TARGET_SYS=Linux.
对于Android
对于Android, 你可以使用Android NDK来交叉编译.
实现编译
这里跳了, 先看确定LuaJIT版本[^1], 然后还需要按照android-ndk-r20b
改一下官网的脚本. LuaJIT-2.1.0-beta3编译没有成功, 就换了LuaJIT-2.1编译也能用.
1 |
|
确定LuaJIT版本
步骤
- IDA打开libgame.so
- 字符串搜索
LuaJIT
发现版本为LuaJIT 2.1.0-beta3
反调试
由于下一步需要使用frida进行调试, 所以需要先看看是否有frida的代码
Java已经看过了, 主要是root检测, 主要查看:
- JNI_OnLoad
- .init_array
JNI_OnLoad
创建了一个线程
1 | jint JNI_OnLoad(JavaVM *vm, void *reserved) |
线程逻辑
1 | void __noreturn sub_1D4E0C() |
检测函数
检测status文件, 如果有进程调试该应用, v5不为1, 所以我们的目的是让该函数返回0
1 | bool __fastcall check_function(unsigned int a1) |
.init_array
没有什么检测函数的特征
1 | .init_array:0000000000E7A708 ; ELF Initialization Function Table |
Hook
绕过后即可开始进行Hook操作
由于大部分的Lua字节码文件都经过加密处理, 而我们可以通过钩取加载Lua文件的函数, 从而找到实际加载的Lua字节码文件.
已知的Hook点(有错误, 后面”恢复乱序opcode”时更改)
已知的Hook点: luaL_loadbuffer(), 但是由于我们分析的libgame.so文件是去符号的, 所以需要恢复符号, 找到luaL_loadbuffer()的文件偏移.
恢复符号(并不是这个函数, 后面”恢复乱序opcode”时更改)
我们可以编译相同版本的libluajit.so, 然后通过bindiff[^2]插件来恢复符号.
关于版本我们前面已经知道了(确定LuaJIT版本[^1])
恢复后我们搜索得到了luaL_loadbuffer()
确定为第一个匹配项的原因:
- 匹配度高, 且其他项的匹配度太低了
- 参数数量和类型与luaL_loadbuffer()相同 (类型长度相同, 需要自己修)
luaL_loadbuffer()原型
1 | LUALIB_API int luaL_loadbuffer(lua_State *L, const char *buf, size_t size, const char *name); |
参数
- L: Lua解释器状态
- buf: 指向当前加载的Lua文件的二进制内容
- size: buf的大小
- name: 模块路径名称
Hook luaL_loadbuffer()
得到函数地址
前面已经恢复了luaL_loadbuffer()函数, 只需要查看其文件偏移即可
编写Hook脚本
编写hook脚本, 就是通过module, 然后通过相对地址来Hook函数 (因为是去符号的, 所以只能用地址来Hook)
1 | { |
游戏进入主界面后输出
1 | [luaL_loadbuffer]: name = assets/src/537350069 |
点击开始游戏后输出
1 | [luaL_loadbuffer]: name = scene/GameScene.pyc |
顶问号方块后输出
我们知道顶到问号方块会给你一个无敌蘑菇, 所以最终输入检验的逻辑应该在这里, 所以core/Util.pyc文件应该包含检验逻辑.
1 | [luaL_loadbuffer]: name = core/Util.pyc |
确定文件类型
根据luaL_loadbuffer()原型可以知道, 其中的buff参数是指向文件内容的指针, 我们可以读取前四个字节, 确定文件类型
修改js代码
添加了一个文件前四字节的输出
1 | { |
得到输出
1 | [luaL_loadbuffer]: name = assets/src/537350069 |
luac64文件
输出的文件头五个字节都是: 1b 4c 4a 02 0A.
- 1b 4c 4a: LuaJIT魔数
- 02: 版本号
- 0A: 标识64位luac64文件
一个经典luac文件
1 | 1b 4c 4a 01 | Header LuaJIT 2.0 BC |
Dump内存得到内存中的luac64文件
frida脚本
1 | { |
得到luac64文件
adb pull只能一个一个拉
恢复乱序OPcode
回头看一下LUA文件编译流程[^4], 如果我们修改了JIT引擎opcode的顺序, 那么用正常的lua反编译就无法获得正确的源码.
lj_obj.h
使用VScode打开LuaJIT安装[^5]的源码, lj_obj.h文件位于src目录中, 其中的内容有:
- 实现垃圾回收功能
- 定义JIT中使用的各种数据类型
- 定义一些函数, 实现垃圾回收, 控制各种类型操作
其中最重要的是: lua_State结构体, 跟opcode解释有关, 所以我们需要跟进该结构体.
lua_State
来自: lj_obj.h
功能: 解释器执行解析时的状态结构体, 包含解析器的全部状态信息. 在JIT解释器运行时会实例化一个lua_State对象, 来管理堆栈/全局变量, 加载和执行代码, 管理内存.
1 | struct lua_State { |
各个成员成员变量的功能:
GCHeade: 不重要
- 用于内存管理
dummy_ffid: 不重要
- 用于确定函数调用是否需要一个新的栈帧
status: 不重要
- 用于记录线程状态
glref: 重要
- 全局状态结构体Global_State的指针(官方注释中有给出), 用于访问全局变量和全局状态信息.
- 其类型为
MRef
, 就是一个指针类型, 根据一个全局的Flag判断是64位还是32位. 重要的是运行时指向的是: 全局状态结构体Global_State.
gclist: 不重要
- 即将被回收的对象的指针
base:
- 当前执行函数的栈帧
top
- 栈顶指针
stack:
- 栈的起始地址
cframe:
- C语言栈的栈顶
Global_State
来自: lua_State的glref成员变量指向的内容.
功能: 保存了LuaJIT运行时的全局变量, 所有线程共享.
1 | typedef struct global_State { |
lua_newstate()
来自: 通过交叉引用lua_State[^6]和Global_State[^7]可以找到.
功能: 在程序启动时调用, 用于初始化一个新的lua_State对象, 且该lua_State对象是唯一的全局lua_State对象. (后面新线程中会有专属的线程lua_State对象).
逻辑: 在其中又有一个新的GG_State结构体, 根据下面的代码可以发现lua_State[^6]和Global_State[^7]都是它的成员.
1 | LUA_API lua_State *lua_newstate(lua_Alloc f, void *ud) |
GG_State
来自: lua_State中新出现的结构体类型
功能: 存储LuaJIT虚拟机的运行时数据
1 | typedef struct GG_State { |
各成员变量功能:
L:
- 主线程的lua_State, 访问读取线程的堆栈信息等
g:
- 全局State
got:
- 不重要
J:
- 记录JIT编译器状态
hotcout:
- 记录Lua函数的热点计数, 编译JIT编译器对高热点进行优化
dispatch: 重要重要重要
- 官方注释: Instruction dispatch tables, 指令调度表
bcff:
- 字节码
luaL_loadbuffer()
从加载字节码的方向继续跟进查看LuaJIT虚拟机执行流程
调用顺序是: luaL_loadbuffer() -> luaL_loadbufferx() -> lua_loadx()
1 |
|
lua_loadx()
继续跟进该函数
逻辑:
- 前面结构体初始化(包括字节码)
- 调用
lj_vm_cpcall(L, NULL, &ls, cpparser);
启动虚拟机, 且传入了字节码
1 | LUA_API int lua_loadx(lua_State *L, lua_Reader reader, void *data, |
lj_vm_cpcall()
功能: 启动虚拟机
逻辑:
- 由于性能要求, 虚拟机的功能逻辑很多直接使用汇编实现, 所以我们要找ARM64的dasc文件.
1 | LJ_ASMF int lj_vm_cpcall(lua_State *L, lua_CFunction func, void *ud, |
参数:
- L: lua_State的指针
- func: 要执行的C函数指针
- ud: 用户数据指针
- cp:
汇编符号
用到的符号(跟后面的汇编对照, “->”表示该寄存器的值发生变化, 变化后的值所具有的含义在后面给出):
CARG1:
- 寄存器: x0
- 功能: 参数寄存器args[0] -> 一个栈偏移还是啥的
L:
- 类型: lua_State
- 寄存器: x23
- 功能: 表示lua_State变量
LREG:
- 寄存器: x23
- 功能: 保存lua_State地址
RA: (调用保留寄存器)
- 寄存器: x27
- 功能: 当前stack的起始地址 -> stack长度
SAVE_L:
- 内存: [sp, #176]
- 功能: 保存参数的lua_State
GL:
- 类型: global_State
- 寄存器: x22
- 功能: 表示GG_State保留变量
RB:
- 寄存器: x17
- 功能: 栈顶指针
SAVEPC:
- 内存: [sp, #168]
- 功能: 还是保存lua_State指针(?)
RC: (调用保留寄存器)
- 寄存器: x28
- 功能: C语言栈顶指针
SAVE_NRES
- 内存: [sp, #200]
- 功能: 堆栈长度(?)
SAVE_ERRF:
- 内存: [sp, #196]
- 功能: 异常函数个数
SAVE_CFRAME:
- 内存: [sp, #160]
- 功能: C语言栈顶指针
fp:
- 寄存器: x29
- 功能: 栈帧
CARG4
- 寄存器: x3
- 功能: args[3]
BASE
- 寄存器: x19
- 功能: args[0]
汇编
逻辑: 初始化初始化初始化….
1 | // 该函数中所有用到的符号在文件开头的定义 |
1 | |->vm_cpcall: // Setup protected C frame, call C. |
3:标签
汇编符号
RB:
- 寄存器: x17
- 功能: 栈顶指针 -> old base
LJ_TISNUM:
- 功能: JIT虚拟机的表示数字类型的tag值, 用于区分不同的类型
感觉都是一些初始化的操作, 后面就没有太仔细看了
汇编
主要任务是: 跟进后面的ins_call, 注意CARG3(因为在C调用中该参数传入的结构体中含有opcode)
1 | | // vm_cpcall的入口点 |
ins_call
汇编
还是跟上面一样的思路: 继续跟进, 注意含有opcode的CARG3
1 | |.macro ins_call |
ins_callt
汇编
注意opcode的指针: ls->rodata->str
1 | |.macro ins_callt |
虚拟机查找执行逻辑地址
找到patch
- TAMP1位指令值 * 8 (因为指令长度为8)
- 加上GL的值
- 原本GL的值 + GG_G2DISP = dispatch数组
- 加上指令序号 * 8就是最终的opcode的地址
1 | #define GG_OFS(field) ((int)offsetof(GG_State, field)) // 返回一个字段相对于GG_State起始的偏移 |
找到ins_call()对应偏移地址, 确定GG_G2DISP
作者并没有讲是怎么得到对应指令的文件偏移, 一开始试了特征代码查找, 但是筛出来的东西太多了.
于是回头看了一下源码, 发现前面bindiff找的luaL_loadbuffer()其实是lua_loadx(). (才发现编译的libluajit.so是32位的, 可能这就是bindiff匹配度比较低的原因吧)
根据下面的对比发现都有相同的字符串"?"
, 所以可以确定时lua_loadx().
lua_loadx()源码
1 | LUA_API int lua_loadx(lua_State *L, lua_Reader reader, void *data, |
ida反编译
1 | __int64 __fastcall luaL_loadbuffer(__int64 a1, __int64 a2, __int64 a3, char *a4) |
继续跟进lua_loadx()
由于lua_loadx()最终会调用lj_vm_cpcall(), 可以尝试跟进找到对应的汇编代码.
跟进了上述IDA反编译代码的v6 = sub_ACFEB4(a1, 0LL);
sub_ACFEB4()
其中有两个JUMPOUT, 这个是非常可疑的, 因为IDA分析出现JUMPOUT的一个原因就是无条件跳转, 而ins_call的代码是使用汇编实现的, 所以有理由怀疑这里可能是ins_call的具体逻辑
1 | void __fastcall sub_ACFEB4(_QWORD *a1, __int64 a2, __int64 a3, __int64 (*a4)(void)) |
跟进JUMPOUT
再第二个JUMPOUT找到了对应的汇编代码, 在倒数第五行的位置得到GG_G2DISP = 0xF30
1 | .text:0000000000ACFE5C loc_ACFE5C ; CODE XREF: sub_ACFD34+5C↑j |
查看opcode
在lj_bc.h中可以找到共有97种opcode
1 | _(ISLT, var, ___, var, lt) \ |
Hook汇编代码找到所有的opcode地址
Hook指令执行
一开始想直接Hook指令执行, 这样不行的原因是:
- 指令执行会执行大量的代码, 且会有很多重复(我的hook下来的输出就是这样), 效率极低
- 有些指令没有被执行, 无法获取全部的指令
1 | // 错误的脚本, hook的是跳转到指定指令的逻辑时的寄存器 |
Hook掉GL地址, 用前面的GG_G2DISP偏移找到dispatch表, 然后打印出来
这个的Hook点挺多的, 前面lj_vm_cpcall()里面的初始化操作多次用到lj_vm_cpcall(), GL对应的寄存器是x22
1 | { |
得到了各个指令的文件偏移
1 | [dispatch]: 0xacdaf0; |
找到对应的opcode
根据frida中的地址找到所有的opcode, 并对照vm_arm64.dasc中的build_ins()函数恢复指令. 主要根据特征的汇编代码找到.
vm_arm64.dasc中用到了很多的define符号, 使用替换功能换回寄存器的形式, 这样跟IDA做比较的时候更容易.
实例
用第一个地址恢复作为实例
vm_arm64.dasc原始代码
1 | case BC_ISLT: case BC_ISGE: case BC_ISLE: case BC_ISGT: |
替换后dasc代码
1 | case BC_ISLT: case BC_ISGE: case BC_ISLE: case BC_ISGT: |
IDA代码
几乎与替换后代码一样, 最后的代码是特征代码, 确定是BC_ISLT指令
1 | .text:0000000000ACDAF0 ; __unwind { // AAD020 |
得到opcode顺序
其中需要注意的点:
代码中定义的符号使用文本替换, 前面讲过了
有些常量定义在其他文件中, 也是全局搜索然后替换就行了
.macro宏定义, 比如: 最常见的ins_next(几乎每个指令都有, 可以拿来确定end), mov_false, mov_true, 主要记住第一条特征指令, 能认出来即可.
还有~取反操作跟取负数的区别
有些指令会定义在宏定义中, 然后直接调用宏定义. (比如: BC_ADDVN, BC_ADDNV, BC_ADDVV)
- 另外说一下, BC_ADDVN这类指令的识别, 主要通过其使用的宏定义
ins_arithdn adds, fadd
传入的adds参数和fadd参数, 根据两个参数生成的vk来识别同类的具体指令.
- 另外说一下, BC_ADDVN这类指令的识别, 主要通过其使用的宏定义
1 | [dispatch]: 0xacdaf0 = BC_ISLT |
总体同类指令一般贴在一起, 顺序很少发生变化, 但是量是真大, 所以整体分析下来很耗时.
上述代码跟原文还有些出入, 原文中只有93和94指令出现相同, 但是这里有5组相同.
反编译lua文件
使用luajit-decompiler, 是跟着原文的步骤做的.
第一处修改
全局搜索**_OPCODES元组**, 替换为下面的内容
1 | (0x0, instructions.ISLT), |
第二处修改
修改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 |
然后按照readme文件中的参数进行反编译即可
分析lua代码
GameScene.lua
collisionH()
首先分析该文件的原因是当我们最后顶到问号方格的时候才加载的, 说明是最终的验证函数.
分析找到了最终赢得游戏的逻辑, 主要的判断依据就是两个成员函数onWin()和doMarioDie(). 应该是最后碰到板栗仔, 如果是NORMAL状态就胜利, 否则马里奥死亡.
1 | function slot0.collisionH(slot0) |
slot0.collisionV()
知道了最终结果的判断跟slot0.mario.bodyType
有关, 则取查找与该字段相关的函数, 只有slot0.collisionV()与此相关.
同时有两个关键的逻辑:
- slot0.mario:changeForGotMushroom()
- slot0.mainMap:breakBrick(slot8, slot0.mario.bodyType) – 与该字段相关逻辑
1 | function slot0.collisionV(slot0) |
slot0.breakBrick()
一开始首先查找的是changeForGotMushroom()
函数, 但是并没有什么结果.
再次查找breakBrick()函数找到了其定义
关键:
- slot0:showNewMushroom(slot1, slot2): 出现蘑菇
于是我们从该函数出发, 找出执行到这里的条件
第一层的if需要执行到elseif
第二层:
- slot5获取input, 并转换成字符串
- 传入slot5到core.Util的create()方法中, 并返回slot6
- 调用slot6:OoO() (注意跟下面的函数不一样, 大小写相反, 是两个函数)
- 根据slot6:oOo()的返回值判断是否执行
slot0:showNewMushroom(slot1, slot2)
1 | function slot0.breakBrick(slot0, slot1, slot2) |
Util.create()
根据上面的逻辑继续跟进, 全局搜索找到create()函数.
note: lua中数组下标从1开始, 所以在还原虚拟机的时候需要关注下标的转换, 分为两类:
- 相对距离: 比如
slot1.slot[2][slot6] = string.byte(slot0, slot6 - 32)
中的slot6-32
就是一个相对距离, 无需修改 - 绝对距离: 比如
slot1.slot[2][slot6 + 96] = slot2[slot6 - 32]
中的[slot6 + 96]
就是一个绝对距离, 需要减一
逻辑: 根据slot2可以看出来是一个虚拟机, create()函数主要执行初始化操作, 执行逻辑在下面的OoO()中.
1 | function slot0.create(slot0) |
slot0.OoO()
分析时需要注意的点:
- 使用C++还原的时候
lil(arg0, 255)
是可以省略的, 该函数主要是避免溢出问题, 而C++可以直接使用unsigned char - 使用文本替换的时候不要直接全部替换了, 该反编译器多个函数间统一使用slotx, 直接替换会把其他函数的变量也改名了, 最好一个一个换
1 | function slot0.OoO(slot0) |
还原虚拟机
使用了C++还原虚拟机逻辑, 分为
- main.cpp
- vm.h
- vm.cpp
main.cpp
1 |
|
vm.h
1 |
|
vm.cpp
1 |
|
得到指令输出
分析前三个, 为相邻位异或和加减1, 注意最后一个加密减去了两次
1 | push 1e |
得到虚拟机加密逻辑
1 | void VM::encrypt(const char *input) { |
得出解密逻辑
1 | void VM::decrypt() { |
得到flag
1 | A76 6957 A53ED A929 |
获取到蘑菇并解出题目
[^1]: # 确定LuaJIT版本
[^2]: # bindiff
通过比较两个IDA数据库(本质上应该就是比较具体的逻辑), 从而恢复函数符号.
# 使用
## IDA打开比较文件
两个文件都是用IDA打开:
* 要恢复符号的文件保留
* 函数库关闭IDA并保存数据库
## 使用BinDiff
* 点击file -> BinDiff
* 选择要比较的数据库
![image](flagio1/image-20230428191458-e1l2k33.png)
## 符号恢复
* 比较完成后会新建四个窗口
* ![image](flagio1/image-20230428191659-ooqxn6y.png)
* Matched Function (匹配成功的函数)
* Statistics (统计)
* primary unmatched (首次不匹配的函数)
* secondary unmatched (第二次仍不匹配的函数)
## 如何打开分析的窗口
BinDiff总共四个窗口, 如果需要重新打开点击 View -> BinDiff -> ....
![image](flagio1/image-20230504202243-3xz2slk.png)
## Matched Function窗口
该窗口用于查看匹配的函数
### 背景颜色
背景颜色代表相似度(绿->红, 相似度逐渐下降), 具体数值可以看字段Similarity
![image](flagio1/image-20230428191940-rnk9e4k.png)
## 如何载入对比文件
使用BinDiff进行对比后, 退出IDA会提示是否保存对比结果, 点击是会保存一个`libgame.so_vs_libluajit.so.BinDiff`文件, 下一次使用的时候可以加载该文件.
如何载入该文件: 点击 File -> Load file -> BinDiff results.
![image](flagio1/image-20230504201848-au4ot0p.png)
# 注意
## 函数名
匹配后没有直接修改函数名 (因为是按照相似度匹配, 所以是否需要改名需要分析者做出判断)
所以只能在Matched Function窗口[^3]中搜索
[^3]: ## Matched Function窗口
该窗口用于查看匹配的函数
### 背景颜色
背景颜色代表相似度(绿->红, 相似度逐渐下降), 具体数值可以看字段Similarity
![image](flagio1/image-20230428191940-rnk9e4k.png)
[^4]: ## LUA文件编译流程
[^5]: ## LuaJIT安装
[^6]: ### lua_State
[^7]: ## Global_State