本文仅用于逆向学习,请勿用于非法用途,如有侵权请联系删除。
背景
最近接了个数据采集的需求,目标是某个国内生活服务类APP的详情页数据。网页端做了严格的访问控制,大部分内容都藏在APP里。一开始觉得不难,抓个包、模拟请求就完了。结果前前后后折腾了两周,踩了一堆坑,方案换了四轮才跑通。
写这篇记录主要是因为,网上关于Frida在模拟器上的版本兼容问题几乎没有系统性的资料,我自己走了很多弯路。希望后来人少踩一些。
整个过程中用到了AI工具(Claude Code)辅助分析反编译代码和编写自动化脚本,效率确实提升不少,后面会顺带提到。
方案一:UA伪装直接请求
第一个想法很朴素。抓包的时候发现,同一个页面URL,换不同的User-Agent请求,服务端返回的内容差距很大。普通浏览器UA直接提示"请使用APP查看",但特定客户端的UA可以拿到完整数据。
代码写起来很快,requests + BeautifulSoup解析HTML,半天搞定。
headers = {
"User-Agent": "Mozilla/5.0 ... (特定客户端标识)"
}
resp = requests.get(target_url, headers=headers)
soup = BeautifulSoup(resp.text, "html.parser")
跑了一批测试数据,问题来了:大概只有不到一成的页面能正常返回内容,剩下的全是频率限制的拦截页。服务端对这类请求做了计数,超过阈值直接返回空。
试过换IP、加延时、分布式请求,都没用。限制是绑定在内容ID上的,不是绑IP。也就是说一个页面被看过几次之后,谁来请求都拿不到了。
放弃,当备用方案都嫌勉强。
方案二:抓包复现API签名
既然网页端走不通,那就直接走APP的API。上mitmproxy挂代理,很快就把API请求结构摸清了。
APP用的是POST请求,参数都在form-data里。返回的JSON结构也很规整,数据质量比网页版好得多。
但问题在签名。
APP请求里带了四个签名字段,缺一个服务端就返回签名校验失败。用jadx反编译APK,追了一下签名逻辑,发现它分两层:
第一层是一个自研的native方法,在 .so 文件里实现。Java层只有一个入口方法,参数进去,签名字段出来,中间是黑盒。
第二层更狠,用了一个商业安全SDK。这个SDK内部是一套字节码虚拟机,把核心逻辑编译成自定义指令集运行。IDA打开.so一看,全是虚拟机dispatch循环,硬逆的话工作量巨大。
两层签名都跟请求参数绑定,改任何一个参数签名就失效。Python纯算复现?想了想放弃了,至少现阶段不现实。
请求参数 → 第一层签名(native .so) → 生成字段A、B
→ 第二层签名(商业SDK VM) → 生成字段C、D
→ 带上A/B/C/D发请求
签名机制过于复杂,纯Python复现成本太高,放弃。
方案三:Frida Hook签名函数
既然自己算不出来,那就让APP帮我算。思路是:Frida注入APP进程,直接调用APP里的签名函数,把参数传进去,签名拿出来。我只需要在Python里构造请求参数,签名的活交给APP自己干。
模拟器选的MuMu 12(Android 12, x86_64)。Frida方案如果跑通,模拟器就只是个"签名服务器",不需要任何UI交互,后台挂着就行。
三天噩梦
第一天装好环境,写了个简单的Frida脚本试水:
Java.perform(function() {
var targetClass = Java.use('com.xxx.libs.http.NativeSign');
console.log("Hook成功");
});
frida-server启动正常,attach进程也没报错,但 Java.perform 直接卡死,回调函数永远不执行。
查了一下,Java.available 返回 false。意思是Frida连Java虚拟机都找不到。
接下来三天我试了所有能想到的办法:
| 尝试 | 结果 | | spawn模式启动 | 一样卡死 | | attach到已运行的进程 | 一样卡死 | | 重命名frida-server二进制 | 无效,不是检测问题 | | 换端口启动frida-server | 无效 | | APK注入Frida Gadget | Gadget注入成功但Java Bridge同样不可用 | | 换雷电模拟器 Android 9 | 同样的问题 | | 怀疑是安全SDK检测Frida | 测试了其他无保护APP,同样的问题 |
换了两个模拟器、试了Gadget模式,都是同一个症状:Java.available = false。这时候我开始怀疑不是APP的问题,是Frida本身在x86_64模拟器上有bug。
转折点
搜了一圈资料,在52pojie上找到一篇帖子(chenchenchen777发的,研究MuMu模拟器下ARM so加载的),里面提到了一句:Frida 16.0.11在MuMu上可以正常Java.perform。
当时我用的是Frida 17.9.1,最新版。
抱着试试看的心态降级:
pip install frida==16.0.11 frida-tools==12.3.0
模拟器端也换成对应的frida-server-16.0.11-android-x86_64。
重新attach,Java.available 返回 true。
三天的问题,换个版本号就解决了。
后来去翻了Frida的GitHub Issues,找到了 #3692,确认是17.x的回归bug,在x86_64模拟器上Java Bridge初始化有问题。不是某个特定版本的问题,是整个17.x分支都有。
跑通签名
Frida能用之后,剩下的就顺了。
Hook目标类,用message模式暴露签名函数给Python端调用:
'use strict';
var SignClass = null;
var HashMapClass = null;
Java.perform(function() {
try {
SignClass = Java.use('com.xxx.libs.http.NativeSign');
HashMapClass = Java.use('java.util.HashMap');
send({type: 'ready'});
} catch(e) {
send({type: 'error', msg: 'Hook failed: ' + e});
}
});
function toJavaMap(obj) {
var map = HashMapClass.$new();
for (var key in obj) {
if (obj.hasOwnProperty(key)) {
map.put(key, String(obj[key]));
}
}
return map;
}
recv('sign', function onSign(message) {
Java.perform(function() {
try {
var params = toJavaMap(message.params);
var gets = toJavaMap(message.gets);
SignClass.INSTANCE.value.signStep1(params, gets, message.api);
SignClass.INSTANCE.value.signStep2(params, gets);
send({type: 'signed', params: fromJavaMap(params)});
} catch(e) {
send({type: 'sign_error', msg: String(e)});
}
});
recv('sign', onSign);
});
Python端构造请求参数,通过Frida message机制发给模拟器里的APP签名,拿到签名字段后直接调API:
class FridaSigner:
def sign(self, params: dict, gets: dict, api: str) -> dict:
self._result = None
self._event.clear()
self._script.post({
"type": "sign",
"params": params,
"gets": gets,
"api": api
})
self._event.wait(timeout=10)
if self._result is None:
raise Exception("Sign timeout")
return self._result["params"]
# 使用
signed = signer.sign(params, gets, api_name)
resp = requests.post(api_url, headers=headers, data=signed, timeout=30)
data = json.loads(resp.content.decode(errors="replace"))
实测签名速度很快,整个流程大约2秒一条数据。模拟器CPU占用也不高,毕竟只是调了个函数。
方案四:ADB自动化 + mitmproxy(备用)
在Frida调通之前,我还搞了一套备用方案。思路是:既然我不会签名,那就让APP自己去请求,我在中间截获响应就行。
ADB模拟点击 → APP打开详情页 → APP自己签名发API请求 → mitmproxy截获响应 → 保存数据
用ADB发触摸事件模拟用户操作,mitmproxy跑一个addon脚本过滤目标API:
class ResponseCapture:
def response(self, flow):
if "target_api_endpoint" in flow.request.url:
data = json.loads(flow.response.content.decode(errors="replace"))
save_to_file(data)
这套方案确实能用,我拿它采了50多条数据验证可行性。但痛点也很明显:
速度慢,大约6秒一条,因为要等APP渲染页面。不稳定,偶尔APP会弹广告弹窗把流程打断。列表滚动到底部时要处理加载更多的逻辑。返回上一页偶尔会出现导航栈错乱。
作为Frida跑不通时的保底还行,但不适合大批量采集。
最终架构
Frida调通之后,整个系统长这样:
┌──────────────────────────────────────┐
│ Python 主控脚本 │
│ 构造请求参数 / 配额管理 / 重试 / 输出 │
└──────────────┬───────────────────────┘
│ Frida RPC (message模式)
┌──────────────▼───────────────────────┐
│ MuMu模拟器(后台,不需要操作UI) │
│ frida-server-16.0.11 │
│ APP进程保持运行,仅提供签名服务 │
└──────────────────────────────────────┘
工程化方面做了一些事情,简单说几个:
错误处理用了三级分类。API返回的各种错误码分成"可重试"、"永久失败"和"配额耗尽"三种。可重试的做指数退避(2的n次方加随机抖动),配额耗尽的切换账号,永久失败的记录日志跳过。
class RetryableError(Exception): pass
class PermanentError(Exception): pass
class QuotaExhausted(Exception): pass
def classify_error(code, msg):
if code in QUOTA_CODES:
return QuotaExhausted()
if code in RETRYABLE_CODES:
return RetryableError()
return PermanentError()
进度保存用了原子写入。先写临时文件再rename,防止中途crash导致进度文件损坏。我中间因为没做好这个,程序crash了一次丢了几十条数据的进度,从此老老实实写原子保存。
去重是内存集合加磁盘文件双写。启动时加载历史ID到内存set里,运行中每处理一条就追加写入文件。重启后能无缝续传。
还做了个预过滤。先调列表API拿到候选ID和摘要信息,根据摘要字段过滤掉空数据(大约占四分之一),只对有效的ID去请求详情。这个优化省了不少配额。
四种方案对比
| 维度 | UA伪装 | Python签名 | Frida Hook | ADB自动化 | | 可行性 | 不到一成可用 | 不可行 | 可行 | 可行 | | 速度 | 快但成功率低 | - | ~2秒/条 | ~6秒/条 | | 稳定性 | 低 | - | 高 | 中 | | 外部依赖 | 无 | - | Frida+模拟器 | ADB+mitmproxy+模拟器 | | 开发成本 | 半天 | 放弃了 | 1天(不算踩坑) | 2天 |
踩坑总结
Frida版本是第一优先级排查项。在x86_64模拟器上用Frida,先试16.0.11。不要从最新版开始。17.x的Java Bridge在模拟器上有已知的回归bug(#3692)。这一条能帮你省三天。
"签名服务器"模式比UI自动化好太多。一开始我觉得ADB方案更直观,后来发现Frida Hook的优势是碾压级的。模拟器只当签名服务器用,不需要管UI状态、不需要处理弹窗、不需要等页面加载。纯函数调用,干净利落。
两层签名不一定要两层都自己算。如果第一层是标准算法(HMAC之类的),可以考虑纯Python复现第一层就行。第二层如果是商业SDK的字节码VM,直接放弃硬逆,用Frida调。把精力花在值得花的地方。
工程化的东西不能偷懒。进度保存、去重、错误分类、配额管理,代码量不大但不能没有。长时间无人值守运行,没有这些东西迟早翻车。
论坛帖子有时候比官方文档管用。Frida版本的问题,官方文档不会告诉你。是咱52pojie上一篇模拟器SO加载分析的帖子给了我关键线索。社区的经验类文章真的很有价值,这也是我写这篇的原因。
后续
目前方案依赖模拟器运行。如果要规模化,模拟器是个瓶颈。后面打算看看Unidbg,试试能不能把.so加载到纯Java环境里直接调用签名函数,去掉模拟器依赖。第一层签名如果是标准算法,也许可以纯算还原。
有进展再写。
遇到类似Frida版本问题的兄弟可以楼下交流。对签名方案有经验的大佬也欢迎指点一下Unidbg的路线。 |