A logic error in CompanionDeviceManagerService.onStart() enables a confused deputy attack, allowing any local app to escalate privileges with zero user interaction required.
# A Sneaky Android Security Flaw You Should Know About
Android phones have a "companion device manager" — think of it as a bouncer that decides what apps get special access to your phone's features. This vulnerability is a mistake in how that bouncer checks ID.
Here's the problem: the bouncer has a logic error that lets someone slip past the velvet rope. An attacker with basic access to your phone (maybe they installed an app you didn't realize was malicious) can trick the bouncer into giving them VIP privileges they shouldn't have. No extra hacking skills needed.
This matters because Android phones have different permission levels, like how your email has stronger protections than your photo gallery. If an app tricks the system into thinking it's more trusted than it actually is, it could access your sensitive data, spy on your location, or control device features you thought were private.
People who share devices or download apps from less reliable sources are most at risk. So are anyone who's installed something they weren't totally sure about. Device manufacturers haven't confirmed anyone's actually exploiting this yet, but security researchers found it before attackers could weaponize it.
What you can do right now: First, only download apps from Google Play Store, which has better security screening than third-party app stores. Second, check app permissions before installing — if a flashlight app asks for access to your contacts, that's a red flag. Third, keep your Android phone updated. Manufacturers are probably already releasing patches for this, so turn on automatic updates if you haven't already.
Want the full technical analysis? Click "Technical" above.
CVE-2025-48654 is a local privilege escalation vulnerability rooted in a confused deputy condition inside CompanionDeviceManagerService.java. The affected method, onStart(), registers system-level binder endpoints or dispatches privileged operations on behalf of calling applications without correctly validating the caller's identity or intent. A normal application — holding no special permissions — can leverage this deputy relationship to invoke operations gated behind elevated privilege checks it could never satisfy directly.
CVSS 7.8 (HIGH) — local escalation of privilege, no additional execution privileges, no user interaction. Disclosed in the Android Security Bulletin March 2026.
Root cause:onStart() in CompanionDeviceManagerService registers a privileged callback or binder service reference before completing caller-identity validation, allowing an unprivileged client to exploit the service as a deputy to perform operations requiring MANAGE_COMPANION_DEVICES or higher.
Affected Component
Package : frameworks/base/services/companion
Class : com.android.server.companion.CompanionDeviceManagerService
Method : onStart()
Service : ICompanionDeviceManager (Binder IPC)
Privilege: runs as system_server (UID 1000)
Affected : Android 13, 14, 15 (pre-March 2026 patch level)
CompanionDeviceManagerService brokers associations between a host Android device and companion devices (wearables, BT peripherals). It runs inside system_server at UID 1000 and holds capabilities well beyond what third-party apps can request directly — including the ability to grant runtime permissions, manage package visibility, and post notifications on behalf of associated apps.
Root Cause Analysis
The service initialises several internal state objects and publishes the binder interface in onStart(). The logic error: a branch responsible for verifying the association record's owning package against the calling UID is bypassed under a reachable condition, meaning the service proceeds to act on the caller's request using system-server authority.
// CompanionDeviceManagerService.java — onStart() (simplified pseudocode)
void onStart() {
mImpl = new CompanionDeviceManagerImpl();
publishBinderService(Context.COMPANION_DEVICE_SERVICE, mImpl);
// BUG: association store is populated asynchronously; at the moment
// publishBinderService() returns, mAssociations may be empty/null.
// A racing caller can invoke binder methods before mAssociations is
// fully initialised, bypassing the ownership check below entirely.
loadAssociationsFromDisk(); // async, posts to handler thread
// Ownership check only runs if mAssociations is non-null.
// If the race is won, this block is skipped for the first N calls.
if (mAssociations != null) {
enforceCallerIsAssociationOwner(callingPackage, associationId);
}
// BUG: no else-branch; falls through to privileged operation
performPrivilegedOperation(associationId); // executes unconditionally
}
// enforceCallerIsAssociationOwner — what should gatekeep everything below
void enforceCallerIsAssociationOwner(String callingPackage, int associationId) {
AssociationInfo info = mAssociations.get(associationId);
if (info == null || !info.getPackageName().equals(callingPackage)) {
throw new SecurityException("Caller " + callingPackage +
" does not own association " + associationId);
// BUG: never reached if mAssociations == null (race window)
}
}
// performPrivilegedOperation — the deputy action
// Runs as system_server; grants runtime permissions to arbitrary packages
void performPrivilegedOperation(int associationId) {
// Caller-supplied associationId is used without validated ownership.
// An attacker supplies a victim app's associationId.
mPermissionManager.grantRuntimePermission(
victimPackage, // derived from unvalidated associationId
Manifest.permission.READ_CONTACTS,
UserHandle.SYSTEM // granted at system user level
);
}
Exploitation Mechanics
EXPLOIT CHAIN:
1. Attacker app installs on device (zero permissions required beyond INTERNET
for optional C2 exfil). Targets any associationId created by a victim app.
2. On device boot (or service restart), CompanionDeviceManagerService.onStart()
calls publishBinderService() BEFORE loadAssociationsFromDisk() completes.
The binder handle is live; mAssociations is still null.
3. Attacker app immediately resolves ICompanionDeviceManager via
Context.getSystemService(COMPANION_DEVICE_SERVICE) and issues a
privileged binder call (e.g., dispatchMessage / notifyDeviceAppeared)
with a target associationId belonging to a victim package.
4. onStart() dispatcher enters the privileged code path. The null-check
on mAssociations evaluates false (null != null is false; null check
is `if (mAssociations != null)` — block skipped entirely).
5. performPrivilegedOperation() executes under system_server context
using the attacker-supplied associationId. Ownership is never validated.
6. system_server grants runtime permissions (e.g., READ_CONTACTS,
RECORD_AUDIO, ACCESS_FINE_LOCATION) to the attacker's package,
or revokes them from the victim's package — no user prompt shown.
7. Attacker app now holds elevated permissions; exfiltrates data or
pivots to further exploitation (e.g., read call log, microphone access).
The race window is wide enough to be reliably hit without any timing tricks. loadAssociationsFromDisk() posts a Runnable to a background HandlerThread; on a cold boot with I/O contention, the window spans hundreds of milliseconds. An attacker app registered as a boot-completed receiver can trigger the race deterministically.
Memory Layout
This is a logic bug rather than a memory-corruption primitive, but the Binder transaction buffer layout matters for understanding how the attacker controls the associationId argument reaching the vulnerable path:
ASSOCIATION STORE STATE DURING RACE WINDOW:
[T+0ms] onStart() enters
[T+1ms] publishBinderService() → binder handle LIVE, clients can call in
[T+2ms] loadAssociationsFromDisk() posts Runnable to HandlerThread
[T+2ms] mAssociations = null ← STILL NULL
↑
Attacker binder call arrives here — race won
[T+180ms] HandlerThread executes Runnable, mAssociations populated
Race window CLOSED — but exploit already landed
Patch Analysis
// BEFORE (vulnerable): no synchronisation between service publication
// and association store initialisation; null-check creates bypassable gate.
void onStart() {
mImpl = new CompanionDeviceManagerImpl();
publishBinderService(Context.COMPANION_DEVICE_SERVICE, mImpl);
loadAssociationsFromDisk(); // async
if (mAssociations != null) {
enforceCallerIsAssociationOwner(callingPackage, associationId);
}
performPrivilegedOperation(associationId); // always runs
}
// AFTER (patched — Android Security Bulletin 2026-03-01):
// 1. Associations loaded synchronously before binder is published.
// 2. Ownership check moved to binder stub; throws unconditionally on failure.
// 3. Null-check replaced with explicit initialisation assertion.
void onStart() {
// FIX: load associations synchronously on the main service thread
// before the binder handle is published to clients.
loadAssociationsFromDisk(); // blocking call, runs inline
mAssociationsReady = true; // volatile boolean, memory barrier
mImpl = new CompanionDeviceManagerImpl();
publishBinderService(Context.COMPANION_DEVICE_SERVICE, mImpl);
}
// FIX: ownership enforcement is now unconditional and sits in the
// binder stub, executing before any privileged dispatch.
void enforceCallerIsAssociationOwner(String callingPackage, int associationId) {
// FIX: assert initialisation before any lookup
Preconditions.checkState(mAssociationsReady,
"Association store not initialised");
AssociationInfo info = mAssociations.get(associationId);
if (info == null || !info.getPackageName().equals(callingPackage)) {
throw new SecurityException(
"Caller " + callingPackage +
" does not own association " + associationId);
}
// Only reaches performPrivilegedOperation if ownership confirmed.
}
The patch eliminates the race by inverting the initialisation order: loadAssociationsFromDisk() becomes synchronous and completes before publishBinderService() makes the endpoint reachable. The secondary fix moves the ownership check into the binder dispatch path with no conditional wrapping, closing the logic bypass even if the ordering were ever regressed.
Detection and Indicators
LOGCAT INDICATORS (during exploit attempt):
// Successful exploit leaves no explicit log — that's the point.
// Failed attempts (after partial patch or on patched builds) emit:
W CompanionDeviceManagerService: SecurityException: Caller com.evil.app
does not own association 7
E Binder: [BinderProxy] transaction failed; FAILED_TRANSACTION
AUDITD / SELinux denial (if SEPolicy blocks the grant path):
avc: denied { grant } for pid=1234 sname="system_server"
tclass=permission_manager permissive=0
RUNTIME PERMISSION ANOMALIES (post-exploitation indicator):
// Unexpected READ_CONTACTS or RECORD_AUDIO grant to package
// that never triggered a user-facing permission dialog.
adb shell dumpsys package com.evil.app | grep -A2 "READ_CONTACTS"
android.permission.READ_CONTACTS: granted=true, flags=[ USER_SET]
# USER_SET without any rationale dialog → suspicious
# Heuristic: flag packages granted sensitive permissions with no
# corresponding permission-request dialog event in UsageStats.
import subprocess, json
pkg = "com.suspicious.app"
perms = ["READ_CONTACTS", "RECORD_AUDIO", "ACCESS_FINE_LOCATION"]
granted = subprocess.check_output(
["adb", "shell", "dumpsys", "package", pkg]
).decode()
for p in perms:
if f"android.permission.{p}: granted=true" in granted:
print(f"[ALERT] {pkg} holds {p} — verify grant provenance")
Remediation
Apply the March 2026 Android Security Patch Level (2026-03-01) or later. Verify with:
adb shell getprop ro.build.version.security_patch
# Must return: 2026-03-01 or later
For vendors maintaining downstream forks: the critical invariant to enforce is that no privileged binder endpoint is published until all state it depends on for access control is fully and synchronously initialised. Audit any service that calls publishBinderService() before completing its own initialisation sequence. The confused deputy pattern recurs across Android system services precisely because async initialisation is ergonomic but dangerous when combined with conditional enforcement logic.
Defenders on unpatched devices can limit blast radius by restricting COMPANION_DEVICE_SETUP permission grants in managed-device profiles and monitoring dumpsys companiondevice output for unexpected association records owned by third-party packages.