字节暗面

手游反 Hook 怎么做:Frida、Xposed 与运行时完整性检测


奖励翻倍这类修改,很多时候连 APK 都不用动。如果是 Unity IL2CPP 游戏,从包里取出 libil2cpp.so 和 global-metadata.dat,metadata 没加密的话,Il2CppDumper 跑一遍,dump.cs 里就有类名、方法名和 RVA。搜到结算函数,用 Frida attach 上去,基址加 RVA 下 Hook,参数和返回值都能改。整个过程没有重打包,签名校验和文件校验也就不会报警。

这两项校验检查的是安装好的 APK 文件,Hook 改的是进程内存里的函数,APK 本身没有变化。metadata 加密和混淆能增加定位难度,函数被找到之后也帮不上忙。要发现这类修改,只能去查运行中的进程和函数。

APK 未变,函数被改

最顺手的第一步是扫 frida 字符串,这一步常被看得太重:扫到了,只能说明运行环境里有工具痕迹,函数改没改还不知道;扫不到,同样得不出函数没被改的结论。“发现工具”和“发现函数被改”是两个问题,混在一起,后面的处置也容易跟着错。

工具痕迹:主要抓默认配置

Frida 注入后,进程映射里可能出现这样一行(地址和路径仅作示意):

7a1c000000-7a1d200000 r-xp 00000000 00:05 12345  /memfd:frida-agent-64.so (deleted)

常见的检查入口大概是这几个:

入口 看什么 局限
/proc/self/maps frida-agent、frida-gadget,来路不明的可执行映射 可改名、可隐藏
/proc/self/task/<tid>/comm gum-js-loop、gmain、gdbus 等线程名 随版本变化,gmain、gdbus 易误报
本地端口 27042,D-Bus 探测 端口能改,也证明不了本进程被注入
类加载 XposedBridge 等经典类名 新框架不一定保留

这些检查成本低,可以留着当第一道筛子,但稍微改过的环境就不一定命中了,比如 LSPosed 依赖的 ART Hook 库 LSPlant,生成的 stub 类名、字段名和方法名都可以配置。

用 fopen 读 maps 时,对方 Hook libc 就能把可疑行滤掉。这类读取最好在 native 里走直接 syscall,绕开 libc 的导出函数。

函数被改过没有:Native 和 Java 分开查

更该问的是:要调用的那个函数,还是不是原来那份代码?.text 比对和 ART 入口检查针对不同层的修改,互相替代不了。开头的结算函数在 Native 层,先从这里说。

Native 层主要看 Inline Hook 和 PLT/GOT Hook。Inline Hook 直接改函数开头的机器指令,ARM64 上的一种常见形态是这样:

// 原始入口(示例)
stp  x29, x30, [sp, #-0x10]!
mov  x29, sp
...               // 下面的改写会覆盖入口前 16 字节

// 被改写后(示例)
ldr  x16, #8      // 50 00 00 58
br   x16          // 00 02 1F D6
.quad <target>    // 跳转目标地址,常落在匿名映射或 memfd 里

跳板还可能是 B、ADRP + BR 或换个寄存器,只靠特征码不够,更稳的是和基线比。AArch64 的 PIC 代码段一般不带文本重定位,地址每次不同,.text 的字节应当和构建产物一致:

// 示意:基线在链接后写入,或由服务端按版本下发
void check_entry(const uint8_t* fn, const uint8_t* baseline, size_t n) {
    uint8_t diff = 0;
    for (size_t i = 0; i < n; ++i) {  // 自己比,别依赖可能被 Hook 的 memcmp
        diff |= fn[i] ^ baseline[i];
    }
    if (diff != 0) {
        record_signal(SIG_CODE_PATCHED, fn);  // 记录信号,不返回 bool
    }
}

工具痕迹与入口基线

写进 SO 的基线可能被一起改,服务端下发则要多走一次网络。代码段由加固方案运行时解密的,校验放在解密之后。范围只挑奖励结算、资源解密、安全检测这些关键函数,在登录、结算、支付等节点抽查,别每帧扫整段 .text。

GOT 存的是运行时解析的地址,每次加载都不同,要看条目是否指向预期模块。白名单也要留:sigaction 解析到 libsigchain 是正常现象,自家 APM、崩溃收集 SDK 也可能做 Hook。

Java / ART 层看 ArtMethod:通过 JNI 的 FromReflectedMethod 拿到方法信息,确认入口落在 OAT 文件、解释器、ART 自身的 trampoline 或 JIT 代码缓存内。方法热了会从解释器转到 JIT,入口本来就会变,这里只判断归属。ArtMethod 布局随 Android 版本变化,jmethodID 也不保证是裸指针,要按版本适配。

LSPlant 把入口改到自己生成的 trampoline 上,归属检查比较容易发现。Frida 麻烦一些:frida-java-bridge(提交 e9a2455)把替换方法标成 native,quickCode 指向 ART 自己的 generic JNI trampoline,入口合法;原方法的已编译代码上还有 inline hook,入口地址未必变化。JIT 和设备上生成的 OAT 代码又没有构建期基线可比,ART 层的检查只能算一个信号。

检测为什么容易失效

失效有两类。一类是检测本身看不到:工具改个名,固定字符串就匹配不上;只改 IL2CPP 虚表或函数指针、靠硬件断点拦截的手法都不动代码段,.text 比对发现不了,要配合函数指针归属检查和反调试信号。

另一类出在写法上。所有检测收拢到一个 checkHook(),入口明显,找到这一处就能全部跳过。最要命的是结果汇总成一个 bool 交回 C# 或 Java 层,对方不用理解任何检测逻辑,把返回值改成 false 就行。

几类检测最好分散到不同模块,信号写入内部状态后再加密上报,模块之间交叉校验,关键业务节点再查一次,避免启动时绕过一次就一路畅通。这些做法保证不了百分百检出,目的是提高修改成本。

从单一出口到分开记录

发现异常之后怎么办

命中工具特征还不能认定作弊。线程名就可能误命中,如果命中后直接闪退或封号,误伤代价不小。我倾向于按信号强度分级:只有工具特征时先记录;关键函数完整性异常时提高风险等级;风险账号进入奖励结算、交易等敏感操作时,再结合服务端校验决定是否限制。

服务端手里有业务状态,能判断这次奖励该不该发、数值是否合理,但看不到进程里的代码有没有被改,这部分只能靠客户端上报。上报也可能被拦截或伪造,是否发奖还是看服务端的业务校验,客户端信号只影响风险等级。

本文检查项汇总

检测层次 检查什么 怎么做 注意事项
工具痕迹 模块、线程名、端口、Xposed 特征 native 直接 syscall 读 /proc,探测本地端口 特征可改,命中只算信号
Inline Hook 关键函数入口和代码段 与基线比对 加固解密后再校验,控制校验范围和频率
PLT/GOT Hook GOT 条目指向 校验地址是否属于预期模块 为 libsigchain 和自家 SDK 留白名单
ART 方法 入口地址归属 FromReflectedMethod 取方法信息,校验入口所在区域 适配版本差异;入口合法不代表方法没被改
不动代码段的手法 IL2CPP 虚表、函数指针、硬件断点 函数指针归属检查,结合反调试信号 .text 比对覆盖不到
检测自身 入口和结果传递 分散检测,不返回 bool,模块交叉校验,关键节点复查 只能提高修改成本
异常处置 多信号风险分级 分级记录和上报,敏感操作结合服务端校验 单个工具特征不直接判定作弊

 

← 返回列表