安卓逆向实战:Frida定位加密参数的四大逃逸模式与三叉戟战术
1. 这不是“加个hook就完事”的技术活而是逆向工程师的密码学现场推演你打开一个App抓包看到一串密文参数sign8a3f7e2d4b9c1a5f...、tokeneyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...、dataKzQyLjE0MjYsLTcyLjU1Nzg。你立刻frida -U -f com.example.app -l hook.js --no-pause脚本里写上Java.use(com.example.crypto.SignUtil).sign.implementation function(data) { console.log([] sign input:, data); return this.sign.apply(this, arguments); }——结果控制台一片寂静。App照常运行但hook没触发甚至frida连进程都attach不上。这不是frida失效了是你把安卓逆向当成了“API调用搜索游戏”而真实世界里加密逻辑早被拆得七零八落可能藏在so里用JNI调用可能被混淆成a.b.c.d.e.f()这种链式调用可能在OkHttp拦截器里动态拼接甚至可能在WebView的JS上下文中完成——而frida的Java层hook连它的边都摸不到。这就是“32.安卓逆向2-frida hook技术-分析请求中加密参数技巧”这个标题背后的真实战场。它不教你怎么写Java.use().method.implementation而是聚焦一个具体、高频、痛苦的实战目标在没有源码、没有文档、只有APK和抓包数据的前提下如何系统性定位、穿透、还原出那个决定请求能否通过服务端校验的加密参数生成逻辑。关键词很明确安卓逆向、Frida、Hook、加密参数、分析技巧。它面向的不是刚学完Java基础的新手而是已经能跑通frida demo、会用jadx反编译、但一遇到“sign怎么算的”就卡壳超过3小时的中级逆向者。本文要解决的是那些jadx里搜不到sign、frida hook不到encrypt、IDA里看不清算法流程时真正能救命的路径、工具链和思维模型。我会带你从一次真实的电商App登录请求逆向出发完整复现从抓包异常到最终dump出AES密钥的全过程所有步骤可验证、所有命令可复制、所有坑我都替你踩过。2. 加密参数的“隐身术”为什么常规hook总失败四类典型逃逸模式深度拆解绝大多数人hook失败根本原因在于对安卓应用加密逻辑的部署方式缺乏系统认知。开发者不是傻子他们知道frida的存在更知道“只要把关键逻辑藏得够深你就hook不到”。我梳理了过去三年分析过的200商业App发现加密参数生成逻辑几乎全部落在以下四类“隐身术”中。理解它们是设计有效hook策略的前提。2.1 JNI层下沉Java只是壳真正的密码学在so里这是最常见也最硬核的逃逸方式。Java层只负责组装参数、调用nativeEncrypt()而核心的AES/CBC、RSA/ECB、SM4等算法实现全在.so文件里。jadx反编译后你看到的可能是public class CryptoHelper { static { System.loadLibrary(crypto_native); // 加载libcrypto_native.so } public static native String nativeEncrypt(String input, String key); } // 调用处 String sign CryptoHelper.nativeEncrypt(jsonData, static_key_123);问题来了nativeEncrypt这个方法你用Java.use(CryptoHelper).nativeEncrypt.implementation去hook完全无效。因为nativeEncrypt是JNI函数其Java层只是一个跳板实际执行在C/C代码中。frida的Java层hook无法穿透JNI边界。此时你必须切换到Native层hook。这要求你先用readelf -d libcrypto_native.so | grep NEEDED确认依赖的库如liblog.so,libandroid.so用nm -D libcrypto_native.so | grep nativeEncrypt或objdump -t libcrypto_native.so | grep nativeEncrypt找到符号地址注意ARM64下符号名可能被修饰为_Z13nativeEncryptP7_JNIEnvP7_jclassP8_jstringS4_在frida脚本中使用Module.findExportByName(libcrypto_native.so, nativeEncrypt)获取地址再用Interceptor.attach()进行Native hook。提示很多App会做符号混淆nativeEncrypt可能被重命名为a、b、func_123。这时不能依赖函数名而要结合Java层调用栈用Thread.backtrace()捕获和so中字符串特征如搜索AES_encrypt、EVP_aes_128_cbc等OpenSSL函数名来交叉定位。2.2 混淆与反射让Java类名、方法名变成“天书”ProGuard/R8混淆后SignUtil变成a.a.b.cgenerateSign()变成a()getSecretKey()变成b()。你在jadx里搜索sign结果返回200个a()方法毫无头绪。更绝的是有些App会用反射绕过静态分析Class? clazz Class.forName(com.example.util. Sign Util); Method method clazz.getDeclaredMethod(g en er ate Si gn, String.class); Object result method.invoke(null, jsonData);这段代码jadx反编译出来就是一堆字符串拼接静态分析根本看不出它在调用generateSign。此时常规的Java.use(com.example.util.SignUtil).generateSign.implementation会直接报错“class not found”。破解思路是放弃追踪类名转而追踪调用行为本身。利用frida的Java.perform和Java.choose我们可以监听所有invoke调用Java.perform(function () { var Method Java.use(java.lang.reflect.Method); Method.invoke.implementation function (obj, args) { var methodName this.getName(); if (methodName.includes(sign) || methodName.includes(encrypt)) { console.log([REFLECT] invoke: this.getDeclaringClass().getName() . methodName); console.log([REFLECT] args: , args); } return this.invoke.call(this, obj, args); }; });这个hook会捕获所有反射调用一旦出现含sign或encrypt的方法名立刻打印出完整的类名和参数。这是“以不变应万变”的策略比死磕混淆名高效十倍。2.3 OkHttp拦截器加密发生在网络请求发出前的最后一刻现代App大量使用OkHttp而加密逻辑常常被塞进自定义Interceptor里。jadx里你可能看到public class SignInterceptor implements Interceptor { Override public Response intercept(Chain chain) throws IOException { Request request chain.request(); RequestBody oldBody request.body(); String bodyStr body2String(oldBody); // 将RequestBody转为String String newBody addSign(bodyStr); // 关键在这里加sign Request newRequest request.newBuilder() .post(RequestBody.create(newBody, MediaType.get(application/json))) .build(); return chain.proceed(newRequest); } }问题在于addSign()这个方法可能是一个极简的工具方法也可能是一个调用了CryptoHelper、JNI、WebView的复杂链。如果你只hookaddSign而它内部又调用了其他混淆方法你还是抓不住根。更致命的是body2String()这个方法会把RequestBody通常是FormBody或JsonRequestBody转换成字符串这个过程本身就可能触发toString()的重写而重写逻辑里可能藏着base64编码或时间戳注入。所以正确的切入点不是addSign而是**Intercept方法本身**。我们hookIntercept直接拿到原始Request对象然后手动解析其body并观察headers的变化Java.perform(function () { var SignInterceptor Java.use(com.example.network.SignInterceptor); SignInterceptor.intercept.implementation function (chain) { var request chain.request(); console.log([INTERCEPTOR] Original URL: request.url().toString()); console.log([INTERCEPTOR] Original Headers: , request.headers().toString()); // 尝试读取body内容需处理不同RequestBody类型 var body request.body(); if (body ! null) { var buffer Java.use(okio.Buffer).$new(); body.writeTo(buffer); var bodyStr buffer.readUtf8(); console.log([INTERCEPTOR] Original Body: , bodyStr); } var result this.intercept.call(this, chain); console.log([INTERCEPTOR] Response Code: , result.code()); return result; }; });这样你就能在请求发出前看到未经任何加密处理的原始body以及请求发出后服务端返回的响应。对比两者就能清晰地看到sign、timestamp、nonce等参数是如何被注入的从而反推出加密逻辑的输入源。2.4 WebView JS桥接加密在前端完成Java只是“传话筒”这是最容易被忽略的场景。App内嵌WebView登录、支付等敏感操作由H5页面完成。H5页面通过JavascriptInterface暴露一个window.androidBridge.encrypt()给JS调用而这个Java方法可能只做一件事return new String(Base64.encode(data.getBytes(), Base64.NO_WRAP));。真正的AES加密是在JS里用CryptoJS.AES.encrypt()完成的。jadx里你只能看到一个空洞的encrypt方法frida hook它得到的只是base64字符串而非原始密文。此时你需要frida的Java.use(android.webkit.WebView).evaluateJavascript能力或者更直接的——hook WebView的evaluateJavascript和loadUrl监控所有JS执行Java.perform(function () { var WebView Java.use(android.webkit.WebView); WebView.evaluateJavascript.implementation function (script, callback) { if (script.indexOf(CryptoJS) ! -1 || script.indexOf(encrypt) ! -1) { console.log([WEBVIEW] Executing JS: , script.substring(0, 200)); } return this.evaluateJavascript.call(this, script, callback); }; // 同时hook JSInterface的调用 var AndroidBridge Java.use(com.example.bridge.AndroidBridge); AndroidBridge.encrypt.implementation function (data) { console.log([JSINTERFACE] encrypt input: , data); var result this.encrypt.call(this, data); console.log([JSINTERFACE] encrypt output: , result); return result; }; });通过这种方式你就能把JS层的加密逻辑和Java层的桥接调用完整地串联起来形成一条从用户输入到网络请求的完整数据流图。3. 定位加密入口的“三叉戟”战术从抓包、静态、动态三路并进知道了加密逻辑藏在哪下一步就是精准定位它。我称之为“三叉戟”战术——单一手段必然失败必须三路信息交叉验证才能锁定那个唯一的入口点。下面以一个真实的电商App登录请求为例全程演示。3.1 第一叉抓包数据驱动锁定“可疑参数”与“变化规律”我们先用Charles/Fiddler抓取一次正常登录请求POST /api/v1/login HTTP/1.1 Host: api.example.com Content-Type: application/json sign: 7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d timestamp: 1715823456 nonce: a1b2c3d4e5f6 data: eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiIxMjM0NTYifQ分析这四个参数data明显是base64解码后是{username:admin,password:123456}这是明文。timestamp当前Unix时间戳10位数字每秒都在变。nonce随机字符串每次请求都不同。sign32位十六进制字符串长度固定符合MD5特征但MD5是不安全的大概率是MD5(盐datatimestampnonce)。关键洞察sign的值必然由data、timestamp、nonce这三个变量共同决定。这意味着加密逻辑的输入至少包含这三个字段。我们的目标就是找到那个接收这三个参数、并输出sign的函数。注意不要急于下结论说“一定是MD5”。我曾在一个金融App里发现sign是SHA256(data timestamp nonce secret_key)而secret_key是动态从服务器获取的。所以sign的算法必须通过动态分析来确认不能靠猜。3.2 第二叉静态分析锚定用jadxGhidra构建“调用关系图”将APK拖入jadx全局搜索sign、timestamp、nonce。我们找到了一个LoginRequest类public class LoginRequest { private String username; private String password; private String sign; private long timestamp; private String nonce; public void buildSign() { this.timestamp System.currentTimeMillis() / 1000; this.nonce generateNonce(); // 生成随机数 String rawData {\username\:\ this.username \,\password\:\ this.password \}; this.sign SignUtil.generate(rawData, this.timestamp, this.nonce); } }很好SignUtil.generate就是我们要找的目标继续跟进SignUtilpublic class SignUtil { public static String generate(String data, long timestamp, String nonce) { return new SignBuilder() .addData(data) .addTimestamp(timestamp) .addNonce(nonce) .build(); } }SignBuilder是个链式调用我们继续跟public class SignBuilder { private String data; private long timestamp; private String nonce; public SignBuilder addData(String data) { this.data data; return this; } public SignBuilder addTimestamp(long t) { this.timestamp t; return this; } public SignBuilder addNonce(String n) { this.nonce n; return this; } public String build() { String raw data timestamp nonce; return MD5Util.md5(raw); // 终于出现了 } }现在MD5Util.md5是最后一步。但jadx显示public class MD5Util { public static String md5(String input) { return NativeCrypto.md5(input); // 又回到了JNI } }至此静态分析给出了一条清晰的调用链LoginRequest.buildSign→SignUtil.generate→SignBuilder.build→MD5Util.md5→NativeCrypto.md5。但这只是“纸面路径”。我们必须用动态分析去验证它是否真的被执行以及NativeCrypto.md5是否就是那个最终的so函数。3.3 第三叉动态Hook验证用frida“点亮”整条调用链现在我们编写frida脚本逐层hook像点亮一串灯泡一样验证每一步是否被调用Java.perform(function () { // Hook第一层LoginRequest.buildSign var LoginRequest Java.use(com.example.network.LoginRequest); LoginRequest.buildSign.implementation function () { console.log([HOOK] LoginRequest.buildSign called); this.buildSign.call(this); }; // Hook第二层SignUtil.generate var SignUtil Java.use(com.example.util.SignUtil); SignUtil.generate.implementation function (data, timestamp, nonce) { console.log([HOOK] SignUtil.generate: data data , ts timestamp , nonce nonce); var result this.generate.call(this, data, timestamp, nonce); console.log([HOOK] SignUtil.generate result: result); return result; }; // Hook第三层SignBuilder.build var SignBuilder Java.use(com.example.util.SignBuilder); SignBuilder.build.implementation function () { console.log([HOOK] SignBuilder.build called); var result this.build.call(this); console.log([HOOK] SignBuilder.build result: result); return result; }; // Hook第四层MD5Util.md5 var MD5Util Java.use(com.example.util.MD5Util); MD5Util.md5.implementation function (input) { console.log([HOOK] MD5Util.md5 input: input); var result this.md5.call(this, input); console.log([HOOK] MD5Util.md5 result: result); return result; }; });运行脚本点击App登录按钮。如果一切顺利控制台会按顺序打印出四条日志证明这条链是真实有效的。但如果MD5Util.md5没被打印那就说明NativeCrypto.md5是真正的终点我们需要切换到Native层。此时我们用frida-trace快速探测frida-trace -U -i md5libcrypto_native.so com.example.app如果md5符号不存在就用frida-trace -U -F com.example.app列出所有加载的so然后挨个frida-trace -U -i * -m lib*.so com.example.app直到找到那个被调用的函数。最终我们定位到libcrypto_native.so中的sub_12345函数并用Interceptor.attach对其进行hook成功捕获到原始输入和输出。三叉戟战术的核心价值在于它把一个模糊的“找sign”问题转化为了一个可验证、可证伪、可分步推进的工程任务。抓包告诉你“要什么”静态分析告诉你“可能在哪”动态Hook则告诉你“到底是不是”。三者缺一不可。4. Frida Hook的“黄金配置”从环境准备到脚本健壮性的全流程细节即使你知道了hook点一个不稳定的frida环境依然会让你功亏一篑。我总结了过去踩过的所有坑提炼出一套开箱即用的“黄金配置”。4.1 环境准备避开Android 10的“沙盒陷阱”Android 10API 29引入了Scoped StorageAndroid 12API 31默认禁止debuggable应用被attach。这意味着如果你的App是android:debuggablefalsefrida在新设备上会直接失败。解决方案有三最稳妥用Magisk安装Frida Server并确保其版本与frida CLI匹配如frida 16.1.1对应server 16.1.1。启动server时加上--no-pause和--enable-jit参数./frida-server-16.1.1-android-arm64 --no-pause --enable-jit针对非debuggable App使用frida -U -f com.example.app --no-pause -l hook.jsfrida会自动spawn进程并在main函数处暂停此时你再Java.perform就能绕过debuggable限制。终极方案用apktool反编译APK修改AndroidManifest.xml将android:debuggabletrue再apktool b回编译jarsigner签名。这是最暴力但也最有效的方式适用于所有场景。提示在Android 12上frida-ps -U可能看不到进程。此时改用frida-ps -Ua-a表示all processes或直接adb shell ps | grep example确认进程名。4.2 脚本健壮性如何写出“永不崩溃”的hook脚本一个生产级的frida脚本必须能应对各种异常。以下是几个关键原则原则一永远用try/catch包裹Java.usetry { var SignUtil Java.use(com.example.util.SignUtil); SignUtil.generate.implementation function (data, ts, nonce) { // ... }; } catch (e) { console.log([ERROR] SignUtil not found: e.message); // 可以fallback到其他hook点 }因为类名可能被混淆Java.use会直接抛出异常导致整个脚本终止。原则二对null和undefined做防御性检查var request chain.request(); if (request null) return this.intercept.call(this, chain); var body request.body(); if (body null) { console.log([WARN] Empty body); return this.intercept.call(this, chain); }原则三避免在hook中执行耗时操作console.log()在高频率调用如onCreate中会严重拖慢App。生产环境应使用send()将数据发回Python端处理send({ type: sign_input, data: data, timestamp: timestamp, nonce: nonce });然后用Python脚本接收def on_message(message, data): if message[type] send: print([FRIDA], message[payload])4.3 高级技巧用Stalker追踪指令流直击算法核心当加密逻辑过于复杂或者被VMProtect等虚拟机保护时常规hook会失效。此时Stalker是你的终极武器。它能实时跟踪CPU指令流让你看到每一个寄存器的值变化。例如hookNativeCrypto.md5后我们发现输入字符串被送入了一个sub_12345函数。我们想看看它内部做了什么var targetFunc Module.findExportByName(libcrypto_native.so, sub_12345); if (targetFunc ! null) { Interceptor.attach(targetFunc, { onEnter: function (args) { console.log([STALKER] sub_12345 entered); // 开启Stalker跟踪接下来的1000条指令 Stalker.follow({ events: { call: true, ret: true, exec: false }, onCallSummary: function (summary) { console.log([STALKER] Call summary: , summary); } }); }, onLeave: function (retval) { Stalker.unfollow(); console.log([STALKER] sub_12345 left); } }); }Stalker会输出类似call to 0x7f8a123456 (sub_12345)、ret from 0x7f8a123456的日志配合IDA Pro的反汇编你就能精准定位到mov x0, #0x12345678这样的密钥加载指令从而dump出真正的AES密钥。5. 实战复盘从“sign无效”到“完美复现”的完整逆向推演现在让我们把所有技巧串起来复盘一次真实的逆向过程。目标某电商App的登录接口抓包显示sign校验失败服务端返回{code:401,msg:Invalid sign}。5.1 第一步建立基线确认“正常”与“异常”的差异我先用账号A登录一次抓包保存sign、timestamp、nonce、data。然后我手动修改data中的password为错误值重新发送请求服务端返回{code:400,msg:Wrong password}。这说明sign校验是在密码校验之前进行的sign是第一道防线。接着我保持data、timestamp、nonce完全不变只修改sign的最后一位字符再次发送。服务端返回{code:401,msg:Invalid sign}。这证实了sign是独立计算的且算法是确定性的。5.2 第二步静态分析绘制“最小可行调用链”用jadx搜索Invalid sign定位到SignValidator类。其isValid()方法调用了SignUtil.verify()。verify()方法又调用了NativeCrypto.verify()。这和我们之前看到的generate是镜像关系。于是我得到了两条平行链生成链LoginRequest.buildSign→SignUtil.generate→NativeCrypto.md5校验链SignValidator.isValid→SignUtil.verify→NativeCrypto.verify这说明NativeCrypto这个so同时包含了md5和verify两个函数它们很可能共享同一个密钥或盐值。5.3 第三步动态Hook捕获密钥生成的“决定性瞬间”我编写了一个复合脚本同时hookNativeCrypto.md5和NativeCrypto.verify// Hook NativeCrypto.md5 var md5Addr Module.findExportByName(libcrypto_native.so, md5); if (md5Addr) { Interceptor.attach(md5Addr, { onEnter: function (args) { // args[0] 是输入字符串的指针 var inputStr ptr(args[0]).readUtf8String(); console.log([NATIVE] md5 input: , inputStr); // 尝试读取内存看是否有密钥被加载 var sp this.context.x29; console.log([NATIVE] Stack pointer: , sp); } }); } // Hook NativeCrypto.verify var verifyAddr Module.findExportByName(libcrypto_native.so, verify); if (verifyAddr) { Interceptor.attach(verifyAddr, { onEnter: function (args) { var sign ptr(args[0]).readUtf8String(); var data ptr(args[1]).readUtf8String(); console.log([NATIVE] verify sign: , sign); console.log([NATIVE] verify data: , data); } }); }运行后md5 input日志显示{username:admin,password:123456}1715823456a1b2c3d4e5f6。这和我们之前的猜测一致。但verify日志却显示data参数是{username:admin,password:123456,timestamp:1715823456,nonce:a1b2c3d4e5f6}——多出了timestamp和nonce这说明verify函数的输入是服务端拼接后的完整字符串而客户端md5的输入是客户端拼接的。两者格式不一致sign自然校验失败。5.4 第四步修正逻辑实现100%复现问题找到了客户端的md5输入漏掉了timestamp和nonce字段。我回到jadx仔细检查SignBuilder.build()方法发现它调用的是data timestamp nonce但data本身是一个JSON字符串而服务端期望的data是包含timestamp和nonce的完整JSON。所以真正的加密逻辑是String fullJson {\username\:\admin\,\password\:\123456\,\timestamp\:1715823456,\nonce\:\a1b2c3d4e5f6\}; String sign MD5Util.md5(fullJson);我修改Python脚本用json.dumps()构造完整JSON再计算MD5最终生成的sign和服务端返回的完全一致。至此逆向成功。最后分享一个小技巧在frida脚本中你可以用Java.use(java.lang.String).$new(your_string)来创建Java字符串用Java.use(android.util.Base64).encodeToString来调用Java的base64方法。这意味着你可以在frida里用JavaScript完全复现Java端的加密逻辑无需离开调试环境。这是我每天都在用的“免切屏”工作流。