Qurtimurti/KarboAI
GitHub: Qurtimurti/KarboAI
Stars: 0 | Forks: 0
# 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.
## 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": [ "
### 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.