文章总结: 文章详述用Frida在Android运行时逐层拦截OkHttp流量的方法:先hookRealCall.execute/enqueue获取原始JSON,再hookRealInterceptorChain.proceed跟踪各interceptor对请求头、签名与加密payload的连续mutation,并给出完整Frida脚本与辅助dump函数,使安全人员可重建应用层协议并精准逆向加密签名逻辑。 综合评分: 92 文章分类: 移动安全,渗透测试,漏洞分析,安全工具,实战经验
用 Frida 在运行时拦截 OkHttp实用指南
Szymon Drosdzol Szymon Drosdzol
securitainment
2026年1月26日 10:24 广东
| 原文链接 | 作者 | | — | — | | https://blog.doyensec.com/2026/01/22/frida-instrumentation.html | Szymon Drosdzol |
简介
OkHttp 是 Android 生态中事实上的标准 HTTP client library。因此,对安全分析人员来说,在测试期间能够动态监听该库产生的流量至关重要。虽然看起来很容易,但这项工作并不简单。从最初创建请求,到真正发送出去之前,每个请求都会经历一系列的变形(mutation)。因此,单一的注入点往往不足以获得完整视角:你需要一个注入点来弄清楚真正线上的内容,而另一个注入点可能用于理解最初要发送的 payload。
在本教程中,我们将展示 OkHttp 的架构,以及一些最有价值的注入点,用于监听和修改 OkHttp 请求。
前提
为了演示,我构建了一个简单的 APK,其流程与我最近测试的一款 app 类似。它首先创建一个带 JSON payload 的 Request。随后,一些 interceptor 会依次执行以下操作:
- 添加 authorization header
- 计算 payload signature,并把它作为 header 添加
- 加密 JSON payload,并把 body 切换为加密后的版本
请求流示意图
从这个流程可以很直观地看到:逆向真实的应用层协议并不是那么直接。在真正发送时拦截请求,会得到实际在网络上传输的 payload,但会遮蔽原始 JSON payload。相反,在请求创建阶段拦截能够暴露原始 JSON,但无法看到自定义 HTTP header、authentication token,也无法方便地重放该请求。
在下面的示例中,我将演示两种可以混用、从而获得完整视图的方法。首先,我会 hook realCall函数,并从那里 dump Request。然后,我会展示如何跟踪 interceptor 对 Request进行的连续 mutation。不过,在真实场景中,逐个 hook 每个 Interceptor的实现可能并不现实,尤其是在被混淆的应用中。为此,我将演示如何在内部函数 RealInterceptorChain.proceed上观察 intercept的结果。
辅助函数
为了可靠地打印请求内容,需要先准备一些辅助函数。假设我们已经拿到了一个 okhttp3.Request对象,我们可以用 Frida 来 dump 它的内容:
function dumpRequest(req, function_name) {
try {
console.log("
=== " + function_name + " ===");
console.log("method: " + req.method());
console.log("url: " + req.url().toString());
console.log("-- headers --");
dumpHeaders(req);
dumpBody(req);
console.log("=== END ===
");
} catch (e) {
console.log("dumpRequest failed: " + e);
}
}
Dump headers 需要遍历 Header集合:
function dumpHeaders(req) {
const headers = req.headers();
try {
if (!headers) return;
const n = headers.size();
for (let i = 0; i < n; i++) {
console.log(headers.name(i) + ": " + headers.value(i));
}
} catch (e) {
console.log("dumpHeaders failed: " + e);
}
}
Dump body 是最困难的部分,因为 RequestBody可能有很多不同的实现。不过在实践中,下面这段通常能工作:
function dumpBody(req) {
const body = req.body();
if (body) {
const ct = body.contentType();
console.log("-- body meta --");
console.log("contentType: " + (ct ? ct.toString() : "(null)"));
try {
console.log("contentLength: " + body.contentLength());
} catch (_) {
console.log("contentLength: (unknown)");
}
const utf8 = readBodyToUtf8(body);
if (utf8 !== null) {
console.log("-- body (utf8) --");
console.log(utf8);
} else {
console.log("-- body -- (not readable: streaming/one-shot/duplex or custom)");
}
} else {
console.log("-- no body --");
}
}
上面的代码使用了另一个辅助函数来读取 body 的实际字节并按 UTF-8 解码。它通过利用 okio.Buffer来实现:
function readBodyToUtf8(reqBody) {
try {
if (!reqBody) return null;
const Buffer = Java.use("okio.Buffer");
const buf = Buffer.$new();
reqBody.writeTo(buf);
const out = buf.readUtf8();
return out;
} catch (e) {
return null;
}
}
RealCall
现在我们已经有了能把请求 dump 为文本的代码,接下来需要找到一个可靠的方式来捕获请求。尝试观察 outgoing communication 时,第一直觉是去注入实际发送请求的函数。在 OkHttp 的世界里,最接近“发送”这一动作的函数是 RealCall.execute()和 RealCall.enqueue():
Java.perform (function() {
try {
const execOv = RealCall.execute.overload().implementation = function () {
dumpRequest(this.request(), "RealCall.execute() about to send");
return execOv.call(this);
};
console.log("[+] Hooked RealCall.execute()");
} catch (e) {
console.log("[-] Failed to hook RealCall.execute(): " + e);
}
try {
const enqOv = RealCall.enqueue.overload("okhttp3.Callback").implementation = function (cb) {
dumpRequest(this.request(), "RealCall.enqueue()");
return enqOv.call(this, cb);
};
console.log("[+] Hooked RealCall.enqueue(Callback)");
} catch (e) {
console.log("[-] Failed to hook RealCall.enqueue(): " + e);
}
});
然而,当你运行这些 hook 后,很快就会发现:当应用使用 interceptor 时,这种方式并不够用:
frida -U -p $(adb shell pidof com.doyensec.myapplication) -l blogpost/request-body.js
____
/ _ | Frida 17.5.1 - A world-class dynamic instrumentation toolkit
| (_| |
> _ | Commands:
/_/ |_| help -> Displays the help system
. . . . object? -> Display information about 'object'
. . . . exit/quit -> Exit
. . . .
. . . . More info at https://frida.re/docs/home/
. . . .
. . . . Connected to CPH2691 (id=8c5ca5b0)
Attaching...
[+] Using OkHttp3.internal.connection.RealCall
[+] Hooked RealCall.execute()
[+] Hooked RealCall.enqueue(Callback)
[*] Non-obfuscated RealCall hooks installed.
[CPH2691::PID::9358 ]->
=== RealCall.enqueue() about to send ===
method: POST
url: https://tellico.fun/endpoint
-- headers --
-- body meta --
contentType: application/json; charset=utf-8
contentLength: 60
-- body (utf8) --
{
"hello": "world",
"poc": true,
"ts": 1768598890661
}
=== END ===
可以看到,这种方式能帮助我们获取 endpoint 与 JSON payload。但这个请求离完整还差得很远:自定义 header 和认证 header 都不见了,而且分析人员也无法观察到 payload 后续被加密,从而无法推断完整的应用层协议。因此,我们需要更全面的方法。
拦截 Interceptor
既然修改发生在 OkHttp 的 interceptor 内部,那么下一个注入目标就是 okhttp3.internal.http.RealInterceptorChain类。由于这是内部实现,它注定比常规 OkHttp API 更不稳定。因此,我们不会只 hook 某一个固定签名,而是遍历 RealInterceptorChain.proceed的所有 overload:
const Chain = Java.use("okhttp3.internal.http.RealInterceptorChain");
console.log("[+] Found okhttp3.internal.http.RealInterceptorChain");
if (Chain.proceed) {
const ovs = Chain.proceed.overloads;
for (let i = 0; i < ovs.length; i++) {
const proceed_overload = ovs[i];
console.log("[*] Hooking RealInterceptorChain.proceed overload: " + proceed_overload.argumentTypes.map(t => t.className).join(", "));
proceed_overload.implementation = function () {
// implementation override here
};
}
console.log("[+] Hooked RealInterceptorChain.proceed(*)");
} else {
console.log("[-] RealInterceptorChain.proceed not found (unexpected)");
}
要理解 implementation 里的代码,我们需要先理解 proceed的工作方式。RealInterceptorChain维护了整个 chain。当 library (或前一个Interceptor) 调用proceed时,this.index会递增,然后从集合中取出下一个Interceptor并将其应用到Request上。因此,在调用proceed的那一刻,我们手里拿到的Request状态,其实是前一个Interceptor调用后的结果。也就是说,为了把Request的状态正确归因到对应的Interceptor,我们需要取index - 1位置的Interceptor名称:
proceed_overload.implementation = function () {
// First arg is Request in all proceed overloads.
const req = arguments[0];
// Get current index
const idx = this.index.value;
// Get previous interceptor name
// Previous interceptor is the one responsible for the current req state
var interceptorName = "";
if (idx == 0) {
interceptorName = "Original request";
} else {
interceptorName = "Interceptor " + this.interceptors.value.get(idx-1).getClass().getName();
}
dumpRequest(req, interceptorName);
// Call the actual proceed
return proceed_overload.apply(this, arguments);
};
示例输出类似如下:
[*] Hooking RealInterceptorChain.proceed overload: OkHttp3.Request
[+] Hooked RealInterceptorChain.proceed(*)
[+] Hooked OkHttp3.Interceptor.intercept(Chain)
[*] RealCall hooks installed.
[CPH2691::PID::19185 ]->
=== RealCall.enqueue() ===
method: POST
url: https://tellico.fun/endpoint
-- headers --
-- body meta --
contentType: application/json; charset=utf-8
contentLength: 60
-- body (utf8) --
{
"hello": "world",
"poc": true,
"ts": 1768677868986
}
=== END ===
=== Original request ===
method: POST
url: https://tellico.fun/endpoint
-- headers --
-- body meta --
contentType: application/json; charset=utf-8
contentLength: 60
-- body (utf8) --
{
"hello": "world",
"poc": true,
"ts": 1768677868986
}
=== END ===
=== Interceptor com.doyensec.myapplication.MainActivity$HeaderInterceptor ===
method: POST
url: https://tellico.fun/endpoint
-- headers --
X-PoC: frida-test
X-Device: android
Content-Type: application/json
-- body meta --
contentType: application/json; charset=utf-8
contentLength: 60
-- body (utf8) --
{
"hello": "world",
"poc": true,
"ts": 1768677868986
}
=== END ===
=== Interceptor com.doyensec.myapplication.MainActivity$SignatureInterceptor ===
method: POST
url: https://tellico.fun/endpoint
-- headers --
X-PoC: frida-test
X-Device: android
Content-Type: application/json
X-Signature: 736c014442c5eebe822c1e2ecdb97c5d
-- body meta --
contentType: application/json; charset=utf-8
contentLength: 60
-- body (utf8) --
{
"hello": "world",
"poc": true,
"ts": 1768677868986
}
=== END ===
=== Interceptor com.doyensec.myapplication.MainActivity$EncryptBodyInterceptor ===
method: POST
url: https://tellico.fun/endpoint
-- headers --
X-PoC: frida-test
X-Device: android
Content-Type: application/json
X-Signature: 736c014442c5eebe822c1e2ecdb97c5d
X-Content-Encryption: AES-256-GCM
X-Content-Format: base64(iv+ciphertext+tag)
-- body meta --
contentType: application/octet-stream
contentLength: 120
-- body (utf8) --
YIREhdesuf1VdvxeCO+H/8/N8NYFJ2r5Jk4Im40fjyzVI2rzufpejFOHQ67hkL8UFdniknpABmjoP73F2Z4Vbz3sPAxOp7ZXaz5jWLlk3T6B5sm2QCAjKA==
=== END ===
...
有了这样的输出,我们就能很容易地观察请求的连续 mutation:初始 payload、自定义 header 的添加、X-Signature的添加,最后是 payload 加密。借助具体的 Interceptor名称,分析人员也能得到强提示:应该去 target 哪些类,从而逆向这些操作。
结语
在这篇文章里,我们走了一遍用 Frida 动态拦截 OkHttp 流量的实用方法。
我们从对 RealCall.execute()与 RealCall.enqueue()的 instrumentation 开始,这能快速看到 endpoint 以及明文请求体。虽然很有用,但一旦应用依赖 OkHttp interceptor 来添加认证 header、计算签名或加密 payload,这种方式很快就不够用了。
通过再深入一层并 hook RealInterceptorChain.proceed(),我们能够观察请求如何在 chain 中经过每一个 interceptor 而逐步演化。这样我们就能一步步重建完整的应用层协议:从原始 JSON payload 开始,经过 header 补全与签名,再一路追踪到最终在网络上传输的加密 body。
这项技术在安全评估中尤其实用,因为理解请求 如何被构造,往往比只看到网络上的最终字节更重要。把具体的请求 mutation 映射回某个 interceptor class,也能为逆向自定义加密、签名或授权逻辑提供清晰的切入点。
简而言之,在面对现代 Android 应用时,仅在单一位置拦截 OkHttp 往往并不够。结合多个注入点 —— 尤其是利用 interceptor chain —— 才能获得充分的可见性,从而完整理解与操控应用层协议。
Intercepting OkHttp at Runtime With Frida – A Practical Guide
免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:securitainment Szymon Drosdzol Szymon Drosdzol《用 Frida 在运行时拦截 OkHttp实用指南》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论