ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

移动端反作弊主动干预实战:Frida与Hook检测对抗

移动端反作弊主动干预实战:Frida与Hook检测对抗 1. 反作弊攻防的战场早已从特征对抗转向运行时博弈做移动端安全的人这两年应该有个明显感受单纯靠静态特征扫描已经很难拦住真正有威胁的作弊行为。原因不复杂——作弊工具本身在进化从早期改内存、改返回值到现在直接注入进程、Hook关键函数、动态篡改逻辑整个攻击面从文件层下沉到了运行时层。你如果还停留在扫一遍so文件看有没有可疑字符串的阶段基本等于没设防。这篇内容聊的是主动干预技术在反作弊实战中的落地流程重点放在代码维度——也就是真正写代码去检测、去干扰、去反制的那部分。涉及的核心工具链是Frida、IDA、ARM64 汇编、Hook 机制这几个词放在一起基本就是当前移动端反作弊攻防的标准配置。适合有一定逆向基础、正在做App安全加固或者风控对抗的工程师看纯小白可能需要先补一下ARM64汇编和Frida的基本用法。我先把结论摆前面反作弊的主动干预本质是在攻击者动手的每一个环节上制造不确定性。攻击者要注入你就检测注入攻击者要Hook你就检测Hook攻击者要调试你就让调试变得极其难受。这不是单点技术而是一整套流程。下面我按实战顺序拆开讲。2. 为什么主动干预比被动检测更值得投入2.1 被动检测的天花板在哪里大部分团队一开始做的都是被动检测启动时校验签名、扫描内存中的可疑模块、检查是否有已知作弊框架的特征。这套东西有用但天花板很低。原因有三第一特征是可以绕的。你今天加了某个Frida特征检测明天攻击者改个端口、改个进程名、换个注入方式特征就失效了。特征对抗是消耗战你永远在追。第二被动检测的时机太晚。很多检测逻辑跑在App启动阶段但攻击者完全可以在你检测完之后再注入。你检测的是启动那一刻的状态攻击者利用的是运行过程中的状态时间窗口根本对不上。第三被动检测只告诉你有问题不解决问题。检测到Frida在跑然后呢弹个框提示用户攻击者直接把这个弹框逻辑也Hook掉。你没有反制手段检测就只是个报警器。2.2 主动干预的核心思路主动干预的逻辑是反过来的不等攻击者来而是主动去攻击攻击者的工具链。具体来说分三个层次检测层识别当前进程是否被注入、是否被Hook、是否处于调试状态。这是基础但要做得多点、做得多时机。干扰层一旦发现异常不是简单退出而是给攻击者制造麻烦——比如让Hook失效、让调试器崩溃、让内存数据变得不可信。反制层在极端情况下可以主动破坏攻击者的工具环境或者收集攻击者信息用于后续风控。这三层里检测层是必须的干扰层是拉开差距的地方反制层要看具体业务场景和合规边界。下面重点讲检测和干扰的代码实现。2.3 一个真实的对抗场景举个我实际遇到过的例子。某次对抗中攻击者用Frida注入后Hook了我们的签名校验函数直接把返回值改成true。我们最初的检测是在Java层做的结果攻击者连Java层的检测函数一起Hook了。后来改成在native层做检测并且把检测逻辑分散到多个so里互相校验。攻击者要Hook就得同时Hook多个点成本一下就上去了。再后来加了主动干扰检测到Frida的线程后不是直接退出而是往Frida的通信通道里写垃圾数据让攻击者的脚本收到错误响应调试体验极差。这个演进过程说明一个事反作弊的强度不取决于你用了多高级的技术而取决于你让攻击者多难受。3. Frida注入的检测点从进程、线程到内存特征3.1 Frida的工作机制决定了它的暴露面要检测Frida先得知道Frida怎么工作的。Frida在Android上的注入方式主要有两种一种是ptrace注入一种是zygote注入。不管哪种最终都会在目标进程里加载frida-agent.so并且会创建一个或多个线程来处理通信。这就留下了几个天然的检测点进程层面frida-server进程、frida相关端口线程层面frida-agent创建的线程名、线程数量异常内存层面frida-agent.so的映射、内存中的特征字符串通信层面Frida默认使用的端口、通信协议特征3.2 代码维度检测frida-server进程和端口最基础的检测是扫进程和端口。但要注意直接读/proc下的进程列表在Android高版本上权限受限而且容易被Hook。更稳的做法是结合多种方式。// 检测frida-server进程简化示例 int detect_frida_process() { DIR *dir opendir(/proc); if (!dir) return 0; struct dirent *entry; while ((entry readdir(dir)) ! NULL) { if (entry-d_type ! DT_DIR) continue; char cmdline_path[256]; snprintf(cmdline_path, sizeof(cmdline_path), /proc/%s/cmdline, entry-d_name); FILE *fp fopen(cmdline_path, r); if (!fp) continue; char cmdline[256] {0}; fread(cmdline, 1, sizeof(cmdline) - 1, fp); fclose(fp); // 检测常见frida进程名 if (strstr(cmdline, frida) || strstr(cmdline, gum-js-loop) || strstr(cmdline, gmain)) { closedir(dir); return 1; } } closedir(dir); return 0; }这段代码看着简单但有几个坑要注意。第一/proc目录的读取在Android 10以上可能被限制需要测试目标设备的实际行为。第二进程名是可以改的攻击者可以把frida-server改名成随便什么所以不能只靠进程名。第三这段代码本身如果被Hook检测就失效了所以要么放在不容易被Hook的地方要么做多重校验。端口检测相对更可靠一些因为Frida默认端口是固定的// 检测Frida默认端口27042 int detect_frida_port() { int sock socket(AF_INET, SOCK_STREAM, 0); if (sock 0) return 0; struct sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons(27042); addr.sin_addr.s_addr inet_addr(127.0.0.1); int ret connect(sock, (struct sockaddr*)addr, sizeof(addr)); close(sock); return (ret 0) ? 1 : 0; }端口检测的局限在于攻击者可以改端口。但改端口意味着攻击者要额外配置增加了操作成本。而且很多自动化工具默认就用27042改端口的往往是老手。所以端口检测作为其中一层是有价值的。3.3 线程检测Frida留下的更隐蔽的痕迹Frida注入后会创建几个特征线程比如gum-js-loop、gmain、gdbus。这些线程名在/proc/self/task/下可以看到。检测线程比检测进程更隐蔽因为攻击者往往只关注进程名和端口忽略了线程名。// 遍历/proc/self/task检测可疑线程名 int detect_frida_threads() { DIR *dir opendir(/proc/self/task); if (!dir) return 0; struct dirent *entry; while ((entry readdir(dir)) ! NULL) { if (entry-d_type ! DT_DIR) continue; char comm_path[256]; snprintf(comm_path, sizeof(comm_path), /proc/self/task/%s/comm, entry-d_name); FILE *fp fopen(comm_path, r); if (!fp) continue; char comm[64] {0}; fread(comm, 1, sizeof(comm) - 1, fp); fclose(fp); if (strstr(comm, gum-js-loop) || strstr(comm, gmain) || strstr(comm, gdbus) || strstr(comm, frida)) { closedir(dir); return 1; } } closedir(dir); return 0; }线程检测的实战价值在于它检测的是Frida运行时的必然产物。攻击者可以改进程名、改端口但改线程名需要改Frida源码重新编译成本高很多。当然高级攻击者确实会这么做所以线程检测也不能单独用。3.4 内存扫描找frida-agent.so的映射Frida注入后frida-agent.so会被映射到进程内存空间。通过读取/proc/self/maps可以找到这个映射。但直接搜frida字符串太容易被绕更稳的做法是结合多个特征。// 扫描maps文件查找可疑模块 int detect_frida_maps() { FILE *fp fopen(/proc/self/maps, r); if (!fp) return 0; char line[512]; int found 0; while (fgets(line, sizeof(line), fp)) { // 检测frida相关映射 if (strstr(line, frida) || strstr(line, gum-js) || strstr(line, gadget)) { found 1; break; } // 检测异常的可执行内存段无文件名的rwx段 if (strstr(line, rwxp) !strstr(line, /)) { found 1; break; } } fclose(fp); return found; }这里有个经验rwx内存段是重点怀疑对象。正常App的so映射一般是r-xp可执行不可写而Frida注入的代码往往需要可写可执行。虽然有些JIT场景也会出现rwx但结合其他特征一起判断准确率会高很多。4. Hook检测从函数入口到指令级别的对抗4.1 Hook的几种常见方式在ARM64上Hook主要有这么几种实现方式Inline Hook直接修改目标函数的指令插入跳转PLT/GOT Hook修改导入表让函数调用跳到自己的实现Java层Hook通过动态代理或反射替换方法实现不同的Hook方式留下的痕迹不一样检测方法也不同。下面重点讲Inline Hook的检测因为这是native层最常用的方式。4.2 Inline Hook的指令特征ARM64的Inline Hook通常会在函数入口处写入一条跳转指令。常见的跳转指令有B指令无条件跳转编码为0x14000000 | offsetLDR BR组合加载地址后跳转BR指令寄存器跳转检测的思路是读取目标函数的入口指令判断是否是跳转指令。但这里有个问题——正常函数的入口也可能是跳转比如PLT stub所以不能一概而论。更精确的做法是对比函数在内存中的指令和它在文件中的指令。如果内存中的指令和文件中的不一致说明被修改了。// 对比内存指令和文件指令检测Inline Hook int detect_inline_hook(void *func_addr, const char *so_path, size_t offset) { // 读取内存中的指令 uint32_t mem_insn *(uint32_t *)func_addr; // 从so文件中读取原始指令 FILE *fp fopen(so_path, rb); if (!fp) return 0; fseek(fp, offset, SEEK_SET); uint32_t file_insn 0; fread(file_insn, sizeof(uint32_t), 1, fp); fclose(fp); // 对比 if (mem_insn ! file_insn) { return 1; // 被Hook了 } return 0; }这段代码的原理很直接但实战中要注意几点第一so文件的路径要能拿到。Android上so可能被压缩在APK里需要先解压或者从内存中读取原始数据。第二offset的计算要准确。函数地址减去so的基址才是文件偏移基址可以通过dladdr或者读maps获取。第三有些函数在加载时会被重定位内存中的指令和文件中的本来就不一样。所以这个方法只适用于那些不会被重定位的函数或者要排除重定位的影响。4.3 检测PLT/GOT HookPLT/GOT Hook修改的是导入表检测方法是读取GOT表项看它指向的地址是否在预期的模块范围内。// 检测GOT表项是否被篡改简化示例 int detect_got_hook(void *got_entry, void *expected_module_base, size_t expected_module_size) { void *target *(void **)got_entry; // 判断目标地址是否在预期模块范围内 if (target expected_module_base || target (void *)((char *)expected_module_base expected_module_size)) { return 1; // 被Hook了 } return 0; }这个方法的难点在于获取GOT表的位置。对于自己编译的so可以在链接时导出符号对于第三方so可能需要解析ELF结构。实战中一般结合IDA分析先定位关键函数的GOT表项再在代码里做检测。4.4 一个容易被忽略的检测点函数序言除了入口指令函数的序言prologue也是Hook的重灾区。很多Hook框架会修改序言来保存寄存器、插入跳转。检测序言的方法和检测入口指令类似但要多对比几条指令。我的经验是对关键函数做多重校验。比如一个签名校验函数既检测入口指令又检测序言还检测函数中间的几条指令。攻击者要Hook就得同时改多个地方而且改完之后还要保证函数逻辑正常成本很高。5. 主动干扰让攻击者的调试体验变得极差5.1 干扰的思路不退出但让你难受检测到异常后直接退出App这是最粗暴的做法。但退出有个问题攻击者很容易定位到是哪段代码触发了退出然后针对性绕过。更好的做法是不退出但让攻击者的工具失效。具体手段包括污染Frida的通信往Frida的通信通道写垃圾数据让脚本收到错误响应让Hook失效主动修改被Hook的函数让Hook跳转到一个空函数制造假数据给攻击者返回错误的内存数据让他分析半天发现是假的延迟触发不在检测到的时候立即反应而是等一段时间再动作增加定位难度5.2 污染Frida通信通道的实现Frida的通信基于socket默认端口27042。如果我们检测到这个端口在监听可以主动去连接并发送垃圾数据。// 向Frida端口发送垃圾数据干扰通信 void disturb_frida_comm() { int sock socket(AF_INET, SOCK_STREAM, 0); if (sock 0) return; struct sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons(27042); addr.sin_addr.s_addr inet_addr(127.0.0.1); if (connect(sock, (struct sockaddr*)addr, sizeof(addr)) 0) { // 发送垃圾数据 char garbage[1024]; for (int i 0; i 100; i) { memset(garbage, rand() % 256, sizeof(garbage)); send(sock, garbage, sizeof(garbage), 0); } } close(sock); }这段代码的实际效果是Frida的脚本可能会收到大量无效数据导致解析错误或者超时。攻击者会发现脚本行为异常但很难第一时间定位到是我们的App在捣乱。注意这种干扰手段要在合规范围内使用不要影响正常用户的设备环境。建议只在检测到明确异常时才触发并且做好频率控制。5.3 让Hook失效的几种方式如果检测到某个关键函数被Hook了可以主动把Hook覆盖掉。比如// 恢复被Hook的函数入口简化示例 void restore_function(void *func_addr, uint32_t original_insn) { // 修改内存保护为可写 mprotect((void *)((uintptr_t)func_addr ~0xFFF), 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC); // 写回原始指令 *(uint32_t *)func_addr original_insn; // 恢复内存保护 mprotect((void *)((uintptr_t)func_addr ~0xFFF), 0x1000, PROT_READ | PROT_EXEC); // 清除指令缓存 __builtin___clear_cache((char *)func_addr, (char *)func_addr 4); }这段代码的关键点是mprotect修改内存权限和清除指令缓存。ARM64有指令缓存修改代码后必须清除缓存否则CPU可能执行旧的指令。__builtin___clear_cache是GCC/Clang提供的内置函数用来做这件事。但要注意恢复函数可能会被攻击者再次Hook。所以更好的做法是循环检测恢复或者干脆把关键逻辑放到不容易被Hook的地方比如内联汇编、或者动态生成的代码。5.4 制造假数据的技巧如果攻击者在Hook我们的函数读取敏感数据我们可以返回假数据。比如攻击者Hook了获取设备ID的函数我们可以返回一个随机生成的假ID。// 返回假设备ID干扰攻击者 char* get_device_id_with_trap() { static char fake_id[64] {0}; if (detect_hook()) { // 检测到Hook返回假数据 snprintf(fake_id, sizeof(fake_id), FAKE-%08X-%08X, rand(), rand()); return fake_id; } // 正常返回真实ID return get_real_device_id(); }这个技巧的价值在于攻击者拿到假数据后可能需要很长时间才能发现是假的。等他发现的时候我们的风控系统可能已经根据他的行为特征把他标记了。6. ARM64汇编层面的对抗细节6.1 为什么要在汇编层面做检测C代码写的检测逻辑编译后就是一堆指令。攻击者要绕过只需要Hook对应的函数就行。但如果检测逻辑直接用内联汇编写或者分散在多个地方攻击者的Hook成本会高很多。更重要的是有些检测只能在汇编层面做。比如检测调试寄存器、检测单步执行、检测断点指令这些用C很难表达。6.2 检测断点指令调试器设置断点的方式是在目标地址写入BRK指令ARM64上编码为0xD4200000。我们可以扫描关键函数的指令看有没有BRK。// 检测函数中是否被插入断点 int detect_breakpoint(void *func_addr, size_t func_size) { uint32_t *insns (uint32_t *)func_addr; size_t count func_size / 4; for (size_t i 0; i count; i) { // BRK指令的编码范围 if ((insns[i] 0xFFE0001F) 0xD4200000) { return 1; // 发现断点 } } return 0; }BRK指令的编码格式是11010100 001imm16 000iii00简化判断可以用掩码0xFFE0001F匹配0xD4200000。这个方法能检测到软件断点但检测不到硬件断点。6.3 检测单步执行ARM64的PSTATE寄存器中有一位SSSoftware Step用于单步执行。可以通过读取PSTATE来判断是否处于单步状态。但用户态代码不能直接读PSTATE需要通过信号或者系统调用间接判断。一个实用的技巧是在关键代码前后插入时间检查。单步执行会导致代码执行时间显著变长。// 通过时间检测单步执行 int detect_single_step() { struct timespec start, end; clock_gettime(CLOCK_MONOTONIC, start); // 执行一段简单代码 volatile int x 0; for (int i 0; i 1000; i) { x i; } clock_gettime(CLOCK_MONOTONIC, end); // 计算耗时纳秒 long elapsed (end.tv_sec - start.tv_sec) * 1000000000L (end.tv_nsec - start.tv_nsec); // 正常执行应该在微秒级别单步执行会慢几个数量级 if (elapsed 10000000) { // 10ms return 1; } return 0; }这个方法的误报率需要实际测试调整。不同设备性能差异大阈值要留足余量。而且攻击者可以用Hook绕过时间检查所以只能作为辅助手段。6.4 用IDA分析对抗逻辑做反作弊的人也得会用IDA因为你需要知道攻击者是怎么分析你的。用IDA打开自己的so看看关键函数的反汇编站在攻击者角度想如果我要Hook这个函数我会在哪里下钩子我的习惯是每写一段检测逻辑就用IDA看一下编译后的汇编。如果发现检测逻辑太集中、太容易被定位就把它拆散、混淆。比如把检测逻辑内联到多个函数里或者用跳转表把控制流打乱。IDA的另一个用途是分析攻击者的工具。拿到一个作弊样本用IDA看它的Hook逻辑了解它的实现方式然后针对性设计检测。这个逆向过程是反作弊工程师的基本功。7. 实战中的坑与经验7.1 检测逻辑本身被Hook怎么办这是最常见的问题。你写了一堆检测代码结果攻击者把你的检测函数也Hook了检测直接失效。解决方案有几个层次自校验检测函数自己校验自己的指令有没有被改交叉校验多个检测函数互相校验A检测BB检测CC检测A分散检测把检测逻辑分散到多个地方不要集中在一个函数里动态生成在运行时动态生成检测代码让攻击者无法提前定位我一般用交叉校验分散检测的组合。比如在so的初始化函数、JNI_OnLoad、以及几个业务函数里都埋检测点互相校验。攻击者要全部绕过工作量很大。7.2 误报问题检测太激进会导致误报正常用户被当成攻击者。我遇到过的情况包括某些定制ROM的进程名和Frida特征撞车某些性能分析工具会创建类似gmain的线程某些调试版本的App自己就会触发检测解决误报的办法是多特征联合判断。不要因为一个特征命中就判定为攻击而是综合多个特征打分。比如进程名命中端口命中线程名命中才判定为Frida。这样误报率会低很多。7.3 性能开销检测逻辑跑得太频繁会影响App性能。我的经验是启动时做一次全面检测运行中做轻量级检测比如只检查关键函数的指令在敏感操作前做一次针对性检测不要在每个函数调用里都做全套检测那样CPU扛不住。7.4 对抗升级的节奏反作弊是一场持续对抗。你今天加的检测攻击者可能下周就绕过了。所以要有持续迭代的准备。我的做法是保留检测日志分析攻击者的绕过方式定期更新检测逻辑不要一套代码用一年关注安全社区的新工具、新方法提前布局检测8. 工具链的配合Frida、IDA、QEMU各自的位置8.1 Frida在攻防两端的角色Frida既是攻击工具也是研究工具。做反作弊的人也要会用Frida因为你需要用它来验证自己的检测逻辑是否有效。比如写一个Frida脚本去Hook自己的检测函数看检测会不会失效。如果会说明检测还不够健壮。用Frida做验证的典型流程// 用Frida验证检测逻辑攻击者视角 Java.perform(function() { var detectClass Java.use(com.example.Detector); // Hook检测函数看能否绕过 detectClass.detectFrida.implementation function() { console.log(检测函数被调用了); return false; // 强制返回未检测到 }; });如果这个脚本能让检测失效说明你的检测逻辑太容易被Hook。需要加自校验或者把逻辑下沉到native层。8.2 IDA的静态分析价值IDA主要用来做静态分析。对于反作弊工程师来说IDA的用途包括分析自己的so看编译后的代码是否容易被Hook分析攻击者的样本了解其Hook方式定位关键函数的偏移用于代码中的自校验IDA Pro 9.3之后的版本对ARM64的支持已经很完善了反汇编准确率很高。配合MCP插件可以做自动化分析效率提升明显。8.3 QEMU模拟ARM64的环境搭建有时候需要在x86机器上跑ARM64的代码做测试QEMU是常用方案。搭建流程大致是# 安装QEMU用户态模拟 sudo apt install qemu-user-static # 下载ARM64的rootfs # 配置binfmt_misc支持 sudo update-binfmts --enable qemu-aarch64 # 运行ARM64程序 qemu-aarch64-static ./your_arm64_binaryQEMU的好处是可以在开发机上快速验证ARM64逻辑不用每次都连真机。但要注意QEMU和真机的行为可能有差异尤其是涉及硬件特性的指令。最终验证还是要上真机。9. 写在最后的一些个人体会反作弊这个方向技术只是一部分更重要的是对抗思维。你得站在攻击者的角度想问题如果我是攻击者我会怎么绕过这个检测然后针对性地加固。我踩过最大的坑是过度依赖单一检测手段。早期做了一个Frida端口检测觉得挺稳结果攻击者改了个端口就绕过了。后来学乖了所有检测都做多层而且层与层之间要有关联。另一个体会是不要追求100%检测率。反作弊的目标不是抓住所有攻击者而是让攻击成本高于收益。当攻击者发现绕过你的防护需要花几天时间而换个目标只需要几分钟他自然就走了。最后说一个实操建议建立自己的对抗样本库。每次遇到新的攻击方式把样本存下来分析其特征更新检测逻辑。这个库是你最宝贵的资产比任何单一技术都值钱。
返回列表