ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Frida-RPC实战:调用App内部函数搞定加密签名接口自动化

Frida-RPC实战:调用App内部函数搞定加密签名接口自动化 上个月帮朋友调试一个票务类App的接口自动化项目第一反应还是老套路抓包、看协议、还原参数。结果打开Charles一看请求体里密密麻麻几十个字段关键的几个全是加密签名连时间戳都套了一层。啃了两天so头皮发麻。后来换了个思路既然App自己能在内存里把数据算出来那我不如直接用Frida把它内部已经跑通的函数暴露成RPC接口让Python像调本地函数一样去调整个过程顺畅得不是一点半点。这篇文章就把这套“安卓逆向 Frida-RPC”的完整思路拆开揉碎抛砖引玉。先声明清楚本文所有内容只用于学习和研究Frida-RPC技术目标App是拿来练手的调试样本。不鼓励任何人用它去写抢票脚本、绕过风控或抓取平台非公开数据。做逆向自动化技术本身是中性的但边界要自己守住。1. 为什么选Frida-RPC而不是死磕加密协议1.1 票务App的加密签名已经把协议层变成了“黑盒”做过App接口自动化的朋友应该都有体会最费时间的不是逻辑而是签名。很多票务、电商类App的请求体里除了常规参数还会有一堆类似sign、token、x-ts、riskData这样的字段有的是服务端下发的有的是本地根据设备信息、时间戳、业务参数动态生成的。如果你要从协议层还原就等于要把App端那套Java层和native层的加密逻辑完整复刻一遍。我一开始也想走这条路但分析到一半就发现不划算Java层的加密逻辑虽然能用反编译工具看但很多关键运算被抽到native层native层又是so文件加载了反调试、指令混淆甚至动态解密更麻烦的是部分参数依赖设备指纹同一套算法在不同模拟器上报出来的结果可能直接被风控拦截。简单说协议层已经被平台方加固成了一个“黑盒”要从外部解开它效率极低。1.2 RPC本质上是在“内存态”复用App的业务能力那我们换个角度加密函数跑在App进程里输入参数是已知的输出结果也是现成的。只要我能进到App进程内部直接用它自己的函数去算那签名就是合法的请求也能正常发给服务端。这就是Frida-RPC的核心价值。Frida这个框架可以在App运行时注入JavaScript代码hook目标的Java方法、native函数甚至直接调用它们。而RPCRemote Procedure Call是Frida提供的一种通信机制注入到App里的JS脚本可以把某些函数注册为“远程过程”宿主机器上的Python/Node脚本通过网络与本机App里的JS脚本通信把参数传进去再把结果取回来。你感受到的过程就是Python 调用 rpc.xxx(...) - 与设备上的 Frida Agent 通信 - JS 脚本调用 App 内部函数 - 返回值原路返回 Python这一步走通之后App在你眼里就不再是黑盒了而是一堆可以随时调用的“业务函数库”。签名、加密、风控参数这些事全部由App自己完成了。1.3 这套方案适合什么场景又不适合什么场景先说适用场景你和目标App之间有明确的授权或者你调试的是自己的App你需要快速拿到某个界面上展示的数据但不想从零破解加密协议你已经登录了账号想复用App里登录态的cookie/token来请求数据App本身没有特别变态的反调试、反注入策略。不太适合的场景也直接说服务端还有额外的频率限制、行为检测那你把接口调到飞起一样会被封号App进程一启动就检测Frida会直接闪退那你得先过反调试这通常又是另一个大坑需要大规模高并发抓取数据RPC方式受设备性能和Agent稳定性拖累远不如正规API。所以别把Frida-RPC当成万能钥匙它是一个“用于学习和定向自动化”的好工具但不是批量爬虫的引擎。我的判断标准很简单如果目标场景是一次性、小规模、需要登录态的查询操作RPC非常合适如果要上量还是老老实实找官方API或者走协议还原加上合规审批。2. 环境搭建版本匹配比选模拟器或真机更重要2.1 我踩过的版本坑Frida Server和Client版本错一位就报错这应该是每个刚开始接触Frida的人都会遇到的问题。Frida客户端PC上的python包和Frida Server安卓设备上运行的agent必须保证主版本号一致最好是完全同版本。比如PC端是frida16.4.5设备上就传frida-server-16.4.5-android-arm64差一个小版本都可能出现Failed to spawn: unable to find process with name ...Unable to start: Error connecting to remote frida-server: connection refused或者干脆连接成功但脚本注入后没有任何输出。我之前偷懒用过一次PC端和Server端版本不一致折腾了半个多小时最后一步步排查才发现是版本号的问题。所以现在的习惯是先确定要用的Frida版本然后同时下载对应版本的客户端和Server并且把版本号写进项目的requirements.txt里防止以后环境重建时踩坑。2.2 在模拟器上注入的隐藏问题SELinux、ro.debuggable、架构设备选择上很多朋友喜欢用模拟器因为方便快照和重装。模拟器本身没有问题但有几个和Frida配合的细节要处理。第一个是架构。现在主流PC是x86/x64但大部分Android App的so库只提供了armeabi-v7a和arm64-v8a。如果你用的是x86模拟器那App运行时会走x86翻译层但你要hook的native函数很可能还是arm指令这就会导致定位不到或无法调用。所以更稳妥的做法是选择arm64架构的系统镜像或者直接在真机上调试。第二个是ro.debuggable。Frida需要目标进程有足够的调试权限。在模拟器上很多系统镜像默认是ro.debuggable1可以直接attach普通App。如果遇到Failed to attach: unable to inject这类问题可以先检查一下系统的ro.debuggable值。第三个是SELinux。部分模拟器系统开了SELinux enforcing模式Frida Server的/data/local/tmp目录执行权限会被限制。可以先用adb shell getenforce看一下状态如果确实不会有太大副作用临时切换为permissive模式能省很多时间但要注意这是测试环境才建议这么做。2.3 验证注入成功的最小流程为了避免后面写了一大堆脚本才发现环境根本没通我建议先跑一个最小验证流程# 1. 将 frida-server push 到 /data/local/tmp adb push frida-server /data/local/tmp/ # 2. 加执行权限 adb shell chmod 755 /data/local/tmp/frida-server # 3. 启动 frida-server adb shell /data/local/tmp/frida-server # 4. 本机验证设备连接 frida-ps -U如果frida-ps -U能看到设备上的进程列表说明Server启动成功且客户端连接没问题。接着再写一个最简脚本测试注入import frida import sys device frida.get_usb_device() process device.attach(目标包名) source Java.perform(function() { console.log(inject ok); }); script process.create_script(source) script.on(message, lambda message, data: print(message)) script.load()如果console.log(inject ok)能在Python终端里打出来那说明整个链路已经通了后面写RPC就只是时间问题。别小看这个验证步骤我见过太多朋友上来直接跑大脚本最后报错都不知道是环境问题还是脚本问题。3. 目标函数定位三板斧字符串、导出表、动态Hook3.1 先用静态分析缩小范围你要RPC调用的函数不会自己写在脸上但可以从业务逻辑入手。比如想做“查询某场演唱会的场次列表”那你关注的核心就是App里“点击某个按钮后发起的请求”。用jadx或GDA打开反编译后的代码搜索关键词比如showList、concert、session、ticket往往能快速定位到Java层的业务类。如果Java层找不到那大概率在native层。这时候可以用IDA或Ghidra加载so文件看导出函数里有哪些和业务相关的符号。很多so导出函数名虽然经过了编译器的mangling但依然带有可读的语义。比如某个函数导出名是Java_com_example_ticket_JNIHelper_queryShowList那基本可以直接锁定。锁定了函数名之后不要急着写RPC。先在静态代码里确认它的参数类型和返回值。Java方法相对好办看方法名和参数列表就行native函数则需要搞明白JNI签名因为Frida里调用native函数时要传入正确的参数类型否则很容易把内存搞出段错误。3.2 动态验证先用console.log观察调用现场静态分析只能给你一个“候选列表”至于函数到底长什么样、参数是什么语义还是要动态验证。我常用的方式是这样先不写RPC而是在目标函数入口处hook一下打印参数和调用栈Java.perform(function() { var targetClass Java.use(com.example.ticket.api.ShowApi); targetClass.queryShowList.implementation function(cityId, page) { console.log(queryShowList called, cityId cityId , page page); var result this.queryShowList(cityId, page); console.log(queryShowList result result); return result; }; });如果这个hook脚本能在控制台里看到调用记录说明你找到了正确的调用点。这时候再手动点击App里的对应页面观察日志触发情况就能确认你的输入参数和输出结果是否符合预期。这一步特别重要因为很多函数有多个重载参数类型也不一样。如果你直接照着一个模糊的记忆去写RPC调用大概率会在运行时得到诡异的结果。3.3 确认JNI签名与参数类型避免RPC调用时内存错乱Java方法还好native函数就得小心。在Frida里调用一个native函数的写法通常是var nativeFunc new NativeFunction( Module.findExportByName(libticketcore.so, Java_com_example_ticket_JNIHelper_queryShowList), pointer, [pointer, pointer, jstring, jint] ); var ret nativeFunc(env, jclass, cityIdStr, page);这里第二参数是JNIEnv的指针第三个是JClass对象具体取决于函数是不是JNI导出函数。如果签名写错轻则返回空值重则直接crash还会连带影响App主进程稳定性。我的习惯是先用一个理论上的JNI签名估算然后在真实调用时做一次防御性输出var funcAddr Module.findExportByName(libticketcore.so, Java_com_example_ticket_JNIHelper_queryShowList); console.log(funcAddr:, funcAddr); if (funcAddr) { var nativeFunc new NativeFunction(funcAddr, pointer, [pointer, pointer, jstring, jint]); var env Java.vm.getEnv(); var clazz Java.use(com.example.ticket.JNIHelper).class; var cityId Java.vm.getEnv().newStringUtf(110000); var result nativeFunc(env, clazz, cityId, 1); console.log(result:, result.readCString()); }确认这个能正常输出了再把这个调用封装到RPC接口里基本就稳了。当然不同的App结构差异很大我这里只是给出一个通用套路具体类名、函数、签名要以你自己拿到手的样本为准。4. 把Hook脚本改造为RPC服务从console.log到rpc.exports4.1 RPC脚本的模块化写法当你通过动态Hook确认了目标函数可以正常调用之后下一步就是把它从“日志工具”升级成“RPC服务”。Frida的RPC机制非常简单核心就是rpc.exports。一个长得比较舒服的RPC脚本是这样组织的Java.perform(function() { var targetClass Java.use(com.example.ticket.api.ShowApi); function queryShowList(cityId, page) { return targetClass.queryShowList(cityId, page); } rpc.exports { queryShowList: queryShowList, getShowDetail: getShowDetail }; });这样Python端就能同步调用rpc script.exports_sync result rpc.query_show_list(110000, 1)注意Frida的RPC命名会自动把蛇形命名转换为驼峰命名脚本里queryShowList对应Python里query_show_list这一点要记住不然会怀疑是不是自己命名错了。如果业务逻辑比较复杂建议把脚本拆成两部分一部分负责Java对象获取和函数封装另一部分专门写RPC导出。这样既能单独测试也方便后续维护。实际项目里我的脚本结构通常是一个rpc_entry.js入口里面require若干个业务模块例如show.js、user.js、order.js每个业务模块暴露自己的函数数组。4.2 Python端调用RPC超时、异常和回调有了RPC接口之后Python端的调用其实很直白但有几个坑值得说一下。第一个坑是rpc.exports的同步调用超时。默认情况下RPC调用的超时时间很短如果你调用的函数内部有网络请求、IO等待或复杂计算经常会碰到类似这样的报错cannot finish rpc call in 30 seconds: nul这个报错我一开始看到很懵后来仔细排查才发现并不一定是我们的函数执行了30秒而是Frida Agent在内部处理某些同步操作时卡住了。常见原因包括JS线程里做了阻塞调用的等待Java方法内部触发了主线程的同步等待而主线程被其他操作占住了个别native方法死循环或卡在某个反调试逻辑上。定位方式也很简单在JS脚本里给目标函数包一层计时日志看它到底跑了多久var startTime Date.now(); var result targetClass.queryShowList(cityId, page); console.log(elapsed:, Date.now() - startTime);如果耗时确实超过30秒那可以把Python端的timeout调大或者在JS端改成异步任务、回调完成后通过send事件通知Python避免同步RPC把Agent堵死。第二个坑是异常传递。Java层抛出的异常如果在JS脚本里不捕获RPC调用会直接报一个没什么可读性的错误。建议在RPC导出的每个函数上都包一层try-catchfunction safeCall(fn) { try { return { ok: true, data: fn() }; } catch (e) { return { ok: false, error: e.message || String(e) }; } }这样Python端收到返回值后先看ok字段再决定是继续处理还是抛出业务异常排查问题会舒服很多。第三个坑是进程生命周期。script.load()之后的RPC调用虽然方便但App进程如果因为后台被清理、崩溃或手动退出连接就会断开。所以理想状态是Python这边要有自动重连机制监听script的message事件一旦检测到进程退出就自动重新spawn/attach并加载脚本。4.3 一次真实的场次查询调用示例脱敏为了避免纸上谈兵我写一个脱敏后的示例。假设目标App有一个Java类com.example.ticket.core.TicketCore里面有个方法public ListTicketSession querySessions(String cityId, String showId)在RPC脚本里我这样写Java.perform(function() { var TicketCore Java.use(com.example.ticket.core.TicketCore); function querySessions(cityId, showId) { try { var sessions TicketCore.querySessions(cityId, showId); var result []; for (var i 0; i sessions.size(); i) { var session sessions.get(i); result.push({ name: session.getName(), time: session.getTime(), priceRange: session.getPriceRange(), stockStatus: session.getStockStatus() }); } return { ok: true, data: result }; } catch (e) { return { ok: false, error: e.message }; } } rpc.exports { querySessions: querySessions }; });Python端调用import frida import sys def main(): device frida.get_usb_device() pid device.spawn([com.example.ticket]) device.resume(pid) session device.attach(pid) with open(rpc_entry.js, r, encodingutf-8) as f: source f.read() script session.create_script(source) script.load() rpc script.exports_sync result rpc.query_sessions(110000, SHOW20250101) print(result) session.detach() if __name__ __main__: main()这个示例里没有用到App任何真实的类名或数据但流程是完整的从Java方法到JS封装再到Python调用。实际项目中你要做的就是替换成自己分析出的类名、方法名和参数结构。有个细节要强调spawn方式启动App时Frida需要在最早阶段注入这样才能在App自初始化代码里hook到目标函数。但有时候spawn太早App自身的初始化库还没加载完反而会引发崩溃。这种情况下可以改用attach已启动的进程或者用chrono延时执行。这个要靠实际项目去调。5. 自动化落地后的稳定性与合规红线5.1 进程重连、异步并发、限速保护RPC说白了还是依赖App进程活着。只要进程一挂调用就直接失败。所以真正要做自动化不能写一次性的Python脚本而是要做成有状态的服务。我目前的方案是在Python端封装一个FridaClient类class FridaClient: def __init__(self, package_name, script_path): self.package_name package_name self.script_path script_path self.device frida.get_usb_device() self.session None self.script None def start(self): pid self.device.spawn([self.package_name]) self.device.resume(pid) self.session self.device.attach(pid) with open(self.script_path, r, encodingutf-8) as f: source f.read() self.script self.session.create_script(source) self.script.on(message, self.on_message) self.script.load() def on_message(self, message, data): if message[type] error: pass def call(self, method, *args): if not self.script: self.start() rpc self.script.exports_sync return rpc[method](*args) def restart(self): self.stop() self.start()这里最核心的就是restart逻辑。一旦调用抛出连接相关异常就自动重启进程、重新挂脚本、重新加载RPC。不过要提醒一点每次重启都会让App重新初始化登录态如果只存在内存里就会丢。所以自动化任务最好保证账号信息能自动恢复或者尽量让App进程长时间存活。并发上Frida的RPC是串行的同一条session里多次调用会被Agent内部的JS线程顺序处理。如果你要并发建议在同一个设备上开多个App进程/多开实例或者直接上多台设备。真机上多开App通常不太靠谱模拟器集群反而是更实际的选择。但模拟器集群又会引入新的问题比如设备指纹、IP共享等这里就不展开讲了。限速保护也很有必要。就算你用App内部函数去请求数据服务端的风控不是只看参数签名的它还会看请求频率、设备特征、行为轨迹。不加限制地高频调用早晚会被反制。我的经验是单设备每秒请求数不要超过12次查询类业务操作之间至少间隔几百毫秒而且任务要有固定的间隔和随机抖动别像定时炸弹一样整点轰炸。5.2 把RPC接口包装成HTTP服务方便业务脚本调用做自动化时我通常不会让业务脚本直接依赖Frida的Python SDK而是再封装一层HTTP服务把RPC方法暴露成RESTful接口。这样业务脚本用requests就能调用不用每台机器都装Frida环境。一个大致的Flask示例from flask import Flask, jsonify, request from frida_client import FridaClient app Flask(__name__) client FridaClient(com.example.ticket, rpc_entry.js) client.start() app.route(/api/sessions, methods[POST]) def get_sessions(): data request.get_json() city_id data.get(cityId) show_id data.get(showId) try: result client.call(querySessions, city_id, show_id) return jsonify({ok: True, data: result}) except Exception as e: return jsonify({ok: False, error: str(e)}), 500 if __name__ __main__: app.run(host127.0.0.1, port5001)把RPC包成HTTP服务之后好处是显而易见的业务脚本不关心底层是Frida还是别的技术每次调用都可以加参数校验、鉴权、日志后续如果要限流、熔断直接在HTTP层做就行。我自己现在做这类项目固定套路都是“设备端Frida Agent 宿主机HTTP封装”。调试时直接Python调RPC上线时走HTTP两层都是一份代码维护。5.3 必须守住的边界授权、频率、数据用途技术聊到最后还是要说一点边界问题。Frida-RPC这个套路的本质是“复用App自身的业务能力”这本身就处在比较微妙的合规地带。你在自己的测试设备、自己的账号、自己授权范围内调试完全没有问题但如果你把它用去抓取平台后台的非公开数据、绕过风控、干扰正常业务那就已经越界了。我的几个个人原则分享给同样做自动化的朋友参考只调试自己有授权或明确许可的应用。只要是没授权的目标连环境都不要去搭。严格控制请求频率。哪怕技术上能做到每秒几十次也不要去挑战服务端风控。数据只用于学习、技术验证或个人小范围分析不用于商业产品。不做与“抢票”“屯票”“恶意占库存”相关的违规动作。这类行为既影响真实用户也容易给自己带来麻烦。我在实际项目里遇到最多的情况是拿自己的测试账号做小批量数据校验量级通常只有几十次请求并且放到夜间或低峰期跑尽量避免对平台产生任何影响。这个度每个搞技术的人都应该有数。最后再分享一个经验之谈Frida-RPC的调试过程中最容易浪费时间的地方不是脚本写不出来而是环境、版本、函数签名这些基础环节。所以不管项目多急都要先跑通最小验证再逐步加功能。把RPC脚本当做一个长期维护的“内部工具”而不是一次性的爆破脚本你会发现它的上限其实很高用起来也越来越顺手。
返回列表