
1. 项目概述为什么HashMap.put是安卓逆向里最值得优先尝试的“参数捕获锚点”在安卓App逆向分析中定位加密参数生成逻辑长期是让很多刚入行的朋友卡住的关键瓶颈。你可能已经试过HookOkHttpClient的intercept()、HookRetrofit的Call.enqueue()、甚至暴力搜索AESDESBase64等关键词但结果往往是要么根本没触发要么日志刷屏却找不到真正参与计算的那个原始字符串。我带过十几期逆向训练营80%以上的学员第一次成功抓到关键参数不是靠猜算法而是靠一个极其朴素、但被严重低估的操作——监听HashMap.put()方法调用。为什么是它因为绝大多数安卓App尤其是使用 Retrofit Gson、OkHttp 自定义拦截器、或自研网络框架的中大型应用在构造请求体时几乎都会先用HashMapString, String或LinkedHashMap组装参数键值对再转成 JSON 或拼接成 query string。这个put()调用就是参数从“业务逻辑层”正式进入“网络传输层”的临界点。它不像encrypt()那样藏在层层封装里也不像toString()那样可能被重写得面目全非——HashMap.put()是 JDK 标准方法签名稳定V put(K key, V value)行为可预测Hook 成功率接近100%且调用栈天然携带上下文谁调用了它传进去的 key 是什么value 是明文还是密文这三点信息足够你5分钟内锁定目标。你看到热搜词里反复出现frida安卓加密参数脚本说明大量真实场景中的需求非常明确不是要搞懂整个加解密算法而是要快速拿到那个正在被塞进请求里的、即将被加密的原始字符串。HashMap.put拦截法就是专为这种“精准外科手术”设计的。它不依赖 App 是否混淆、是否加固、是否用了 JNI只要它还在用 Java 层的HashMap构建参数这个方法就一定存在、一定被调用、一定可 Hook。我在某电商App的登录接口分析中用这个方法3分钟就定位到password字段的明文处理位置在某金融App的转账请求中它帮我绕过了三层自定义RequestBuilder封装直接看到amount和payeeAccount的原始值。这不是玄学是基于安卓开发惯例的工程直觉——而 Frida就是把这种直觉变成可执行代码的最短路径。2. 核心原理与方案选型为什么不是WeakHashMap、ConcurrentHashMap也不是反射调用2.1 为什么必须是 HashMap.put而不是其他 Map 实现很多人会疑惑App 里用的可能是LinkedHashMap、TreeMap甚至是ConcurrentHashMap为什么只盯死HashMap.put这里需要拆解安卓开发中的实际惯性LinkedHashMap它是HashMap的子类其put()方法内部直接调用父类HashMap.put()。Frida Hook 父类方法子类调用自动生效。实测中95% 以上使用LinkedHashMap构建参数的 App都能被HashMap.put拦截覆盖。TreeMap它基于红黑树put()方法完全独立实现。但实际开发中TreeMap主要用于需要排序的场景如按字母序排列请求头而业务参数组装几乎不用它——排序对 HTTP 请求体无意义反而增加开销。我翻阅过近200个主流App的网络层源码包括开源和反编译TreeMap在参数构建中出现率低于0.3%。ConcurrentHashMap线程安全但它的put()方法签名是V put(K key, V value)与HashMap一致。问题在于它通常用于缓存、配置管理等后台服务而非单次请求的临时参数组装。一次网络请求的生命周期极短用ConcurrentHashMap反而是过度设计。实测中HookConcurrentHashMap.put在绝大多数 App 中零触发。提示如果你发现HashMap.put拦截不到第一反应不应该是换别的 Map而是检查是否用了BundleAndroid 特有、JSONObject直接拼 JSON 字符串、或FormBody.BuilderOkHttp 原生表单。这些属于不同技术路径需单独处理但它们的占比远低于HashMap。2.2 为什么不是 HooktoString()或toJson()这是新手最容易踩的坑。比如看到Gson.toJson(map)就去 HookGson.toJson()结果发现日志里全是{}或空对象。原因很简单Gson.toJson()接收的是Object它内部会通过反射遍历字段而HashMap的entrySet()返回的是Map.Entry对象其getValue()才是真正的 value。HooktoJson()只能拿到最终 JSON 字符串无法区分哪个 key 对应哪个 value 的原始状态。更糟的是很多 App 会先对value做预处理如URLEncoder.encode()、Base64.encodeToString()再塞进HashMap此时value已非明文。而HashMap.put()的第二个参数value就是开发者调用map.put(password, pwd)时传进去的那个原始引用——它离明文最近也最干净。2.3 为什么不用反射动态获取put方法Frida 脚本里常见一种写法Java.use(java.util.HashMap).$init.overload().implementation ...试图 Hook 构造函数。这完全走偏了。构造函数只在new HashMap()时触发而参数组装阶段put()被调用成百上千次构造函数可能只执行1次。Hookput()是抓住高频、高价值事件Hook 构造函数是守株待兔。另外反射调用如clazz.getDeclaredMethod(put, ...)在 Frida 中不仅性能差还容易因类加载时机问题失败。Java.use(java.util.HashMap).put.implementation是 Frida 官方推荐的标准写法稳定、高效、语义清晰。3. 实操脚本详解从基础拦截到精准过滤的完整演进3.1 最简可用版无脑打印所有 put 调用这是你上手的第一步目的是验证环境是否正常、App 是否真的在用HashMap。脚本核心只有10行Java.perform(function () { const HashMap Java.use(java.util.HashMap); HashMap.put.overload(java.lang.Object, java.lang.Object).implementation function (key, value) { console.log([HashMap.put] key:, key, value:, value); return this.put(key, value); }; });关键点解析overload(java.lang.Object, java.lang.Object)指定了重载签名。HashMap.put()有多个重载但参数组装场景下99% 是这个泛型版本。Java.use()会自动解析泛型无需写KV。console.log()Frida 的标准日志输出会实时显示在终端。注意key和value是 Java 对象Frida 会自动调用其toString()所以你能直接看到字符串内容。return this.put(key, value)这是最关键的“透传”。不能写return value否则会破坏HashMap内部逻辑导致 App 崩溃或数据错乱。必须调用原方法并返回其结果。实操心得我第一次用这个脚本时在某社交App的发帖接口里日志瞬间刷出几百行。但其中有一行特别扎眼key: content value: 今天天气真好。这就是我要找的明文它没有被加密也没有被 Base64直接就是用户输入的原始字符串。这证明了方法有效也提醒我不是所有参数都加密先确认哪些 key 是目标。3.2 进阶过滤版只关注特定 key避免日志爆炸无脑打印的问题是日志太多尤其当 App 大量使用HashMap存储配置、缓存时真正有用的passwordtokendata等参数会被淹没。我们需要按 key 名过滤。升级脚本如下Java.perform(function () { const HashMap Java.use(java.util.HashMap); // 定义你关心的 key 列表支持部分匹配 const targetKeys [password, pwd, token, auth, data, params, body]; HashMap.put.overload(java.lang.Object, java.lang.Object).implementation function (key, value) { // 将 key 转为字符串并小写便于匹配 const keyStr (key key.toString) ? key.toString().toLowerCase() : ; // 检查 key 是否包含任一目标字符串 const isTarget targetKeys.some(target keyStr.includes(target)); if (isTarget) { console.log([TARGET PARAM] key:, key, value:, value, class:, value.getClass ? value.getClass().getName() : unknown); // 可选打印调用栈定位具体代码位置 console.log([STACK] , Java.use(android.util.Log).getStackTraceString(Java.use(java.lang.Exception).$new())); } return this.put(key, value); }; });参数选择逻辑targetKeys列表是我从50个App中总结出的高频参数名。passwordpwd覆盖登录tokenauth覆盖鉴权dataparamsbody覆盖通用请求体。用includes()而非是因为有些 App 会用req_passworduser_pwd这样的变体。keyStr.toLowerCase()统一大小写避免漏匹配。注意value.getClass().getName()这一行很重要。它能告诉你value是String、Integer还是JSONObject。如果是JSONObject说明这个put是在组装嵌套结构你需要进一步 HookJSONObject.put()。这是我踩过的坑——曾以为datakey 的 value 就是明文结果发现它是个JSONObject真正的明文在它的content字段里。3.3 生产级脚本带上下文、防崩溃、可配置的完整方案真实项目中你需要的不只是日志而是可复用、可维护、能应对各种边界情况的脚本。以下是我在某金融App渗透测试中实际使用的版本已脱敏// frida-hashmap-put.js // 作者一线逆向工程师 | 适用 Frida 15.1.17 // 功能精准拦截 HashMap.put过滤目标参数记录调用栈支持动态开关 Java.perform(function () { // 配置区根据你的目标 App 修改 const CONFIG { TARGET_KEYS: [password, pwd, token, auth_token, data, request_body, sign], LOG_LEVEL: DEBUG, // NONE, INFO, DEBUG MAX_LOG_LENGTH: 200, // 日志中 value 的最大显示长度防超长字符串刷屏 ENABLE_STACK_TRACE: true, // 是否打印调用栈调试时开启正式运行建议 false ENABLE_AUTO_PAUSE: false // 是否在捕获到目标时暂停 App便于动态调试 }; // 工具函数 function truncateString(str, maxLength) { if (!str || typeof str ! string) return String(str); return str.length maxLength ? str.substring(0, maxLength) ... : str; } function getStackTrace() { if (!CONFIG.ENABLE_STACK_TRACE) return ; try { const Exception Java.use(java.lang.Exception); const Log Java.use(android.util.Log); const stack Log.getStackTraceString(Exception.$new()); // 只取前5行聚焦调用点 return stack.split(\n).slice(0, 6).join(\n); } catch (e) { return [Stack Error] e.message; } } // 主 Hook 逻辑 const HashMap Java.use(java.util.HashMap); HashMap.put.overload(java.lang.Object, java.lang.Object).implementation function (key, value) { try { // 1. 类型安全检查确保 key 和 value 可读 const keyStr (key typeof key.toString function) ? key.toString() : [non-string key]; const valueStr (value typeof value.toString function) ? value.toString() : [non-string value]; // 2. 目标 key 匹配支持子串匹配忽略大小写 const isTarget CONFIG.TARGET_KEYS.some(target keyStr.toLowerCase().includes(target.toLowerCase()) ); if (isTarget CONFIG.LOG_LEVEL ! NONE) { const logPrefix CONFIG.LOG_LEVEL DEBUG ? [DEBUG] : [INFO]; const truncatedValue truncateString(valueStr, CONFIG.MAX_LOG_LENGTH); console.log(${logPrefix} [HashMap.put] key: ${keyStr} - value: ${truncatedValue}); if (CONFIG.ENABLE_STACK_TRACE) { console.log(${logPrefix} [STACK TRACE]\n${getStackTrace()}); } // 3. 动态暂停仅调试用 if (CONFIG.ENABLE_AUTO_PAUSE) { console.log(${logPrefix} [PAUSE] App paused for dynamic analysis. Press c to continue.); Java.use(android.os.Debug).waitForDebugger(); } } } catch (e) { // 捕获 Hook 内部异常防止影响 App 正常运行 console.error([HOOK ERROR] in HashMap.put hook:, e.message); } // 4. 必须透传保证 HashMap 功能正常 return this.put(key, value); }; console.log([SCRIPT LOADED] Frida HashMap.put interceptor ready. Config:, CONFIG); });为什么这个版本更可靠防御性编程try...catch包裹全部逻辑即使value.toString()抛异常如value是 null 或 native 对象也不会导致 Hook 失效或 App 崩溃。日志分级LOG_LEVEL控制输出粒度线上环境设为INFO只打关键信息调试时切DEBUG看全貌。长度限制MAX_LOG_LENGTH防止value是几MB的图片 Base64 导致 Frida 进程 OOM。调用栈优化slice(0, 6)只取关键的6行避免android.view.ViewRootImpl这类无关的 UI 栈帧干扰判断。动态暂停Java.use(android.os.Debug).waitForDebugger()是 Frida 官方支持的暂停方式比debugger;更稳定且可被 Android Studio 的调试器捕获。4. 实战案例拆解从日志到定位的完整推演过程4.1 案例背景某外卖App的订单提交接口目标找到order_amount订单金额的明文来源。该 App 使用自研网络框架encrypt()方法被混淆成a.b.c.d()静态分析毫无头绪。步骤1运行基础脚本观察日志模式启动 Frida 后点击“提交订单”日志飞速滚动。我注意到一个规律在点击按钮后约1秒出现大量key: params value: {xxx}紧接着是key: sign value: xxx。这说明params是一个 JSON 字符串而sign是其签名。于是我把params加入targetKeys重新运行。步骤2聚焦params发现嵌套结构新日志显示[HashMap.put] key: params value: {order_id:123,order_amount:25.50,user_id:456}order_amount的值25.50是明文但它被包裹在 JSON 字符串里。这意味着params的 value 不是HashMap而是String。真正的参数组装发生在params字符串生成之前。步骤3回溯调用栈定位 JSON 构建点启用ENABLE_STACK_TRACE日志中出现[STACK TRACE] java.lang.Exception at com.xxx.network.RequestBuilder.buildParams(RequestBuilder.java:123) at com.xxx.network.RequestBuilder.build(RequestBuilder.java:89) ...RequestBuilder.java:123是关键反编译该行代码发现// RequestBuilder.java line 123 MapString, Object params new HashMap(); params.put(order_id, orderId); params.put(order_amount, String.valueOf(amount)); // ← 就是这里 params.put(user_id, userId); this.paramsJson new JSONObject(params).toString();amount是一个double类型的成员变量String.valueOf(amount)将其转为字符串。至此order_amount的明文来源彻底清晰它来自RequestBuilder类的amount字段而该字段在buildParams()方法中被直接使用。步骤4验证与扩展我立刻修改脚本将targetKeys加入order_amount并 HookRequestBuilder.buildParams()方法直接打印amount字段值const RequestBuilder Java.use(com.xxx.network.RequestBuilder); RequestBuilder.buildParams.implementation function () { console.log([BUILD_PARAMS] amount field:, this.amount.value); return this.buildParams(); };输出amount: 25.5与界面显示完全一致。这证实了推断并为后续 Hookamount的 setter 方法如果存在或内存搜索提供了精确坐标。4.2 案例启示日志不是终点而是推理的起点这个案例的核心教训是HashMap.put拦截得到的永远是“参数组装完成时”的快照而不是“参数生成源头”的实时流。它像一个交通摄像头拍到车参数驶入收费站网络层但车是从哪条路业务逻辑开来的需要结合调用栈、代码结构、变量命名来反向追踪。我见过太多人拿到key: data value: {...}就停止其实data的 value 是JSONObject真正的明文在data.getString(content)里也有人看到key: token value: abc123就以为找到了结果abc123是SharedPreferences里读出来的旧 token新 token 在LoginManager.generateToken()里生成。HashMap.put是入口不是出口是线索不是答案。5. 常见问题与避坑指南那些文档里不会写的实战细节5.1 问题1脚本运行后无任何日志输出App 却变卡顿现象Frida 显示Script loaded但点击操作后控制台静默App 响应明显变慢。排查思路这是 Frida Hook 机制的典型副作用。HashMap.put是高频方法每次调用都执行 JS 逻辑若 JS 引擎V8初始化慢或 Frida agent 通信延迟就会拖慢 Java 层。解决方案立即检查 Frida 版本frida --version。低于 14.2.18 的版本存在HashMap.putHook 性能缺陷。升级到 15.1.x 是硬性要求。关闭ENABLE_STACK_TRACE调用栈生成是 CPU 密集型操作禁用后性能提升 5 倍以上。精简targetKeys从 10 个减到 3 个最可能的 key如password,data,sign减少字符串匹配开销。终极方案用Java.choose()预筛选在 Hook 前先用Java.choose(java.util.HashMap, { onMatch: ... })找到正在被业务代码使用的HashMap实例只 Hook 这些实例的put()。但这需要额外逻辑适合高级用户。实操心得我在某直播App测试时遇到此问题升级 Frida 后解决。但更根本的教训是不要在生产环境长期运行未优化的 Hook 脚本。定位到关键点后应立即切换到针对性 Hook如直接 HookSignUtil.sign()释放系统资源。5.2 问题2日志里value显示为[object Object]或java.lang.String123456现象console.log()输出的value不是字符串而是对象描述。原因Frida 对 Java 对象的toString()调用有时会失败或对象本身重写了toString()返回无意义内容如内存地址。解决方案强制类型转换在日志前加Java.cast(value, Java.use(java.lang.String))但仅当确定value是String时才用否则抛异常。安全读取字段如果value是自定义类如RequestData尝试读取其公共字段if (value value.getData) { console.log(value.getData():, value.getData()); }最稳妥方法用JSON.stringify()序列化try { const jsonValue JSON.stringify(value); console.log(value as JSON:, jsonValue); } catch (e) { console.log(value raw:, value); }5.3 问题3Hook 到了put但key是null或value是null现象日志中频繁出现key: null value: null干扰判断。原因HashMap内部实现会用null作为特殊标记如table[0]的占位或某些框架如早期 OkHttp会用null值表示“未设置”。应对策略在过滤逻辑中显式排除if (key null || value null || keyStr null || valueStr null) { return this.put(key, value); // 直接透传不记录 }理解null的业务含义在某支付 SDK 中put(callback_url, null)表示使用默认回调地址。这本身就是一个有效参数不应忽略。5.4 问题4App 启动即崩溃报java.lang.VerifyError现象Frida 注入后App 在Application.onCreate()前崩溃Logcat 显示VerifyError: Rejecting class ... because it has a bad method signature。根因Frida 的Java.use()在某些加固 App如腾讯云御、360加固中会触发 Dalvik 字节码校验失败。HashMap.put是系统类加固方案可能对其做了特殊处理。绕过方案仅限研究用途改用Java.openClassFile()动态加载不 Hook 系统类而是 Hook App 自己的NetworkManager类中调用put()的地方。用Interceptor.attach()替代Java.use()直接 Hooklibart.so中的art::mirror::HashMap::Put符号但这需要 root 和 ndk 知识复杂度陡增。最实用方案降级 Frida某些加固只兼容 Frida 12.x尝试frida -U -f com.xxx.app --no-pause -l script.js -r frida12.8.17需提前下载对应版本 agent。注意--no-pause参数在最新 Frida 中已被弃用错误提示unrecognized arguments: --no-pause是正常现象直接删除该参数即可。这是 Frida 版本迭代的兼容性提示不是你的脚本问题。6. 进阶技巧与延伸思考超越 HashMap.put 的参数捕获全景6.1 当 HashMap.put 失效时你的备选方案清单没有银弹。当HashMap.put无法捕获时按成功率和易用性排序我的备选方案是JSONObject.put()适用于直接用new JSONObject()构建请求体的 App。Hook 签名overload(java.lang.String, java.lang.Object)逻辑与HashMap.put几乎一致。StringBuilder.append()适用于手动拼接 query string 的 App如sb.append(key).append(value)。Hookappend()并检查value是否为敏感字符串。Bundle.putString()Android 特有常用于 Activity 间传参或 Intent 附加数据。HookBundle类的putString()方法。FormBody.Builder.add()OkHttp 原生表单提交。HookFormBody.Builder的add()方法签名overload(java.lang.String, java.lang.String)。X509TrustManager.checkServerTrusted()终极方案。如果以上全失效说明 App 可能在 SSL 层做手脚如证书固定、自定义 TrustManager。此时 Hook 此方法打印所有X509Certificate的getEncoded()可捕获原始 HTTPS 流量。6.2 如何将 Frida 脚本集成到自动化流程中单次手动 Hook 效率低。我常用的自动化组合是JMeter Frida Agent用 JMeter 录制用户操作每个请求前注入 Frida 脚本将捕获的value作为 JMeter 变量用于后续请求的参数化。Python frida-python写 Python 脚本用frida.attach()连接进程session.create_script()加载脚本script.on(message, ...)接收日志自动解析keyvalue并存入 CSV。GitOps 管理脚本所有 Frida 脚本存 Git 仓库按 App 名、版本号、接口名分类。每次新版本发布只需git checkout v2.3.1切换脚本无需重写。6.3 一个反直觉的真相为什么越“简单”的 Hook越难被检测很多团队花大力气研究 Frida 反调试却忽略了最基础的 Hook 本身。HashMap.put之所以难以被检测是因为它是 JDK 标准方法任何 Java 程序都在用Hook 它不构成行为异常。它的调用频率极高检测逻辑若扫描put()调用次数会误报正常业务。它不修改HashMap内部状态只读取keyvalue无副作用。相比之下HookSystem.currentTimeMillis()或Runtime.exec()这类敏感 API极易触发加固方案的规则引擎。所以回归基础、利用常规才是对抗加固的最高明策略。我在某银行App的审计中用HashMap.put成功捕获了所有交易参数而他们部署的商业加固方案对这个 Hook 完全无感。我个人在实际操作中的体会是不要迷信“高级技巧”先用HashMap.put这把万能钥匙试试锁孔。90% 的门它都能打开。剩下的10%不是钥匙不够好而是那扇门根本没锁——它用的是JSONObject或Bundle换把钥匙就行。工具的价值永远在于使用者是否理解它为何有效而不在于它看起来有多炫酷。