基于 Arcaea 文件加密需求的 Frida 初探与 Gadget 持久化

 

前言

说是初探,其实不是首次接触,和之前 Android 无 ROOT 动调 有那么一点点关系,手上一直都没有可 root 真机,于是动调什么的都是老大难问题。在很久很久以前,曾经尝试使用模拟器来动调,无一例外地失败了,当时知道了处理器架构不同导致无法正常下断点等一系列问题,而因为静态调试能解决当下困难,于是动调搁置了很久……最近有些闲心来弄动调了,于是奇迹地发现 IDA 无 root 动调大法,于是觉得 Frida 说不定也行,再次翻看文档、查阅资料,发现一堆解决方案,比如 Frida 已经可以支持模拟器了,比如 Frida 的 Gadget 通过注入 so 可以无 root 持久化脚本,随即开始捣鼓捣鼓……

还是很累的,脑子都坏了,傻得不行的问题也犯了不少,哦对,荣幸选做测试的例子的应用程序是某创新立体节奏游戏 Arcaea,因为没有加密、加壳、反调试和签名校验等等,甚至曾经有符号表可以对照,绝对是最好的简单的逆向上手练习题 XD,以及我们的目的是在程序中帮忙加密一下它的明文文本文件(

以下是主要的参考资料:

  1. 魏佬写的 论安卓使用 frida 修改 Arcaea 1论安卓使用 frida 修改 Arcaea 2
  2. 某位大佬的电子书 Frida so层中的hook
  3. Frida 文档 JavaScript API
  4. 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.jsarcaea.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 的指针;cipherivtagplain 的意思是清晰的,密文、偏移 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));

首先要说明的是,实际上后面的调用代码我随便塞到某个函数的钩子中了,我可没试过直接调用会不会有问题

这里还有不少坑,我们来一个个说:

  1. key 不是 16 位吗,为啥用了个 u8[17] 呢,因为第一位是判断是否存在的 flag,鬼知道这里用了什么数据结构,这真的坑死我了
  2. std::vector 的内存结构需要知道,根据搜索引擎可知,其占据三个指针的大小,第一块为数据起始地址,第二块为数据终止地址,第三块为容器末尾地址,容器大小不够时是会自动扩容的,不过在我们的例子中无需关心,容器大小等于数据大小即可
  3. 这里专门写了个 bytes2vector 函数来生成 std::vector 结构,然后在测试的时候发现似乎没有成功,追踪数据发现,容器还在,但是里面的数据乱掉了,幸好有了解过垃圾回收,稍稍一会就想到了原因,这里指向数据块的裸指针在函数结束时认为没有引用,被释放了,所以我们迫不得已使用对象来返回,保证容器和数据的生命周期相同
  4. 一个小坑,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.attachonLeave 回调函数

这里我们自行设计了一个加密文件格式,具体为 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 后打包进去测试时发现脚本根本不工作了呀,无数遍的尝试后,忽然灵光一闪,然后突然觉得自己傻爆了……

先解决一下日志输出问题,总所周知没日志就根本不知道发生了什么,而打包进去就没有 sendconsole.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 会想起来加密文件的(