June 6, 2026
Hunting Android Lockscreen Bypasses on Pixel: A Campaign Walkthrough — Contd. — II
05 — L4: Biometric Framework — The HAL Is the Whole Story
Farhad Sajid Barbhuiya
4 min read
05 — L4: Biometric Framework — The HAL Is the Whole Story
Phase A static review · frameworks/base/services/core/java/com/android/server/biometrics/
The biometric stack is the most direct route to dismissing a keyguard: put your finger on the sensor, the HAL says "match," the framework tells SystemUI, SystemUI calls keyguardDone().
We went into L4 looking for two things: -
(1) any way for an unprivileged caller to inject a fake "match," and
(2) any place where the framework's own validation of the match could be confused. The answer to (1) was no. The answer to (2) was more nuanced than expected.
Step 1: Trace the success path, end to end
We followed a fingerprint match from the moment the HAL emits it to the moment the keyguard animates away, noting at each hop what's validated:
ISessionCallback.onAuthenticationSucceeded(enrollmentId, HardwareAuthToken) [HAL→fwk binder]
AidlResponseHandler.onAuthenticationSucceeded() :177
HardwareAuthTokenUtils.toByteArray(hat) ← pure serialise, no validation
handleResponse(AuthenticationConsumer.class,…) → posts to scheduler handler
→ mScheduler.getCurrentClient() ← no per-op cookie; "whatever's current"
FingerprintAuthenticationClient.onAuthenticated() :176
AuthenticationClient.onAuthenticated() :176
• bg-auth gate (isKeyguard / isSystem / isBackground) :208-223
• KeyStoreAuthorization.addAuthToken(byteToken) :253 ← FIRST HAT VALIDATION
(Keystore2/KeyMint HMAC verify; failure is Slog'd ONLY — not propagated)
• listener.onAuthenticationSucceeded(...) :264
ClientMonitorCallbackConverter.onAuthenticationSucceeded() :92
→ IFingerprintServiceReceiver.onAuthenticationSucceeded(fp, userId, isStrong) :100
← HAT DROPPED HERE
FingerprintManager$AuthenticationCallback (in SystemUI process)
KeyguardUpdateMonitor.handleFingerprintAuthenticated(authUserId, isStrong) :975
• check authUserId == selectedUserId :984
• check !isFingerprintDisabled(userId) :988
KeyguardUpdateMonitor.onFingerprintAuthenticated() :885
mUserFingerprintAuthenticated.put(userId, BiometricAuthenticated(true,isStrong))
→ BiometricUnlockController → keyguardDoneISessionCallback.onAuthenticationSucceeded(enrollmentId, HardwareAuthToken) [HAL→fwk binder]
AidlResponseHandler.onAuthenticationSucceeded() :177
HardwareAuthTokenUtils.toByteArray(hat) ← pure serialise, no validation
handleResponse(AuthenticationConsumer.class,…) → posts to scheduler handler
→ mScheduler.getCurrentClient() ← no per-op cookie; "whatever's current"
FingerprintAuthenticationClient.onAuthenticated() :176
AuthenticationClient.onAuthenticated() :176
• bg-auth gate (isKeyguard / isSystem / isBackground) :208-223
• KeyStoreAuthorization.addAuthToken(byteToken) :253 ← FIRST HAT VALIDATION
(Keystore2/KeyMint HMAC verify; failure is Slog'd ONLY — not propagated)
• listener.onAuthenticationSucceeded(...) :264
ClientMonitorCallbackConverter.onAuthenticationSucceeded() :92
→ IFingerprintServiceReceiver.onAuthenticationSucceeded(fp, userId, isStrong) :100
← HAT DROPPED HERE
FingerprintManager$AuthenticationCallback (in SystemUI process)
KeyguardUpdateMonitor.handleFingerprintAuthenticated(authUserId, isStrong) :975
• check authUserId == selectedUserId :984
• check !isFingerprintDisabled(userId) :988
KeyguardUpdateMonitor.onFingerprintAuthenticated() :885
mUserFingerprintAuthenticated.put(userId, BiometricAuthenticated(true,isStrong))
→ BiometricUnlockController → keyguardDoneThe thing that jumped out: the framework path performs zero HAT verification. The HardwareAuthToken — a HMAC-signed proof from the TEE that a match occurred — is serialised, passed to KeyStoreAuthorization.addAuthToken as a side effect, and then dropped on the floor before reaching SystemUI. The IFingerprintServiceReceiver callback (line :100) doesn't carry the HAT at all. Keyguard never sees it.
And addAuthToken's return code? Logged with Slog.w and ignored. listener.onAuthenticationSucceeded fires regardless.
This became O-L4–1: the keyguard's lockscreen-dismiss decision does not depend on the HAT being valid. It depends solely on the HAL having invoked onAuthenticationSucceeded over its HAL→system_server binder. A forged or empty HAT is rejected by Keystore (so auth-bound keys won't unlock) but not by the keyguard.
Why this is (probably) fine, and why we logged it anyway: the HAL is the trust root by design. CDD mandates the matcher live in the TEE, and the ISessionCallback binder is HAL-side, not a published service — no untrusted app can reach it. We checked the test surfaces too: BiometricTestSessionImpl.acceptAuthentication is gated by TEST_BIOMETRIC (signature), and the virtual HAL is gated by Build.isDebuggable(). No reach found. But it means any compromise of the fingerprint HAL = full keyguard bypass with zero further server-side checks. We treat it as the L4 invariant for variant-hunting: look for any unprotected proxy onto getHalSessionCallback().
Step 2: Can an app hijack the keyguard's auth result?
If we can't inject a fake match, can we steal a real one — make the HAL's "success" for the user's actual fingerprint get delivered to the keyguard's receiver via our app's auth request?
We traced FingerprintService.authenticate() (:268–356):
- Permission:
USE_FINGERPRINTorUSE_BIOMETRIC— bothnormal, any app can hold them. Utils.isKeyguard(ctx, opPackageName)early-allow requiresUSE_BIOMETRIC_INTERNAL(signature) and package match. Untrusted app fails the perm half.- For everyone else:
checkAppOpsbindsopPackageNameto caller UID, andUtils.isForegroundrequires the app be foreground.
The result routing: each scheduled AuthenticationClient stores its own ClientMonitorCallbackConverter (the caller's receiver binder). AidlResponseHandler.handleResponse() dispatches to mScheduler.getCurrentClient() — and here's the key: a third-party authenticate() call cancels the keyguard's pending operation (AuthenticationClient.interruptsPrecedingClients() = true). Keyguard's receiver is dropped, not hijacked. There's no shared receiver to confuse.
O-L4–5 notes that the HAL response demux is "current client of class," with no per-operation nonce — so a late onAuthenticationSucceeded from a previous (genuine) match could in theory be delivered to whatever client is now current. But since the late event still represents a real TEE match, it's not a spoof; and KeyguardUpdateMonitor rejects authUserId != selectedUserId anyway. Logged for completeness, severity none.
Step 3: Lockout reset
Brute-force protection comes from two places: the HAL's own counter, and the framework's LockoutCache / MultiBiometricLockoutState. We looked at every path that clears the framework side:
EntryPermissionHAT useIFingerprintService.resetLockoutRESET_FINGERPRINT_LOCKOUT (signature)forwarded to HAL ISession.resetLockout(hat) — HAL verifies HMACIBiometricService.resetLockoutUSE_BIOMETRIC_INTERNAL (signature)HAT ignored — clears framework cache only (O-L4-6)HAL-initiated onLockoutClearedn/a (HAL trust)clears framework cache unconditionally, even with no ResetLockoutClient current — O-L4-3
O-L4–3 is the interesting one. If the HAL emits onLockoutCleared unsolicited (sensor reset, firmware quirk), AidlResponseHandler (:206–214) wipes the framework LockoutCache and dispatches LockoutResetDispatcher → keyguard's mFingerprintLockedOut*=false. No HAT, no challenge. A physical attacker who can perturb the HAL into emitting that event clears the framework brute-force counter. Combined with a HAL whose own counter is volatile, that's a rate-limit bypass — not directly a keyguard bypass (still need a real match), but an enabler. Confidence M, OEM-HAL-dependent.
We also confirmed LockoutCache and MultiBiometricLockoutState are RAM-only — lockout resets to zero on every reboot. Mitigated by STRONG_AUTH_REQUIRED_AFTER_BOOT, which both KeyguardUpdateMonitor.isUnlockingWithBiometricAllowed() and the server-side FingerprintService.authenticate veto honour. So "reboot to clear lockout" doesn't yield a pre-first-unlock bypass.
Step 4: Class-1 vs Class-2
CDD says Class-1 (CONVENIENCE) biometrics — think "is there a face in front of the camera" — must not unlock the keyguard. We checked whether KeyguardUpdateMonitor enforces that.
It doesn't, directly. KUM's gate (getUserUnlockedWithBiometric, :1434–1440) only branches on isStrongBiometric (true/false). A sensor registered as STRENGTH_CONVENIENCE arrives with isStrong=false and is gated identically to STRENGTH_WEAK — just by the non-strong idle timeout. The CDD rule is enforced only by device configuration: AOSP never registers a fingerprint sensor as Class-1, and BiometricPrompt's PreAuthInfo filters them out. But on the keyguard-direct FingerprintManager.authenticate path, KUM has no "STRENGTH_CONVENIENCE → reject" check. O-L4-4: an OEM that mis-registers a sensor (or downgrades one via BiometricStrengthController) gets a Class-1 sensor that dismisses the keyguard. Config-dependent; not exploitable on Pixel.
Step 5: A small isKeyguard() decay
O-L4–2: Utils.isKeyguard() calls context.checkCallingOrSelfPermission(USE_BIOMETRIC_INTERNAL). But AuthenticationClient.onAuthenticated runs on the scheduler's Handler thread — not a binder thread — so checkCallingOrSelfPermission checks system_server itself, which always holds the permission. The "keyguard" background-auth bypass therefore reduces to a string compare on opPackageName. For untrusted apps that's still UID-bound via AppOps; but any uid-1000 caller can spoof opPackageName="com.android.systemui" and skip the background-auth gate. Severity Low (uid 1000 is already privileged).
Output
Headline: no untrusted-app-reachable path to signal biometric success without a real HAL match. The framework adds no cryptographic check on top of the HAL — keyguard's entire biometric trust is delegated to the ISessionCallback binder. That's by design, but it means O-L4-1 is the L4 invariant: any bug that lets you reach that callback is a full bypass. We carry O-L4-1/3/4 forward, all parked behind HAL/OEM preconditions we can't reach on stock Pixel.