大家好,我是威哥。前阵子帮楼下开社区团购的张哥做个竞品价格监控,选了本地最大的那家连锁生鲜APP,本来以为用之前的Magisk+JustTrustMe就能搞定,结果一上来就被三重防护按在地上摩擦——双向证书绑定绕不过,刚开Frida APP就闪退,好不容易绕过闪退,接口又说“客户端签名无效”。
前前后后折腾了快两周,换了三台测试机,刷了N个Magisk模块,终于把这三重坑全踩平了。今天把完整的实战流程分享出来,没有网上抄烂的理论,全是我跑通了、能长期稳定用的硬货,每个步骤都有具体的避坑点,看完你就能直接套用到类似的APP上。
第一步:先搭个能抓包的基础环境,别一开始就碰Frida
很多新手一上来就开Frida,结果APP直接闪退,连抓包的机会都没有。我建议先从最基础的双向证书绑定绕起,搭个能看到接口的环境,再一步步往上加防护绕过。
1. 双向证书绑定的终极绕法:Magisk+LSPosed+TrustMeAlready
JustTrustMe模块虽然经典,但现在很多大厂的双向证书绑定已经能检测到它的Hook点了,我这次用的是TrustMeAlready,它是基于LSPosed的,Hook点更隐蔽,能绕过99%的双向证书绑定和SSL Pinning。
具体步骤:
这里踩了个巨坑:一开始我刷的是最新的Magisk 26.4,结果LSPosed Zygisk版本的模块根本装不上,换了Magisk 24.3才成功;还有,TrustMeAlready一定要勾选“系统框架”和“目标APP”两个选项,只勾选目标APP有时候绕不过系统级的SSL Pinning。
第二步:绕过Frida检测,让APP在Hook下正常运行
能抓包之后,我发现接口里有个32位的client_sign参数,复制到Postman里改个时间戳,直接返回403,说“客户端签名无效”。这个参数肯定是在APP里生成的,我准备用Frida Hook看看生成逻辑,结果刚开Frida,APP就直接闪退了——这是遇到了Frida检测。
1. 常见的Frida检测手段
我先查了一下这个生鲜APP的反编译代码(用jadx-gui),搜索“frida”、“xposed”、“magisk”这些关键词,找到了几个常见的检测点:
- 检测进程名里有没有“frida”、“frida-helper”这些关键词;
- 检测/proc/self/maps里有没有“frida”、“libfrida”这些库;
- 检测系统里有没有安装Frida的APK;
- 检测端口27042(Frida默认的监听端口)有没有被占用;
- 检测Zygisk的特征,比如/proc/self/status里有没有“TracerPid”不为0的情况。
2. 终极绕过方案:Frida-Gadget+重打包+Magisk Hide
普通的Frida Spawn模式(frida -U -f 包名 -l hook.js)很容易被检测到,我这次用的是Frida-Gadget+重打包+Magisk Hide的组合拳,能绕过几乎所有的Frida检测:
具体步骤:
- 用apktool反编译生鲜APP的APK:apktool d xxx.apk -o xxx;
- 把下载好的Frida-Gadget重命名为libfrida-gadget.so,放到反编译后的lib/arm64-v8a/目录下(如果是32位手机,放到lib/armeabi-v7a/目录下);
- 找到反编译后的AndroidManifest.xml文件,在<application>标签里添加android:debuggable="true"属性(如果已经有了,就不用加了);
- 找到反编译后的smali/com/xxx/xxx/MainActivity.smali文件(或者APP启动的第一个Activity的smali文件),在onCreate方法的第一行添加加载Frida-Gadget的代码:const-string v0, "frida-gadget"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
- 用apktool重打包反编译后的文件:apktool b xxx -o xxx_mod.apk;
- 用jarsigner或者apksigner给重打包后的APK签名(注意要用自己的签名文件,不要用系统默认的):apksigner sign –ks mykey.keystore –ks-key-alias mykey xxx_mod.apk
- 卸载原来的生鲜APP,安装重打包后的APK;
- 打开Magisk Manager,点“设置”→“Magisk Hide”,勾选重打包后的生鲜APP;
- 打开LSPosed,取消勾选原来的生鲜APP,勾选重打包后的生鲜APP;
- 打开重打包后的生鲜APP,APP会自动加载Frida-Gadget,然后停在启动页(这是正常的,因为Frida-Gadget在等待连接);
- 用adb连接手机,运行frida-ps -U,你会看到重打包后的生鲜APP的进程名;
- 运行frida -U 进程名 -l hook.js,APP会自动继续运行,你就能在终端里看到Hook的结果了。
这里踩了好几个坑:
- 一开始我用的是Frida Spawn模式,结果APP直接闪退,换了Frida-Gadget+重打包才成功;
- 重打包后的APK一定要用自己的签名文件签名,不然安装不上;
- 一定要在Magisk Hide里勾选重打包后的APP,不然APP会检测到Root,直接闪退;
- 加载Frida-Gadget的代码一定要放在Activity的onCreate方法的第一行,不然APP会先启动,然后再加载Frida-Gadget,Hook不到启动时的代码。
第三步:Hook签名生成逻辑,用Python复现
绕过Frida检测之后,我终于能Hook签名生成逻辑了。我先查了一下反编译代码,搜索“client_sign”关键词,找到了签名生成类com.xxx.common.utils.SignUtils,里面有个getClientSign方法,核心逻辑是调用native层的getNativeSign函数,实现放在libsign.so文件里。
1. Hook Java层和Native层的函数
我写了一个Frida脚本,同时Hook Java层的getClientSign方法和Native层的getNativeSign函数,看看入参和出参:
Java.perform(function() {
// Hook Java层的getClientSign方法
var SignUtils = Java.use("com.xxx.common.utils.SignUtils");
SignUtils.getClientSign.overload('java.lang.String', 'java.lang.String', 'long').implementation = function(path, body, timestamp) {
console.log("=== Java层入参 ===");
console.log("请求路径:" + path);
console.log("请求体:" + body);
console.log("时间戳:" + timestamp);
var result = this.getClientSign(path, body, timestamp);
console.log("生成的client_sign:" + result);
return result;
};
// Hook Native层的getNativeSign函数
var nativeAddr = Module.findExportByName("libsign.so", "Java_com_xxx_common_utils_SignUtils_getNativeSign");
if (nativeAddr) {
Interceptor.attach(nativeAddr, {
onEnter: function(args) {
console.log("=== Native层入参 ===");
console.log("入参1(请求路径+时间戳+排序后的请求体JSON):" + Java.vm.getEnv().getStringUtfChars(args[2], null).readCString());
console.log("入参2(固定盐值):" + Java.vm.getEnv().getStringUtfChars(args[3], null).readCString());
},
onLeave: function(retval) {
console.log("Native层输出(MD5后的client_sign):" + Java.vm.getEnv().getStringUtfChars(retval, null).readCString());
}
});
}
});
运行脚本之后,我直接傻了——native层根本没有做复杂加密,只是把Java层传进来的拼接好的字符串做了个MD5!所谓的native加密就是个障眼法,真正的拼接规则在Java层就已经完成了:client_sign = MD5(请求路径 + 时间戳 + 按字典序排序后的请求体JSON + 固定盐值),固定盐值是从libsign.so里的一个全局变量里拿的,我直接在Frida脚本里打印出来了。
2. 用Python复现签名生成逻辑
拿到拼接规则和固定盐值之后,复现代码非常简单,而且可以批量生成不同设备的client_sign,完全不用依赖手机:
import hashlib
import time
import json
import uuid
import requests
# 从Frida脚本里打印出来的固定盐值
SALT = "xxx1234567890abcdefghijklmnopqrst"
# 生鲜APP的接口地址
BASE_URL = "https://api.xxx.com"
def generate_client_sign(path: str, body: dict, timestamp: int) –> str:
"""生成client_sign参数"""
# 按字典序排序请求体
sorted_body = dict(sorted(body.items()))
body_json = json.dumps(sorted_body, separators=(',', ':'))
# 按规则拼接字符串
sign_str = f"{path}{timestamp}{body_json}{SALT}"
# MD5加密,转小写
client_sign = hashlib.md5(sign_str.encode()).hexdigest().lower()
return client_sign
def get_merchant_list(city_id: str, page: int, page_size: int) –> dict:
"""获取商家列表"""
# 随机生成设备ID,模拟不同设备
device_id = str(uuid.uuid4()).replace('-', '')
# 生成时间戳(毫秒级)
timestamp = int(time.time() * 1000)
# 请求路径
path = "/api/v1/merchant/list"
# 请求参数
body = {
"cityId": city_id,
"page": page,
"pageSize": page_size,
"deviceId": device_id
}
# 生成client_sign
client_sign = generate_client_sign(path, body, timestamp)
# 完整的请求参数
params = {
**body,
"timestamp": timestamp,
"clientSign": client_sign
}
# 请求头(完全复制Charles里的请求头)
headers = {
"Host": "api.xxx.com",
"Connection": "keep-alive",
"User-Agent": "Mozilla/5.0 (Linux; Android 11; Mi 10 Lite Build/RKQ1.200826.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.6099.230 Mobile Safari/537.36 xxx/1.0.0",
"Accept": "application/json",
"Accept-Language": "zh-CN,zh;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"X-Requested-With": "com.xxx.xxx"
}
# 发起请求
resp = requests.get(BASE_URL + path, params=params, headers=headers, timeout=15)
resp.raise_for_status()
return resp.json()
# 测试调用
if __name__ == "__main__":
# 获取北京地区的商家列表,第1页,每页20条
result = get_merchant_list("110000", 1, 20)
print(result)
这里踩了个小坑:请求头里的User-Agent一定要完全复制Charles里的,包括APP的版本号、手机的型号和系统版本,不然接口会返回“客户端版本过低”或者“设备不兼容”。
最后说几句
做了这么多年APP反爬,我最大的感受就是:企业级APP反爬的核心从来都不是加密算法有多复杂,而是检测手段有多隐蔽。不管是双向证书绑定、Frida检测还是签名校验,只要你能找到对应的检测点,然后用合适的工具绕过它,就能拿到你想要的数据。
当然,还是要提醒大家:一定要严格遵守《网络安全法》《数据安全法》,遵守APP的用户协议,只爬取公开的、合规的数据,不要爬取用户隐私、商业机密等敏感信息,更不要给对方服务器造成过大的访问压力。


