28、协议逆向与模拟调用:基于Frida的协议抓取、Python模拟请求、签名算法还原
协议逆向,说白了就是搞清楚App跟服务器之间是怎么“说话”的。你抓到了包,看到了请求参数,但服务器就是不认——八成是签名校验在作怪。今天我们就来聊聊怎么用Frida把协议扒干净,再用Python把请求原样模拟出来。
我个人习惯把协议逆向分成三步走:抓→析→仿。抓,是用Frida hook关键函数拿到原始数据;析,是分析参数拼接和签名逻辑;仿,就是用Python把这些逻辑复现出来。每一步都有坑,我踩过的坑不少,今天一并分享给你。
28.1 用Frida精准抓取协议数据
很多人喜欢用Charles或Burp直接抓包,但遇到SSL pinning或者自定义协议就抓瞎了。这时候Frida才是真正的利器。
我一般先hook java.net.HttpURLConnection 或者 okhttp3.OkHttpClient 的请求方法。举个例子,你要抓一个App的登录请求,可以这样写:
Java.perform(function() {
var OkHttpClient = Java.use('okhttp3.OkHttpClient');
var Request = Java.use('okhttp3.Request');
OkHttpClient.newCall.implementation = function(request) {
console.log('[+] 拦截到请求: ' + request.url().toString());
console.log('[+] 请求头: ' + JSON.stringify(request.headers().toMultimap()));
// 如果是POST请求,打印body
if (request.method() === 'POST') {
var body = request.body();
if (body != null) {
var buffer = Java.array('byte', []);
// 这里需要根据实际情况读取body内容
console.log('[+] 请求体长度: ' + body.contentLength());
}
}
return this.newCall(request);
};
});
嗯,这里要注意——直接hook newCall只能看到请求的元数据,看不到body里的具体内容。为什么?因为OkHttp的body是延迟写入的,真正的数据在拦截器链里才被序列化。
我建议你hook okhttp3.RequestBody.writeTo 方法,这样能拿到原始的字节流:
var RequestBody = Java.use('okhttp3.RequestBody');
RequestBody.writeTo.implementation = function(sink) {
// 这里sink是BufferedSink,可以读取内容
console.log('[+] 写入请求体数据');
this.writeTo(sink);
};
我在项目中遇到过一个问题:App用了protobuf序列化,抓到的body全是乱码。这时候别慌,把字节流dump下来,用protoc工具反序列化就行。说白了,协议逆向就是一层层剥洋葱,总能看到真相。
frida-gadget 注入,或者hook TrustManager 的 checkServerTrusted 方法,直接绕过证书校验。我常用的脚本就几行,网上搜“frida ssl pinning bypass”能找到很多现成的。
28.2 分析签名算法:从Java层到Native层
抓到请求数据后,你会发现每个请求都带一个 sign 参数。这个sign是怎么算出来的?我一般从两个方向入手:
- Java层签名:直接hook
MessageDigest、Mac、Cipher等加密类,看输入输出。 - Native层签名:如果Java层hook不到,说明签名在so文件里,需要hook
JNI_OnLoad或者RegisterNatives找到native函数。
举个例子,我曾经逆向一个金融App,它的签名算法在Java层是这样写的:
public static String sign(Map<String, String> params) {
// 1. 按key排序
TreeMap<String, String> sorted = new TreeMap<>(params);
// 2. 拼接成 key=value&key=value 格式
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : sorted.entrySet()) {
sb.append(entry.getKey()).append('=').append(entry.getValue()).append('&');
}
// 3. 去掉最后一个&
String base = sb.substring(0, sb.length() - 1);
// 4. 加上固定盐值
String raw = base + "&secret=your_secret_key";
// 5. MD5加密
return md5(raw);
}
你想想看,这种签名其实很好还原。但有些App会把盐值藏在so文件里,或者用动态加载的方式。我遇到过最狠的一个,它的盐值是每次启动时从服务器下发的,而且只存活30分钟。
28.3 Python模拟请求:把协议跑起来
签名算法还原后,用Python模拟请求就很简单了。我习惯用 requests 库,配合 hashlib 做签名:
import requests
import hashlib
import time
def sign(params, secret):
# 按key排序
sorted_params = sorted(params.items(), key=lambda x: x[0])
# 拼接
base = '&'.join([f'{k}={v}' for k, v in sorted_params])
raw = base + f'&secret={secret}'
# MD5
return hashlib.md5(raw.encode()).hexdigest()
def login(username, password):
url = 'https://api.example.com/login'
params = {
'username': username,
'password': password,
'timestamp': str(int(time.time()))
}
secret = 'your_secret_key' # 从逆向结果中获取
params['sign'] = sign(params, secret)
headers = {
'User-Agent': 'Mozilla/5.0 (Android 12; Mobile)',
'Content-Type': 'application/x-www-form-urlencoded'
}
resp = requests.post(url, data=params, headers=headers)
return resp.json()
# 测试
result = login('test_user', 'test_pass')
print(result)
这里有个细节——请求头的User-Agent一定要跟App保持一致。为什么?因为很多服务器会校验User-Agent,不一致就直接返回403。我建议你用Frida把App的完整请求头dump下来,原样复制到Python里。
另外,有些App会校验时间戳。你想想看,如果服务器和客户端时间差太大,签名验证就会失败。我一般用 time.time() 生成时间戳,但要注意单位——有的App用秒,有的用毫秒。
28.4 核心逻辑:协议逆向的完整流程
说了这么多,我画个图帮你理清思路。协议逆向不是瞎蒙,是有套路的:
这个流程我用了很多年,基本没失手过。但有一点要提醒你——有些App会做请求重放检测。比如同一个请求只能发一次,或者请求里带了随机数nonce。遇到这种情况,你需要把nonce的生成逻辑也逆向出来。
28.5 实战案例:还原一个带时间戳和随机数的签名
我最近逆向了一个社交App,它的签名算法比较复杂。简单说下过程:
- 抓包:用Frida hook了
okhttp3.RequestBody.writeTo,拿到了完整的请求体。 - 分析:发现请求参数里除了常规字段,还有
ts(时间戳)和nonce(随机字符串)。 - 定位签名:hook
MessageDigest.getInstance("SHA-256"),发现签名用的是SHA-256。 - 还原算法:签名逻辑是
SHA256(参数拼接 + ts + nonce + 固定盐值)。 - 模拟:用Python的
hashlib.sha256实现,nonce用uuid.uuid4().hex生成。
你猜怎么着?第一次模拟请求就成功了。但第二天再跑,发现服务器返回了签名错误。排查了半天,原来是盐值每天凌晨会更新一次……嗯,这种动态盐值就需要写个定时任务去拉取了。
28.6 常见问题与解决方案
| 问题 | 原因 | 解决方案 |
|---|---|---|
| Frida hook不到请求 | App用了WebView或原生Socket | hook WebView的loadUrl或Socket的write方法 |
| 签名算法在so文件里 | Native层实现,Java层看不到 | 用Frida hook RegisterNatives定位native函数,再逆向so |
| 模拟请求返回403 | User-Agent或Cookie不一致 | 用Frida dump完整的请求头,原样复制 |
| 签名验证失败 | 参数顺序或编码方式不对 | 检查URL编码、大小写、空值处理 |
最后说一句——协议逆向不是一锤子买卖。App会更新,签名算法会变,盐值会换。我建议你把Frida脚本和Python模拟代码做成模块化的,哪天App更新了,改几个参数就能继续用。
好了,今天就聊到这儿。记住:抓包是基础,分析是核心,模拟是目的。每一步都走扎实了,没有逆向不了的协议。