Security disclosures

TrustManagerService.isActiveUnlockRunning: the one Binder method in ITrustManager with no caller check

Researcher
Ayush Kumar
Program
Android & Google Devices VRP
Android ID
491430113
Reported
2026-03-10
researcher profile
/team/ayush-kumar

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

Source on cs.android.com


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.