当前位置: 首页 > news >正文

Mumu模拟器+ Frida安卓逆向实战:绕过反调试与稳定Hook方案

1. 为什么是Mumu模拟器+ Frida,而不是真机或其它环境?

我第一次在客户现场调试一个金融类APK时,被要求全程在模拟器上完成所有逆向分析——不是因为客户抠门不给真机,而是他们明确告知:该APP的反调试逻辑会主动检测设备是否为真实Android硬件,一旦识别为Pixel、Samsung等常见机型,立即触发崩溃或降级行为;更麻烦的是,它还会校验系统分区的只读性、内核模块签名、甚至检查/dev/block/by-name/目录下是否存在boot、system等标准分区节点。真机上跑,还没开始Hook,APP自己就先“自毁”了。

这时候Mumu模拟器的价值就凸显出来了。它不是简单的QEMU虚拟化,而是基于深度定制的Android-x86内核+自研显卡加速层,在系统指纹层面做了大量“去真机化”处理:默认关闭SELinux enforcing模式、/proc/cpuinfo中CPU型号伪装成通用Intel Core系列、build.prop里ro.product.model和ro.build.fingerprint都设为泛化值(如“MumuPlayer”“Android/sdk_phone_x86_64/generic_x86_64:12/SPB2.220421.005/8390747:userdebug/test-keys”),最关键的是,它允许用户通过配置文件直接禁用ro.debuggable=0ro.secure=1这两个关键属性,让adb shell获得root级shell权限——而这是Frida注入的前提。

你可能会问:那用Genymotion不行吗?实测过。Genymotion虽然启动快,但它的内核模块加载机制与Frida的frida-server存在ABI兼容性问题。我在Genymotion 3.2.0(Android 11)上部署frida-server-16.1.12-arm64,执行frida -U -f com.example.app --no-pause时,进程能启动,但frida-server日志里反复报dlopen failed: cannot locate symbol "clock_gettime",根源在于Genymotion使用的glibc版本太老,而Frida新版本依赖POSIX.1b实时扩展。Mumu用的是musl libc + 自研轻量级系统调用拦截层,反而更稳定。

还有人提Nox,但Nox的root机制是通过修改init.rc注入su服务,这种方案在Android 10+上极易被检测到——APP只要调用ProcessBuilder.start()执行getprop ro.build.tags,就能发现返回值含test-keys,立刻退出。Mumu的root是通过内核模块mumu_ko.ko实现的,它劫持了sys_call_table中的sys_openatsys_read,对特定路径(如/system/build.prop)返回伪造内容,这种底层hook比应用层su干净得多,也更难被静态扫描发现。

所以,当你看到标题里强调“Mumu模拟器”,这不是随便选的,而是经过至少三轮客户现场验证后沉淀下来的最优解:它在系统可控性、反检测绕过能力、Frida兼容性三者之间取得了最佳平衡点。如果你现在手头只有真机,别急着放弃——后面我会讲怎么用Magisk+Zygisk模块临时“模拟”Mumu的指纹特征,但那是Plan B,Mumu才是开箱即用的主力战场。

2. Frida环境搭建的五个致命细节,90%的人卡在第三步

很多人按官方文档走完pip install frida-tools、下载对应架构的frida-server、adb push进/data/local/tmp、chmod +x、./frida-server &,然后frida -U -f com.xxx,结果报错Failed to spawn: unable to find process。问题往往不出在命令本身,而在五个被忽略的细节上。

2.1 Mumu模拟器必须启用“开发者选项”且开启USB调试

这听起来像废话,但Mumu的开发者选项默认是隐藏的。你需要连续点击“关于平板电脑”里的“版本号”7次——注意,不是“关于手机”,Mumu界面写的是“关于平板电脑”。点完后弹出“您现在处于开发者模式”,再返回设置主菜单,才能看到“开发者选项”。这里有个坑:USB调试开关旁边有个“USB安装”选项,默认是关闭的,必须手动打开。因为Frida在spawn模式下需要通过adb install临时部署一个调试代理APK(frida-gadget的变体),如果USB安装关闭,frida会卡在Waiting for process to spawn...长达30秒后超时。

2.2 frida-server版本必须与目标APK的so架构严格匹配

Mumu模拟器默认是x86_64架构,但很多APK为了兼容旧设备,会同时打包armeabi-v7a、arm64-v8a、x86、x86_64四套so。你以为push x86_64版frida-server就行?错。你得先确认APP主so加载的是哪一套。方法很简单:adb shell pm dump com.example.app | grep "nativeLibraryDir",假设输出是/data/app/~~abc123==/com.example.app-xyz123==/lib/arm64-v8a,那就说明APP运行时加载的是arm64-v8a的so,此时你必须用frida-server-16.1.12-android-arm64.xz,而不是x86_64版。我曾因此浪费两小时——用x86_64版server hook arm64-v8a so,frida能连上进程,但所有Java层Hook全部失效,因为JVM的JNI函数表地址映射错乱。

2.3 frida-server必须以root权限运行,且绑定到0.0.0.0

Mumu的adb shell默认就是root,但frida-server启动时如果不加参数,它会绑定到127.0.0.1:27042,而frida-tools从PC端连接时走的是adb reverse tcp:27042 tcp:27042,这个reverse通道在Mumu里有时会失败。正确做法是:./frida-server -l 0.0.0.0:27042 -D。其中-l指定监听地址,0.0.0.0确保所有网络接口可访问;-D后台守护模式,避免shell退出导致server终止。另外,-D必须放在最后,如果写成./frida-server -D -l 0.0.0.0:27042,frida-server会忽略-l参数,还是绑定到127.0.0.1。

2.4 必须关闭Mumu的“游戏加速”功能

这是最隐蔽的坑。Mumu右上角有个闪电图标,叫“游戏加速”,默认开启。它会接管CPU调度策略,把所有进程优先级提到RT(Real-Time)级别,并禁用Linux CFS(Completely Fair Scheduler)的负载均衡。结果就是:frida-server的线程被过度调度,频繁抢占主线程时间片,导致Java层Hook的回调函数(如Java.perform)执行超时,frida报ScriptRuntimeError: Timed out waiting for script to load。关掉游戏加速后,一切恢复正常。这个设置藏在Mumu设置→性能设置→游戏加速,把它关掉。

2.5 Frida脚本里必须显式调用Java.perform

很多人写Hook Java函数时,直接这么写:

Java.use("com.example.LoginHelper").checkToken.implementation = function(token) { console.log("token:", token); return this.checkToken(token); };

结果什么日志都不输出。原因在于:Frida的Java API必须在Java VM初始化完成后才能工作,而spawn模式下,VM可能还没ready。正确写法是包一层Java.perform:

Java.perform(function() { Java.use("com.example.LoginHelper").checkToken.implementation = function(token) { console.log("token:", token); return this.checkToken(token); }; });

Java.perform会等待VM就绪,然后在正确的上下文中执行你的Hook逻辑。漏掉这一层,等于在沙滩上盖楼——地基都没打牢。

提示:验证frida-server是否正常工作的最快方法是frida-ps -U,它应该列出所有正在运行的进程。如果返回空或报错,说明server没起来或adb连接异常。不要一上来就写复杂脚本,先确保基础通信畅通。

3. 实战APK样本解析:如何从混淆代码中精准定位Hook点

这次我们用的实战APK是一个电商类应用(已脱敏,包名com.shop.demo),核心需求是监控用户登录时提交的手机号和密码明文。但它的代码经过ProGuard深度混淆,所有类名都是a、b、c,方法名是a()、b()、c(),字符串全加密。直接看smali或JADX反编译,满屏都是if-eqz v0, :cond_17,根本没法读。

3.1 用动态日志缩小搜索范围:先Hook Log类

与其硬啃混淆代码,不如让APP自己“招供”。我们先写一个最简单的Frida脚本,Hook Android的Log类:

Java.perform(function() { var Log = Java.use("android.util.Log"); Log.d.overload('java.lang.String', 'java.lang.String').implementation = function(tag, msg) { if (tag.indexOf("Login") >= 0 || msg.indexOf("phone") >= 0 || msg.indexOf("pwd") >= 0) { console.log("[LOG] " + tag + ": " + msg); } return this.d(tag, msg); }; });

保存为log_hook.js,执行frida -U -f com.shop.demo -l log_hook.js --no-pause。APP启动后,控制台刷出一堆日志:

[LOG] LoginHelper: phone=138****1234, pwd=abc123!@# [LOG] NetworkUtil: POST /api/v1/login body={"phone":"138****1234","pwd":"abc123!@#"}

瞬间锁定关键类名LoginHelperNetworkUtil。注意,这里LoginHelper是混淆后的名字,但至少它是个有意义的标识符,比a.b.c强多了。

3.2 用堆栈追踪定位调用链:谁在调用LoginHelper?

光知道类名不够,得知道哪个Activity或Fragment触发了它。我们增强脚本,Hook LoginHelper的构造函数,打印调用栈:

Java.perform(function() { try { var LoginHelper = Java.use("LoginHelper"); LoginHelper.$init.overload().implementation = function() { console.log("[STACK] LoginHelper init called from:"); console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new())); return this.$init(); }; } catch(e) { console.log("LoginHelper not found yet"); } });

运行后,日志显示:

[STACK] LoginHelper init called from: java.lang.Throwable at LoginHelper.<init>(Unknown Source:0) at LoginActivity.a(LoginActivity.java:127) at LoginActivity$b.onClick(LoginActivity.java:89) at android.view.View.performClick(View.java:7441)

原来是在LoginActivity.java第127行创建的!马上反编译LoginActivity,找到第127行附近:

// LoginActivity.smali line 127 invoke-direct {v0}, LLoginHelper;-><init>()V

再往上翻,看到onClick方法里调用了a(),而a()方法体里有this.a.setText(this.b.getText().toString())——显然this.b是手机号输入框,this.a是密码框。至此,我们完全不需要看LoginHelper内部逻辑,就已经掌握了数据来源。

3.3 Hook Native层SO:绕过Java层的字符串加密

但事情没完。APP在LoginHelper里对密码做了AES加密,我们HookcheckToken方法拿到的是密文。真正的明文在Native层。用adb shell cat /proc/<pid>/maps | grep .so查到它加载了libcrypto.solibshopcore.so。重点盯libshopcore.so,用Ghidra打开,搜索字符串"aes_encrypt",找到函数Java_com_shop_core_Security_encrypt。它的JNI签名是:

JNIEXPORT jstring JNICALL Java_com_shop_core_Security_encrypt(JNIEnv *env, jclass clazz, jstring input)

Frida Hook它:

var libshopcore = Module.findBaseAddress("libshopcore.so"); if (libshopcore !== null) { console.log("libshopcore base: " + libshopcore); // 找到encrypt函数的偏移地址(Ghidra里看) var encrypt_addr = libshopcore.add(0x12345); // 假设偏移是0x12345 Interceptor.attach(encrypt_addr, { onEnter: function(args) { var input_str = Java.vm.getEnv().getStringUtfChars(args[2]); console.log("[NATIVE] encrypt input:", Java.vm.getEnv().getStringUtfChars(args[2])); }, onLeave: function(retval) { // retval是jstring,需转换 } }); }

注意:args[2]是第三个参数,即jstring input。getStringUtfChars能直接拿到C字符串指针,比用GetStringUTFChars安全,不会触发GC。这样,无论Java层怎么混淆,Native层的明文输入都逃不过我们的监控。

注意:Native Hook必须在so加载后进行。如果APP是懒加载so(比如点击登录按钮才dlopen),你的脚本要在onEnter里判断so是否已加载,未加载则setTimeout重试。我封装了一个工具函数:

function waitForSo(soName, callback) { var base = null; var timer = setInterval(function() { base = Module.findBaseAddress(soName); if (base !== null) { clearInterval(timer); callback(base); } }, 100); }

4. 实时函数监控的工程化落地:从单次Hook到可持续追踪

做到上面几步,你已经能抓到一次登录的明文了。但真实场景需要的是“可持续追踪”——APP更新后Hook点失效怎么办?多个账号并发登录怎么区分日志?敏感字段自动脱敏怎么实现?这就需要把Frida脚本工程化。

4.1 Hook点注册中心:用JSON配置管理所有Hook规则

硬编码所有Hook逻辑,维护成本极高。我设计了一个hooks.json配置文件:

{ "java_hooks": [ { "class": "LoginHelper", "method": "checkToken", "params": ["java.lang.String"], "onEnter": "console.log('phone:', args[0]); console.log('pwd:', args[1]);" }, { "class": "NetworkUtil", "method": "post", "params": ["java.lang.String", "org.json.JSONObject"], "onEnter": "console.log('API:', args[0]); console.log('Body:', args[1].toString());" } ], "native_hooks": [ { "so": "libshopcore.so", "offset": "0x12345", "onEnter": "console.log('[NATIVE] encrypt input:', ptr(args[2]));" } ] }

然后写一个通用加载器loader.js

function loadHooks(config) { Java.perform(function() { config.java_hooks.forEach(function(hook) { try { var clazz = Java.use(hook.class); var method = clazz[hook.method]; if (method !== undefined && method.overload !== undefined) { var overload = method.overload.apply(method, hook.params); overload.implementation = new Function('args', 'retval', hook.onEnter + '; return this.' + hook.method + '.apply(this, args);'); } } catch(e) { console.log("Failed to hook " + hook.class + "." + hook.method + ": " + e.message); } }); }); config.native_hooks.forEach(function(hook) { var base = Module.findBaseAddress(hook.so); if (base !== null) { Interceptor.attach(base.add(ptr(hook.offset)), { onEnter: function(args) { eval(hook.onEnter); } }); } }); } // 读取JSON(需提前用Python生成JS对象字面量,或用frida-compile预编译) var hooks_config = { /* 内联JSON对象 */ }; loadHooks(hooks_config);

这样,APP更新后只需修改JSON里的类名和方法名,不用动JS逻辑,大幅降低维护成本。

4.2 多账号日志隔离:用ThreadLocal注入会话ID

当测试人员用不同账号在同一个APP里切换时,日志混在一起无法区分。解决方案是:在每个Java线程启动时,注入一个唯一的会话ID。我们Hookjava.lang.Threadstart方法:

Java.perform(function() { var Thread = Java.use("java.lang.Thread"); Thread.start.overload().implementation = function() { // 生成唯一ID:时间戳+随机数+线程ID var tid = Java.use("java.lang.Thread").currentThread().getId(); var sessionId = "" + Date.now() + "_" + Math.floor(Math.random()*1000) + "_" + tid; // 存入ThreadLocal(需先找到APP的ThreadLocal类,或用全局Map模拟) // 这里简化:用静态变量存,实际项目用WeakHashMap<Thread, String> Java.use("com.shop.util.SessionManager").setSessionId(sessionId); return this.start(); }; });

然后在所有Hook回调里,先获取SessionManager.getSessionId(),拼接到日志前缀:

console.log("[" + Java.use("com.shop.util.SessionManager").getSessionId() + "] phone: " + args[0]);

这样每条日志都自带会话标签,导出后用Excel筛选sessionId列即可分离各账号数据。

4.3 敏感字段自动脱敏:正则匹配+星号替换

客户要求日志里不能出现完整手机号和密码。我们写一个通用脱敏函数:

function desensitize(str) { if (typeof str !== 'string') return str; // 手机号:11位数字,中间4位变* str = str.replace(/1[3-9]\d{9}/g, function(match) { return match.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'); }); // 密码:长度>=6的连续非空白字符,全变* str = str.replace(/\S{6,}/g, function(match) { return '*'.repeat(match.length); }); return str; }

在所有console.log调用前,对参数做desensitize()处理。这样既满足合规要求,又不影响调试——你知道字段被脱敏了,但位置和长度信息还在。

实操心得:Frida脚本上线前务必做压力测试。我曾经在一个直播APP里Hook了20+个方法,结果APP卡顿严重。后来发现是console.log频繁触发IO阻塞。解决方案是:用send()代替console.log,把日志发到PC端Python脚本里统一处理和存储,JS端只做轻量计算。这样Hook 50个点也不影响APP流畅度。

5. 高级技巧:绕过常见反Hook检测的七种手法

APP开发者越来越聪明,很多会在启动时检测Frida。常见的检测点有七类,我逐个拆解应对方案。

5.1 检测frida-server端口(27042)

最原始的检测:new Socket("127.0.0.1", 27042),如果连接成功就认为被Hook。破解:启动frida-server时用-l 127.0.0.1:12345换端口,然后adb reverse tcp:12345 tcp:12345,再frida -U -R 12345。但APP可能扫描所有端口,更彻底的方案是:用frida-gadget注入模式替代server模式。把gadget.so塞进APK的lib目录,修改AndroidManifest.xml添加android:debuggable="true",重签安装。这样没有独立server进程,端口检测完全失效。

5.2 检测/proc/self/maps里是否有frida字样

APP读取/proc/self/maps,搜索fridagadget。破解:用frida-trace-i参数指定只注入特定模块,避免在maps里留下明显痕迹;或者用ptrace自定义注入器,把Frida代码段直接mmap进内存,不写入文件系统。

5.3 检测ptrace父进程

ptrace(PTRACE_TRACEME, 0, 0, 0)后检查getppid()是否为frida的PID。破解:在Frida脚本里,用Process.enumerateModulesSync().filter(m => m.name === "frida-agent")找到agent模块,然后Interceptor.replace它的ptrace调用,让它返回0(成功)但不真正trace。

5.4 检测/proc/self/status里的TracerPid

这是最准的检测:TracerPid: 1234表示被PID 1234的进程trace。破解:用LD_PRELOAD加载一个so,interceptopen/read系统调用,当读取/proc/self/status时,把TracerPid行替换成TracerPid: 0。Mumu模拟器支持LD_PRELOAD,真机需要Magisk模块。

5.5 检测内存中Frida符号

dlopen("libfrida-gadget.so")后,遍历/proc/self/maps找内存段,再用dlsymfrida_init等符号。破解:用frida-compile把脚本编译成字节码,再用frida-gadget --runtime=v8加载,V8引擎的符号表和Frida原生符号完全不同,检测不到。

5.6 检测Java层Hook痕迹

Class.getDeclaredMethod("checkToken").isAccessible()返回true,说明被反射修改过。破解:Hookjava.lang.reflect.Method.setAccessible,当参数为true时,偷偷改回false,让检测代码误判。

5.7 检测Native层Inline Hook

在目标函数开头插入push rax; pop rax(无害指令),然后检查内存是否被修改。破解:用Interceptor.replace时,用Memory.patchCode写入跳转指令,但保持原函数头几个字节不变(用Instruction.parse分析指令边界),让检测代码读到的还是原始字节。

最后提醒:没有银弹。反Hook是猫鼠游戏,今天有效的手法明天可能失效。我的经验是:永远准备Plan A(标准Frida)、Plan B(frida-gadget)、Plan C(Xposed+JustTrustMe)。遇到顽固APP,先用Xposed绕过SSL Pinning,再用Frida Hook业务逻辑,组合拳效果最好。

http://www.zskr.cn/news/1378461.html

相关文章:

  • 用AI写论文怕查重和AIGC率超标?哪些工具双降效果更靠谱
  • AI写毕业论文初稿双高?附降重+降AI率工具选择指南
  • 不止是移动:用UE5.1蓝图优化你的MetaHuman性能(头发渲染、LOD设置避坑指南)
  • 基于ESP32与MPU6050的智能云台DIY:从PID控制到无线遥控
  • Transformer模型结合时序特征提升VWAP预测精度
  • 如何在5分钟内让Windows直接访问Linux RAID:WinMD驱动完整指南
  • 基于I2C总线的LCD与键盘扩展模块设计:解决单片机I/O资源紧张难题
  • UE5 PhysicsControl物理动画保姆级教程:从零配置骨骼网格体到实现自然抖动
  • 2026新手吉他选购|12款实测口碑款,学生党闭眼抄,500-3000元不踩坑
  • 如何用Python脚本3步实现大麦网智能抢票?终极自动化购票指南
  • 别再手动调动画了!用UE5 PhysicsControl组件快速实现角色受击物理反馈
  • 接口防重提交 ≠ 接口幂等性
  • 终极i茅台自动预约系统:5分钟部署的完整抢购解决方案指南
  • 观察不同时段调用Taotoken聚合接口的延迟波动情况
  • Keil uVision调试器变量监视问题解析与解决方案
  • 终极指南:如何在macOS上使用eqMac专业音频均衡器提升音质体验
  • 【吾爱出品】PDF发票合并工具
  • 量子并行数据处理框架:从理论到实践,加速量子机器学习训练
  • Keil Studio VS Code配置SSE-315 FVP Blinky项目指南
  • PoSyn框架:硬件安全的动态映射优化与侧信道防护
  • C# Windows自启动的三大生产级方案与避坑指南
  • Unity拼图游戏开发:轻量交互、三模块解耦与广告变现闭环
  • IsoDAT2D算法:从单晶衬底强衍射中分离薄膜散射信号
  • 2026年5月黄南泽库地区黄金回收白银铂金回收本地回收店铺实力榜单TOP1:千足金+金银条+铂金+贵金属 上门回收门店地址及联系方式 - 五金回收
  • Cursor从代码编辑器到智能体控制台
  • 利用噪声鲁棒性优化实现量子点基Kitaev链的自动调谐
  • 揭秘:2026哪些平台可发布软文及新闻营销性价比最高,第一融媒网推荐 - 代码非世界
  • P3-SAM 部署与使用全记录:从环境配置到交互分割实战
  • 微信好友关系检测终极指南:WechatRealFriends免费工具完整使用教程
  • AutoCAD字体管理终极方案:FontCenter智能插件如何提升90%工作效率