☰
用Frida-RPC实现安卓逆向:演唱会数据自动化获取实践
2026/10/2 4:31:17 网站建设 项目流程

说实话,第一次把目标锁定在“安卓逆向 + 演唱会数据”这两个词上的时候,我脑子里蹦出来的第一个念头是:这东西能做成自动化吗?很多人一提逆向就想到破解、脱壳、分析so文件,一听就觉得门槛特别高。但真正把这些流程跑下来之后,我发现最难的其实不是逆向本身,而是怎么把逆向的结果变成一条稳定、可复用的数据通道。这篇文章就围绕我用Frida-RPC实现演唱会数据自动获取的完整过程来写,核心关键词是安卓逆向、Frida-RPC、自动化。目标是让有安卓基础、但对Frida桥接调用还比较陌生的朋友,看完之后能自己动手跑通一条从Hook到RPC再到数据的流水线。

我选择的目标应用是票务类APP,大家应该都熟,就是那个每逢热门演唱会就“抢不到票”的大麦。我要拿的是演唱会列表数据:场次、城市、演出名称、票价区间、状态这些公开信息。为什么不直接抓包?因为票务APP的接口做了一层又一层防护,直接拿到加密数据也解不开;为什么不是简单Hook一下就算完?因为只看不导出,数据留在App进程里,没法给后续的数据分析、定时巡检用。Frida-RPC正好解决这两个问题:让App自己把数据解密、自己把列表组装好,然后通过RPC通道把结果交到我手里。

这篇不会讲怎么绕过支付、怎么薅羊毛,那些事情碰了就是给自己找麻烦。我只会把技术链路讲清楚:App端怎么Hook、RPC接口怎么设计、Python端怎么调用、数据怎么做清洗。咱们走的是技术研究加效率工具的路子,合规这条线我从头到尾都会守着。

1. 为什么选Frida-RPC这条路

1.1 直接抓包不行吗?——先把痛点列清楚

很多人一听到“获取App数据”,第一反应就是用Charles或者Fiddler抓包,把请求地址复制出来,自己在代码里复现一遍请求。这个思路在普通App上没毛病,我以前也这么干。但到了大麦这种体量的票务App上,直接抓包会遇到三座大山:

第一,HTTPS证书校验。App内部做了证书固定,就算你给手机装了用户证书,客户端照样不认。你要么用Frida去Hook掉证书校验逻辑,要么就只能在模拟器里装Xposed模块去绕过。这条路不是走不通,而是每次App更新你都得重新适配一次,维护成本非常高。

第二,请求参数是加密的。就算你拿到了接口地址,body里的关键参数也不是明文。有的是把参数做了MD5加盐,有的是走了自定义加密算法,你光看着加密后的字符串根本不知道后端校验了什么。想逆推加密算法?票务App的so文件做了加固,字符串也不给你明着放,逆向成本直接拉满。

第三,返回数据不一定是明文。有些接口返回的JSON里,关键字段是加密过的,需要客户端拿本地密钥解密后再展示到界面上。也就是说,就算你把响应体原封不动搬过来,得到的也是一堆没法用的密文。

所以我当时的判断是:与其在外围跟加密算法死磕,不如让App自己把活干了。数据在界面上已经显示出来了,说明App内部一定有一段代码完成了从密文到明文的转换。我要做的,就是找到那个已经处理完数据的对象,把里面的值掏出来。这就是Frida最擅长的事情。

1.2 为什么是Frida,而不是Xposed或者Magisk模块

Xposed我早些年也用过,功能很强,但有两个让我很头疼的问题:第一,装Xposed框架需要重启设备,调试一次重启一次,来回折腾非常浪费时间;第二,Xposed的Hook代码是写死在模块里的,要改逻辑就得改模块、重新打包、重装,迭代效率太低。

Frida完全换了一个思路。它把JavaScript引擎注入到目标进程里,Hook代码用JS写,改完逻辑保存一下,重新运行一次脚本就生效了,连App都不用重启。调试的时候,你能在命令行里实时看到输出,能动态地把对象内容打印出来,这种感觉跟Xposed那种“装好模块再祈祷它能跑”的体验完全不是一个量级。

还有一个特别实用的点:Frida是跨平台的。电脑上装一个frida-tools,手机端跑一个frida-server,两边通过USB连起来就行。Hook逻辑写在JS文件里,控制逻辑写在Python里,两边用一套接口对接。这样一来,我的数据脚本可以很自然地嵌进自动化测试框架,比如GitLab CI/CD里那种定时任务——晚上跑一次,数据入库,第二天早上直接看报表,全程不用人管。

1.3 用RPC而不是简单Hook的理由

如果只是临时看一眼数据,那直接写Hook脚本打console.log就够了。但我的目标是自动化,是要让外部程序主动来取数据,那就得换一种思路:由App进程提供一个“服务”,外部通过USB通道来调用。

Frida的RPC机制干的就是这件事。你在JS脚本里用rpc.exports把函数暴露出去,Python端就能像调用本地函数一样,直接调用App进程内的Java方法。听起来很玄乎,其实你可以这样理解:普通Hook像是你站在窗外往厨房里看,能看见厨师在做什么菜,但你吃不到;RPC是你在厨房墙上开了一个小窗口,厨师把做好的菜直接从这个窗口递出来给你。窗口开好了,以后你随时想吃什么,喊一声就行。

从自动化角度来看,这个能力是决定性的。因为可以把获取数据的动作拆成一个个可复用的函数,需要的时候就调一下,不需要人一直盯着脚本执行。App在里面帮我把数据准备好,我在外面收割结果,整个过程是双向的、动态的,而不是一次性的静态Hook。

2. 项目前置准备与整体方案设计

2.1 环境清单:设备、工具和版本

在动手写代码之前,先把环境整理好。我用的是一台小米手机作为测试机,系统是Android 9,已经解锁了Bootloader并刷入了Magisk。真机的兼容性比模拟器好很多,因为模拟器的硬件特征和真实手机差别较大,有些App会做模拟器检测。如果你手头没有可折腾的旧手机,用云手机也行,但稳定性会差一些,实测下来真机最省心。

Frida版本我用了15.2.18,这个版本比较稳定,往上的一些新版本在Android低版本上反而容易出问题。电脑端需要装Python 3.8以上,frida-tools直接用pip安装就好。

工具版本要求用途
测试真机Android 8~10均可运行目标App,承载Frida注入
frida-server与电脑端frida版本一致手机端注入服务,核心组件
frida-tools15.x电脑端命令行工具与Python绑定
Python3.8+编写调用RPC的控制脚本
ADB最新版手机与电脑通信的基础通道
抓包工具HttpCanary(手机端)辅助确认接口位置,不是主力方案

安装frida-server的时候有个坑:必须保证手机端的frida-server版本和电脑端的frida-tools版本一致,否则连接的时候会报“version mismatch”。这个错很好排查,但它就是爱在关键时候冒出来打断思路,所以建议一开始就固定版本。

2.2 整体流程:从APK到结构化数据

我的完整流程分六步,走通之后就可以形成一个自动化的闭环:

  1. 手机连接电脑,启动frida-server,用frida -U -f cn.damai -l hook.js命令启动App并注入Hook脚本。这一步的作用是让Frida先于App业务代码运行,保证不会漏掉早期注册的对象。
  2. 手动在App里刷一遍目标页面,比如进入某个城市的演唱会列表页,触发数据的加载。
  3. Hook脚本实时观察内存中的数据结构,定位承载数据的对象和字段。
  4. 把验证过的代码封装成RPC函数,挂到rpc.exports上。
  5. Python端通过frida.get_device_manager()连接USB设备,调用RPC函数拿数据。
  6. 对拿到的JSON做清洗和结构转换,存入本地文件或者数据库。

整套流程看着不复杂,但每一步都有很多细节,尤其是第三步——定位关键对象,直接决定后面所有工作能不能顺利推进。

2.3 先给自己定三条不能碰的红线

写逆向相关的代码,我始终提醒自己要有边界。这个项目我只做数据获取和展示,下面三条红线从头到尾没碰过:

第一条,不碰支付和账号体系。获取演唱会数据只需要浏览公开页面,不需要登录账号,更不需要处理余额、优惠券之类的东西。凡是涉及支付逻辑的类和方法,看到了也直接跳过。

第二条,不做签名校验绕过。我全程没有去替换签名、Hook掉完整性校验,也没有对App做任何暴力破解。一个合格的测试机会检测到篡改后处于异常状态,但我的目标本来就不是绕过它。

第三条,不对接口做高频恶意请求。Frida-RPC的调用频率和正常用户浏览时的数据加载频率保持一致,不会去压接口、刷验证码或者做任何影响服务端稳定的事情。技术是用来做效率工具的,不是用来搞破坏的。

这些问题想清楚之后,后面的编码就不会走偏。

3. 核心实战:从Hook定位到RPC导出

3.1 定位目标函数:三步找到下刀口

定位是整个项目里最花时间的一步。我总结下来的方法可以分成三步:

第一步,从UI层面反推。打开演唱会列表页,看页面上的数据长什么样。我需要的字段基本都能在界面上看到:演唱会名称、艺人、城市、时间、票价区间、销售状态。那么在App内部,一定会有一个对象把这些字段都装起来,并且大概率是一个列表。我只需要在Frida里找到这个Adapter或者数据源对象。

第二步,用枚举法缩小范围。Frida有一个很实用的API叫Java.choose,它可以在Java堆里按类名搜索所有存活对象。我可以先把可能跟演唱会相关的类名粗略扫一遍,比如类名里带Concert、ShowItem、ProjectItem的,打印出每个对象的toString结果,看看哪个对象里的数据正好和我屏幕上看到的一致。

第三步,确认并深入。找到目标对象之后,用反射把它的字段全部打出来,看每个字段的类型和名字。这一步是纯体力活,但也是最能让人感到“原来如此”的时刻——你会亲眼看到App在内存里已经把数据准备得清清楚楚,我们只是临门一脚把它导出来。

下面是我当初定位时写的一个简单脚本框架,作用是枚举与演唱会条目相关的对象并打印toString内容:

// hook_find.js Java.perform(function () { var targetClasses = [ "cn.damai.commonbusiness.seatbiz.view.model.BasePriceInfo", "cn.damai.ticklet.ui.detail.bean.TicketShowItem", "cn.damai.projectview.bean.ProjectItem" ]; targetClasses.forEach(function (clsName) { try { Java.choose(clsName, { onMatch: function (instance) { console.log("[found] " + clsName + " -> " + instance.toString()); }, onComplete: function () { console.log("[done] " + clsName); } }); } catch (e) { // 类不存在或者加载失败,直接忽略 } }); });

这段代码要配合App已经打开列表页的状态来跑,因为对象没被创建的话,Java.choose是搜不到东西的。我第一次跑的时候等了两分钟什么都没输出,后来才发现是手机端的frida-server挂了,重新启动之后马上就有数据。

3.2 从验证Hook到写RPC接口

定位到目标对象之后,先不要急着写RPC。先把数据打印出来,确认字段名和我们理解的一致,这个阶段我叫它“验证Hook”。验证通过之后,再把脚本改造成RPC导出的模式。

RPC的语法很直接,在JS脚本里用rpc.exports定义一个导出对象,每个属性就是一个可被外部调用的函数。下面的代码是我当时改造成RPC接口的一个简化版本,演示了如何从页面中抓取列表数据并返回给外部:

// hook_rpc.js Java.perform(function () { var ProjectItem = Java.use("cn.damai.projectview.bean.ProjectItem"); var ArrayList = Java.use("java.util.ArrayList"); function getConcertList() { var result = []; Java.choose("cn.damai.projectview.bean.ProjectItem", { onMatch: function (instance) { var item = { name: "", city: "", showTime: "", priceRange: "" }; try { item.name = instance.getName(); } catch (e) {} try { item.city = instance.getCityName(); } catch (e) {} try { item.showTime = instance.getShowTime(); } catch (e) {} try { item.priceRange = instance.getPriceStr(); } catch (e) {} result.push(item); }, onComplete: function () {} }); return result; } rpc.exports = { getConcertList: getConcertList }; });

这段代码的意图是,在App内存里找到所有ProjectItem对象,把关键字段取出来,装进一个数组返回。实际运行时你会发现,App滚动列表只会加载当前屏幕周围的数据,所以拿到的条数取决于列表当前位置。想要拿全量数据,要么配合手动滚动,要么在RPC函数里去调用App内部的数据加载方法。

3.3 给RPC接口做参数化改造

演唱会数据跟城市强相关,光有一个getConcertList肯定不够用。我后面把它改造成了支持参数的形式:外部传入城市ID和页码,函数先去找到对应的列表数据源,再调用App内部的分页加载逻辑,最终返回指定页码的数据。

改造的关键点在于,RPC函数运行在App进程里,而参数是从Python端传进来的。在有限的生命周期里,目标对象可能还没有准备好,所以代码里要做空值保护。还有一个比较隐蔽的问题:Frida的RPC调用是从JS线程发起的,如果直接在RPC函数里去更新UI或者触发网络请求,有可能会遇到线程安全或者UI线程限制的问题。我的处理方式是,RPC函数只读内存中已有的数据,不触发网络请求,网络请求通过手动在App里滑动列表来触发。这样写起来简单,也足够稳定。

为了减少重复代码,我把对象转JSON的逻辑抽成了一个统一的方法,不同数据源传不同字段名就行。

3.4 Python端调用RPC

JS端准备完毕,接下来就是Python控制端的活了。连接Frida并调用RPC函数的代码非常简单,核心逻辑就几行:

import frida import time device = frida.get_usb_device() session = device.attach("cn.damai") script = session.create_script(open("hook_rpc.js", encoding="utf-8").read()) script.load() # 调用JS中暴露的RPC函数 data = script.exports_sync.get_concert_list() print(type(data), data)

这里有一个容易被坑的地方:script.exports和script.exports_sync的区别。前者是异步的,返回值是一个Future,得自己处理await;后者是同步的,直接阻塞到JS端函数执行完返回结果。对于数据获取这种严格依赖返回值的场景,用exports_sync省心很多。

另一个坑是设备连接状态。手机息屏、USB线松动、frida-server崩溃、App被系统回收,任何一个问题都会让Python端报连接错误。我后来封了一层重试逻辑,发现连接断了就等待3秒重新attach,实测可以解决大部分偶发问题。代码大致长这样:

def get_concer_list_with_retry(max_retry=3): for attempt in range(max_retry): try: device = frida.get_usb_device(timeout=5) session = device.attach("cn.damai") script = session.create_script(open("hook_rpc.js", encoding="utf-8").read()) script.load() return script.exports_sync.get_concert_list() except Exception as e: print(f"尝试第{attempt + 1}次失败:{e}") time.sleep(3) return None

加上重试逻辑之后,整个脚本的健壮性明显提升,不再需要人盯在旁边处理偶发故障了。

4. 数据清洗与结构化输出

4.1 演唱会数据的常见字段结构

从RPC接口返回的数据是JSON格式,但结构比较乱,有嵌套对象、null值、还有字段缺失的情况。为了做数据分析和定时巡检,我会把它清洗成一张规范的二维表。

结合大麦演唱会列表页常见的信息,整理之后的字段大概长这样:

字段名类型说明示例
project_idstring项目唯一ID381728
namestring演出名称“某某巡回演唱会·北京站”
artiststring艺人/团体“某某乐队”
citystring举办城市“北京”
venuestring场馆“国家体育场”
show_timestring演出时间“2025-07-12 周六 19:30”
price_rangestring票价区间“380-1680元”
status_textstring销售状态“预售”/“热卖中”/“已售罄”
update_timestring数据抓取时间“2025-04-06 23:10:33”

update_time这个字段是我自己加的,非常重要。定时巡检任务跑完之后,如果没有这个时间戳,下游根本不知道这份数据是什么时候抓的,遇到数据过期的问题会很难排查。

4.2 清洗逻辑与入库实践

JSON原始数据里经常出现这种情况:某个字段值为None,某个字段明明存在但内容是空字符串,还有个别字段在JSON key里根本没出现。我的清洗规则是:所有字段都转成字符串,空值统一填"",时间字段做格式规范化。简单来说,就是保证下游读数据时永远不需要再做空值判断。

清洗完的数据我直接写入SQLite。用SQLite的原因很简单:单文件、零配置、Python内置支持,完全够用。建立一张concerts表,字段就跟上面表格一样,主键是project_id加update_time的组合,这样每天跑一次巡检,同一场演唱会会有多条带时间戳的记录,方便追溯状态变化。

import sqlite3 import json import datetime def clean_rows(raw_rows): clean = [] for row in raw_rows: item = {} item["project_id"] = str(row.get("projectId") or "") item["name"] = str(row.get("name") or "") item["artist"] = str(row.get("artistName") or "") item["city"] = str(row.get("cityName") or "") item["venue"] = str(row.get("venueName") or "") item["show_time"] = str(row.get("showTime") or "") item["price_range"] = str(row.get("priceStr") or "") item["status_text"] = str(row.get("statusText") or "") item["update_time"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") clean.append(item) return clean def save_to_db(rows, db_path="damai_concerts.db"): conn = sqlite3.connect(db_path) c = conn.cursor() c.execute(""" CREATE TABLE IF NOT EXISTS concerts ( project_id TEXT, name TEXT, artist TEXT, city TEXT, venue TEXT, show_time TEXT, price_range TEXT, status_text TEXT, update_time TEXT, PRIMARY KEY (project_id, update_time) ) """) c.executemany( "INSERT OR REPLACE INTO concerts VALUES (?,?,?,?,?,?,?,?,?)", [(r["project_id"], r["name"], r["artist"], r["city"], r["venue"], r["show_time"], r["price_range"], r["status_text"], r["update_time"]) for r in rows] ) conn.commit() conn.close()

用这套逻辑跑了一段时间之后,我发现一个有趣的变化:某场演唱会的状态从“预售”变成了“热卖中”,然后又变成了“已售罄”。这就是带时间戳的好处,你能从历史记录里看到状态变化的过程,而不是只看到当下的一瞬间。

5. 常见问题与排查技巧实录

5.1 我踩过的五个高频坑

整个项目做下来,有五个坑出现频率最高,每次都要花时间排查。我把它们整理成一张表格,希望能帮你少走弯路:

问题现象根本原因解决方案
Frida连接报unable to connect to remote frida-serverUSB连接断开或frida-server未启动确认adb devices有设备,重新执行adb shell /data/local/tmp/frida-server &
注入后脚本不输出任何日志目标类未加载先手动打开对应页面,触发数据加载后再执行Hook
RPC调用一直阻塞不返回exports_sync调用时JS端出现异常在JS函数里加try/catch,返回友好错误信息
拿到的列表数据条数很少只有当前屏幕附近的对象存活配合手动滑动列表,或多触发几次分页加载
App启动时崩溃frida-server版本与frida-tools不一致统一版本,重新推送frida-server

其中第四个坑最有迷惑性。我第一次跑通RPC接口时特别兴奋,结果返回的数据只有5条,当时还以为是字段名取错了。后来仔细想了一下才明白:Java.choose只能在堆里找存活对象,列表滚到哪,堆里的对象就到哪。屏幕外被回收的对象,你自然拿不到。所以要么手动慢慢滚,要么用代码触发App内部的列表加载逻辑,让数据源把所有数据都拉进来。

5.2 提升稳定性的几个操作细节

稳定性是自动化脚本能否落地的关键,分享三个我实操后觉得很值得的细节。

第一个,新增脚本启动时先做一个“暖场”操作。App冷启动之后,列表页一般不会立刻加载数据,RPC调用会扑空。我的做法是:启动App后先等3秒,然后模拟一次滑动操作,再等2秒,最后才开始调RPC。这套“等–滑–等”节奏虽然粗暴,但实测效果很好。

第二个,给手机开启“不锁定屏幕”模式。手机息屏之后,USB通信和App运行状态都可能受影响,Frida连接会变得不稳定。我在开发者选项里把锁屏方式改成了“无”,然后保证充电线一直插着,这样脚本跑到半夜也不会掉线。

第三个,隔离环境。这台测试机除了用来跑数据获取脚本之外,不装任何多余的应用,也不作为日常手机使用。App的缓存、通知、弹窗,这些干扰因素能减少就减少。干净的环境能让问题定位快很多,尤其是当脚本偶发失败时,你不会怀疑是哪里的通知弹窗挡住了界面。

5.3 关于工具能力边界的反思

Frida-RPC这套方案强不强?很强。它能直接钻进App进程里,把内存中整理好的数据搬出来,效率比模拟点击、OCR识别高一个量级。但我也清楚地知道这套方案的边界:它依赖App内部的代码结构,一旦App改了类名、改了实现方式,脚本就要跟着适配。它不是一劳永逸的方案,而是需要持续维护的工具。

从另一个角度看,Frida-RPC只是安卓逆向工具箱里的一把好用的扳手,它解决的是效率和自动化问题,不是所有数据问题。如果访问的频率不高、数据量不大,直接从公开接口或者网页端抓取也许是更轻量的选择。技术选型永远是场景说了算,不是工具说了算。

写在最后的个人体会

这个项目做完之后,我对“自动化获取数据”这件事有了更具体的理解。以前总以为自动化就是写个爬虫、跑个定时任务,但真正落到安卓App这个场景时,你会发现最大的障碍不是“怎么写代码”,而是“怎么获得干净的数据”。加密、加固、风控,每一层都在提醒你:数据不是免费的,想拿就要付出对应的技术成本。

Frida-RPC给我的感觉是,它提供了一个非常优雅的交互模型:App在它的世界里准备好材料,我在我的世界里发出请求,两边通过一条USB线对话。这个过程中,我没有对App做任何破坏性的修改,没有绕过签名校验,也没有触碰支付和账号体系,我只用了一个调试工具,读取了它本来就展示在界面上的公开信息。

如果你也想在自己的安卓逆向项目里尝试Frida-RPC,我的建议是:别一上来就啃大而全的框架,先拿一个简单App练手,比如一个新闻客户端、一个工具类App,定位一个列表数据源,跑通一个RPC函数。等到你对Java.choose、rpc.exports、exports_sync这套链路有了体感之后,再回头处理复杂App,你会发现那些加密、混淆、加固,都没有想象中那么可怕。真正的门槛不在工具,在于你愿不愿意一层一层往下看。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询