奖励翻倍这类修改,很多时候连 APK 都不用动。如果是 Unity IL2CPP 游戏,从包里取出 libil2cpp.so 和 global-metadata.dat,metadata 没加密的话,Il2CppDumper 跑一遍,dump.cs 里就有类名、方法名和 RVA。搜到结算函数,用 Frida attach 上去,基址加 RVA 下 Hook,参数和返回值都能改。整个过程没有重打包,签名校验和文件校验也就不会报警。
这两项校验检查的是安装好的 APK 文件,Hook 改的是进程内存里的函数,APK 本身没有变化。metadata 加密和混淆能增加定位难度,函数被找到之后也帮不上忙。要发现这类修改,只能去查运行中的进程和函数。

最顺手的第一步是扫 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,模块交叉校验,关键节点复查 |
只能提高修改成本 |
| 异常处置 | 多信号风险分级 | 分级记录和上报,敏感操作结合服务端校验 | 单个工具特征不直接判定作弊 |