home intel cve-2025-48654-companiondevicemanager-confused-deputy-lpe
CVE Analysis 2026-03-02 · 9 min read

CVE-2025-48654: CompanionDeviceManagerService Confused Deputy LPE

A logic error in CompanionDeviceManagerService.onStart() enables a confused deputy attack, allowing any local app to escalate privileges with zero user interaction required.

#confused-deputy#privilege-escalation#logic-error#android-service#authorization-bypass
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2025-48654 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2025-48654HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

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:

BINDER TRANSACTION BUFFER (malicious dispatchMessage call):
Offset  Field                   Value (attacker-controlled)
------  ----------------------  ---------------------------
+0x00   transaction code        TRANSACTION_dispatchMessage (0x3)
+0x04   flags                   0x00 (synchronous)
+0x08   data_size               0x18
+0x0C   [padding]               0x00000000
+0x10   associationId (int)     0x00000007  <-- victim app's association
+0x14   messageType (int)       0x00000001
+0x18   message payload ptr     0xdeadbeef  (irrelevant, checked later)

system_server unparcels associationId = 7
→ mAssociations null-check fails (race)
→ ownership enforcement skipped
→ privilege operation fires with id=7 (victim's record)
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.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →