前言
说是初探,其实不是首次接触,和之前 Android 无 ROOT 动调 有那么一点点关系,手上一直都没有可 root 真机,于是动调什么的都是老大难问题。在很久很久以前,曾经尝试使用模拟器来动调,无一例外地失败了,当时知道了处理器架构不同导致无法正常下断点等一系列问题,而因为静态调试能解决当下困难,于是动调搁置了很久……最近有些闲心来弄动调了,于是奇迹地发现 IDA 无 root 动调大法,于是觉得 Frida 说不定也行,再次翻看文档、查阅资料,发现一堆解决方案,比如 Frida 已经可以支持模拟器了,比如 Frida 的 Gadget 通过注入 so 可以无 root 持久化脚本,随即开始捣鼓捣鼓……
还是很累的,脑子都坏了,傻得不行的问题也犯了不少,哦对,荣幸选做测试的例子的应用程序是某创新立体节奏游戏 Arcaea,因为没有加密、加壳、反调试和签名校验等等,甚至曾经有符号表可以对照,绝对是最好的简单的逆向上手练习题 XD,以及我们的目的是在程序中帮忙加密一下它的明文文本文件(
以下是主要的参考资料:
- 魏佬写的 论安卓使用 frida 修改 Arcaea 1 和 论安卓使用 frida 修改 Arcaea 2
- 某位大佬的电子书 Frida so层中的hook
- Frida 文档 JavaScript API
- Frida Github Issues 里胡乱搜索的一堆
除此之外,搜索引擎解决了不少过程中问题,一般来自看雪论坛和 Stack Overflow,也有部分 CSDN 的(
相关环境
- Windows 11 Insider Preview (10.0.22631.2199)
- 模拟器 BlueStacks 5 Nougat 64(应该有(伪)root,确定 adb 可以
su
) - HarmonyOS 3 (Android 10)
- Frida 16.1.3
- Python 3.7.8
- IDA Pro v7.5
- Arcaea v4.7.0c
- ADB 调试工具
- 解包、打包、签名相关工具
- apktool 2.7.0
- keytool、jarsigner
应用程序选取其 arm 32 架构的二进制文件 libcocos2dcpp.so
进行分析和注入,但注意到模拟器是 x86_64 架构
Frida Server Hook
Server 启动
先按照文档用 pip 安装 Frida,接着下载 Frida Github Releases 中版本和平台都对上的文件,在我们的场景中是 frida-server-16.1.3-android-x86_64
,这基于平台处理器架构,可用 adb 命令 adb shell getprop ro.product.cpu.abi
查看,将下好的文件解压然后 adb push <input> <output>
推送到模拟器内部
然后就是连接 adb shell,对了注意一下,BlueStacks 5 的 adb 端口每次启动都会变,所以要在设置里查看,然后手动连接 adb connect <ip:port>
,不然 adb devices
可是看不到模拟器设备的,使用命令 adb shell
进入命令行后,去找刚刚传进去的文件,并赋予权限和启动它,比如在我们的例子中,上述的完整步骤是:
adb connect 127.0.0.1:27034
adb push frida-server-16.1.3-android-x86_64 /data/local/tmp
adb shell
cd /data/local/tmp
su
chmod 777 frida-server-16.1.3-android-x86_64
./frida-server-16.1.3-android-x86_64
然后看似就卡住了,如果没报错就是正常的,不用管了,注意这个 CMD 或者什么终端窗口不要关
测试脚本
下面就是如何连接 Frida server 的故事了,直接从官方文档里抄一段 Python 代码过来,随便改改:
# arcaea.py
import frida, sys
def on_message(message, data):
if message['type'] == 'send':
print("[*] {0}".format(message['payload']))
else:
print(message)
jscode = None
with open("arcaea.js", 'r', encoding="utf-8") as f:
jscode = f.read()
device = frida.get_usb_device()
pid = "Arcaea" # "Arcaea" # device.spawn(["moe.low.arc"])
process = device.attach(pid, realm='emulated')
# device.resume(pid)
print('Arcaea Hook Start!')
script = process.create_script(jscode)
script.on('message', on_message)
script.load()
sys.stdin.read()
这里有一些重要的点,首先,我们在模拟器上进行测试,于是这里有了 realm='emulated'
来声明这件事,虽然不知道能支持多少模拟器,但在 BlueStacks 上没有问题;其次,这里我们选用文件读取的方式来加载脚本 arcaea.js
,因为这样能隔离 Python 和 JavaScript 的代码,编辑器就能正常语言支持了,没有代码高亮那真的难受;接着,on_message
函数似乎是为了处理 js 脚本中的 send
函数,输出时添加一个前缀,实际当然没有太多作用咯,这是官方例子自带的;最后,也是最重要的一点,注意 pid
后面的注释,如果我们采用脚本随应用程序启动的方式,那应该采用 spawn 的方式多进程启动,此时传入包名 "moe.low.arc"
,但是,如果是在应用启动后加载脚本,那么似乎应该直接传入进程 PID 或者 Name,经过 adb ps
调查发现,在模拟器上 Name 是 "Arcaea"
而不是包名(不知道为什么,难道是架构翻译导致的?),对于本次测试,更倾向于启动后加载,而且这也方便反复修改代码,于是就这么写啦
写完加载脚本的脚本后,就轮到主要的 Hook 脚本了,手上有别的不知名大佬写的脚本,厚颜无耻地抄过来作为第一个例子:
// arcaea.js
const LIB_NAME = "libcocos2dcpp.so";
function findExport(name) {
return Module.findExportByName(LIB_NAME, name);
}
const PKCS12_parse_addr = findExport('PKCS12_parse');
Interceptor.attach(PKCS12_parse_addr, {
onEnter: function (args) {
send('PKCS12_parse enter');
},
onLeave: function (retval) {
send('PKCS12_parse leave');
}
});
这里的重点就是 Interceptor.attach
可以按照函数指针地址来 hook,时机为函数进入时和函数返回时,参数 args
是个对象,可以当做数组来使用下标访问各个参数,而另一边的参数 retval
一般是一个值,注意到 Module.findExportByName
是通过函数导出表 export 中的函数名字来获取函数指针的,那么如果函数是动态加载的,那就很麻烦,需要手动按地址加载
上面的脚本是直接就可以加载的,将 arcaea.js
和 arcaea.py
放在同一目录,使用 python arcaea.py
运行即可注入成功,当然此时要求模拟器上的应用程序已经开始运行,进行一些网络请求后可以发现控制台有输出,这当然喽,因为这是 OpenSSL 加载 HTTPS 证书的相关函数
逐个击破
为了实现文件加密后能被正常读取,那必然要关注读取文件的方法,以及寻找一种加密手段,总不能用 js 跑解密算法吧(,找了半天,决定了我们的核心钩子函数是 Cocos2d-x 的 FileUtils 相关函数,以及会主动调用 AES-128-GCM 算法的解密函数
FileUtils
按照官方文档,这里面有一堆一堆的方法,看名字凭直觉,先选取了 getDataFromFile
测试了一下,发现好像文本不用这个加载,主要是图片资源,于是转战另一个 getStringFromFile
函数,直接命中!
经过 IDA 静态逆向,瞎七瞎八找一通得到静态地址 0x862148
,然后开始我们的表演:
// arcaea.js
// `getStringFromFile` hook part
const LIB_NAME = "libcocos2dcpp.so";
const base_addr = Module.findBaseAddress(LIB_NAME);
function func_addr(addr) {
return base_addr.add(addr + 1);
}
Interceptor.attach(func_addr(0x862148), {
onEnter: function (args) {
send("------Func Enter------");
this.args_0 = args[0];
this.args_1 = args[0];
this.args_2 = args[0];
send(args[0]);
send(args[1]);
send(args[2]);
// send(args[3]);
},
onLeave: function (retval) {
send("------Func Leave------");
send(retval);
// console.log(base_addr.add(retval).readByteArray(128));
// console.log(ptr(this.args_0).readByteArray(128));
let str_ptr = ptr(this.args_0).add(8).readPointer()
let head = str_ptr.readU32();
// console.log(head === 1768191297);
if (head !== 1768191297) return;
let len = ptr(this.args_0).add(4).readUInt();
let str = str_ptr.readUtf8String(len);
send(str.slice(0, 64));
send(len)
ptr(this.args_0).add(4).writeUInt(61);
str_ptr.writeUtf8String("AudioOffset:214\n-\ntiming(0,189.00,3.00);\n(2222,1);\n(2222,4);\n")
}
});
经过了不断地尝试我们写出了上面的代码,我们发现此函数的第一个参数,实际上是 std::string
的指针或者说引用,于是这里有了第一个坑,就是 std::string
的内存结构长啥样,网上分析很多搜搜就知道了,不过建议通过实践来认知,直接给出结果,其占了三个函数指针的长度:第一块是短字串的栈放置位,如果放不下就会放到堆里,经过我们测试这里在 arm 32 上会表示为 01 08 00 00
,经过网络搜索可知第一位非零直接可以认定数据在堆里,当然我们这很特殊,读取的文件都不会很小,所以直接认为是长字符串了;第二块是总长度的 ulong
表示;第三块是数据头部的指
第二个坑,我们通过动态获取模块基址 base_addr
来计算函数真正的地址,但有没有发现 func_addr
里,函数地址被加一了?至今我也不知道具体为啥,但是似乎 32 位平台就是要 +1,不然会闪退,这可以在用 IDA 追到虚函数表中看到
可以发现这里我们判断了字符串的头部,这是为了判断是否是我们想要处理的文件,在这里那个诡异的数字其实是个 ascii 文本的对应数值啦,在最后我们修改了字符串,这是通过改长度和改数据块内容来实现的,最终效果显然是所有谱面都变成两个 note 的谱面了草(笑)
AES-128-GCM Decrypt
这个加密算法是我们很熟悉很熟悉的,因为在 Link Play 模式里被用过了,同样根据一通乱找可得函数静态地址 0x56F004
,我们不妨将此函数叫做 AES_GCM_decrypt
,于是可以写出差不多的钩子代码:
// arcaea.js
// `AES_GCM_decrypt` hook part
Interceptor.attach(func_addr(0x56F004), {
onEnter: function (args) {
send("------Func Enter------");
this.in_args = [args[0], args[1], args[2], args[3], args[4]];
send(args[0]);
send(args[1]);
send(args[2]);
send(args[3]);
send(args[4]);
send(ptr(this.in_args[0]).readByteArray(64));
send(ptr(this.in_args[1]).readPointer().readByteArray(64));
send(ptr(this.in_args[2]).readPointer().readByteArray(64));
send(ptr(this.in_args[3]).readPointer().readByteArray(64));
},
onLeave: function (retval) {
send("------Func Leave------");
send(retval);
console.log(ptr(this.in_args[0]).readPointer().readByteArray(128));
console.log(ptr(this.in_args[1]).readPointer().readByteArray(128));
console.log(ptr(this.in_args[2]).readPointer().readByteArray(128));
console.log(ptr(this.in_args[3]).readPointer().readByteArray(128));
console.log(ptr(this.in_args[4]).readPointer().readByteArray(128));
}
});
我们省略了前述的一些函数和变量的定义,通过人肉观察钩子的输出数据,基本可以猜出全部的参数含义,直接给出结果 flag = aes_gcm_decrypt(key, cipher, iv, tag, plain)
,声明一下类型并稍微解释一下:flag: int
是成功与否的标志位,其实应该是 bool
类型;key: u8 *
显然是 16 位密钥 KEY 的指针;cipher
、iv
、tag
和 plain
的意思是清晰的,密文、偏移 IV、校验 TAG 和明文,注意本应用最特别的一点是,这里的 IV 和 TAG 都是 12 字节的(老早就知道了草),它们四个的数据结构是一样的,都是 std::vector
的指针或引用
好了,我们的目的是调用此函数,经过万般艰难的反复调试后,得到了以下调用测试代码:
// arcaea.js
// `AES_GCM_decrypt` call part
const PTR_SIZE = Process.pointerSize;
const AES_GCM_DECRYPT_ADDR = 0x56F004;
function hex2bytes(hex) {
let bytes = [];
for (let c = 0; c < hex.length; c += 2) bytes.push(parseInt(hex.substr(c, 2), 16))
return bytes
}
function bytes2vector(bytes) {
let vec = Memory.alloc(PTR_SIZE * 3);
let len = bytes.byteLength;
let ptr = Memory.alloc(len);
ptr.writeByteArray(bytes);
vec.writePointer(ptr);
vec.add(PTR_SIZE).writePointer(ptr.add(len));
vec.add(PTR_SIZE * 2).writePointer(ptr.add(len));
return {
vec: vec,
ptr: ptr // 用于防止内存回收
};
}
const aes_gcm_decrypt = new NativeFunction(func_addr(AES_GCM_DECRYPT_ADDR), 'int', ['pointer', 'pointer', 'pointer', 'pointer', 'pointer']);
let key = Memory.alloc(16 + 1);
key.writeU8(1);
key.add(1).writeByteArray(hex2bytes("a13e3fd54444ce7eb4eed8e66dc26d79"));
let cipher = bytes2vector(hex2bytes("d01e7a09ce6a58b2273116eab74bca64"))
let iv = bytes2vector(hex2bytes("ebf4d01c358a4a8491732cb4"))
let tag = bytes2vector(hex2bytes("299397eeba0b2d585111dda8"))
let plain = Memory.alloc(12);
console.log(key.readByteArray(64));
console.log(cipher.vec.readPointer().readByteArray(64));
console.log(iv.vec.readPointer().readByteArray(64));
console.log(tag.vec.readPointer().readByteArray(64));
let ret = aes_gcm_decrypt(key, cipher.vec, iv.vec, tag.vec, plain);
send(ret);
console.log(plain.readPointer().readByteArray(64));
首先要说明的是,实际上后面的调用代码我随便塞到某个函数的钩子中了,我可没试过直接调用会不会有问题
这里还有不少坑,我们来一个个说:
key
不是 16 位吗,为啥用了个u8[17]
呢,因为第一位是判断是否存在的 flag,鬼知道这里用了什么数据结构,这真的坑死我了std::vector
的内存结构需要知道,根据搜索引擎可知,其占据三个指针的大小,第一块为数据起始地址,第二块为数据终止地址,第三块为容器末尾地址,容器大小不够时是会自动扩容的,不过在我们的例子中无需关心,容器大小等于数据大小即可- 这里专门写了个
bytes2vector
函数来生成std::vector
结构,然后在测试的时候发现似乎没有成功,追踪数据发现,容器还在,但是里面的数据乱掉了,幸好有了解过垃圾回收,稍稍一会就想到了原因,这里指向数据块的裸指针在函数结束时认为没有引用,被释放了,所以我们迫不得已使用对象来返回,保证容器和数据的生命周期相同 - 一个小坑,
writeByteArray
绝对是不可替换的,使用诸如writeUtf8String
的其它方法会导致字符串无法解析
整合其时
好的,既然两个主要函数都调查研究清楚了,那么就要将二者结合起来,在程序读取文本文件的时候,提取返回值文本并主动调用解密函数,最终将解密得到的明文覆盖在原来的结果上即可,说起来倒是简单,坑一点也没少,直接给出完整代码如下:
// arcaea.js (libarcaea.so)
const LIB_NAME = "libcocos2dcpp.so";
const PTR_SIZE = Process.pointerSize;
const GET_STRING_FROM_FILE_ADDR = 0x862148; // 从文件中读取字符串
const AES_GCM_DECRYPT_ADDR = 0x56F004; // AES_GCM_DECRYPT
const PKCS12_PARSE_ADDR = 0x75A3F4; // PKCS12_parse
const AES_KEY = "123456789012345678901234567890ff";
const HEAD_CHECK = 926033953; // 字符串头部校验
const IF_LOG = true;
const android_log_write = new NativeFunction(Module.getExportByName(null, '__android_log_write'), 'int', ['int', 'pointer', 'pointer']);
const log_tag = Memory.allocUtf8String("Arcaea Hook");
const log = (str) => {
if (typeof str !== 'string') str = str.toString();
const log_msg = Memory.allocUtf8String(str);
if (IF_LOG) {
android_log_write(3, log_tag, log_msg);
send(str);
}
}
log("Hook Start ...");
// Module.ensureInitialized(LIB_NAME);
log("Hooking " + LIB_NAME + " ...");
function hook() {
const base_addr = Module.findBaseAddress(LIB_NAME);
log("base_addr: " + base_addr);
function findExport(name) {
return Module.findExportByName(LIB_NAME, name);
}
function func_addr(addr, offset = 0) {
return base_addr.add(addr + 1 + offset); // 函数地址 +1,也有可能不 +1,多试试
}
function hex2bytes(hex) {
let bytes = [];
for (let c = 0; c < hex.length; c += 2) bytes.push(parseInt(hex.substr(c, 2), 16))
return bytes
}
const PKCS12_parse_addr = findExport('PKCS12_parse')
log("PKCS12_parse: " + PKCS12_parse_addr);
const addr_offset = PKCS12_parse_addr.toInt32() - (base_addr.toInt32() + PKCS12_PARSE_ADDR); // 动态校准地址
log("addr_offset: " + addr_offset)
const aes_gcm_decrypt = new NativeFunction(func_addr(AES_GCM_DECRYPT_ADDR, addr_offset), 'int', ['pointer', 'pointer', 'pointer', 'pointer', 'pointer']);
function bytes2vector(bytes) {
let vec = Memory.alloc(PTR_SIZE * 3);
let len = bytes.byteLength;
let ptr = Memory.alloc(len);
ptr.writeByteArray(bytes);
vec.writePointer(ptr);
vec.add(PTR_SIZE).writePointer(ptr.add(len));
vec.add(PTR_SIZE * 2).writePointer(ptr.add(len));
return {
vec: vec,
ptr: ptr // 用于防止内存回收
};
}
const KEY = Memory.alloc(16 + 1);
KEY.writeU8(1);
KEY.add(1).writeByteArray(hex2bytes(AES_KEY));
log("getStringFromFile: " + func_addr(GET_STRING_FROM_FILE_ADDR, addr_offset));
Interceptor.attach(func_addr(GET_STRING_FROM_FILE_ADDR, addr_offset), {
onEnter: function (args) {
// log("------Func Enter------");
this.str_ptr = args[0];
// send(args[0]);
// send(args[1]);
// send(args[2]);
},
onLeave: function (retval) {
// log("------Func Leave------");
// send(retval);
// console.log(base_addr.add(retval).readByteArray(128));
// console.log(ptr(this.str_ptr).readByteArray(64));
let data_ptr = ptr(this.str_ptr).add(PTR_SIZE * 2).readPointer()
let head = data_ptr.readU32();
if (head !== HEAD_CHECK) return; // 字符串头部校验
let len = ptr(this.str_ptr).add(PTR_SIZE).readULong();
let bytes = data_ptr.readByteArray(len);
log(len)
let cipher = bytes2vector(bytes.slice(28, len))
let iv = bytes2vector(bytes.slice(4, 16))
let tag = bytes2vector(bytes.slice(16, 28))
let plain = Memory.alloc(PTR_SIZE * 3); // std::vector
// console.log(KEY.readByteArray(64));
// console.log(cipher.vec.readPointer().readByteArray(64));
// console.log(iv.vec.readPointer().readByteArray(64));
// console.log(tag.vec.readPointer().readByteArray(64));
let ret = aes_gcm_decrypt(KEY, cipher.vec, iv.vec, tag.vec, plain);
log(ret);
// console.log(plain.readByteArray(32));
// console.log(plain.readPointer().readByteArray(64));
let real_len = len - 28;
let text_ptr = plain.readPointer();
let n = 0;
while (text_ptr.add(real_len - 1).readU8() === 0 && n <= 16) { real_len--; n++; }
log(real_len);
ptr(this.str_ptr).add(4).writeULong(real_len);
ptr(this.str_ptr).add(PTR_SIZE * 2).writePointer(text_ptr);
}
});
}
hook();
整体看来稍显复杂,此处省略一点内容放在后面再讲,着重去看 Interceptor.attach
中 onLeave
回调函数
这里我们自行设计了一个加密文件格式,具体为 4 字节的固定头部 + 12 字节偏移 IV + 12 字节 TAG + 密文段,注意密文段是 16 字节的整数倍,缺少的部分使用了 0x00
空字节进行对齐
由于上述的文件结构呢,我们拿到密文时就会先校验文件头,接着再进行数据切片和解密操作,其结果会保存到一个 std::vector
中,再读取这段数据的尾部,排除掉空字节,重新计算真实长度,最后塞回到拿密文的地方,替换为明文的指针和真实长度即可
Frida Gadget 持久化
狠狠注入
在讲最后一个让我头疼不已的坑之前(虽说其实是我菜),先讲讲此处的持久化方案,Frida Gadget 会提供二进制文件或者说动态链接库,程序运行时加载它,它就会去加载指定用户脚本,然后开始我们刚刚所做的一系列操作,让程序加载动态库很显然可以直接在 java 层干,修改反编译出来的 smali 文件让它加载动态库即可,另一种方法就是我们采用的,本身就会加载 native 层的应用,只需要将动态库作为依赖加进去就行
直接照抄魏佬写的就行,安装个 LIEF 库 pip install lief
,下载 Frida Github Releases 中对应版本的 Gadget 库,注意架构要和应用程序中的库一致,在本文中指的是 arm 32 的 frida-gadget-16.1.3-android-arm.so
,改名字为 libgadget.so
,然后开始注入:
import lief
libcocos2dcpp = lief.parse("libcocos2dcpp.so")
libcocos2dcpp.add_library("libgadget.so")
libcocos2dcpp.write("out.so")
真就一个字我也没改,不过我想说的是,魏佬那边说是十分钟,在我这就一两秒就好了,以及他用了 elf_reader.py
来检查是否依赖成功,我想说 PowerShell 的 readelf
实在太香了(
现在我们有了 out.so
,改名覆盖 libcocos2dcpp.so
,加上 libgadget.so
一起丢进 lib
文件夹中,接着写个 libgadget.config.so
文件来当作 JSON 配置文件:
{
"interaction": {
"type": "script",
"path": "/data/data/moe.low.arc/lib/libarcaea.so"
}
}
当然,在第一次尝试时我们还是得加个 "on_change": "reload"
来热加载脚本的,另外建议测试时把脚本放到 /data/local/tmp
中比较好,这样直接 adb push
进去热更新很方便的(猜猜我测试了多少次.jpg
有没有发现最终放的脚本位置很诡异,直接指向了 lib
好吧,文件名规则要求 lib 开头且后缀名是 so,都做到了哦~这样就可以直接且方便地和程序打包了,assets
文件夹是不会解压的,千万别放那里面
脚本修正
将 arcaea.js
修改为 libarcaea.so
后打包进去测试时发现脚本根本不工作了呀,无数遍的尝试后,忽然灵光一闪,然后突然觉得自己傻爆了……
先解决一下日志输出问题,总所周知没日志就根本不知道发生了什么,而打包进去就没有 send
和 console.log
给我们输出了,根据搜索引擎找到的某个回答,在 Android 上钩住 __android_log_write
函数就行了,于是我们有了之前完整代码开头的那个 log
函数的实现
一开始主要是猜测脚本加载时,模块没有加载导致钩不到函数,于是采用了各种各样能搜到的办法去确保模块加载后运行,然后发现没什么乱用,仔细想想,已经作为模块的依赖了,那么二者加载肯定几乎同时,没必要关心细节了
然后怀疑是不是 Frida 版本更新导致用法不同了,到网上抄了官方的一段,顺带说明实际运行的也是下面一段而不是简单的 hook();
:
// arcaea.js
// end part
rpc.exports = {
init(stage, parameters) {
log('[init]', stage, JSON.stringify(parameters));
hook();
},
dispose() {
log('[dispose]');
}
};
实际这段到底有没有用、有什么用我都不清楚,盲猜两种写法都可以吧
接着想想,是不是模拟器的问题啊,到真机上一试还是挂掉了,那是不是 Gadget 的问题啊,那就加个简单函数测一下,就选之前的 PKCS12_parse
好了……一测发现……函数地址不对劲啊,脑子忽然就想通了,绝了,给动态库加依赖那么后面的函数地址必然有变动啊,我们之前那些靠着函数地址来 hook 的全都炸了
懒得再开个 IDA 测新的静态地址了,盲猜相对地址关系不变,用 PKCS12_parse
的地址变动差来修正就行,于是就有了上述完整代码中的地址校准部分
后记
好了,终于搞定,开心地跑起来了,现在文本文件可以是加密的文件了,当然,这种方法存在多少问题还是很不确定的,毕竟 Frida Gadget 不太可能支持全部的 Android 机型,想要比改二进制更加兼容是不大可能的,不过这确实是一次很好的练习啊哈哈哈哈
其实绝对可以用这方法弄个查分器,可惜基本不玩了也没太大兴致……好吧,我觉得总有一天 lowiro 会想起来加密文件的(