Qurtimurti/KarboAI

GitHub: Qurtimurti/KarboAI

Stars: 0 | Forks: 0

image # KarboAI: reversing the request authentication mechanism A research write-up on how the KarboAI Android client signs calls to its own API, and where that scheme has thin spots on the server side. ## 1. About the app KarboAI is a Russian interest-based social app in the spirit of the old Amino: topical communities, blogs, chats, voice rooms, and AI role-play bots. | Field | Value | |---|---| | Store name | KarboAI | | Android package | `com.karboworld.karbo` | | Version (at time of analysis) | `3.2.1+173` | | Google Play | [play.google.com/.../com.karboworld.karbo](https://play.google.com/store/apps/details?id=com.karboworld.karbo) — 10K+ installs, 4.6★ from 406 reviews, Social category, 16+ age rating | | App Store | [apps.apple.com/.../id6758021546](https://apps.apple.com/us/app/karboai/id6758021546) | | Official site | [karboai.com](https://karboai.com) | | Developer (Play) | Kireva | | Backend API | `https://api.karboai.com` (HTTPS + WebSocket) | | Client tech stack | Flutter + a native Android layer (Kotlin/Java) | ## 2. TL;DR Every HTTP request to the API is signed with asymmetric cryptography. An ECDSA key pair on the P-256 curve is generated on the device inside the Android Keystore; the private key never leaves hardware-backed storage; the signature goes into the `x-signature` header and the timestamp into `x-timestamp`. The client side is built carefully: the key is hardware-protected, the signing string is canonicalized, and the signature covers the method, path, and request body. But **the server-side validation, as it currently stands, has two gaps**, both found experimentally: 1. **No replay protection.** `x-timestamp` is signed, but the server does not check how fresh the timestamp is — a year-old request goes through just like today's. 2. **The key's `attestation_chain` is not validated.** The server accepts any public key at device registration: with the certificates reordered, with a single certificate and no path to a root, with broken DER, even with an empty `[]` chain. The second point hollows out the whole scheme: the point of the Keystore and attestation is to guarantee to the server that the private key really is non-extractable and lives in hardware. If the server doesn't check this, nothing stops you from generating a key pair in software (outside the Keystore) and signing requests autonomously — that is, automating API access while bypassing the entire machinery. A request assembled and signed independently (section 8) was accepted by the server with `HTTP 200`. ## 3. Scope of analysis The analysis was run on two devices: - **A physical phone**, arm64, Android 13 — to cross-check artifacts on the production architecture. - **An Android Studio emulator**, x86_64, Android 13, Google APIs image — the primary platform; the instrumentation Java bridge ran more reliably there. The APK is distributed as a split APK. The following went into the analysis: - `base.apk` — contains `classes.dex` with the Java/Kotlin code (the native signature handler). - `split_config..apk` — the native libraries `libapp.so` (the Dart AOT snapshot of Flutter) and `libflutter.so` (the Flutter runtime, including statically linked BoringSSL). ## 4. Tools and approach | Tool | What it was used for | |---|---| | **Blutter** | Extracting the structure of the Dart AOT snapshot from `libapp.so`: the object pool (strings, constants), a partial class map, and a stub instrumentation script. | | **IDA Pro** | Disassembling `libapp.so` (with Blutter's data annotations applied) and `libflutter.so`. TLS functions were located using BoringSSL anchor strings. | | **Frida** | Dynamic work: hooking at the Dart ↔ native boundary, hooking the write function inside `libflutter.so` to observe HTTP traffic before encryption, and calling the app's internal methods as a "signing oracle". | | **friTap / BoringSecretHunter** | Supporting role — working with the statically linked BoringSSL inside `libflutter.so`. | | **curl_cffi** | Sending independently assembled requests with a plausible TLS fingerprint. | ### 4.1. What had to be worked around The app is protected noticeably better than average. A few things needed dedicated workarounds: - **Dart obfuscation.** Class and method names in `libapp.so` are obfuscated; navigation relied on string anchors — the names of MethodChannel channels and error-message text preserved in the object pool. - **Stripped BoringSSL symbols.** `libflutter.so` does not export TLS functions. `SSL_write` had to be located via cross-references to anchor strings in IDA and confirmed dynamically. - **Certificate pinning.** The client is pinned to two SHA-256 fingerprints of the `api.karboai.com` certificate — classic MITM through a proxy does not work. - **Anti-tampering.** The client reacts to modification of its own code in memory: after patching the certificate-check function, it dropped into a no-network mode. So the entire analysis was done **without modifying any code** — purely by passive observation (`Interceptor.attach`) and by calling the app's own methods. ## 5. Client-side signing architecture The app is built on Flutter; the network layer is Dart HTTP via the `dio` adapter. The signing itself is a chain made of a Dart wrapper and the native Android layer: HTTP request (dio) │ ▼ RequestSigningInterceptor ← Dart, libapp.so │ assembles the canonical message string ▼ TeeSignatureService.sign(message) ← Dart wrapper │ call via Flutter MethodChannel ▼ channel "com.karboworld.karbo/tee_signature" │ ▼ native handler (class F.l) ← Kotlin/Java, classes.dex │ MethodChannel handler + crypto primitive ▼ Android Keystore ← hardware-backed storage signs with the private key "karbo_device_signing_key" The key point: **the cryptography is not done in Dart code**, but in the native Java/Kotlin layer in `classes.dex`, which reaches the Android Keystore through a MethodChannel. Dart only prepares the data and formats the result into headers. The service name (`TeeSignatureService`) and the StrongBox option in the code make it clear what the developers were conceptually relying on: a trusted execution environment, hardware isolation of the key. image ## 6. Protocol: bootstrap, signing formula, headers ### 6.1. Bootstrap — device registration Before any ordinary requests, the client goes through an initial two-call registration. **`POST /auth`** — body: { "type": "google", "token": "ya29.a0AQ... (Google OAuth access token)", "public_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE... (EC P-256, SPKI, base64)", "attestation_chain": [ "", "", "" ] } The request combines three things at once: identity confirmation via Google OAuth, delivery of the device's public key, and the attestation chain for that key. A curious detail: the request is **already signed** with the device key (`x-signature` is present) even though there is no session token yet — meaning the key pair is generated locally and used from the very first call, and the server receives its public part in the body. The response carries the account data and a session token. **`POST /device/register-key`** — with the session already established, body ~2.4 KB: re-delivery of the public key and attestation data to bind it to the account. ### 6.2. The attestation chain `attestation_chain` is a sequence of X.509 certificates issued by the Android Keystore. By design it cryptographically proves to the server that the public key corresponds to a private key generated and locked inside the hardware storage of a specific device. The root should be Google's hardware attestation root certificate. On the research device (the emulator) the root turned out to be `CN=Droid Unregistered Device CA, O=Google Test LLC` — a **test** root. On the physical device, the genuine Google hardware root was present in the memory dump. The fact that the server accepted requests from the emulator too (with the test root) was the first hint about the server-side validation; this was checked systematically in section 7. ### 6.3. The canonical `message` string What gets signed is built by concatenating four fields with `\n` (`0x0A`): message = "\n" "\n" "\n" - `timestamp` — Unix time in seconds (decimal string). The same value goes into the `x-timestamp` header. - `HTTP-METHOD` — the request method in uppercase. - `path` — the path without scheme, host, or query. - `hex(SHA-256(body))` — SHA-256 of the request body as lowercase hex. For requests with no body, the constant is `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`. A captured example for a `GET`: 1778754718 GET /community/3/online e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 Run through the native crypto primitive, this string produced a signature that **matched byte-for-byte** the `x-signature` header of a real outgoing request. At that point the formula was considered confirmed. ### 6.4. Key parameters and the signing operation - Algorithm: **ECDSA**, curve **secp256r1 / P-256**. - Storage: **Android Keystore** (the `"AndroidKeyStore"` provider). - Alias: `karbo_device_signing_key`. - At generation an attestation challenge is set; StrongBox binding is requested optionally. - The private part is non-exportable by Keystore construction. The signing itself, in the native handler of the `com.karboworld.karbo/tee_signature` channel (class `F.l` in `classes.dex`): Signature s = Signature.getInstance("SHA256withECDSA"); s.initSign(privateKey); // karbo_device_signing_key from the Keystore s.update(message_bytes); // message from §6.3, in UTF-8 byte[] sig = s.sign(); // ECDSA signature in DER (ASN.1) // → Base64(sig, NO_WRAP) → the value of the x-signature header So `x-signature` is the **Base64 of the DER-encoded ECDSA signature** over the `message` string. The observed values began with `MEUCIQ.../MEQCI...`, which corresponds to `SEQUENCE { INTEGER r, INTEGER s }`. Beyond signing itself, the channel handler can also: check whether a key exists, delete a key, generate a key pair, return the public key, and return the attestation chain. These commands service the bootstrap from §6.1. image ### 6.5. Anatomy of an HTTP request **`GET` example** (in the clear, before TLS): **`POST` example** (with a body): Header values: | Header | Purpose | |---|---| | `x-timestamp` | Unix seconds; the first line of `message`. | | `x-signature` | Base64(DER ECDSA) over `message`. | | `token` | Session token (128 hex). The same one is used to authenticate the WebSocket connection. | | `device-id` | Android ID. | | `x-device-trust` | A string device-trust level; on the research device it was `low`. Looks like the result of a local assessment of the environment (presence of hardware storage, etc.). | | `x-cfg-env` | A numeric environment identifier (`0`). | | `x-render-mode` | A numeric mode (`1`). | | `device-name` | Device type (`Android`). | The WebSocket (`wss://api.karboai.com/socket.io/`) is authenticated separately — by passing `token` in the first Socket.IO message; custom signing headers are not used on the WS handshake. ## 7. What the server-side validation analysis showed To understand how strictly the server checks the proofs the client presents, I ran a series of controlled experiments: I built requests with deliberate deviations and watched the reaction. ### 7.1. Signature verification | Experiment | Description | Result | |---|---|---| | Control | A correct signed request | `HTTP 200` | | Corrupted signature | A few characters changed in `x-signature` | `HTTP 403` | | Signature for a different path | A valid signature for path `A` sent with path `B` | `HTTP 403` | The signature is verified, and verified properly — the binding to method and path works. You cannot forge the signature or reuse it for a different resource. This part of the scheme does exactly what it's meant to. ### 7.2. Timestamp verification (replay) | Experiment | Description | Result | |---|---|---| | Old timestamp | A correctly signed request with `x-timestamp` ≈ an hour ago | `HTTP 200` | The timestamp is part of the signature, but the server **does not check that it is current**. A request with an old timestamp is accepted. There is no replay protection — a once-observed signed request stays valid indefinitely. ### 7.3. Attestation chain verification The body of `POST /auth` with a valid Google token was sent with various ways of corrupting `attestation_chain`: | Experiment | Description | Result | |---|---|---| | Control | A valid 3-certificate chain | `HTTP 200` | | Reorder | Certificates swapped around | `HTTP 200` | | Truncated chain | Only the first certificate kept (no path to a root) | `HTTP 200` | | Empty chain | `attestation_chain: []` | `HTTP 200` | | Broken DER | A byte inverted in the first certificate (X.509 structure broken) | `HTTP 200` | The server **does not validate `attestation_chain` in any form** — neither the order and connectivity of the certificates, nor the presence of a path to a trusted root, nor even the syntactic correctness of the DER encoding. An empty chain is accepted just like a valid one. The field is effectively ignored by the server logic. ### 7.4. Summary | Check | Status | |---|---| | Signature integrity | implemented correctly | | Signature binding to method and path | implemented correctly | | Timestamp freshness (anti-replay) | **missing** | | Key attestation-chain validation | **missing** | ## 8. Verification: an independently assembled request To check the completeness and correctness of my understanding of the mechanism, I assembled a standalone client that does not use the app to build the request. You can't get the private key out of the Keystore, so to obtain signatures I used a "device-as-oracle" approach: through Frida I called the app's own native signing method, fed it an arbitrary `message` string, and assembled and sent the request itself outside the app. The sequence: **Result:** `HTTP 200`, a correct response body with up-to-date data. The server accepted the independently assembled request as legitimate. Replay still depends on the device: the signature is made by the key in its Keystore. But combined with the result of §7.3 this limitation goes away — see below. ## 9. Conclusions and recommendations ### 9.1. What's done well - Signing is based on asymmetric cryptography with a hardware-protected key. The right choice of primitive. - The private key is generated in the Android Keystore and never leaves it. There is no direct extraction on a stock device. - The signing string is canonicalized and covers the method, path, and body — forging and off-target reuse of a signature are blocked (§7.1). - The combination of supporting measures (pinning, anti-tampering, obfuscation) noticeably raises the effort needed to analyze the client. ### 9.2. Weak spots **1. No replay protection.** The timestamp is signed but not checked for freshness. Any once-observed signed request can be replayed after an indefinite amount of time. **2. No attestation-chain validation — the main one.** The server accepts any public key at device registration, without checking `attestation_chain` (see §7.3, all the way down to an empty chain). The consequence here is fundamental. The whole scheme rests on a single premise: the private key is non-extractable because it lives in hardware, and attestation proves this to the server. If the server doesn't check attestation, the premise stops holding. You can generate a key pair **in software**, with an ordinary library, outside the Keystore; the private key is then fully under the control of whoever is registering the device. Such a key is registered through `/auth` and `/device/register-key` (with an arbitrary or empty `attestation_chain`), and from then on requests are signed with it **autonomously** — without any real device and without an "oracle". That is, the hardware binding of the key, for which all the client-side machinery (Keystore, TEE, StrongBox, attestation) was built, is backed by nothing on the server side and is bypassed. The mechanism's resistance to automated API access, as it currently stands, comes down mostly to the effort of reverse-engineering the client, not to cryptographic guarantees. ### 9.3. Recommendations 1. **Validate `attestation_chain` on the server.** Check chain connectivity, the path up to Google's trusted hardware root, the syntactic correctness of the certificates, and that the attestation extension matches what's expected (application identifier, binding to the public key from the request body). Without that check — refuse registration. 2. **Add a freshness check for `x-timestamp`** — accept requests only within a narrow time window, and ideally track reuse of the (timestamp, signature) pair. 3. Include additional fields in the signed string to reduce the value of intercepted requests: a request identifier, a hash of the header set, and so on. 4. Align the strictness of the attestation check with the policy for supporting devices without hardware storage: on such devices attestation won't be hardware-backed, and the server policy should account for this explicitly — flag such keys with a reduced trust level rather than accepting them silently. ## 10. Appendix A. Artifacts The scripts and supporting files ship alongside this document. | File | Purpose | |---|---| | `frida_http_capture.js` | A Frida script that intercepts outgoing HTTP requests by hooking `SSL_write` inside `libflutter.so` — observing requests before TLS encryption. | | `karbo_client.py` | A demonstration of autonomous signing: a software-generated ECDSA key, with manual assembly of `/auth` and signed requests outside the app. | | `oracle_replay.py` | Independent assembly, signing (via the device "oracle"), and sending of a request; reproducibility check (§8). | | `attestation_chain_probe.py` | The series of experiments on server-side validation of `attestation_chain` (§7.3). | | `auth_body.json` | A sample valid `POST /auth` body, used as the base for the §7.3 experiments. | ## 11. Disclaimer Everything described above is accurate as of **May 25, 2026** and applies to the specific app version `3.2.1+173`. The server logic may be changed by the developer at any time; the results of the server-side validation experiments (section 7) reflect the state at the time of the research. I do **research**, share observations, and guarantee nothing: not the reproducibility of the results on other versions, not their accuracy at the time you read this, and not the absence of mistakes in the protocol reconstruction. Any conclusions and decisions made on the basis of this document are the responsibility of the reader.