Android逆向(七) 解密COCOS游戏lua脚本-第1篇

目录

一、系统环境

二、详细分析

前言:

1.lua脚本是什么?

2.为什么游戏开发要用lua脚本?

正文:

获取 Lua 脚本的几种方法:

1.直接在 assets 目录提取

2.在 luaL_loadbuffer函数处获取

3.在底层的 reader 函数处获取

1.静态分析

2.动态分析


一、系统环境

OS: Windows_NT x64 10.0.19045

JADX:1.5.0

010Editor:12.0.1

IDA:7.7.220118

python:3.8.10

Node.js: 18.17.1

frida :14.2.14

objection:1.11.0

vscode: 1.87.2

device:nexus 5x-8.1.2

二、详细分析

前言:

解密 COCOS游戏 lua脚本实例共有3篇内容,分别对应不同的 lua 脚本加解密方式

1.lua脚本是什么?

百度百科:Lua 是一个小巧的脚本语言。它是巴西里约热内卢天主教大学(Pontifical Catholic University of Rio de Janeiro)里的一个由Roberto Ierusalimschy、Waldemar Celes 和 Luiz Henrique de Figueiredo三人所组成的研究小组于1993年开发的。 其设计目的是为了通过灵活嵌入应用程序中从而为应用程序提供灵活的扩展和定制功能。Lua由标准C编写而成,几乎在所有操作系统和平台上都可以编译、运行。Lua并没有提供强大的库,这是由它的定位决定的。所以Lua不适合作为开发独立应用程序的语言。Lua 有一个同时进行的JIT项目,提供在特定平台上的即时编译功能。

Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,这使得Lua在应用程序中可以被广泛应用。不仅仅作为扩展脚本,也可以作为普通的配置文件,代替XML、ini等文件格式,并且更容易理解和维护。 Lua由标准C编写而成,代码简洁优美,几乎在所有操作系统和平台上都可以编译、运行。 一个完整的Lua解释器不过200k,在所有脚本引擎中,Lua的速度是最快的。这一切都决定了Lua是作为嵌入式脚本的最佳选择。

2.为什么游戏开发要用lua脚本?

  1. 灵活性:Lua是一种轻量级的脚本语言,易于学习和使用,可以快速修改和调试游戏中的各种逻辑和功能,提高开发效率。

  2. 扩展性:Lua支持动态类型和动态数据结构,可以方便地扩展和修改游戏的功能,使游戏更加灵活和可定制。

  3. 效率:由于Lua是一种解释性语言,可以实时修改脚本代码并立即生效,避免了重新编译和打包的时间,节省了开发时间。

  4. 跨平台性:Lua是跨平台的脚本语言,在不同的游戏引擎和平台上都可以使用,提高了游戏的可移植性和适应性。

  5. 社区支持:Lua拥有庞大的社区支持和活跃的开发者社区,可以获取到大量的开源代码和教程,方便开发者学习和解决问题。

在我自己看来,游戏使用 lua 最重要的原因是效率和跨平台

效率:游戏的版本更新相对普通应用来说是非常频繁的,新功能上线、节日活动、BUG修复等等,特别是遇到紧急BUG的时候,如果按照 代码修复->测试验收->打包上传->平台审核->强制更新 这个流程来说,特别是IOS平台,等到平台审核通过后,估计黄花菜都凉了

这时可见 脚本热更新 功能是多么重要

跨平台:对多端平台(IOS,Android,Windows,MacOS,WEB,小游戏)特别重要,如果对每个平台的差异都要构建不同的代码的话成本是比较高的,当然现在主流的开发引擎(COCOS,Unity)都已经做了适配,不过游戏功能代码还是要自己写的,肯定不可能所有代码都用 C/C++或C#去写,LUA支持跨平台,基于上面【效率】提到的优点,部分代码用LUA写是更好得选择

所以核心功能用跨平台的 C/C++ 编写,提供接口给 LUA 调用,LUA即可以快速开发修改,又可以热更新,招写 LUA 的还比写 C/C++的便宜(doge),这么多好处游戏公司怎么会不愿意用呢!

正文:

现在的手游开发主要使用的是 COCOS 和 Unity 框架,首先要识别游戏是用的哪个框架,打开压缩包,找到 lib 目录,查看 so 文件名称,一般用 COCOS框架开发的名字包含 libcocos2d,libgame,libhellolua,因为要嵌入Lua引擎,所以一般来说首先看最大的文件(如果名字无法确定就拖入到IDA搜索是否有 Lua 相关字符串),还有一个地方是看 assets 目录是否有 lua,luac文件

获取 Lua 脚本的几种方法:

1.直接在 assets 目录提取

这种一般是没有对 lua 脚本做任何加密,assets 目录存放的是 Lua 或 Luac 源码

Lua源码类型可以直接修改再用 APKtool 重打包

Luac源码类型可以使用 Unluac 等工具还原为 Lua 源码修改后再用 APKtool 重打包

2.在 luaL_loadbuffer函数处获取

luaL_loadbuffer是一个被频繁调用的加载点, Cocos2d-x引擎的 Lua 加载器为 cocos2dx_lua_loader,最终都是调用 luaL_loadbuffer 函数来加载,Lua脚本会在 luaL_loadbuffer函数上层进行解密,luaL_loadbuffer函数的第2个参数就是解密后的 Lua 脚本

 

3.在底层的 reader 函数处获取

Lua引擎加载 Lua 脚本的底层是 lua_read 函数,这个函数负责底层的脚本内容遍历,这里获取的 Lua 脚本是所有加密已经被去除的明文脚本(修改 Lua opcode 或者引擎逻辑除外),在 lua_reader函数中获取不到足够的文件信息(例如文件名、文件内容、索引等),需要配合上层函数获取 Lua 脚本的文件信息

好了,进入正题,今天来解密一款手游的LUA脚本

1.静态分析

先打开lib目录查看so

通过最后一个文件名可以看出这个游戏是用cocos2dlua框架开发的,也就是说这是一个建立在cocos2D-X上更上层的封装(直接使用Lua开发调用封装好的C++接口,更加方便快捷)

查看 assets 目录下 lua 文件,发现文件是已编译状态,每个文件开头都有一段相同的字符串,可能是加密的符号,根据现在情况来看,只能使用第2、3种方法获取 Lua 脚本

IDA加载 libcocos2dlua.so 之后搜索 luaL_loadbuffer,鼠标双击进入函数内部

选中函数,键盘按 X 查找引用

 在弹出的窗口种可以看到出现了 cocos2d::LuaStack::luaLoadBuffer,鼠标双击进入

 结合源码可以分析出 脚本先在 xxtea_decrypt 解密然后返回给 luaL_loadbuffer加载,也就是 luaL_loadbuffer 的第2个参数 ptr

上面有两个 luaL_loadbuffer 调用,进入 if 判断区域的表示脚本使用了加密,进入 else 判断区域的表示脚本未使用加密

通过查询资料得知 xxtea_decrypt 是解密函数,xxtea_encrypt 加密函数,这个算法属于可逆算法,即然是可逆的,能否通过静态分析直接找到解密 Key 呢?

XXTEA 加密算法的 C 实现icon-default.png?t=N7T8https://github.com/xxtea/xxtea-c/blob/master/README_zh_CN.md

在上篇文章中我们了解到反调试代码是在程序的初始化启动阶段设置的,cocos2d中设置密钥会不会也是在程序初始化启动阶段呢?

搜索 cocos2d 启动过程了解到 applicationDidFinishLaunching 是启动时调用的函数

查看 cocos2d源码可以看到出现了 setXXTEAKeyAndSign 这个函数,说明设置密钥key就是在这个函数中操作的

 

再到 IDA 搜索这个函数的实现(代码比较多,只截图了部分代码)

 

 代码基本上是差不多的,只不过设置 密钥和符号的 setXXTEAKeyAndSign 这个函数没有找到明文,看起来可能是被专门处理过,看来静态分析无法找到密钥key,需要进行动态分析了

2.动态分析

 从前面的静态分析可以知道,只要拿到 key 和 sign就可以对 lua 脚本进行解密,在调用 xxtea_decrypt 这个函数的时候必然会传入 key 和 sign,所以只需要在动态调试的时候在 xxtea_decrypt 函数入口下断点就可以拿到 key 和 sign

之前使用 IDA 进行动态调试需要做许多繁琐配置操作,今天使用 frida hook来更快速的获取

function hook(){
    #IDA 中找到 xxtea_decrypt的符号
    var targetAddress = Module.findExportByName("libcocos2dlua.so","_Z13xxtea_decryptPhjS_jPj");
    console.log("decrypt Address: ",targetAddress);

    Interceptor.attach(targetAddress,{
        onEnter:function (args){
            console.log(Memory.readCString(args[0]));
            console.log(args[1]);
            console.log(Memory.readCString(args[2]));
            console.log(args[3]);
            console.log(args[4]);
            
        },onLeave:function(retval){
            console.log("rel:"+retval);
        }
    })
    console.log("success!");
}


function main(){
    Java.perform(function (){
        hook();
    })
}
setImmediate(main);

 

# 注入方式1

#hook设备当前界面应用(app已启动状态下) 当前使用这种方式
$ frida -U com.xxx.xxx -l ./getpokerlua.js

# 注入方式2

# 自动启动应用后hook(app未启动状态下,app不会暂停)
$ frida -U -f com.xxx.xxx -l ./getpokerlua.js --no-pause

# 注入方式3

# 自动启动应用后hook(app未启动状态下,app会暂停,需要执行 %resume 恢复app正常运行)
$ frida -U -f com.xxx.xxx -l ./getpokerlua.js

运行 frida 脚本后发现游戏进程直接结束了,很明显游戏有反调试(这种在游戏已启动状态下hook任然会被检测的基本是开启了线程循环检测)

 

要解决反调试就需要找到反调试代码的位置,定位反调试代码来源哪个 so

# hook 遍历加载 so 系统函数android_dlopen_ext,游戏在哪里结束进程反调试代码就在哪个so
function hook_dlopen() {
    let is_hook = false;
    Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"),
        {
            onEnter: function (args) {
                var pathptr = args[0];
                if (pathptr !== undefined && pathptr != null) {
                    var path = ptr(pathptr).readCString();
                    console.log(path)
                }
            }
        }
    );
}


function main(){
    Java.perform(function (){
        hook_dlopen();
    })
}
setImmediate(main);

$ frida -U -f com.xxx.xxx --no-pause -l ./getpokerlua.js

 

 发现反调试来源

 

定位反调试代码,IDA载入so查看发现so被修改了

初始化 .init_xxx段都没找到

 

尝试修复so,参考下面的文章进行操作,步骤写的很详细,我就不赘述了

GG内存dump so 以及修复_sofixer-CSDN博客文章浏览阅读2.8k次。手机端启动cmd中执行进入adb shell切换su,查看目标APP进程信息使用cat命令将信息输出至文件中将文件pull到电脑中查看在文件中找到so内存地址。_sofixerhttps://blog.csdn.net/qq_49619863/article/details/131155604

修复后可以找到 .init_xxx段了

简单分析了 .init_array 和 JNI_OnLoad,发现重要的代码都做了混淆,这个游戏用的是商业化的保护,对抗需要耗费不少时间精力,先尝试从更简单的方式来应对

 

从前面的 frida 运行情况来看,这个游戏的反调试是用的线程循环检测,可不可以对这个检测的线程做手脚呢?

先看看这个so对线程创建函数 pthread_create 的调用情况

// hook pthread_create 看是否被目标so调用
function hook_pthread_create(){
    var interceptor = Interceptor.attach(Module.findExportByName(null, "pthread_create"),
    {
        onEnter: function (args) {
            var module = Process.findModuleByAddress(ptr(this.returnAddress))

            if (module != null) {
                console.log("[pthread_create] called from", module.name)
            }else {
                console.log("[pthread_create] called from", ptr(this.returnAddress))
            }
        },onLeave: function(retval) {
        }
    })
}

setImmediate(hook_pthread_create);

# 使用注入方式2

$ frida -U -f com.xxx.xxx -l ./getpokerlua.js --no-pause

可以看到目标so调用了两次,也就是创建了两个线程

我们可以创建一个假的线程函数给这个so调用两次,通过欺骗so以为反调试线程被正常创建就可以达到绕过反调试的目的

// hook xxtea_decrypt获取参数
function hook_xxtea_decrypt(){
    var targetAddress = Module.findExportByName("libcocos2dlua.so","_Z13xxtea_decryptPhjS_jPj");
    console.log("decrypt Address: ",targetAddress);

    Interceptor.attach(targetAddress,{
        onEnter:function (args){
            console.log("arg1:"+Memory.readCString(args[0]));
            console.log("arg2:"+args[1]);
            console.log("arg3:"+Memory.readCString(args[2]));
            console.log("arg4:"+args[3]);
            console.log("arg5:"+args[4]);
            
        },onLeave:function(retval){
            console.log("rel:"+retval);
        }
    })
    console.log("success!");
}



function hook_dlsym() {
    var count = 0
    console.log("=== HOOKING dlsym ===")

    var interceptor = Interceptor.attach(Module.findExportByName(null, "dlsym"),
        {
            onEnter: function (args) {
                const name = ptr(args[1]).readCString()
                console.log("[dlsym]", name)
                if (name == "pthread_create") {
                    count++
                }
            },onLeave: function(retval) {
                if (count == 1) {
                    retval.replace(fake_pthread_create)
                }else if (count == 2) {
                    retval.replace(fake_pthread_create)
                    // 完成2次替换, 停止hook dlsym
                    interceptor.detach()
                }
            }
        }
    )
    return Interceptor
}

function hook_dlopen() {
    var interceptor = Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"),
        {
            onEnter: function (args) {
                var pathptr = args[0];
                if (pathptr !== undefined && pathptr != null) {
                    var path = ptr(pathptr).readCString();
                    console.log("[LOAD]", path)
                    if (path.indexOf("libmsaoaidsec.so") > -1) {
                        hook_dlsym()
                    }
                }
            }
        }
    )
    return interceptor
}

function create_fake_pthread_create() {
    const fake_pthread_create = Memory.alloc(4096)
    Memory.protect(fake_pthread_create, 4096, "rwx")
    Memory.patchCode(fake_pthread_create, 4096, code => {
        const cw = new Arm64Writer(code, { pc: ptr(fake_pthread_create) })
        cw.putRet()
    })
    return fake_pthread_create
}

// 创建虚假pthread_create
var fake_pthread_create = create_fake_pthread_create()
var dlopen_interceptor = hook_dlopen()

//延迟5秒等待lua so加载后再hook
setImmediate(function() {
    setTimeout(hook_xxtea_decrypt, 5000);
});

# 使用注入方式2

$ frida -U -f com.xxx.xxx -l ./getpokerlua.js --no-pause

从输出结果可以看到,arg3就是我们要找的 key

sign就是lua文件开头的字符串

有了 key 和 sign 就可以对lua文件进行解密了,因为 xxtea 算法本身是开源且可逆的,可以通过 xxtea的源码写代码来还原,不过现在已经有前人开发了可视化工具,也就没必要重复造轮子了

解密完成后的文件

感谢您的耐心阅读。

参考文章:

ELF文件解析_elf 文件用什么工具解析-CSDN博客

绕过最新版bilibili app反frida机制_frida hook dlsym-CSDN博客

  • 18
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值