Intercepting OkHttp at Runtime With Frida - A Practical Guide
22 Jan 2026 - Posted by Szymon DrosdzolIntroduction
OkHttp is the defacto standard HTTP client library for the Android ecosystem. It is therefore crucial for a security analyst to be able to dynamically eavesdrop the traffic generated by this library during testing. While it might seem easy, this task is far from trivial. Every request goes through a series of mutations between the initial request creation and the moment it is transmitted. Therefore, a single injection point might not be enough to get a full picture. One needs a different injection point to find out what is actually going through the wire, while another might be required to understand the initial payload being sent.
In this tutorial we will demonstrate the architecture and the most interesting injection points that can be used to eavesdrop and modify OkHttp requests.
Premise
For the purpose of demonstration, I built a simple APK with a flow similar to the app I recently tested. It first creates a Request with a JSON payload. Then, a couple of interceptors perform the following operations:
- Add an authorization header
- Calculate the payload signature, adding that as a header
- Encrypt the JSON payload and switch the body to the encrypted version
Looking at this flow it becomes obvious how reversing the actual application protocol isn’t straightforward. Intercepting requests at the moment of actual sending will yield the actual payload being sent over the wire, however it will obscure the JSON payload. Intercepting the request creation, on the other hand, will reveal the actual JSON, but will not reveal custom HTTP headers, authentication token, nor will it allow replaying the request.
In the following examples, I’ll demonstrate two approaches that can be mixed and matched for a full picture. Firstly, I will hook the realCall function and dump the Request from there. Then, I will demonstrate how to follow the consecutive Request mutations done by the Interceptors. However, in real life scenarios hooking every Interceptor implementation might be impractical, especially in obfuscated applications. Instead, I’ll demonstrate how to observe intercept results from an internal RealInterceptorChain.proceed function.
Helper Functions
To reliably print the contents of the requests, one needs to prepare the helper functions first. Assuming we have an okhttp3.Request object available, we can use Frida to dump its contents:
function dumpRequest(req, function_name) {
try {
console.log("\n=== " + function_name + " ===");
console.log("method: " + req.method());
console.log("url: " + req.url().toString());
console.log("-- headers --");
dumpHeaders(req);
dumpBody(req);
console.log("=== END ===\n");
} catch (e) {
console.log("dumpRequest failed: " + e);
}
}
Dumping headers requires iterating through the Header collection:
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);
}
}
Dumping the body is the hardest task, as there might be many different RequestBody implementations. However, in practice the following should usually work:
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 --");
}
}
The code above uses another helper function to read the actual bytes from the body and decode it as UTF-8. It does it by utilizng the okio.Buffer function:
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
Now that we have code capable of dumping the request as text, we need to find a reliable way to catch the requests. When attempting to view an outgoing communication, the first instinct is to try and inject the function called to send the request. In the world of OkHttp, the functions closest to this are RealCall.execute() and 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);
}
});
However, after running these hooks, it becomes clear that this approach is insufficient whenever an application uses interceptors:
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 ===
As can be observed, this approach was useful to disclose the address and the JSON payload. However, the request is far from complete. The custom and authentication headers are missing, and the analyst cannot observe that the payload is later encrypted, making it impossible to infer the full application protocol. Therefore, we need to find a more comprehensive method.
Intercepting Interceptors
Since the modifications are performed inside the OkHttp Interceptors, our next injection target will be the okhttp3.internal.http.RealInterceptorChain class. Given that this is an internal function, it’s bound to be less stable than regular OkHttp classes. Therefore, instead of hooking a function with a single signature, we’ll iterate all overloads of RealInterceptorChain.proceed:
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)");
}
To understand the code inside the implementation, we need to understand how the proceed functions work. The RealInterceptorChain function maintains the entire chain. When proceed is called by the library (or previous Interceptor) the this.index value is incremented and the next Interceptor is taken from the collection and applied to the Request. Therefore, at the moment of the proceed call, we have a state of Request that is the result of a previous Interceptor call. So, in order to properly assign Request states to proper Interceptors, we’ll need to take a name of an Interceptor number index - 1:
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);
};
The example result will look similar to the following:
[*] 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 ===
...
With such output we can easily observe the consecutive mutations of the request: the initial payload, the custom headers being added, the X-Signature being added and finally, the payload encryption. With the proper Interceptor names an analyst also receives strong signals as to which classes to target in order to reverse-engineer these operations.
Conclusion
In this post we walked through a practical approach to dynamically intercept OkHttp traffic using Frida.
We started by instrumenting RealCall.execute() and RealCall.enqueue(), which gives quick visibility into endpoints and plaintext request bodies. While useful, this approach quickly falls short once applications rely on OkHttp interceptors to add authentication headers, calculate signatures, or encrypt payloads.
By moving one level deeper and hooking RealInterceptorChain.proceed(), we were able to observe the request as it evolves through each interceptor in the chain. This allowed us to reconstruct the full application protocol step by step - from the original JSON payload, through header enrichment and signing, then all the way to the final encrypted body sent over the wire.
This technique is especially useful during security assessments, where understanding how a request is built is often more important than simply seeing the final bytes on the network. Mapping concrete request mutations back to specific interceptor classes also provides clear entry points for reverse-engineering custom cryptography, signatures, or authorization logic.
In short, when dealing with modern Android applications, intercepting OkHttp at a single point is rarely sufficient. Combining multiple injection points — and in particular leveraging the interceptor chain — provides the visibility needed to fully understand and manipulate application-level protocols.
