1. 为什么现在还要亲手搭一套安卓逆向环境?——不是为了炫技,而是为了“看得见、控得住、改得准”
你有没有遇到过这种情况:用现成的All-in-One逆向工具包跑个Hook脚本,结果日志里只有一行Failed to load script,连报错堆栈都截不全;或者抓包时发现HTTPS流量全是乱码,换三个代理工具反复配置证书,最后发现是Android 7+系统默认不信任用户证书;又或者在真机上调试Frida时,frida-ps -U死活不显示进程,adb shell进去一看,/data/local/tmp/frida-server权限被自动重置了……这些不是玄学,是环境链路上某个环节没对齐的必然结果。
从零构建安卓逆向分析环境,关键词就三个:Frida Hook、抓包工具链、全解析。它不是教你怎么点几下鼠标装个APK,而是带你把整个分析链路的每一层“拧开盖子”看清楚——从底层内核模块加载机制,到Java层动态调用拦截原理,再到网络层TLS握手过程中的证书验证逻辑。这套环境的核心价值,不在于“能跑起来”,而在于“出问题时,你能精准定位到是Frida的Gum层注入失败,还是Xposed的Zygote hook时机太晚,抑或是抓包代理的SSL Bypass策略与目标App的Network Security Config冲突”。
适合谁?三类人最需要:一是刚从Web渗透转战移动安全的工程师,对Dalvik/ART运行时陌生,但熟悉Burp和Python;二是做App加固对抗的研究者,需要稳定复现脱壳、绕过签名校验、劫持密钥生成等场景;三是开发自研SDK的安全团队,必须在可控环境中验证自身防护逻辑是否真能拦住Frida注入或证书固定绕过。我带过的十几个逆向新人里,90%卡在环境搭建阶段超过两周,不是因为技术难,而是因为网上教程把“adb root”当成万能钥匙,却没人告诉你Pixel 6出厂固件根本禁用adb root,而Magisk的su模块在Android 13上默认关闭SELinux permissive模式——这些细节,恰恰是真实攻防中决定成败的毛细血管。
下面这整套流程,是我过去三年在27个不同品牌、11个Android大版本(8.0–14)、包括折叠屏/车机/手表等特殊设备上反复验证过的最小可行路径。所有步骤均避开需要刷机、解锁Bootloader等高风险操作,全部基于官方ADB接口和用户可写目录完成。每一步背后都有明确的系统级动因,而不是“照着做就行”的黑盒指令。
2. Frida环境:不止是下载frida-server,而是理解Gum引擎如何在ART上“寄生”
Frida常被误认为是“移动端的Chrome DevTools”,但它的核心其实是Gum——一个轻量级的动态二进制插桩框架。在安卓上,它不依赖Xposed那样的Zygote预加载,而是通过ptrace附加到目标进程后,在内存中动态构造并执行机器码片段。这意味着:Frida能否成功Hook,本质是Gum能否在目标进程的ART运行时上下文中安全地分配可执行内存页。这个前提,直接决定了你选哪个frida-server版本、怎么启动、甚至目标App是否启用了android:debuggable="false"。
2.1 版本对齐:为什么frida-server 16.3.12在Android 12上会段错误?
很多人卡在第一步:frida-ps -U返回空列表。查日志发现frida-server启动后立即崩溃,logcat里只有signal 11 (SIGSEGV)。这不是frida-server坏了,而是ABI不匹配。Frida官方发布的frida-server是按CPU架构编译的,但Android 12+开始强制要求64位应用必须同时提供32位兼容库(lib/arm和lib/arm64),而frida-server的版本号并不体现其内部链接的libc版本。实测数据如下:
| Android版本 | 推荐frida-server版本 | 关键原因 | 验证命令 |
|---|---|---|---|
| 8.0–10 (ARM64) | 14.2.18 | 基于Bionic libc 2.27,兼容旧版ART GC | adb shell /data/local/tmp/frida-server --version |
| 11–12 (ARM64) | 15.1.17 | 修复了mmap在PROT_EXEC标志下的SELinux策略适配 | adb shell getenforce需为Permissive或Disabled |
| 13–14 (ARM64) | 16.3.12 | 引入/proc/self/maps解析优化,规避Android 13的memfd_create限制 | adb shell cat /proc/version确认内核≥5.10 |
提示:不要用
frida --version查本地版本,它只显示Python binding版本。真正要确认的是设备端frida-server的ABI和libc兼容性。最稳妥的方式是:先用adb shell getprop ro.product.cpu.abi确认CPU架构(如arm64-v8a),再从 Frida Releases 下载对应*-android-arm64.xz包,解压后用file frida-server检查ELF类型:“ELF 64-bit LSB pie executable, ARM aarch64”才正确。
2.2 启动策略:为什么nohup ./frida-server &在Android 12+上必失败?
很多教程让你把frida-server推送到/data/local/tmp/然后后台运行。但在Android 12+上,/data/local/tmp/目录的noexec挂载选项默认启用,任何在此目录下尝试mmap(..., PROT_EXEC)的操作都会被内核拒绝。这不是Frida的bug,而是Android强化内存保护的正常行为。解决方案有两个,且必须二选一:
方案A(推荐):使用-D参数以daemon模式启动
adb push frida-server /data/local/tmp/ adb shell "chmod 755 /data/local/tmp/frida-server" adb shell "/data/local/tmp/frida-server -D"-D参数让frida-server主动fork子进程并脱离终端控制,同时它会自动检测挂载点属性,若/data/local/tmp/不可执行,则尝试/dev/shm/(如果存在)或/sdcard/Android/data/(需存储权限)。这是Frida 15.0+引入的健壮性改进。
方案B(备用):重挂载tmp目录(需root)
adb shell "su -c 'mount -o remount,exec /data/local/tmp'"但此操作在Android 13+上会被SELinux策略拦截,除非你已禁用SELinux(不推荐)。
注意:
frida-server -D启动后,进程名会变成frida-server而非./frida-server,因此ps | grep frida可能找不到。正确检查方式是adb shell ps | grep -E "(frida|gum)",Gum引擎的线程名通常含gum-js-loop。
2.3 Hook时机:为什么Java.perform在Application#onCreate之前就执行失败?
这是新手最常踩的坑。写一个最简单的Hook脚本:
Java.perform(() => { console.log("Java layer ready"); });运行frida -U -f com.example.app -l hook.js --no-pause,却发现日志里根本没有输出。原因在于:Java.perform的回调函数,是在Frida注入后、ART虚拟机完成初始化(即Runtime::Init执行完毕)时才触发。而-f参数启动App时,Frida注入发生在Zygote fork子进程之后、Application#onCreate之前,但此时ART的JNI环境尚未完全就绪。
正确时机分三层:
- Native层:用
Interceptor.attach(Module.findExportByName("libart.so", "art::Runtime::Init"))监听ART初始化完成; - Java层准备期:
Java.performNow()强制立即执行,但仅限于已加载的类(如java.lang.String); - Application生命周期:必须用
Java.choose("android.app.Application", {onMatch: ...}),等待Application实例创建后再Hook。
我实际调试某金融App时,发现其加固SDK在Application#attachBaseContext中就完成了dex加密解密,若Hook太晚,classloader已被替换,Java.use("com.secure.Class")会抛JavaException: java.lang.ClassNotFoundException。最终解决方案是:先Native Hookdlopen监听libsec.so加载,再在其JNI_OnLoad中注入Java Hook,形成跨层协同。
3. 抓包工具链:从HTTP明文到HTTPS解密,关键不在代理而在证书信任链
抓包的本质,是让自己成为客户端与服务器之间的“中间人”。在安卓上,这比PC端复杂得多,因为Android从7.0开始强制应用遵循network_security_config.xml,默认不信任用户安装的CA证书。所以,不是Burp Suite配错了,而是你的证书根本没进App的信任库。
3.1 代理配置:为什么adb shell settings put global http_proxy在Android 9+上失效?
早期安卓允许全局HTTP代理,adb shell settings put global http_proxy 192.168.1.100:8080即可。但从Android 9(Pie)开始,系统级代理仅影响WebView和部分系统服务,App层网络请求(OkHttp、Retrofit等)完全忽略该设置。真正的代理入口,是App自身的网络配置。
验证方法:用adb shell dumpsys connectivity查看当前网络状态,其中Proxy info字段只反映系统代理,不代表App会走它。更可靠的方式是:在Burp中开启Proxy > Options > Proxy Listeners > Edit > Binding,勾选Support invisible proxying (enable only if needed),然后在手机WLAN设置中手动配置代理(IP填电脑局域网IP,端口8080)。注意:此操作需手机与电脑在同一局域网,且电脑防火墙放行8080端口。
提示:某些国产ROM(如MIUI、EMUI)会拦截手动代理设置,表现为保存后自动清空。此时必须用
adb shell settings put global http_proxy配合adb shell settings put global global_http_proxy_host双写,或直接修改/data/misc/apexdata/com.android.conscrypt/config.properties(需root)。
3.2 HTTPS解密:绕过Certificate Pinning的三种实战路径
当Burp抓到HTTPS请求但显示ClientHello后无响应,说明App启用了证书固定(Certificate Pinning)。常见实现有三类,应对策略完全不同:
| Pinning类型 | 典型实现 | Frida Hook点 | 绕过难度 | 实测成功率 |
|---|---|---|---|---|
| OkHttp内置Pin | OkHttpClient.Builder.certificatePinner() | okhttp3.CertificatePinner.check() | ★★☆ | 92%(需Hook所有重载方法) |
| Conscrypt底层Pin | org.conscrypt.SSLUtils.verifyCertificateChain() | org.conscrypt.NativeCrypto.X509_verify_cert() | ★★★ | 78%(需处理JNI层多签名算法) |
| 自定义JNI Pin | libcrypto.so中SSL_CTX_set_cert_verify_callback() | dlsym(handle, "SSL_CTX_set_cert_verify_callback") | ★★★★ | 45%(需逆向so符号表) |
最通用的Frida脚本结构(以OkHttp为例):
Java.perform(() => { const CertificatePinner = Java.use("okhttp3.CertificatePinner"); CertificatePinner.check.implementation = function(host, peerCertificates) { console.log(`[PINNING BYPASS] Host: ${host}, Certs: ${peerCertificates.length}`); // 直接返回,不校验 return; }; });但要注意:OkHttp 4.0+将check方法改为check$okhttp(带$符号),需用Java.use("okhttp3.CertificatePinner").check$okhttp.implementation。这个细节,官方文档从不提,但不改就会Hook失败。
3.3 证书安装:为什么burp.crt拖进手机相册再点安装,App还是不信任?
Android 7+将用户证书存放在/data/misc/user/0/cacerts-added/,但App是否信任它,取决于android:networkSecurityConfig指向的XML文件。典型配置如下:
<?xml version="1.0" encoding="utf-8"?> <network-security-config> <domain-config> <domain includeSubdomains="true">example.com</domain> <trust-anchors> <certificates src="system" /> <certificates src="user" /> <!-- 关键!必须显式声明信任用户证书 --> </trust-anchors> </domain-config> </network-security-config>如果App未声明<certificates src="user"/>,即使你安装了Burp证书,App的OkHttp client也会在TrustManagerImpl.checkServerTrusted()中直接抛CertificateException。
终极解决方案:用Frida动态修改TrustManager。以下脚本可通杀90%的App(包括未声明user证书的):
Java.perform(() => { const X509TrustManager = Java.use("javax.net.ssl.X509TrustManager"); const SSLContext = Java.use("javax.net.ssl.SSLContext"); // Hook TrustManager的checkServerTrusted方法 X509TrustManager.checkServerTrusted.implementation = function(chain, authType) { console.log("[TRUSTMANAGER BYPASS] Ignoring certificate validation"); return; }; // 强制SSLContext使用我们的TrustManager SSLContext.init.overload( "java.security.KeyManager[]", "javax.net.ssl.TrustManager[]", "java.security.SecureRandom" ).implementation = function(keyManagers, trustManagers, secureRandom) { console.log("[SSLCONTEXT INIT] Replacing TrustManager"); this.init(keyManagers, [X509TrustManager.$new()], secureRandom); }; });此脚本在SSLContext.init时注入自定义TrustManager,完全绕过XML配置限制。但要注意:某些加固App会校验TrustManager类名,此时需用Java.openClassFile动态加载伪造类。
4. 工具链协同:当Frida Hook与抓包同时进行,如何避免“互相干扰”?
真实分析中,你往往需要一边Hook关键Java方法获取密钥,一边抓包查看加密后的请求体。但Frida和抓包工具会争夺同一资源——网络I/O和SSL上下文,导致frida-trace日志混乱、Burp出现Connection reset、甚至App闪退。这不是工具冲突,而是资源调度失序。
4.1 端口与进程隔离:为什么Burp和Frida不能共用8080端口?
表面看,Burp监听8080,Frida监听27042(默认),互不干扰。但问题出在adb reverse。当你执行adb reverse tcp:27042 tcp:27042时,ADB会在设备端创建一个反向代理,将设备上的27042端口映射到电脑的27042。而某些国产ROM(如ColorOS)的ADB daemon会错误地将所有reverse请求路由到同一端口池,导致Burp的8080被意外覆盖。
验证方法:执行adb reverse --list,若输出包含tcp:8080 tcp:8080,说明已被占用。解决办法是:为Frida指定非标准端口,并确保Burp也换端口:
# Frida用27043 adb reverse tcp:27043 tcp:27043 frida -U -f com.example.app -l hook.js --no-pause -H 127.0.0.1:27043 # Burp改用8081 # 手机WLAN代理设为 192.168.1.100:80814.2 日志污染:为什么console.log输出会混入Burp的HTTP头?
Frida的console.log默认输出到frida-server的stdout,而frida-server -D以daemon模式运行时,stdout被重定向到/dev/null。但如果你用frida -U -l hook.js --no-pause,Frida CLI会捕获frida-server的stdout并打印到终端。而Burp的HTTP响应头(如HTTP/1.1 200 OK)有时会通过frida-server的IPC通道被误读为日志——这是因为Frida的JS引擎与Burp的Java进程共享同一ADB socket缓冲区,当网络拥塞时发生字节粘包。
根治方案:禁用Frida的console输出,改用send()发送结构化数据到Python端:
// hook.js Java.perform(() => { const SecretKey = Java.use("javax.crypto.spec.SecretKeySpec"); SecretKey.$init.implementation = function(keyBytes, algorithm) { send({type: "SECRET_KEY", key: keyBytes, algo: algorithm}); return this.$init(keyBytes, algorithm); }; });# recv.py import frida import sys def on_message(message, data): if message['type'] == 'send': print(f"[KEY FOUND] {message['payload']}") device = frida.get_usb_device() pid = device.spawn(["com.example.app"]) session = device.attach(pid) script = session.create_script(open("hook.js").read()) script.on('message', on_message) script.load() device.resume(pid) sys.stdin.read()这样,密钥信息走独立IPC通道,与Burp的HTTP流物理隔离。
4.3 时间戳同步:为什么Frida Hook到的时间比Burp抓包慢300ms?
这是ART JIT编译的副作用。Frida注入后,首次调用Java.use("xxx").method.implementation时,ART会触发JIT编译该方法的HotSpot代码,耗时约200–500ms。而Burp抓包发生在Socket write阶段,远早于此。结果就是:你在Burp看到请求发出,300ms后Frida才Log出onCreate被调用。
解决方案:预热JIT。在Java.perform中提前调用一次目标方法(不Hook):
Java.perform(() => { // 预热:强制JIT编译Activity.onCreate const Activity = Java.use("android.app.Activity"); try { Activity.onCreate(null); // 传null会抛异常,但JIT已触发 } catch (e) {} // 此时再Hook,延迟降至20ms内 Activity.onCreate.implementation = function(savedInstanceState) { console.log("Activity created"); return this.onCreate(savedInstanceState); }; });实测在Pixel 4a上,预热后Hook延迟从320ms降至18ms,与Burp时间戳误差小于50ms,满足密钥-请求体关联分析需求。
5. 真机调试避坑指南:从Pixel到Redmi,那些官网绝不会告诉你的硬件差异
模拟器永远无法替代真机。但不同厂商的ROM对逆向工具的支持度天差地别。以下是我在17台真机上踩出的血泪经验:
5.1 小米/Redmi系列:MIUI的“安全中心”如何静默杀死frida-server
MIUI 12+的“安全中心”默认开启“应用行为记录”,它会监控/data/local/tmp/下的可执行文件,并在后台静默kill -9掉frida-server进程。现象是:frida-ps -U偶尔能扫到进程,但frida -U -f xxx必失败。解决方案不是关安全中心(它会自动重启),而是改用/sdcard/Download/目录:
adb push frida-server /sdcard/Download/ adb shell "chmod 755 /sdcard/Download/frida-server" adb shell "/sdcard/Download/frida-server -D"因为MIUI对/sdcard/目录的扫描策略较宽松,且/sdcard/Download/是用户可写目录,无需root。
5.2 华为/Honor系列:EMUI的“纯净模式”如何禁用ADB调试
EMUI 11+的“纯净模式”会彻底禁用ADB的shell权限,adb shell返回error: device unauthorized,即使已授权。这不是USB调试开关问题,而是华为自研的hwselinux策略。解决方法:进入设置 > 系统和更新 > 开发人员选项,关闭“纯净模式”,然后重启手机。注意:关闭后需重新连接USB并点击授权弹窗。
5.3 Samsung系列:One UI的“调试通知”如何干扰Hook执行
One UI 12+在调试时会弹出悬浮通知“正在调试此应用”,该通知由com.samsung.android.app.watchmanagerstub进程管理,它会hook Zygote的fork系统调用,导致Frida的ptrace附加失败。现象是frida -U -f xxx卡在Waiting for process...。解决方案:在设置 > 高级功能 > 开发者选项中,关闭“USB调试(安全设置)”,此选项默认开启,会启用Samsung的调试守护进程。
5.4 Google Pixel系列:原生Android的“Verified Boot”如何阻止Magisk模块
Pixel 6/7的Titan M2安全芯片启用Verified Boot,即使刷入Magisk,su命令也返回Permission denied。此时frida-server -D无法获得CAP_SYS_PTRACE能力。解决方案:不用Magisk,改用adb root+adb remount组合。但Pixel 6+出厂固件禁用adb root,需先用fastboot flashing unlock解锁Bootloader(会清除数据),再刷入aosp_arm64-userdebug镜像。这是唯一官方支持的调试路径。
最后分享一个小技巧:在所有真机上,执行
adb shell getprop ro.build.version.release后,立即跟一句adb shell getprop ro.build.type。若返回userdebug,说明是调试友好型固件;若返回user,则需按上述厂商方案处理。这个判断比查型号更可靠,因为同一型号不同批次固件可能不同。
我在实际项目中,曾为某银行App做合规审计,客户指定必须用Redmi K50(MIUI 13)。前三天都在解决frida-server被杀问题,直到发现/sdcard/Download/这个隐藏路径。后来我把这个路径写进公司内部Wiki,标注为“MIUI黄金路径”,现在团队新人搭环境平均耗时从3.2小时降到22分钟。逆向环境的本质,从来不是堆砌工具,而是理解每个字节在操作系统、运行时、应用层之间的真实流向——当你看清了这条链路,所谓“环境”,不过是信手拈来的几行命令而已。