1. 项目概述与核心价值
最近在分析一个安卓App的网络请求时,遇到了一个典型的“黑盒”问题:它的关键请求参数被加密了,抓包看到的是一串毫无规律的密文。对于逆向分析来说,第一步就是要找到这个加密逻辑的入口。如果直接去啃庞大的反编译代码,无异于大海捞针,效率极低。这时候,动态插桩工具Frida就派上了大用场。今天要分享的,就是一个非常高效且通用的定位技巧——HashMap.put拦截法。这个方法的核心思路,不是去硬磕加密算法本身,而是去拦截那些最终承载了加密结果的、最常见的数据结构,比如HashMap的put方法。
为什么是HashMap?在安卓开发中,无论是使用原生的HttpURLConnection、OkHttp,还是其他网络库,在构建最终发送的请求参数时,开发者常常会将参数键值对放入一个Map结构中,而HashMap正是最常用的实现类。加密后的参数,在最终被放入请求体或拼接到URL之前,极有可能会通过HashMap.put(String key, Object value)这个方法“落袋为安”。因此,拦截这个方法,我们就能在参数被发送前的那一刻,清晰地看到哪些键(key)对应了加密后的值(value)。这就像在快递打包的最后一步,看一眼快递单上写的到底是什么,比去翻整个仓库的进货记录要快得多。
这个方法尤其适合快速定位未知加密参数的位置,为后续的算法逆向打下坚实基础。无论你是移动安全研究员、爬虫工程师,还是对安卓逆向感兴趣的开发者,掌握这个技巧都能让你的分析工作事半功倍。接下来,我将从原理、环境搭建、脚本编写到实战调试,完整地走一遍这个流程,并附上我优化过的完整脚本和踩坑心得。
2. 核心原理与方案选型
2.1 为什么选择拦截 HashMap.put?
在逆向工程中,定位目标代码有静态分析和动态分析两种路径。静态分析(阅读反编译的Smali或Java代码)虽然全面,但面对混淆严重、逻辑复杂的App时,容易迷失方向。动态分析(运行时Hook)则能让我们直接观察程序的实际行为,精准打击。
在动态分析中,我们有多种Hook目标可以选择:
- 加密函数本身:如
MD5、AES、RSA的入口。但这要求我们事先知道或能猜出用了什么算法,或者需要先进行大量的静态分析来定位这些类,门槛较高。 - 网络库的发送函数:如
OkHttpClient.newCall()或HttpURLConnection.getOutputStream()。在这里我们能抓到完整的请求数据,但数据可能已经是加密后的最终形态,我们不知道是哪个环节、哪个函数产生的这些密文。 - 参数组装过程:这就是
HashMap.put拦截法的用武之地。它处于加密函数和网络发送函数之间。大多数业务逻辑中,参数会先被放入一个Map,然后再由网络库将其转换为表单、JSON或Query String。拦截put方法,好处非常明显:- 通用性强:
HashMap是Java标准库的一部分,绝大多数App都会使用,不受第三方网络库的限制。 - 信息完整:我们能同时看到参数的键(Key)和值(Value)。Key的名字(如
sign、token、data)常常直接提示了该参数的作用(例如签名、令牌、加密数据),这提供了至关重要的上下文。 - 调用栈清晰:当我们在
put方法处断下时,可以打印即时的调用堆栈(Call Stack)。这个堆栈能清晰地告诉我们,是哪个类的哪个方法最终调用了这次put,从而一步步逆向回溯到加密函数的调用点。
- 通用性强:
2.2 Frida在动态分析中的优势
要实现运行时对HashMap.put的拦截,我们需要一个强大的动态插桩框架。Frida正是这方面的佼佼者,相比其他工具(如Xposed),它有如下优势:
- 无需重启:可以随时将脚本注入到正在运行的进程中,修改逻辑,立即生效,非常适合快速迭代和测试。
- 脚本化:使用JavaScript编写Hook逻辑,开发调试速度快,修改灵活。
- 跨平台:不仅支持安卓,也支持iOS、Windows、macOS等。
- 功能强大:除了Hook,还能调用函数、修改内存、枚举模块和类,几乎能完成所有运行时分析需求。
我们的方案就是:使用Frida编写一个JavaScript脚本,Hook住目标App中所有HashMap实例的put方法,并打印出关键的调用信息。
3. 环境准备与工具配置
3.1 基础环境搭建
工欲善其事,必先利其器。一个稳定的Frida环境是成功的第一步。
设备与系统:
- 推荐使用真机:模拟器(尤其是x86架构)可能存在兼容性问题。一部已经Root的安卓手机是最佳选择。如果没有Root,也可以使用
frida-gadget的注入方案,但过程稍复杂。 - 安卓版本:建议使用Android 7.0到12之间的版本。太老的版本Frida支持可能不完善,太新的版本(13+)可能遇到更强的反调试或SELinux限制。
- 推荐使用真机:模拟器(尤其是x86架构)可能存在兼容性问题。一部已经Root的安卓手机是最佳选择。如果没有Root,也可以使用
Frida环境安装:
- 在电脑(客户端)上安装:在你的PC(Windows/macOS/Linux)上,使用Python的pip包管理器安装。建议使用清华镜像源加速。
安装后,命令行输入pip install frida-tools -i https://pypi.tuna.tsinghua.edu.cn/simplefrida --version能显示版本号即表示成功。 - 在手机上安装(服务端):首先需要根据你手机的CPU架构下载对应的
frida-server二进制文件。去Frida的GitHub Releases页面下载。用adb shell getprop ro.product.cpu.abi命令查看架构(通常是arm64-v8a)。# 下载后,推送到手机并赋予执行权限 adb push frida-server-16.1.4-android-arm64 /data/local/tmp/frida-server adb shell "chmod 755 /data/local/tmp/frida-server" # 运行服务端(建议在后台运行) adb shell "/data/local/tmp/frida-server &" - 验证连接:在电脑上执行
frida-ps -U,如果能看到手机上的进程列表,说明连接成功。
- 在电脑(客户端)上安装:在你的PC(Windows/macOS/Linux)上,使用Python的pip包管理器安装。建议使用清华镜像源加速。
辅助工具:
- ADB (Android Debug Bridge):必备,用于与手机通信。
- 抓包工具:如Charles或Fiddler,用于捕获网络请求,确认加密参数的名字和位置。你需要配置好手机代理和CA证书。
- 反编译工具:如Jadx-GUI,用于静态查看App的Java代码,辅助理解类名和方法名,虽然我们主要用动态方法,但静态查看可以验证我们的Hook结果。
3.2 目标App分析与初步侦察
在开始写脚本前,我们需要对目标App有个基本了解。
- 启动App并抓包:打开抓包工具,启动目标App,进行触发加密参数的网络操作(如登录、搜索)。在抓包工具中,找到目标请求,记下明显是加密参数的键名。例如,你可能会看到
sign=4F89A1B2C3...或encrypted_data=eyJhbGciOiJ...。这个键名(sign,encrypted_data)就是我们后续过滤的重要依据。 - 定位App进程名:使用
frida-ps -U找到目标App的进程名。它通常与包名相关,可能是包名本身,也可能是包名加后缀(如:pushservice)。记下这个准确的进程名,我们稍后需要用它来附加(Attach)。
注意:有些App会进行反调试或反注入检测。如果遇到Frida无法附加或附加后App闪退的情况,可能需要考虑使用定制版的Frida、对抗检测的脚本,或者在更早的时机(如App启动时)进行注入。这是进阶话题,本文假设目标App没有强力的防护。
4. Frida脚本编写与核心逻辑解析
下面是我在实践中不断优化后的一个增强版HashMap.put拦截脚本。它不仅打印信息,还加入了过滤、堆栈深度控制等实用功能。
// hook_hashmap_put.js Java.perform(function () { console.log("[*] 脚本已加载,开始Hook HashMap.put..."); // 1. 获取HashMap类 var HashMap = Java.use("java.util.HashMap"); // 2. Hook put 方法 HashMap.put.implementation = function (key, value) { // ----- 关键过滤逻辑(根据你的目标调整) ----- var targetKey = "sign"; // 替换为你在抓包中看到的加密参数键名,例如'sign', 'token', 'data' // 如果想监控所有put,可以将下面if条件注释掉或改为 if(true) if (key === targetKey) { // ----- 信息打印 ----- console.log("\n=== 捕获到目标参数 ==="); console.log("[-] 时间戳: " + new Date().toLocaleString()); console.log("[-] HashMap实例: " + this.hashCode()); console.log("[-] 键 (Key): " + key); console.log("[-] 值 (Value): " + value); console.log("[-] 值类型: " + (value ? value.getClass().getName() : "null")); // ----- 调用堆栈分析(最核心的部分)----- // 限制堆栈深度,避免输出过长,通常看前10-15条就足够了 var stackDepth = 12; var stackTrace = Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new()); var stackLines = stackTrace.split('\\n'); console.log("[-] 调用堆栈 (前" + stackDepth + "行):"); // 跳过堆栈的第一行(它总是"java.lang.Exception") for (var i = 1; i < stackLines.length && i <= stackDepth; i++) { // 过滤掉一些无关的堆栈行,如Thread.run,让输出更干净 if (!stackLines[i].includes("java.lang.Thread") && !stackLines[i].includes(".perform(")) { console.log(" " + stackLines[i].trim()); } } // ----- 尝试获取更多上下文(可选)----- // 有时参数值本身是一个复杂对象,可以尝试调用其toString方法获取更可读的信息 try { if (value && typeof value.toString === 'function' && value.toString() !== '[object Object]') { var stringValue = value.toString(); // 避免打印过长的Base64字符串等 if (stringValue.length < 500) { console.log("[-] Value.toString(): " + stringValue); } else { console.log("[-] Value.toString() 过长,已截断。长度: " + stringValue.length); } } } catch (e) { // 忽略toString可能抛出的异常 } console.log("=== 结束 ===\n"); } // ----- 执行原方法并返回结果 ----- // 非常重要:必须调用原方法,否则会破坏App的正常逻辑,可能导致崩溃或功能异常。 var result = this.put(key, value); return result; }; console.log("[*] HashMap.put Hook 设置完成。"); });4.1 脚本逻辑逐行解析
Java.perform:这是Frida脚本的入口函数,确保所有Java层操作在一个安全的上下文中执行。- 获取并Hook类:
Java.use(“java.util.HashMap”)获取到HashMap类的引用。然后重写其put方法的implementation属性,这是Frida Hook的标准做法。 - 过滤逻辑:
if (key === targetKey)这行是脚本的“眼睛”。在实战中,你通常只关心一两个特定的加密参数。通过抓包获取其键名(如sign)并赋值给targetKey,可以避免脚本输出海量无关的put调用日志,让信息聚焦。初期探索时,你可以将其注释掉,先看看所有put调用,找到规律后再设置过滤。 - 信息打印:打印时间戳、HashMap对象哈希值、键、值及其类型。类型信息
getClass().getName()有时能提示值的来源(如是一个String还是某个自定义类的对象)。 - 调用堆栈获取:这是脚本的“灵魂”。
Java.use(“android.util.Log”).getStackTraceString(...)是安卓环境下获取当前线程堆栈的常用方法。- 我们创建一个新的
Exception对象,它的堆栈恰好就是当前代码的执行路径。 - 解析堆栈字符串,跳过无关行(如Frida自身、线程调度),打印出关键的调用链路。这个堆栈直接告诉你,是
com.example.app.EncryptUtils.calculateSign()调用了这个put,还是com.example.app.NetworkHelper.buildParams()调用的。顺着这个堆栈往上回溯,你就能快速定位到加密函数所在的具体类和方法。
- 执行原方法:
var result = this.put(key, value);这一行调用了原始的put方法。这是Hook的黄金法则:除非你有明确意图要修改返回值,否则一定要调用原函数并返回其结果,否则App的逻辑链会断裂,必然导致崩溃或异常行为。
4.2 脚本的进阶优化点
- 多键名过滤:如果目标有多个加密参数,可以将
targetKey改为数组,使用if (array.includes(key))进行判断。 - 值内容分析:对于值(Value),可以增加更深入的分析。例如,如果值是字符串,可以判断其是否看起来像Base64、Hex或JSON。
- 性能考虑:如果App的
put调用极其频繁,即使过滤后日志量也很大,可以考虑将日志输出到文件,或者添加采样逻辑(如每10次打印一次)。 - 防检测:一些安全意识强的App会检测
HashMap.put等常用方法是否被Hook。可以尝试Hook更底层的java.util.AbstractMap.put,或者使用Frida的NativeFunction在Native层进行更隐蔽的监控(这属于高阶技巧)。
5. 实战操作与问题排查
5.1 执行Hook脚本
环境准备好,脚本写好后,我们开始实战。
- 启动目标App:确保App处于你可以触发加密请求的状态(如登录界面)。
- 附加进程并注入脚本:在电脑命令行中执行:
frida -U -l hook_hashmap_put.js -f com.target.app.package --no-pause-U: 连接到USB设备。-l: 加载指定的脚本文件。-f: 启动一个新的App进程并附加。如果你已经启动了App,可以用-n “App名称”来附加到已有进程。使用-f可以确保从最早时机开始Hook。--no-pause: 启动后立即恢复进程执行,不中断。
- 触发网络请求:在手机上操作App,触发那个携带加密参数的网络请求(比如点击登录按钮)。
- 观察控制台输出:如果一切顺利,你将在电脑的命令行中看到类似下面的输出:
看!调用堆栈清晰地指出了路径:[*] 脚本已加载,开始Hook HashMap.put... [*] HashMap.put Hook 设置完成。 === 捕获到目标参数 === [-] 时间戳: 2023-10-27 14:30:15 [-] HashMap实例: 203458210 [-] 键 (Key): sign [-] 值 (Value): a1b2c3d4e5f67890... [-] 值类型: java.lang.String [-] 调用堆栈 (前12行): at com.xxx.xxx.network.RequestBuilder.buildParams(RequestBuilder.java:125) at com.xxx.xxx.network.ApiService.login(ApiService.java:88) at com.xxx.xxx.activity.LoginActivity$2.onClick(LoginActivity.java:205) ... === 结束 ===LoginActivity中的点击事件调用了ApiService.login,后者又调用了RequestBuilder.buildParams,正是在buildParams方法的第125行附近,我们的sign参数被put进了HashMap。
5.2 常见问题与排查技巧
在实际操作中,你可能会遇到以下问题,这里是我的排查实录:
脚本注入失败,提示
TypeError: cannot read property ‘implementation’ of undefined- 原因:最常见的原因是类名没找到。虽然
java.util.HashMap是标准库,但有些App可能会使用自定义的Map实现(如ArrayMap),或者使用了ProGuard混淆,将标准库类也“优化”了(极少见)。 - 排查:
- 先注释掉Hook代码,在
Java.perform里添加console.log(Java.enumerateLoadedClassesSync().filter(c => c.includes(“Map”))),打印所有已加载的包含“Map”的类,看看有没有HashMap。 - 尝试Hook其父类
java.util.AbstractMap的put方法,因为HashMap继承自它。 - 如果确实没有,你可能需要调整策略,去Hook网络库本身的参数组装方法。
- 先注释掉Hook代码,在
- 原因:最常见的原因是类名没找到。虽然
注入后App立刻闪退
- 原因:
- 脚本错误:你的脚本可能存在语法错误或逻辑错误(如未调用原函数)。
- 反调试/反注入:App检测到了Frida。
- Hook时机不对:在某个关键类初始化完成前就Hook,可能导致依赖问题。
- 排查:
- 首先用最简单的脚本测试(如只Hook
java.lang.String.toString),确认基础Frida环境是否被检测。 - 使用
—no-pause参数,避免进程暂停导致检测超时。 - 尝试使用
frida-gadget以非Root模式注入,或使用对抗Frida检测的脚本(如修改Frida默认端口、特征)。
- 首先用最简单的脚本测试(如只Hook
- 原因:
能看到
put调用,但堆栈信息不完整或全是Frida内部调用- 原因:堆栈获取方式可能在某些环境下受限,或者被混淆了。
- 排查:
- 确保使用的是
android.util.Log.getStackTraceString,这是安卓环境下的可靠方法。 - 检查堆栈过滤逻辑是否过于严格,误删了有用行。可以暂时注释掉过滤代码,查看完整堆栈。
- 如果堆栈被混淆(类名和方法名是a,b,c),你需要结合反编译工具(Jadx),将混淆后的堆栈映射回源码。虽然看起来费劲,但路径逻辑是不变的。
- 确保使用的是
日志输出太多,找不到想要的信息
- 原因:过滤条件
targetKey设置不正确或App内部有大量无关的HashMap操作。 - 排查:
- 再次确认抓包中加密参数的确切键名,注意大小写。
- 可以先不设过滤,运行一次,触发请求,从海量日志中搜索你的加密值(密文片段),找到对应的那次
put调用,从而确认正确的key。 - 除了Key过滤,可以加一层Value过滤,比如判断value是否包含特定字符或长度是否符合密文特征。
- 原因:过滤条件
Hook成功,但堆栈没有指向加密函数
- 原因:加密可能发生在更早的阶段,加密后的结果作为一个整体对象(如一个
JSONObject或自定义DataBean)被put进来。此时,你put的Value是一个对象,密文藏在对象内部。 - 应对:
- 查看打印出的
值类型,如果是自定义类(如com.xxx.EncryptedData),你需要用Frida去Hook这个类的构造函数或它的toString、getData等方法,进一步深入。 - 或者在堆栈中,寻找
put之前最近的那个方法,它很可能就是负责组装和加密的入口,即使它没有直接调用加密算法。
- 查看打印出的
- 原因:加密可能发生在更早的阶段,加密后的结果作为一个整体对象(如一个
6. 从定位到深入:后续逆向策略
成功定位到put调用点只是第一步。拿到调用堆栈后,你的逆向工作就进入了“按图索骥”的阶段。
- 静态代码对照:用Jadx等工具打开目标App的APK,根据堆栈信息找到对应的类和方法。例如,找到
com.xxx.xxx.network.RequestBuilder.buildParams方法。 - 分析上下文:在
buildParams方法中,查看sign参数的值是如何计算出来的。它可能来自于某个方法的返回值,比如sign = EncryptUtils.generateSign(params)。 - Hook加密函数:一旦找到了疑似加密函数(如
generateSign),你就可以编写新的Frida脚本去Hook它,打印其输入参数(明文)和输出结果(密文),验证其功能。 - 算法还原:通过动态Hook,你可以收集多组明文和对应的密文。观察输入输出变化规律,结合静态分析加密函数的代码,逐步还原出加密算法(可能是MD5、SHA256加盐、AES、RSA,或者是自定义的混淆算法)。
- 模拟实现:在Python或你熟悉的语言中,复现这个加密算法,用于脱离App环境生成有效的请求参数。
整个流程,从模糊的加密请求到清晰的算法定位,HashMap.put拦截法扮演了那个关键的“突破口”角色。它用最小的代价,给了你一张通往核心逻辑的“地图”。这个方法我已经在数十个不同复杂度、不同防护等级的App上实践过,成功率非常高。当然,没有一种方法是万能的,当遇到极度定制化的网络层或强混淆时,可能需要结合其他Hook点(如JSON序列化库的toJson方法、特定网络库的拦截器等)。但无论如何,将HashMap.put作为动态分析的起点,绝对是一个高效且明智的选择。