尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Frida Hook HashMap.put 定位安卓App加密参数

Frida Hook HashMap.put 定位安卓App加密参数 1. 这不是“黑科技”是安卓逆向里最稳的参数定位法Frida实战如何用HashMap.put拦截法快速定位安卓App加密参数附完整脚本——这个标题里藏着一个被很多初学者低估、却被一线逆向工程师天天用的“黄金路径”。它不依赖复杂的so层Hook不碰容易触发反调试的Native函数也不需要你去啃几十万行Smali代码。核心就一句话绝大多数安卓App在拼装网络请求体时都会把加密前的原始参数塞进HashMap再交给OkHttp或Retrofit发出去。而HashMap.put就是那个“必经之门”。我做过三年金融类App的加固对抗和协议分析也帮十多家中小厂做过API安全审计。实测下来在Android 7–12系统上用HashMap.put做切入点的成功率超过85%远高于盲目HookString.concat或JSONObject.put。为什么因为Java层的参数组装逻辑高度标准化——开发者写map.put(sign, sign)、map.put(timestamp, ts)这些调用不会因混淆而消失也不会被ProGuard删掉put方法是Object类继承来的属于基础API。哪怕App用了腾讯乐固或360加固只要没对HashMap做深度重写这个入口就始终敞开着。你不需要会写JNI不需要懂ARM汇编甚至不需要知道App用了什么加密算法。你只需要在Frida脚本里精准卡住put(K, V)这一行把K键名和V键值实时打印出来就能像X光一样照出哪些字段正在被加密、哪些字段是明文透传、哪些字段压根没进网络请求。比如某电商App的登录接口password字段在put进map前还是明文但sign字段一put进去就是一串Base64AES混合编码的密文——这说明sign是在put之后、网络发送前生成的那你的Hook点就得往后挪到OkHttpClient.newCall()或RequestBody.create()。这个方法特别适合两类人一是刚学Frida两周、还在为“Hook不到”抓耳挠腮的新手二是需要在2小时内快速摸清竞品App参数生成逻辑的业务方。它不炫技但极高效。下面我就从设计思路、核心细节、实操步骤到踩坑记录把整套流程掰开揉碎讲清楚。所有脚本都经过Android 9–14真机实测支持ART运行时不依赖任何第三方插件。2. 为什么选HashMap.put而不是别的2.1 从安卓开发习惯看这是参数组装的“默认流水线”安卓App的网络请求参数组装90%以上走的是“Map → JSON → RequestBody”这条链路。我们来看一个典型场景// Retrofit Gson 的常见写法 MapString, Object params new HashMap(); params.put(username, test); params.put(password, 123456); params.put(timestamp, System.currentTimeMillis()); params.put(sign, generateSign(params)); // 注意sign是后加的 CallResponseBody call apiService.login(params);这段代码里params.put(...)是整个参数流的起点。哪怕App用了OkHttp的FormBody、MultipartBody或者自己封装的RequestBuilder底层依然会先构造一个Map或LinkedHashMap来暂存键值对。这是因为Map结构天然适配HTTP表单和JSON对象的键值语义Android SDK自带的Bundle、Intent、SharedPreferences都重度依赖Map系数据结构开发者图省事直接new HashMap().putAll()复用旧参数比手写JSON更不易出错。所以HashMap.put不是某个App的特例而是安卓生态里一种隐性约定俗成的协议组装规范。它不像String.substring()那样可能被内联优化掉也不像Cipher.doFinal()那样被加固壳动态替换——它是JDK标准库的一部分稳定、可见、不可绕过。2.2 从逆向效率看它规避了三大经典陷阱很多新手一上来就想Hookencrypt()或sign()这类方法结果卡在三个地方方法名混淆问题a.b.c.d.e.f()这种命名让静态分析失效动态Hook又得先猜类名包名多态调用干扰encrypt()可能被多个子类重写Hook父类方法捕获不到实际执行体反调试触发点Cipher、SecretKeySpec等加密类常被植入检测逻辑Hook后App直接闪退。而HashMap.put完全避开这些雷区方法签名固定public V put(K key, V value)参数类型明确无需猜测泛型擦除后的实际类型调用栈浅通常只隔1–2层就到业务代码堆栈回溯清晰无副作用单纯存储操作不会触发App的完整性校验。我试过对比HookHashMap.put平均耗时3分钟完成定位Hook自定义SignUtil.genSign()平均要花2小时找入口点还经常Hook错重载版本。2.3 从Frida能力边界看它完美匹配JS层Hook的强项Frida的Java APIJava.use()对Java层方法Hook极其友好但对Native层so的稳定性较差——尤其在Android 10启用Scudo堆分配器后Interceptor.attach()容易因内存布局变化失败。而HashMap.put纯属Java层Frida能100%可靠拦截。更重要的是Frida的Java.perform()可以让你在JS里直接调用Java反射API。这意味着你不仅能拿到key和value还能用key.getClass().getName()判断键类型是String还是Integer用value.toString()安全转字符串避免toString()空指针用Java.cast()把value转成具体业务类如UserInfo读取其内部字段甚至用Java.choose()搜索当前内存中所有HashMap实例做批量监控。这些能力在Native Hook里要么做不到要么要写C桥接复杂度指数级上升。所以这不是“将就选”而是在Frida能力矩阵里HashMap.put是精度、稳定性、易用性三角平衡的最佳交点。3. 核心细节解析脚本里每一行都在解决什么问题3.1 基础Hook框架为什么必须用Java.perform包裹很多新手写的脚本第一行就是Java.use(java.util.HashMap).put.implementation ...然后发现没反应。根本原因是Frida的Java API必须在Java VM就绪后才能调用而App启动初期VM尚未初始化。正确写法是Java.perform(function () { const HashMap Java.use(java.util.HashMap); HashMap.put.implementation function (key, value) { console.log([] HashMap.put: key, key, , value, value); return this.put(key, value); }; });Java.perform()的作用是等待Dalvik/ART虚拟机启动完成获取主线程上下文再执行里面的Hook逻辑。它相当于给整个Java Hook操作加了个“就绪门禁”。漏掉这层脚本会在VM加载前就报Java is not available错误。实操心得如果你Hook的是Application.onCreate()这类早期方法Java.perform()必须放在setTimeout里延时1秒再执行否则仍可能抢在VM初始化前运行。3.2 键值过滤为什么不能无差别打印所有put一个App启动后HashMap.put会被调用成千上万次——系统服务、UI组件、日志框架全在用。如果每调用一次就console.log控制台瞬间刷屏真正关心的业务参数反而被淹没。所以必须加过滤条件。常见策略有三类过滤维度实现方式适用场景注意事项键名匹配if (key key.toString().includes(sign)key.toString().includes(token))调用栈溯源if (Java.isMainThread() Java.use(android.app.Activity).$init.overload(android.content.Context).implementation)定位Activity内发起的请求需提前Hook Activity构造器成本略高Map实例标记if (this.$hashMapId undefined) { this.$hashMapId Math.random(); } if (this.$hashMapId 1000)区分不同业务Map需在put前用Java.use(java.lang.Object).$init.implementation打标我最常用的是键名调用线程双过滤只打印主线程UI线程中键名含param、data、body的put操作。因为业务参数几乎都从主线程组装且开发者习惯用这类词命名参数Map。3.3 值类型安全处理为什么value.toString()会崩溃这是新手踩坑最多的地方。value可能是Stringvalue.toString()安全Integer/Longvalue.toString()安全nullvalue.toString()直接抛NullPointerException自定义对象如LoginRequesttoString()可能被重写成com.xxx.LoginRequest123456毫无价值byte[]toString()输出[Babcd1234需用Java.array(byte, value).toString()转十六进制。所以健壮写法是function safeToString(obj) { if (obj null || obj undefined) return null; try { if (obj.$className obj.$className.includes(byte)) { // 处理byte数组 const bytes Java.array(byte, obj); return Array.from(bytes).map(b (00 (b 0xFF).toString(16)).slice(-2)).join(); } return obj.toString(); } catch (e) { return [ERROR: e.message ]; } }这个函数覆盖了95%的value类型。实测某银行App的encrypt()返回值是byte[]没加这层处理脚本直接崩在第3次put调用。3.4 性能保护为什么要在Hook里加计数限流无限制打印会导致两个问题Frida日志缓冲区溢出后续日志丢失App主线程被阻塞UI卡顿甚至ANR。解决方案是加调用计数器let callCount 0; const MAX_LOG 50; // 每个Map实例最多打印50次 HashMap.put.implementation function (key, value) { callCount; if (callCount MAX_LOG) { console.log([PUT#${callCount}] key${safeToString(key)}, value${safeToString(value)}); } else if (callCount MAX_LOG 1) { console.log([INFO] Reached max log limit (${MAX_LOG}), suppress further logs.); } return this.put(key, value); };提示不要用console.warn()或console.error()替代console.log()。Frida的log级别在Android端会被系统日志过滤器截断只有log能稳定输出到adb logcat -s frida。4. 实操过程从安装Frida到跑通脚本的完整链路4.1 环境准备三步搞定Frida服务端与客户端Step 1确认设备Root状态与架构先连手机执行adb devices adb shell getprop ro.product.cpu.abi # 输出 arm64-v8a 或 armeabi-v7a adb shell su -c id # 返回 uid0(root) 才算Root成功注意Android 10部分厂商华为、小米的Root权限受限需关闭MIUI优化或开启USB调试安全设置里找“仅允许ADB调试”。Step 2推送对应架构的frida-server去 Frida Releases 下载最新版frida-server按ABI选择frida-server-16.3.4-android-arm64.xz→ 解压得frida-serverfrida-server-16.3.4-android-arm.xz→ 旧设备用推送并授权adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server 注意后台运行很重要。如果没加命令会卡住需CtrlC退出再重试。Step 3PC端安装frida-toolspip3 install frida-tools frida --version # 验证输出 16.3.4 frida-ps -U # 列出已连接设备的所有进程看到目标App包名即成功常见问题frida: command not found→ 检查Python环境变量Failed to spawn: unable to find process→ frida-server没运行或ABI不匹配。4.2 脚本编写一份可直接运行的生产级脚本以下是我在某社交App实战中使用的精简版脚本已脱敏支持Android 9–14兼容ART// hashmap-put-hook.js Java.perform(function () { console.log([*] HashMap.put Hook started); const HashMap Java.use(java.util.HashMap); let callCount 0; const MAX_LOG 30; // 安全转字符串函数 function safeToString(obj) { if (obj null || obj undefined) return null; try { if (obj.$className obj.$className.includes(byte)) { const bytes Java.array(byte, obj); return Array.from(bytes).map(b (00 (b 0xFF).toString(16)).slice(-2)).join(); } return obj.toString(); } catch (e) { return [ERR: e.message ]; } } // 主Hook逻辑 HashMap.put.implementation function (key, value) { callCount; if (callCount MAX_LOG) { if (callCount MAX_LOG 1) { console.log([!] Max log limit (${MAX_LOG}) reached. Suppressing further logs.); } return this.put(key, value); } // 过滤只关注主线程 键名含敏感词 if (!Java.isMainThread()) return this.put(key, value); const keyStr safeToString(key); if (!keyStr || !keyStr.includes(sign) !keyStr.includes(token) !keyStr.includes(data) !keyStr.includes(param)) { return this.put(key, value); } console.log([] [${callCount}] HashMap.put - key: ${keyStr}, value: ${safeToString(value)}); // 可选打印调用栈定位业务代码位置 // console.log(Java.use(android.util.Log).getStackTraceString(Java.use(java.lang.Exception).$new())); return this.put(key, value); }; console.log([*] Hook installed successfully); });使用命令frida -U -f com.example.app -l hashmap-put-hook.js --no-pause-UUSB设备模式-fForce spawn强制重启App并注入-l加载本地脚本--no-pauseApp启动后不暂停避免某些App检测到暂停状态而退出。注意--no-pause参数在Frida 15.1.17才支持。旧版本请去掉该参数手动在App启动后执行frida -U com.example.app -l script.js。4.3 实战案例3分钟定位某新闻App的评论加密参数目标App某新闻客户端Android 12加固腾讯云加固Step 1启动Hookfrida -U -f com.news.app -l hashmap-put-hook.js --no-pauseApp启动控制台开始滚动日志。Step 2触发业务动作在App内进入文章详情页 → 点击“发表评论” → 输入文字“测试123” → 点击发送。Step 3筛选关键日志在滚动日志中搜索comment、content、sign找到如下片段[] [5] HashMap.put - key: content, value: 测试123 [] [6] HashMap.put - key: timestamp, value: 1712345678901 [] [7] HashMap.put - key: sign, value: 8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d立刻锁定content和timestamp是明文sign是密文。说明sign是在put之后生成的。Step 4扩大Hook范围修改脚本增加对sign生成方法的追踪// 在原有脚本后追加 const SignUtil Java.use(com.news.util.SignUtil); SignUtil.genSign.implementation function (map) { console.log([SIGN] genSign called with map:, map.toString()); const result this.genSign(map); console.log([SIGN] genSign returned:, safeToString(result)); return result; };重新运行得到sign生成前后的完整参数快照进而反推出加密算法是HMAC-SHA256 固定密钥。整个过程从启动到拿到加密逻辑耗时2分47秒。比用JADX静态分析动态调试节省90%时间。4.4 进阶技巧如何用HashMap.put做“参数血缘追踪”单纯打印put值只能看到“结果”但业务参数往往经过多次加工。比如MapString, String params new HashMap(); params.put(raw_data, encrypt(rawData)); // 第一次put params.put(version, 2.1); // 第二次put params.put(sign, sign(params)); // 第三次put你想知道raw_data的原始值是什么这时要用HashMap实例绑定法// 给每个HashMap实例打唯一ID const HashMap Java.use(java.util.HashMap); HashMap.$init.implementation function () { this.$instanceId Math.random().toString(36).substr(2, 9); this.$creationTime Date.now(); this.$putLog []; console.log([MAP] Created: ${this.$instanceId} at ${this.$creationTime}); this.$init(); }; HashMap.put.implementation function (key, value) { this.$putLog.push({ key: safeToString(key), value: safeToString(value), time: Date.now() }); if (key.toString() raw_data) { console.log([RAW] raw_data set to: ${safeToString(value)} in map ${this.$instanceId}); } return this.put(key, value); };这样当看到raw_data被put时你就能关联到它的HashMap实例ID再回溯$putLog数组看到之前put了哪些字段——实现参数组装全过程的“录像回放”。5. 常见问题与排查技巧实录5.1 典型问题速查表问题现象可能原因排查命令/方法解决方案frida: command not foundPython环境未加入PATH或pip安装失败which python3,pip3 list | grep frida重装pip3 install --upgrade frida-toolsFailed to spawn: unable to find processfrida-server未运行或ABI不匹配adb shell ps | grep frida,adb shell getprop ro.product.cpu.abi重新推送对应ABI的server确认chmod 755脚本运行无日志输出Java.perform()未执行或Hook点不存在frida -U -l test.js -qquiet模式看报错检查App包名是否正确加console.log(start)验证脚本加载TypeError: Cannot read property put of undefinedJava.use(java.util.HashMap)返回undefinedfrida -U -l debug.js在debug.js里console.log(Java.available)确保Java.perform()包裹且App已启动日志刷屏无法定位目标未加过滤put调用过于频繁adb logcat -s frida | grep -E (sign|token|data)在脚本中加入键名过滤和计数限流value.toString() crashesvalue为null或byte[]console.log(typeof value, value)使用safeToString()封装所有value处理Hook后App闪退触发反调试或内存异常adb logcat -s AndroidRuntime关闭App的反调试检测或改用this.put.overload(java.lang.Object,java.lang.Object)指定重载5.2 独家避坑技巧那些文档里不会写的细节技巧1用Java.choose()动态扫描HashMap实例比静态Hook更灵活有些App用LinkedHashMap或自定义Map子类Java.use(java.util.HashMap)Hook不到。这时可以用内存扫描Java.perform(function () { Java.choose(java.util.HashMap, { onMatch: function (instance) { console.log([SCAN] Found HashMap instance:, instance); // 对每个实例单独Hook其put方法 const clazz Java.use(instance.getClass().getName()); clazz.put.implementation function (k, v) { console.log([DYNAMIC] Instance put:, k, v); return this.put(k, v); }; }, onComplete: function () { console.log([SCAN] Done); } }); });虽然性能稍差但能覆盖所有Map实现包括TreeMap、ConcurrentHashMap。技巧2当put被内联优化时HookputAll()作为备选ART在O2优化级别可能把map.put(a,b); map.put(c,d)内联成map.putAll({...})。此时put调用消失但putAll仍在。加一行HashMap.putAll.implementation function (m) { console.log([PUTALL] keys:, Array.from(m.keySet()).map(k safeToString(k))); this.putAll(m); };技巧3用console.log替代send()避免Frida RPC通信开销新手喜欢用send({key,value})把数据发到Python端处理但Frida的IPC有延迟大量调用会导致日志丢失。实测console.log()在Android端吞吐量是send()的5倍以上。真要导出数据用console.log(JSON.stringify({...}))再用adb logcat -s frida log.txt保存。技巧4针对加固App优先Hookjava.util.LinkedHashMap腾讯乐固、360加固常把HashMap替换成LinkedHashMap以规避基础Hook。直接把脚本里的java.util.HashMap换成java.util.LinkedHashMap成功率提升40%。5.3 性能实测数据不同Hook策略的耗时对比我在Pixel 4aAndroid 12上对某电商App做了压力测试统计1000次put调用的平均耗时Hook方式平均单次耗时日志输出稳定性是否触发ANR适用场景HashMap.put无过滤12.4ms低日志缓冲区溢出是500次/秒调试阶段HashMap.put键名线程过滤3.1ms高否生产环境HashMap.putAll8.7ms中否内联优化场景LinkedHashMap.put2.9ms高否加固AppConcurrentHashMap.put5.3ms高否多线程网络库结论带过滤的HashMap.put是综合最优解。它在保证定位精度的同时对App性能影响最小且适配面最广。6. 后续可扩展方向不止于参数定位这套方法论的价值远不止“找到加密参数”。它是一把打开安卓App业务逻辑的万能钥匙接口测绘连续Hook多个Activity的HashMap.put自动聚类出所有网络请求的参数模板生成OpenAPI文档草稿埋点审计监控Analytics.trackEvent()传入的Map检查是否违规上传IMEI、手机号等敏感字段热修复验证App更新后对比新旧版本HashMap.put的日志差异快速验证热修复是否生效自动化测试用Frida脚本模拟用户操作自动提取每次请求的sign、timestamp喂给后端做一致性校验。我自己就用这套逻辑搭了一个轻量级协议分析工具手机端跑Frida脚本收集参数日志PC端用Python解析日志生成请求模板再用Requests批量重放——整个流程全自动每天能扫10个竞品App的接口变动。最后分享一个小技巧如果你发现某个App的sign总在put之后生成不妨试试HookOkHttpClient.newCall()把request.body().string()打出来——很多时候加密后的完整请求体就藏在这里比逆向算法更快捷。毕竟逆向的终极目的不是炫技而是用最短路径拿到想要的数据。
返回列表