HTTPS抓包与逆向分析:从中间人代理到App签名算法还原 1. 分析前的工程准备抓包、反编译与绕过证书检测1.1 为什么抓包环境会直接决定后续分析效率我第一次处理某虾App的数据需求时心里想得很简单把接口请求抓到照着参数模拟一套不就完事了结果打开抓包工具一看请求头里多了一个sign字段每次请求都变根本没法直接搬。后面一连串的 403、风控提示才让我意识到这类App早就不是“裸请求”时代了。加密参数分析这件事看着是算法题实际上第一步是环境题抓包能不能抓干净、证书校验能不能绕过、反编译之后能不能顺利找到关键代码每一步都决定你后面是走直线还是绕远路。先说抓包。某虾App走的是HTTPS抓包工具常见就是 Charles 或 mitmproxy原理都是中间人代理客户端信任代理证书代理把流量解密后转发给服务端。听起来简单但实际会遇到两个大坑一是 Android 7.0 及以上版本默认不信任用户安装的证书你装了 Charles 证书到“用户证书”区域对大多数App根本不生效二是某虾App在客户端做了 SSL Pinning也就是证书锁定即使你信任了代理证书它校验的还是服务端证书指纹校验不过直接断开连接。所以真正的准备工作不是“装个证书”就完事而是要让目标App老老实实走你的代理。我的习惯是会用真机加 Frida而不是一上来就重打包。重打包虽然也能用但很多App有签名校验重打包之后根本跑不起来白白浪费时间。Frida 的优势是不改App文件运行期注入脚本既能绕过证书锁定又能顺手做动态调试后面定位加密函数时也要靠它。1.2 绕过SSL校验的两种实操方案方案一重打包并修改系统证书信任区域。这个方案适合“没有反篡改校验”的App操作路径是先用 apktool 解包把抓包证书转成系统证书格式/system/etc/security/cacerts/下的.0文件塞进 APK 的res/raw目录再修改AndroidManifest.xml里的networkSecurityConfig最后重打包签名安装。缺点很明显某虾App这种体量的应用基本都有签名校验和完整性检测只要检测到 APK 被改动过要么闪退要么接口直接拒绝。我一般不把它作为首选。方案二Frida Hook 绕过 SSL Pinning。这才是主流。核心思路是运行时替换掉App内部的证书校验逻辑让代理证书“合法化”。你需要在电脑上装 Frida手机端跑 frida-server两端版本要对齐然后写一段 JavaScript 脚本注入目标进程。常用的姿势是 HookTrustManagerImpl的checkServerTrusted或者直接替换X509TrustManager的实现Java.perform(function () { var X509TrustManager Java.use(javax.net.ssl.X509TrustManager); var TrustManagerImpl Java.use(com.android.org.conscrypt.TrustManagerImpl); TrustManagerImpl.checkTrustedRecursive.implementation function () { return null; }; });这段脚本的意思是“绕过递归信任校验”让客户端不再严格核实服务端证书链。执行frida -U -f com.xia.app -l bypass_ssl.js之后再去触发请求Charles 里就能正常看到明文数据了。这里有个经验不要在 App 启动前急着注入有时 App 启动阶段的反调试检测会先跑起来容易把注入过程打断。我习惯先启动App再附加进程虽然会漏掉启动阶段的部分请求但后续主要接口都能抓到分析效率更高。1.3 反编译工具链与壳处理的注意点抓包拿到请求后下一步是反编译。某虾App这类商业应用大概率会加壳常见如腾讯加固、阿里聚安全等。直接用 jadx 打开 APK 会看到壳的加载入口真正的业务代码不在里面。这时候要么先脱壳要么换一个思路如果只是为了找加密函数可以先跑起来再 hook 内存不一定非要静态脱壳。但静态反编译依然很有价值尤其是拿到加固前的包或者通过 dump 内存获取 dex 后反编译。我的处理顺序是这样先放着apktool解包看资源文件和清单再用jadx尝试反编译如果发现关键类被加密或抽空就借助 Frida 的 DEX dump 脚本把运行时加载的 dex 拉出来再丢给 jadx 分析。有关键代码之后搜索范围立刻缩小到“签名”“加密”“md5”这些关键词附近。这个阶段别急着读全部代码先列出所有可疑类和方法再逐个验证。2. 拆解请求参数从原始数据中圈定签名生成规则2.1 一次完整请求里有哪些值得关注的字段抓包正常之后我对着某虾App的评论接口看了很久。一个典型请求长这样{ pageNum: 1, pageSize: 20, songId: 778899, ts: 1699999999, devId: 3e4a6e8c9f2b7d1a, sign: a3f2c09d8b7e6f1a2c45d0e9b8a7c6f5 }一眼扫过去pageNum、pageSize、songId这些是业务参数ts是时间戳devId是设备标识只有sign看起来像“签名”。签名是典型的32位十六进制字符串长度和 MD5 一致但也可能是 MD5 之后再加工。我们不能光凭外观下结论真正要搞清楚的是哪些字段参与了签名计算、计算顺序是什么、有没有附加的盐或密钥。2.2 用差分法确认sign的输入范围这里的核心方法我叫它“差分法”固定其它输入只改一个字段观察sign是否变化。具体操作是在 Charles 里复制同一个请求右键修改参数后重发对比返回结果。我建议每个关键字段都单独测一遍像下面这样记录变更字段sign是否变化结论pageNum从 1 改成 2变化参与签名songId从 778899 改成 778800变化参与签名ts改早 1 秒变化参与签名devId固定不变时改值变化参与签名请求头User-Agent改成其他值最终返回 403可能作为签名上下文这个表格做完签名输入的范围就大致锁定了pageNum、pageSize、songId、ts、devId这些参数基本都参与了计算。还要注意App 端对请求体可能有“参数按字典序排列再拼接”的规则所以我们要在还原代码时仔细检查排序逻辑不能想当然按肉眼看到的顺序来。2.3 常见签名外观特征速判签名分析多了之后我习惯先看外观再猜算法。像a3f2c09d...这种32位hex大概率是 MD5如果是40位hex可能是 SHA-164位hex基本就是 SHA-256 或对应的 HMAC 变体。如果末尾带着可能是 Base64 编码的结果如果字符串里大小写混排且字符集明显不是十六进制那就是自定义编码难度会高一个量级。某虾App这里看起来是32位hex所以我第一猜测是某种 MD5 加盐变种。但这只是猜测最终要回到代码里去验证。外观特征的意义在于帮你缩小搜索范围而不是直接给出答案别在这上面投入太多精力。3. 静态定位加上动态Hook找到加密函数的真实入口3.1 静态代码分析搜索签名相关字符串拿到反编译代码后第一步是全局搜索关键词。在 jadx 里直接搜sign、md5、encrypt、secret、api_key这类字符串能快速定位到某个工具类。某虾App的代码里大概率会有一个类似SignUtils或EncryptUtil的类里面集中了签名逻辑。我见过的大部分签名工具类代码结构都挺相似一个入口方法接收业务参数转成字符串拼上时间戳和密钥再调用 MD5 或 HMAC 方法最后返回签名。比如反编译后可能是这个样子public class SignUtils { private static final String SECRET_KEY xI#2024_XiaSign; public static String getSign(MapString, String params, long ts) { StringBuilder sb new StringBuilder(); try { TreeMapString, String sortedParams new TreeMap(params); for (Map.EntryString, String entry : sortedParams.entrySet()) { sb.append(entry.getKey()).append(); sb.append(entry.getValue()).append(); } String raw ts sb.toString() SECRET_KEY; return MD5Util.md5(raw); } catch (Exception e) { return ; } } }看到TreeMap要注意Java 的TreeMap默认按字典序排序这说明参数排序就是“按 key 升序”。如果换成HashMap顺序就不可控签名结果会乱所以这类代码几乎都会刻意用有序结构。排序细节是所有还原过程中最容易被忽略又最容易出错的地方。3.2 动态Hook确认调用链静态代码只能证明“可能有这样一个函数”不能证明线上请求真的调用了它。想要确认加密入口我用 Frida 直接 hook 这个类的getSign方法打印输入输出Java.perform(function () { var SignUtils Java.use(com.xia.app.encrypt.SignUtils); SignUtils.getSign.implementation function (params, ts) { var sign this.getSign(params, ts); console.log([SignUtils.getSign] params JSON.stringify(params)); console.log([SignUtils.getSign] ts ts); console.log([SignUtils.getSign] sign sign); return sign; }; });运行脚本后在 App 里随便触发一次评论列表加载就会在 Frida 控制台看到对应的入参和返回值。对比 Charles 里抓到的sign值如果一致说明我们找到了真正的生成入口如果不一致说明签名逻辑还有别的分支可能是走了另一个重载方法。这一步比较关键的还有 hook 时机App 刚启动时某些加密类的加载还没完成Java.use会失败。我通常先在 Frida 里用Java.perform包一层同时给脚本加上延迟重试逻辑或者直接在Java.classFactory.loader加载完成之后再 hook避免“类还没加载”的问题。3.3 原始算法还原从jadx代码到伪代码转换定位到函数只是第一步真正费时间的是还原算法细节。某虾App的签名过程从反编译代码和 Hook 日志综合下来逻辑大致是将业务参数放入一个可排序 Map按 key 升序排列拼接成key1value1key2value2的形式最前面拼上当前时间戳ts最后拼上一个固定密钥字符串对整个字符串做 MD5取32位十六进制小写结果。这段伪代码看着简单实际还原时我会反复确认几个细节拼接时放在哪个位置、末尾是否额外带有、拼接顺序是ts params key还是params ts key、MD5 输出是十六进制还是 Base64。这些细节全都要靠 Frida 打印日志和实际抓包逐字符对比否则你写的生成器只能在极少数请求下碰巧成功。4. 把算法翻译成可运行的签名工具4.1 某虾App签名过程的伪代码与Python实现算法弄清楚之后我用 Python 快速写了一个签名生成器。核心逻辑和 App 端保持完全一致参数排序、拼接、加盐、MD5。一个可运行的示意版本如下import hashlib import time SECRET_KEY xI#2024_XiaSign def build_sign(params: dict, ts: str) - str: sorted_items sorted(params.items(), keylambda item: item[0]) param_str .join(f{k}{v} for k, v in sorted_items) raw ts param_str SECRET_KEY return hashlib.md5(raw.encode(utf-8)).hexdigest() if __name__ __main__: params { pageNum: 1, pageSize: 20, songId: 778899, devId: 3e4a6e8c9f2b7d1a } ts str(int(time.time())) sign build_sign(params, ts) print(ts:, ts) print(sign:, sign)你可能会问为什么用 Python 而不是继续用 Java 写因为后续做数据采集或自动化验证时Python 的生态更顺手requests 发请求、pandas 处理数据都不用额外搭环境。只要算法逻辑一致语言本身无所谓。4.2 对齐细节大小写、URL编码、参数排序代码写完第一轮跑出来的签名大概率对不上。这不是算法错了而是细节没对齐。我踩过的问题主要有三个第一是排序规则。Python 的sorted()默认按 Unicode 码点排序Java 的TreeMap默认也是按字典序但这个“字典序”在不同字符集和 Java 版本下可能略有差异。最好把参数名固定为纯 ASCII避免非英文字符带来排序歧义。第二是大小写。MD5 十六进制输出有的大写有的小写某虾App的sign是小写而我在最初实现时用了一个自动转大写的工具函数结果怎么都对不上。这个只要看一眼抓包里的 sign 样例就能确定但容易在代码里顺手埋坑。第三是 URL 编码。如果业务参数里包含中文、空格、特殊符号App 端在拼接前可能会有一次 URLEncoder.encode。我在某次请求里往keyword参数塞了中文用模拟签名请求时签名校验一直失败后来发现 App 端把中文编码成了%E6%9F%90%E8%99%BE再参与签名。所以参数值在参与拼接前是否编码、编码的字符集是什么都要单独验证。4.3 回归测试用真实请求验证签名是否一致签名工具写完最后要做的就是回归验证。我的验证方式分两步。第一步是回放验证把抓包得到的真实请求原封不动包括原始ts和sign保存下来用我的 Python 生成器传入同样的参数和ts看生成的 sign 是否和抓到的 sign 完全一致。只要这一个请求能对上就说明算法核心正确。第二步是实弹验证用 Python 重新构造一个新的请求ts用当前时间sign 由生成器计算直接发送给某虾App接口。返回 HTTP 200 或包含业务数据的 JSON说明签名通过返回 403、401 或风控提示就说明还有某个参与签名的参数没被覆盖。这里有个细节要注意有些请求头部也会参与签名比如User-Agent、deviceId的自定义请求头。某虾App虽然主签名在 body 里但有些接口的签名会把User-Agent一并拼进原文。我在实测中就遇到过“单独验签通过、整体请求失败”的情况最后把请求头和 body 一起检查才发现问题。5. 版本迭代后的维护思路与一些通用心得5.1 App升级后签名变化的快速定位方法某虾App大概每两个星期就会发一版签名算法不一定会变但密钥、拼接规则、校验策略都可能微调。一旦线上版本升级旧签名大概率立刻失效。遇到这种情况我重新分析的顺序是先看新版本是否有加壳变化如果壳变了优先跑 Frida 的 DEX dump 把新 dex 拉出来重新反编译全局搜关键词sign、SECRET_KEY、getSign对比和上一版的差异重点看常量字符串直接 hook 旧版定位到的SignUtils类如果类名和方法名还在就能迅速确认改动范围用差分法重新跑一遍请求确认参与签名的参数是否新增或减少。多数情况下算法骨架不会大改变的只是密钥或者多了某个参与签名的字段。比如某次更新后某虾App在签名输入里多拼了一个设备活跃时间变量导致签名每次请求都不同但那段时间我靠 Frida 日志对比半天就定位到了新增字段。5.2 长期维护的避坑清单与合规边界最后整理一份避坑清单给长期做这类分析的朋友参考Frida 连接不稳定时先确认 frida-server 版本和手机架构匹配x86 模拟器和 arm 真机的 frida-server 不是同一个文件抓包证书一定要安装到系统证书目录用户证书在 Android 7 上默认无效Hook 签名方法时尽量打印完整拼接原文不要只打印入参对象否则拼接细节错误时很难排查模拟请求时把请求头里的User-Agent、X-Forwarded-For等敏感头保留原值减少被风控误判的概率自动化请求频率要克制不建议高频并发否则接口返回 403 之后会误以为是签名问题实际上是被限流了。还要强调一点这类加密参数分析技术只建议用于学习研究、接口调试或合规条件下的数据获取场景。任何绕过风控、高频抓取、商业化滥用行为可能涉及法律风险。我的原则是技术分析可以做但使用边界必须想清楚不要让工具变成给自己惹麻烦的东西。最后再分享一个实际体会加密参数分析这件事真正难的往往不是算法本身而是整个排查链路里那些“差一点就对了”的细节。一次成功的还原背后通常是几十次抓包对比、几十段 Hook 日志、几十遍请求重放的累积。保持耐心把每一步的输入输出都记录下来你会发现这类 App 的签名再复杂也终究有迹可循。