TrustManagerService.isActiveUnlockRunning: the one Binder method in ITrustManager with no caller check
Android ID 491430113 · Reported to the Android & Google Devices VRP, 10 March 2026
1. Summary
ITrustManager exposes sixteen Binder methods. Fifteen of them enforce something — a
signature-level permission, an @EnforcePermission annotation, or a cross-user boundary check
through ActivityManager.handleIncomingUser(). One does not.
isActiveUnlockRunning(int userId) takes a user ID from an arbitrary caller, performs no
permission check, never resolves the caller against the user it was handed, and then calls
Binder.clearCallingIdentity() — so the lookup runs under system_server's own identity
regardless of who asked.
An app with zero declared permissions can therefore ask, for any user on the device, whether Active Unlock is currently running. Including the work profile.
The control sits in the same file: isDeviceLocked(userId) called from the same app with the
same arguments throws SecurityException.
2. Affected surface
| Property | Detail |
|---|---|
| Component | frameworks/base/services/core/java/com/android/server/trust/TrustManagerService.java |
| Interface | frameworks/base/core/java/android/app/trust/ITrustManager.aidl |
| Affected versions | Android 14+ (from the introduction of isActiveUnlockRunning) |
| Confirmed on | Android 15 (SDK 35) |
| Attacker position | Any installed app |
| Permissions required | None |
| User interaction | Install only |
3. Root cause
3.1 The unguarded method
// TrustManagerService.java:2118
@Override
public boolean isActiveUnlockRunning(int userId) {
// No permission check.
// No handleIncomingUser() call.
final long token = Binder.clearCallingIdentity();
try {
return mActiveUnlockRunningForUser.get(userId, false);
} finally {
Binder.restoreCallingIdentity(token);
}
}
Three things are missing at once, and the third makes the first two matter more.
There is no permission gate, so any caller reaches the body. There is no
handleIncomingUser(), so the userId argument is used as supplied rather than resolved
against who is asking — the parameter is trusted. And clearCallingIdentity() discards the
caller's identity before the lookup, so even if something downstream wanted to make a decision
based on the caller, by then there is no caller left to inspect: the read happens as UID 1000.
3.2 The protected sibling
// TrustManagerService.java:1866
@Override
public boolean isDeviceLocked(int userId, int deviceId) {
userId = ActivityManager.handleIncomingUser(getCallingPid(),
getCallingUid(), userId, false, true,
"isDeviceLocked", null);
// Cross-user calls → SecurityException
}
requireFull = true here means a caller asking about a user other than its own needs
INTERACT_ACROSS_USERS_FULL, which is signature-level and unavailable to a third-party app.
That is the enforcement isActiveUnlockRunning skips.
3.3 One method out of sixteen
The interface is otherwise consistent, which is what makes the gap legible as an omission rather than a design decision:
| Method | Enforcement |
|---|---|
reportUnlockAttempt |
enforceReportPermission |
reportUserRequestedUnlock |
enforceReportPermission |
reportUserMayRequestUnlock |
enforceReportPermission |
reportUnlockLockout |
enforceReportPermission |
reportEnabledTrustAgentsChanged |
enforceReportPermission |
registerTrustListener |
TRUST_LISTENER |
unregisterTrustListener |
TRUST_LISTENER |
reportKeyguardShowingChanged |
enforceReportPermission |
setDeviceLockedForUser |
enforceReportPermission |
isDeviceLocked |
handleIncomingUser (requireFull=true) |
isDeviceSecure |
handleIncomingUser (requireFull=true) |
isTrustUsuallyManaged |
@EnforcePermission("TRUST_LISTENER") |
unlockedByBiometricForUser |
enforceReportPermission |
clearAllBiometricRecognized |
enforceReportPermission |
isActiveUnlockRunning |
none |
isInSignificantPlace |
@EnforcePermission("ACCESS_FINE_LOCATION") |
The last row is worth pausing on. isInSignificantPlace — a neighbouring method reading
related trust state — is gated behind ACCESS_FINE_LOCATION, a dangerous permission requiring
an explicit user grant. The platform's own judgement is that this class of state is
location-sensitive.
4. What the access is worth
Cross-profile leakage. The attacker enumerates userId 0, 10, 11 and reads Active Unlock
state for each. Work profiles exist precisely so that a personal-profile app cannot observe the
work side; secondary users exist so that one user cannot observe another. Both boundaries are
crossed by passing a different integer.
A signal the platform treats as sensitive elsewhere. Active Unlock is a function of trust
agents: a paired smartwatch in range, a trusted Bluetooth device, a trusted place. Its state is
therefore correlated with physical circumstance — whether the user is near a particular device,
or at a particular location. The platform already reflects this judgement by gating
isInSignificantPlace behind ACCESS_FINE_LOCATION. Reading a correlated signal through an
ungated method reaches the same class of information without the permission, the prompt, or a
manifest entry a user could inspect. What can be inferred in practice depends on the victim's
trust-agent configuration and is not measured here.
Polling is cheap. The method returns immediately, takes no lock the caller can observe, and costs nothing to call repeatedly — so the leak is a continuous state feed rather than a single read.
5. Proof of concept
A zero-permission app calling Binder transaction 15
(ITrustManager.TRANSACTION_isActiveUnlockRunning) directly.
for userId in 0, 10, 11:
isActiveUnlockRunning(userId) → returns immediately, no SecurityException
isDeviceLocked(userId) → SecurityException for any userId not the caller's own
The second line is the control. Same app, same UID, same target user, same file — one call is refused and the other is not.
6. Suggested fix
Match the sibling. Resolve the incoming user before the read, and require the same signature-level permission any other cross-user query in this interface requires:
@Override
public boolean isActiveUnlockRunning(int userId) {
userId = ActivityManager.handleIncomingUser(getCallingPid(), getCallingUid(),
userId, false, true, "isActiveUnlockRunning", null);
final long token = Binder.clearCallingIdentity();
try {
return mActiveUnlockRunningForUser.get(userId, false);
} finally {
Binder.restoreCallingIdentity(token);
}
}
Resolving the user before clearCallingIdentity() is the ordering that matters: once the
identity is cleared there is nothing left to check against.
7. Disclosure timeline
| Date | Event |
|---|---|
| 2026-03-10 | Reported to the Android & Google Devices VRP |
| 2026-03-10 | Triaged |
| 2026-03-12 | Assessment completed |
| 2026-09-12 | Google confirms no objection to publication |
Disclosure status. Published. The writeup was shared with Google ahead of publication and Google confirmed it had no objection before this page was made public.
Reported by Ayush Kumar to the Android & Google Devices Vulnerability Reward Program. Published under coordinated disclosure.