Intercepting OkHttp at Runtime With Frida - A Practical Guide

Wed, 21 Jan 2026 15:00:47 -0800

# Intercepting OkHttp at Runtime With Frida - A Practical Guide

22 Jan 2026 - Posted by Szymon Drosdzol

### Introduction

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.