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工具反序列化就行。说白了,协议逆向就是一层层剥洋葱,总能看到真相。

小技巧:如果App用了SSL pinning,可以用Frida的 frida-gadget 注入,或者hook TrustManagercheckServerTrusted 方法,直接绕过证书校验。我常用的脚本就几行,网上搜“frida ssl pinning bypass”能找到很多现成的。

28.2 分析签名算法:从Java层到Native层

抓到请求数据后,你会发现每个请求都带一个 sign 参数。这个sign是怎么算出来的?我一般从两个方向入手:

  1. Java层签名:直接hook MessageDigestMacCipher 等加密类,看输入输出。
  2. 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分钟。

避坑指南:我曾经花了两天时间在so文件里找盐值,最后发现它其实藏在AndroidManifest.xml的meta-data里……嗯,有时候最简单的方案反而最容易被忽略。先检查Java层,再考虑Native层,别一上来就逆向so。

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 核心逻辑:协议逆向的完整流程

说了这么多,我画个图帮你理清思路。协议逆向不是瞎蒙,是有套路的:

协议逆向与模拟调用核心流程 阶段一:协议抓取(Frida) hook OkHttp/HttpURLConnection → 获取URL、Headers、Body → dump原始请求数据 阶段二:签名分析(Java/Native) hook MessageDigest/Mac/Cipher → 定位签名函数 → 还原签名算法与盐值 阶段三:模拟调用(Python) 用requests库复现请求 → 实现签名算法 → 验证请求是否成功 验证:对比Python请求与App原始请求的响应是否一致

这个流程我用了很多年,基本没失手过。但有一点要提醒你——有些App会做请求重放检测。比如同一个请求只能发一次,或者请求里带了随机数nonce。遇到这种情况,你需要把nonce的生成逻辑也逆向出来。

28.5 实战案例:还原一个带时间戳和随机数的签名

我最近逆向了一个社交App,它的签名算法比较复杂。简单说下过程:

  1. 抓包:用Frida hook了 okhttp3.RequestBody.writeTo,拿到了完整的请求体。
  2. 分析:发现请求参数里除了常规字段,还有 ts(时间戳)和 nonce(随机字符串)。
  3. 定位签名:hook MessageDigest.getInstance("SHA-256"),发现签名用的是SHA-256。
  4. 还原算法:签名逻辑是 SHA256(参数拼接 + ts + nonce + 固定盐值)
  5. 模拟:用Python的 hashlib.sha256 实现,nonce用 uuid.uuid4().hex 生成。

你猜怎么着?第一次模拟请求就成功了。但第二天再跑,发现服务器返回了签名错误。排查了半天,原来是盐值每天凌晨会更新一次……嗯,这种动态盐值就需要写个定时任务去拉取了。

核心要点:协议逆向的本质是“信任链”的破解。App信任自己的签名算法,服务器信任App发来的签名。你只要把这个信任链复现出来,就能以App的身份跟服务器通信。说白了,你就是个“中间人”,只不过这次是合法的。

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更新了,改几个参数就能继续用。

好了,今天就聊到这儿。记住:抓包是基础,分析是核心,模拟是目的。每一步都走扎实了,没有逆向不了的协议。