做 App 安全测试的同行应该都有这种体会:一个看似不起眼的 token 参数,往往藏着客户端最核心的加密逻辑。这次我把 zp_stoken 的完整逆向过程整理成文——从动态 Hook 定位函数,到用 RPC 把算法改造成可随时调用的服务,整个过程不算复杂,但里面的坑比想象中多得多。
先交代背景。某业务 App 的每个接口请求头里都带一个 zp_stoken,服务端靠它校验请求是否合法。这个值看上去是一串不定长的字符串,混合大小写字母和数字,尾部还跟着一段疑似十六进制时间戳的东西。按常规思路,应该先抓包看规律,再用静态分析找算法,最后动态验证。但实际动手后发现,静态分析这条路被加固和混淆堵得死死的,效率极低。于是换了更务实的路线:先 Hook 拿到函数入口和输入输出,确认算法边界,再用 RPC 把这套流程固化成长驻脚本,让外部程序随时调用。
这篇文章主要写给两类人:一类是做移动端安全测试、需要分析第三方 App 签名或 token 机制的同行;另一类是刚接触 Frida、想搞清楚 Hook 和 RPC 到底怎么配合使用的学习者。通篇以 zp_stoken 为例(代称),把环境搭法、脚本写法、踩坑点一次讲透。
1. 项目定位与逆向思路拆解
1.1 为什么 Hook 先行、静态分析靠后
先把结论放在前面:如果你面对的 token 算法藏在加固和混淆之后,纯静态分析基本是死路一条,动态 Hook 才是性价比最高的突破口。
这次分析的 zp_stoken 是某业务 App 请求头里的动态令牌,每次请求的值都不完全一样,目测带时间因子。最初我把 APK 丢进反编译工具,花了大半天只定位到几个疑似类,核心算法被抽到了 so 层,还做了控制流混淆,看起来像一团乱麻。真正让我转弯的是一次抓包观察:我连续触发了十几次相同业务请求,发现 token 在变化,但肉眼可见最后一段总是十六进制风格,说明里面大概率有时间戳或计数器参与。这个规律提醒我,不需要读懂全部算法,只要找到"生成入口",观察它的输入输出关系,就能达到目的。
于是路线确定为:用动态插桩先确认函数边界,再用 RPC 的方式把函数调用固化下来。这么做的好处是绕开了静态分析里最难的算法还原环节,直接把"App 自己怎么算"变成"外部程序也能让它算"。你可以理解为,我们不去复刻一台机器的内部构造,而是想办法拿到这台机器的操作按钮,需要产物的时候按一下按钮就行。
1.2 技术路线选型背后的三个考量
选型这事,很多人只看工具名气,不看场景匹配度。我这次选 Frida 做动态插桩,主要基于三个考量。
第一是工程成本。Frida 不需要改包、重签、重启,脚本写好直接注入运行中的进程,改脚本后重挂即可生效。相比之下,走 Xposed 模块那条路,要写模块、装框架、重启设备,一个调试周期动辄几分钟,交互式分析完全不现实。逆向分析本来就是反复试错的过程,工具迭代速度直接决定效率上限。
第二是结果复用。Frida 的 rpc.exports 机制可以把 Hook 逻辑直接封装成交互接口,Python 侧调用起来跟调本地函数没有区别。这意味着一次分析工作的产物不只是分析报告,还是一个可持续使用的自动化工具,后续做测试数据构造、批量验证都直接用得上。
第三是排查便捷。动态插桩最怕信息不透明,Frida 的 console 输出、栈回溯、内存检索都很好用,遇到异常能快速定位。特别是在反调试对抗阶段,有没有日志输出基本决定了排查速度。
2. 环境准备与核心工具选型
2.1 运行环境与版本搭配
先把我实测过的稳定环境列出来,这套组合反复用,没出过兼容性问题:
- 测试设备:root 过的 Android 模拟器(快照功能很香,折腾坏了秒恢复)
- 系统版本:Android 9 或 10,Frida 在这两个版本上的表现最稳定
- Frida Server:14.2.18
- PC 端:frida-tools 12.x,Python 3.8 以上
- 辅助工具:抓包代理 + 反编译工具 jadx
这里最想强调的坑是版本匹配。Frida Server 装在设备端,frida-tools 跑在 PC 端,两边的版本号必须对应。版本不一致最常见的报错是 "unable to communicate with the remote server",很多人一看到这个报错就去检查网络、端口、驱动,折腾半天,其实只是版本没对齐。我现在的习惯是装完后第一时间用frida-ps -U验证连通性,通了再继续。
提示:Frida Server 和 PC 端的版本一致性是新手最容易踩的坑。版本一换,其他全得跟着换,别只更新一边。
模拟器选择上,建议用带 root 的官方镜像。真机也行,但真机要么有 root 权限问题,要么有厂商内核限制,SELinux 策略还要单独处理,效率远不如模拟器。Frida Server 启动时如果遇到权限拒绝,多半是 SELinux 拦截,临时放宽策略即可,分析完记得恢复。
2.2 工具链分工逻辑
工具不在多,在于各自干好分内事。我这次的工具链分工是:抓包工具负责定义问题,反编译工具负责缩小范围,Frida 负责最终突破。
抓包是第一步,也是最容易偷懒的一步。先把携带 zp_stoken 的正常请求完整抓下来,注意记录 token 出现的位置、长度、字符集、变化规律。这些信息是后面验证 Hook 是否正确的基础。比如你 Hook 到一个函数生成了 32 位定长字符串,但真实请求里的 token 是 48 位,说明 Hook 错了层级。
反编译工具在这套流程里不是主角,但也不可或缺。用 jadx 打开 APK,全局搜索 "zp_stoken",通常能找到它在请求头拼接代码里的位置,顺着调用往上翻,就能看到哪个方法负责生成它。这一步定位到的类名和方法名,就是接下来 Frida 脚本里Java.use的目标。哪怕类名被混淆了也没关系,你用描述性的调用关系去判断,不必纠结可读性。
Frida 是收尾的关键角色。它负责两件事:确认候选函数确实是 token 出口,然后把确认后的调用关系固化成 RPC 接口。很多文章喜欢把 Hook 讲得很玄,其实核心就是一个循环:hook 候选函数,打印入参出参,比对抓包结果,不对就换下一个候选。
3. Hook 阶段:从函数定位到参数还原
3.1 定位加密函数入口的三个步骤
定位阶段我习惯分三步走,每步都有明确产出,避免瞎试。
第一步,枚举目标类。写一段很短的 Frida 脚本,用 Java.choose 或 enumerateLoadedClasses 遍历内存中已加载的类,把类名里包含 token、sign、encrypt 等关键字的类全部打印出来。这一步的产出是一份"候选类清单",比在反编译代码里瞎翻快得多。
Java.perform(function () { Java.enumerateLoadedClasses({ onMatch: function (className) { if (/token|sign|encrypt/i.test(className)) { console.log(className); } }, onComplete: function () {} }); });第二步,验证候选方法。拿到类名后,用 Java.use 加载目标类,hook 里面疑似生成 token 的方法。怎么判断"疑似"?看方法签名和调用位置。比如反编译代码里看到TokenManager.generate(ctx, userId)这种形态,大概率就是核心入口。hook 上去以后,打印入参和返回值,拿返回值跟抓包里的 zp_stoken 比对。
第三步,处理 Native 层。如果 Java 层方法只是个壳,真正逻辑在 so 里,就需要换用 Interceptor.attach。怎么确认有 so 参与?看方法里有没有 System.loadLibrary 调用,或者函数体短得可疑、返回值一眼看不出和输入的关系。Native 函数的定位可以通过 Module.enumerateExports 枚举 so 的导出函数,也可以从 JNI 注册表反查。
3.2 关键 Hook 脚本的写法与参数观察
Java 层 Hook 的模板可以直接抄,但要注意两个细节。
第一个细节是重载签名。Java 方法经常会重载,你必须指定完整的参数类型列表,否则 Frida 不知道你要 hook 哪一个。签名不确定时,先打印 overloads 列表:
var method = TokenHelper.generate; if (method.overloads) { method.overloads.forEach(function (overload) { console.log("overload: " + overload); }); }拿到签名后,再按目标重载写 implementation。以com.example.token.TokenHelper的generate(String, String)为例:
Java.perform(function () { var TokenHelper = Java.use("com.example.token.TokenHelper"); TokenHelper.generate.overload('java.lang.String', 'java.lang.String').implementation = function (a, b) { console.log("[+] generate called"); console.log(" param1 = " + a); console.log(" param2 = " + b); var result = this.generate(a, b); console.log(" result = " + result); return result; }; });第二个细节是返回值校验。有的方法内部有缓存逻辑,连续调用两次可能第二次走的是缓存分支,返回值虽然正确但没走完整算法。所以 Hook 验证阶段要刻意改变入参,多次调用,观察输出是否随之变化。如果输出恒定不变,说明候选函数可能不是真正的生成入口,或者存在内部缓存。
Native 层 Hook 的参数读取要费点心思。JNI 函数的参数布局是固定的:args[0] 是 JNIEnv 指针,args[1] 是 jobject 或 jclass,从 args[2] 开始才是业务参数。如果业务参数是 jstring,要先用 Java.vm.getEnv() 拿到 JNI 环境,再转成可读字符串。这一步写起来麻烦,但必须做,否则打印出来的全是内存地址。
3.3 算法还原的边界:什么时候该收手
这是我最想写给新手的一段。很多人拿到 Hook 结果后,开始疯狂收集输入输出对,试图在外部复现整个算法,美其名曰"彻底搞懂"。这里要泼一盆冷水:大多数场景根本不需要还原算法。
第一种场景是安全评估。你要回答的问题是"这个 token 机制是否安全",那只需要验证几件事:token 是否可预测、是否与用户身份绑定、是否有时效、能否重放。这些用 Hook 加抓包就能验证,不需要完整还原加密算法。
第二种场景是自动化数据准备。你要批量构造合法的业务请求,那更简单,RPC 调用原函数即可,让 App 自己算 token,你只管拿结果。
真正需要完整还原算法的,只有一种场景:你需要在非 App 环境里独立生成合法 token,比如服务端回调验证、跨端逻辑迁移。这种场景工作量会指数级上升,因为除了算法本身,还要复现它的所有输入来源:随机种子、时间戳格式、用户状态参数等等。我的建议是:动手还原之前,先写清楚这份算法还原的验收标准和用途,否则很容易陷入"逆向爱好"式的无限探索,产不出实际价值。
4. RPC 调用阶段:把算法变成可复用服务
4.1 RPC 桥接脚本的骨架设计
Hook 阶段确认了函数入口,下一步就是让它从"调试工具"变成"生产工具"。
RPC 化改造的核心是 rpc.exports。你在 Frida 脚本里定义一个导出对象,外部就能用 Python 调用这些方法,就像调用本地函数一样。基本的骨架如下:
Java.perform(function () { var TokenHelper = Java.use("com.example.token.TokenHelper"); rpc.exports = { generateToken: function (param1, param2) { return Java.perform(function () { var result = TokenHelper.generate(String(param1), String(param2)); return result; }); } }; });这里有个关键点必须反复强调:rpc.exports 的方法体虽然写在 JS 里,但它被外部调用时运行在 Frida 的 JS 运行时线程,不是 App 的业务线程。所以任何涉及 Java 对象的操作都要重新包进 Java.perform 里,确保运行在正确的运行上下文。我早期写过不带 Java.perform 的版本,结果十个调用有八个返回 null,查了好久才反应过来。
脚本加载方式上,推荐用 Python 侧动态加载,而不是命令行直接挂脚本。因为 Python 侧可以更方便地集成进测试框架、做异常处理和并发管理。加载脚本的代码很简单,frida 模块的 create_script 和 load 两个方法就够用了。
4.2 调用侧的并发与异常处理
RPC 调用看起来简单,但生产环境里坑很多。token 生成依赖 App 的内部状态,比如全局单例、登录态、时间基准,任何一个状态异常都会导致调用失败或返回错误 token。所以在调用侧,我做三层防护。
第一层是异常捕获。每次调用都包在 try/except 里,把异常信息完整打印,包含调用参数和堆栈。不要吞异常,否则排查问题时会一头雾水。
第二层是重试机制。如果 App 只是临时状态抖动,比如某个懒加载对象还没初始化,重试几次大概率能恢复。我的重试策略是:指数退避,第一次失败等 0.5 秒,第二次 1 秒,第三次 2 秒,最多三次。不要无脑重试,因为如果 App 进程已经崩溃,重试只会浪费更多时间。
第三层是健康检查。高频调用场景下,每调用若干次就检查一次进程是否还活着,会话是否有效。如果进程挂了,就重新启动进程再 attach。这个检查很关键,因为长时间运行的 App 可能被内存回收或者被检测机制干掉,你不检查就继续调,会拿到一堆失败返回。
4.3 一套稳定的调用封装示例
直接给你看一份完整的封装代码,它在多个项目里跑过,稳定性和并发表现都不错:
import frida import time JS_CODE = """ Java.perform(function () { var TokenHelper = Java.use("com.example.token.TokenHelper"); rpc.exports = { generateToken: function (param1, param2) { return Java.perform(function () { return TokenHelper.generate(String(param1), String(param2)); }); }, ping: function () { return "pong"; } }; }); """ class TokenClient: def __init__(self, device_id, package_name): self.device = frida.get_usb_device(device_id) self.package_name = package_name self.session = None self.script = None self.attach_and_load() def attach_and_load(self): self.session = self.device.attach(self.package_name) self.script = self.session.create_script(JS_CODE) self.script.load() def get(self, param1, param2, max_retry=3): last_err = None for i in range(max_retry): try: return self.script.exports_sync.generate_token(param1, param2) except Exception as e: last_err = e time.sleep(0.5 * (i + 1)) self.attach_and_load() raise RuntimeError(f"generate_token failed: {last_err}") def close(self): if self.script: self.script.unload() if self.session: self.session.detach()几个设计细节说明一下。get 方法里如果调用失败,除了重试还会重新 attach 并加载脚本,这相当于给整个调用链路加了一层自愈能力,能应对 App 进程重启或脚本意外卸载的场景。ping 这个导出方法是我特意加的,用于快速验证链路连通性,排查问题时很管用。
多设备并发的场景,我给每台模拟器实例化一个 TokenClient,放进线程池,每台设备互不干扰。实测 8 台模拟器同时跑,单台每秒约 50 次调用,整体稳定。
5. 常见问题与排查技巧实录
5.1 反调试与检测对抗
这节是全文最"实战"的部分。成熟 App 基本都内置了反调试检测,检测手段层出不穷。最常见的几类:检测 Frida Server 默认端口、扫描进程内存映射里的 frida 特征字符串、检测调试状态、对 Java 层方法做完整性校验。
应对思路要分层。第一层是环境层面做伪装:把 Frida Server 默认端口改成冷门端口,启动时用参数指定;如果检测点包括默认路径下的文件名,就换个自定义路径放置 Server。第二层是脚本层面绕过检测函数:先通过枚举找到执行校验的方法,再 hook 住它,强制返回"校验通过"。
这里我强烈建议一个方法论:把"业务逻辑分析"和"反调试对抗"解耦。先临时关掉或绕过所有检测,把 token 分析流程跑通;确认业务链路无误后,再回过头单独研究检测机制本身。很多人喜欢边对抗边分析,结果两边互相干扰,排查问题的时候根本分不清是业务逻辑错了还是检测没绕过。解耦之后,每类问题单独排查,效率至少翻倍。
5.2 调用栈与线程问题
Hook 过程中我遇到的另一个高频问题来自线程和调用栈。典型表现是:方法被 hook 到了,但打印的参数是 undefined,或者方法压根不执行。
第一种情况,多半是没包 Java.perform。Frida 脚本的 JS 环境和 App 的 Java 运行时不在同一个线程,操作 Java 对象前必须切过去。这个前面已经强调过,但确实是最常见的低级错误,再次提醒。
第二种情况,方法是动态加载的。比如 so 文件不是启动时加载的,而是业务运行中途才被加载。如果你 attach 得太早,Module.findExportByName 可能找不到对应的导出函数。解决方案是先跑业务触发 so 加载,再执行 attach。或者用 Module.enumerateModules 周期性轮询模块列表,一旦目标 so 出现就立即 hook。
还有一种很隐蔽的情况:你 hook 的 Java 方法是父类声明的,但实际被调用的可能是子类重写后的实例方法。静态 hook 只对声明类有效,子类重写后走的是另一套逻辑。这时候需要把 hook 目标改为实际调用方的对象类型,或者对多个可能类都做 hook。
5.3 加解密流程的验证思路
怎么确认你 hook 的函数就是 token 的真正出口?我用的方法是"篡改验证法"。
具体操作很简单:在 Hook 脚本里把目标方法的返回值强制改成一段固定字符串,比如 "HOOKED_MARK_12345",然后让 App 正常发请求,用抓包工具看实际发出的 token 值。如果抓到的 token 变成了你填入的固定字符串,说明这个函数就是最终出口;如果抓到的值还是原来的格式,说明函数调用链中还有别的环节在包装或二次处理。
这个方法每次分析我都会用,它比单纯打印日志高效得多,因为日志只能告诉你"函数被调用了",但不能告诉你"这个调用是否真的影响最终结果"。篡改验证直接检验因果,哪怕在复杂调用链里也能精准定位关键节点。
值得注意的是,篡改验证后要记得恢复原始逻辑,否则后续分析的请求数据就失真了。我会专门为这种验证准备独立的模拟器快照,做完验证就恢复快照,干净利落。
5.4 一些问题速查表
把上面这些经验整理成一张速查表,方便你遇到问题时快速对照:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| attach 报 unable to communicate | Frida Server 与 PC 版本不匹配 | 统一版本号后重启 Server 再试 |
| Java.use 报类找不到 | 目标类未加载或已被混淆移除 | 先枚举已加载类确认真实类名 |
| overload 报错 | 方法有多个重载,签名不全 | 打印 overloads 列表逐一尝试 |
| rpc 调用返回 null | 脚本里没有包 Java.perform | 检查导出方法体内运行上下文 |
| App 启动后秒退 | 检测到插桩特征 | 更换端口/路径,hook 检测函数 |
| native 函数 attach 不到 | so 加载时机或导出符号名问题 | 枚举模块列表,等待加载后再 attach |
| 篡改返回值后请求没变化 | hook 的函数不是最终出口 | 沿调用链向上继续定位出口函数 |
6. 合规边界与经验总结
6.1 什么样的场景可以做
这篇写到这里,必须把边界问题讲清楚。逆向分析是安全研究的常规手段,但它有明确的使用前提。我分享这套完整流程,是基于以下三类场景的正当性:一是你有明确测试授权,比如甲方委托的渗透测试或安全评估;二是目标就是你自己团队开发的应用,属于内部安全验证;三是学习研究用途,对象是 CTF 题目、开源项目或你明确拥有分析权的示例程序。
未经授权分析他人应用、利用分析结果批量调用接口、绕过技术限制去获取本不该获取的能力,这些行为轻则违反平台规则,重则触及法律红线。技术分享的责任是传播方法论,而不是引导滥用。文中的所有目标均以代称出现,参数、类名、脚本均为演示性质,目的就是希望你掌握思路本身。
6.2 我个人踩过的一些坑
最后分享几个实战中踩过、且代价不小的坑,希望能帮你绕过去。
第一,别把脚本写得太"完美"再运行。早期我喜欢一次性写完完整分析脚本再执行,结果 Bug 一层套一层,报错信息根本看不懂谁是谁。后来改成小步迭代:先枚举类,确认列表后再打印参数,确认参数后再打印返回值,确认因果关系后再做篡改验证,每一步小验证都通过才进入下一步。看着慢,实际整体耗时最少。
第二,动手前一定要保存原始请求基线。我有一段时间上来就改脚本、hook 函数,一忙起来忘了先抓包存档,后面想对比 token 的原始格式和改动后的差异时,发现没有基准数据,只能推翻重来。现在的规矩是:任何分析开始前,先把干净环境下的完整正常请求保存好,这是所有后续验证的坐标原点。
第三,设备时间必须与服务端对齐。token 生成通常依赖时间戳,如果模拟器时间与真实服务器时间偏差过大,即使 RPC 调用成功,生成的 token 也会因为时间窗口不符而被服务端拒绝。这个坑我踩得非常冤,明明所有逻辑都正确,排查了两天才发现是模拟器时间慢了五分钟。
总体来看,这套从 Hook 到 RPC 的流程,本质上是把"逆向还原"这个高难度目标,转化成"程序调用"这个低难度目标。它不可能替代你分析算法细节的能力,但它能在大多数实际场景里,快速帮你拿到想要的结果。希望这套方法论对你有用。