SuperFlagio
在做xctf决赛的题时,明显感觉跟平常的题不是一个等级,当时选了lambda来做,发现并不是很好做,那个题真就做了一天,赛后复现一下SuperFlagio这个题。
所需知识
Lua
lua是小巧脚本语言。其设计目的是为了通过灵活嵌入应用程序中从而为应用程序提供灵活的扩展和定制功能。
lua,luac,luajit三种文件区别:
lua是明文代码,可看见代码
luac是lua编译后的字节码,头文件为0x1B 0X4C 0X75 0X61 版本号构成
lua虚拟机能够直接解析lua和luac脚本文件,而luajit是另一个文件lua的实现版本,luajit比lua和luac更高效,文件头是0X18 0X4C 0X4A
lua符号恢复
首先搜索到lua版本,然后编译对应版本
1 | curl -R -O http://www.lua.org/ftp/lua-5.3.4.tar.gz |
编译完成在bin文件中找到lua文件,然后ida载入lua,关闭并保存得到i64,然后打开要恢复的lua文件,ctrl+6打开bindiff,选择diff database,将confidence和similarity全部改成0.5,点ok,然后再点ctrl+6,恢复成功。
luac反编译
unluac
unluac1.2.2.155是比较稳定的一个版本,命令:
1 | java -jar unluac.jar main.luac |
luadec
安装luadec方法:
1 | git clone https://github.com/viruscamp/luadec |
版本切换:
1 | git submodule update --init lua-5.3 |
luajit反编译
luajit-decomp
推荐使用ljd,还有一种是luajit-decomp不推荐使用(比较难看)
首先确定luajit版本,通过字符串搜索可知。
下载luajit对应版本并编译,直接在文件夹根目录下:
1 | mingw32-make |
然后复制编译后的LuaJIT-2.1.0-beta2\src路径下的lua51.dll、luajit.exe文件和jit文件夹覆盖到luajit-decomp目录中。将原脚本默认是只反编译当前目录下名为test.lua的文件,所以我们要重命名我们要反编译的文件为test.lua,接着运行decoder_new.exe,最终生成test.asm out.lua out2.lua,out2.lua就是我们要的文件
ljd
在根目录下使用
1 | python main.py -f <script_name>.luac64 > <script_name>.lua |
即可编译。
luajit如何编译成arm文件?
1 | git clone https://github.com/LuaJIT/LuaJIT.git |
即可
目前遇到了三种类型的lua题目,第一种是lua加载时魔改了lua chunk,第二种是lua加载魔改opcode,第三种是luajit加载魔改opcode,显然还有一种是luajit加载魔改lua chunk的情况没有出现。
首先给了一个apk文件,用jadx打开发现OnCreate中有很多检测,比如:
下面是java层,Cocos2dxUtil.lowEngineVersion 对是否root进行检测:
1 | package org.cocos2dx.lib; |
如何绕过root检测,如果不绕过是没法用root手机打开的,只需要面具里装shamiko插件就行:https://zhuanlan.zhihu.com/p/541299125
在native层中存在pthread_create检查是否有idaserver和frida server。
这个题显然是用cocos2d-x引擎和lua开发的,这个游戏是将flag输对才能通关。传统的cocos2d-x luac加密是xxtea,密钥很好找,而这个题修改了源码,加密不一样了。一般cocos-x游戏脚本被存放在asserts/src目录下,但是该目录下文件名称全部被加密了。
如何将luajit编译成arm架构的libluajit.so呢?
1 | git clone https://github.com/LuaJIT/LuaJIT.git |
然后生成的lib.so文件在src目录中,用bindiff还原函数名称,发现lua加载函数luaL_loadbuffer 的地址为 0xAC4E9C,该函数的原型为 LUALIB_API int luaL_loadbuffer(lua_State *L, const char *buf, size_t size, const char *name)
,第二个参数指向当前加载字节码文件的二进制内容,第三个参数指明 buf 的大小,第四个参数为模块路径名。
采用hook该函数,打印第四个参数,可以获得加载的lua模块路径名
1 | var lib_base = Module.findBaseAddress("libgame.so"); |
1 | Load: scene/GameScene.pyc |
中间遇到了很多问题,还没解决。
把加载的文件dump出来:
1 | var base_dir = "/data/data/cn.org.xctf.flagio/"; |
然后发现这五个lua文件全部是64位luadjit文件,前面已经了解了如何反编译,但是这个题不能直接反编译,因为luajit加载函数改变了,该题是将Luajit中的opcode顺序进行了修改,如何在native层中识别并且还原出引擎的opcode顺序就成为了主要目标。
在lj_obj.h中,可以找到lua_State结构体定义
1 | /* Per-thread state object. */ |
glref字段,这个字段指向了globle_State结构体,保存着luajit全局信息,通过这个可以找到GG_State结构体,这个结构体保存着dispatch字段,这个字段是一个数组,这个数组维护luajit内部指令跳转表,每条luajit虚拟机指令都能在这个数组中找到对应处理,这个GG_State在lj_dispatch.h头文件中
1 | /* Global state, main thread and extra fields are allocated together. */ |
lua_loadbuffer函数是加载运行luajit字节码的,所以
1 | LUALIB_API int luaL_loadbuffer(lua_State *L, const char *buf, size_t size, |
首先在调用lua_loadx前会保存当前变量和长度,然后通过调用lj_vm_cpcall函数来启动虚拟机。但接下来是比较难分析的,因为该函数是汇编写法,总之,该汇编基本原理如下:
取指令通过ldr INSw, [PC], #4
实现,PC和INSw定义在文件头,对应x21和x16寄存器。这句汇编的意思就是从 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 x9 = x22 + (((unsigned int)(x16 & 0xFF)) << 3) |
这里的 TMP0 和 TMP1 分别是 x8 和 x9 寄存器,INS 就是 x16 寄存器。
其中 GG_G2DISP 定义在 lj_dispatch.h
中:
1 | C |
即 GG_State 结构体的 g 字段与 dispatch 字段的地址差值,该值可在 IDA 中查看,为 0xF30:
因此可以进一步理解三条汇编:首先通过GL寄存器加上0xf30找到dispatch数组,该数组每一项(8字节)都是处理例程的指针,元素下标位该处理例程的opcode,在此基础上加上opcode*8得到当前ocpode处理例程。
从lj_bc.h可以知道该版本下的luajit有97种opcode:
1 |
根据上述规则,我们可以用hook找到这些在so中的处理例程的地址:
1 | var output = false; |
得到所有地址:
1 | 0xacdaf0 |
接下来就是对照源码vm_arm64.dasc,在ida中标出97个地址对应的opcode。具体就是在源码build_ins函数中找到和汇编一样的case,然后修改函数名为opcode名称,这样我们就将 opcode 的顺序找到了,下一步就该对之前 dump 下来的 luac64 文件进行反编译了。
综上所述,总结一下:该apk是通过cocos2d引擎lua开发的游戏,这个题的lua通过luajit加载器加载到了so文件中,而且这个luajit加载器的源码被改过。所以想反编译就必须找到正确的opcode顺序,可以通过hook,找到每个opcode处理地址,然后手动对比dump出的地址处的汇编和源码汇编并修改函数名称,再用idapython dump出对应顺序,最后修改 ljd/rawdump/luajit/v2_1/luajit_opcode.py
_OPCODES元组,ljd/bytecode/instructions.py处的指令和ljd/ast/builder.py指令解析。
该指令方法如下:
1、打开ljd/rawdump/luajit/v2_1/luajit_opcode.py
,修改_OPCODES如下:
1 | (0x0, instructions.ISLT), |
2、修改ljd/bytecode/instructions.py的第97行开始,按照1的函数顺序
1 | ISLT = _IDef("ISLT", T_VAR, None, T_VAR, "if {A} < {D}") |
3、修改ljd/ast/builder.py
1 | diff -urNa ljd-old/ast/builder.py ljd-new/ast/builder.py |
然后在luajit-decompiler-master根目录运行python main.py -f <script_name>.luac64 > <script_name>.lua
可以得到单个文件的反编译结果
最后就可以看到lua代码,而不是汇编(这点还是很友善地)
代码分析
在GameScene.lua中有判断胜利的条件:
1 | if (slot0.mario.face == MarioState.RIGHT or slot3 == MarioState.JUMP_RIGHT) and slot0.mainMap.enemyList[1] and slot4.position.x <= slot2.x + slot0.marioSize.width and slot2.y <= slot4.position.y + 10 then |
首先是要求马里奥的bodyType大小是normal,正常初始是small,发现在collisionV 函数中
1 | for slot6 = 6, slot0.marioSize.width - 7 do |
存在changeForGotMushroom(),猜测是吃蘑菇变大,在GameMap.lua中有isMarioEatMushroom来判断是否吃蘑菇,在这个函数上方有showNewMushroom函数,是显示蘑菇,这个就比较重要了
1 |
|
这个函数是在breakBrick函数调用
1 | function slot0.breakBrick(slot0, slot1, slot2) |
这个东西是最重要的:
1 | for slot9 = 0, 31 do |
也就是通过顶砖块,进行32个字符的输入,输入正确就能得到蘑菇,击败板栗就能成功。之后将输入的拼接成slot5传递给util.lua文件的create函数,返回slot6,这个值再调用OoO(),然后调用slot6:oOo(),返回1就行,所以如何使slot6:oOo()函数返回true。
看util.lua函数会发现,出题人实现了一个虚拟机
1 | function slot0.OoO(slot0) |
slot0.slot[5] + 1是计数的,slot0.slot[1]是数组,slot0:lil函数代表第一个操作数的运算,比如:
1 | slot0.slot[1][33 + slot1] = slot0:lil(slot0.slot[1][33 + slot1] + 1, 255) |
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 | memory[1] = 30 |
其实就能看出来运算了,给出脚本:
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 ] |
得到flag:A766957A53EDA9290CCF8E03F1A9B7E0