home intel cve-2026-0034-managed-services-notification-policy-desync-lpe
CVE Analysis 2026-03-02 · 9 min read

CVE-2026-0034: ManagedServices Policy Desync Enables Local Privilege Escalation

A missing validation check in setPackageOrComponentEnabled() allows an unprivileged caller to desync Android's notification policy state, enabling local privilege escalation without user interaction.

#privilege-escalation#input-validation#notification-policy#local-attack#managed-services
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-0034 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2026-0034HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

CVE-2026-0034 is a logic-class vulnerability residing in ManagedServices.java, specifically within the setPackageOrComponentEnabled() method responsible for governing which packages and components are permitted to act as notification listeners, condition providers, or assistant services. Improper input validation allows an attacker to induce a notification policy desync — a state where the in-memory representation of allowed components diverges from the persisted policy on disk — which can be leveraged for local escalation of privilege. No additional execution privileges are required, and no user interaction is necessary. CVSS 8.4 (HIGH).

The vulnerability was patched in the Android March 2026 Security Bulletin. It affects all Android releases prior to the patch date across the framework component frameworks/base.

Root cause: setPackageOrComponentEnabled() fails to validate that the caller-supplied component name resolves to a package already present in the approved managed services set before mutating the enabled/disabled state map, allowing a desync between runtime policy state and the persisted allowlist that the system trusts for capability checks.

Affected Component

The bug lives entirely within the Android framework notification subsystem:

  • File: frameworks/base/services/core/java/com/android/server/notification/ManagedServices.java
  • Method: setPackageOrComponentEnabled(String pkgOrComponent, int userId, boolean isPrimary, boolean enabled, boolean userSet)
  • Callers: NotificationManagerService, ConditionProviderService binding logic, Settings UI via INotificationManager Binder interface
  • Permission context: Callable by system UID and, critically, by the owning package for self-management paths — the validation gap exists on the self-management path

Root Cause Analysis

ManagedServices maintains two authoritative data structures: mApproved (the persistent allowlist of approved packages/components per user) and mEnabledServicesForCurrentProfiles (the runtime set used for capability checks). The policy desync occurs because setPackageOrComponentEnabled() writes into mEnabledServicesForCurrentProfiles without first confirming the target is present in mApproved.

// Reconstructed pseudocode of the vulnerable method in ManagedServices.java
// Equivalent Java logic shown as pseudocode for clarity.

void setPackageOrComponentEnabled(
        String pkgOrComponent,
        int    userId,
        boolean isPrimary,
        boolean enabled,
        boolean userSet) {

    // Normalize to ComponentName or package string
    ComponentName cn = ComponentName.unflattenFromString(pkgOrComponent);

    // BUG: No validation that pkgOrComponent exists in mApproved[userId]
    // before modifying the enabled state. An attacker-supplied component
    // that was never approved can be inserted into mEnabledServicesPackages
    // or removed from it, causing the runtime set to diverge from policy.

    ArrayMap> userServices = mEnabledServicesForCurrentProfiles;

    if (cn == null) {
        // Package-level enable/disable path
        ArraySet pkgSet = userServices.getOrDefault(isPrimary, new ArraySet<>());
        if (enabled) {
            pkgSet.add(pkgOrComponent);   // BUG: unconditional add, no approval check
        } else {
            pkgSet.remove(pkgOrComponent);
        }
        userServices.put(isPrimary, pkgSet);
    } else {
        // Component-level path — same issue
        String flat = cn.flattenToString();
        ArraySet compSet = userServices.getOrDefault(isPrimary, new ArraySet<>());
        if (enabled) {
            compSet.add(flat);            // BUG: attacker-supplied component inserted
        } else {
            compSet.remove(flat);
        }
        userServices.put(isPrimary, compSet);
    }

    // Policy written back to disk without cross-checking mApproved
    rebindServices(false, userId);        // triggers capability re-evaluation
}

The key invariant that should hold is: mEnabledServicesForCurrentProfiles ⊆ mApproved. The missing check breaks this invariant. When rebindServices() subsequently iterates over mEnabledServicesForCurrentProfiles to bind notification listener services, it trusts this set as authoritative and will attempt to bind — and grant listener capabilities to — components that were never formally approved.

Exploitation Mechanics

EXPLOIT CHAIN — CVE-2026-0034:

1. SETUP: Attacker installs a malicious app (com.evil.listener) containing a
   declared NotificationListenerService component. App is NOT granted
   notification access through the normal Settings approval flow, so
   com.evil.listener is absent from mApproved[userId].

2. TRIGGER: Attacker calls INotificationManager.setNotificationListenerAccessGranted()
   (or the package self-management Binder path) with:
     pkgOrComponent = "com.evil.listener/com.evil.listener.ListenerSvc"
     enabled        = true
     userSet        = false  <-- bypasses the userSet guard in some paths

3. DESYNC: setPackageOrComponentEnabled() inserts the component into
   mEnabledServicesForCurrentProfiles without consulting mApproved.
   Runtime state now shows com.evil.listener as enabled.

4. REBIND: rebindServices() iterates the now-poisoned runtime set,
   calls bindServiceAsUser() for com.evil.listener.ListenerSvc,
   granting it BIND_NOTIFICATION_LISTENER_SERVICE — a privileged binding
   that exposes all active notifications cross-user.

5. ESCALATION: The bound ListenerService receives onNotificationPosted()
   callbacks containing notification payloads from all foreground UIDs,
   including system app notifications carrying auth tokens, OTP codes,
   and inter-process messaging data from privileged callers.

6. PERSISTENCE: Because the poisoned entry lives in the runtime set and
   rebindServices() is called on boot, the malicious listener survives
   device reboots until mApproved is explicitly reconciled.

Memory Layout

This is a logic/state desync vulnerability rather than a memory corruption bug, but the critical data structure layout is worth examining. The desync affects two heap-resident ArrayMap objects within the ManagedServices singleton in the system_server process.

MANAGED SERVICES STATE — NORMAL (BEFORE EXPLOIT):

  mApproved (persisted allowlist, userId=0):
  ┌────────────────────────────────────────────────────────┐
  │  KEY: isPrimary=true                                   │
  │  VALUE: { "com.android.settings/SettingsListener",     │
  │           "com.trusted.app/TrustedListener" }          │
  └────────────────────────────────────────────────────────┘

  mEnabledServicesForCurrentProfiles (runtime, userId=0):
  ┌────────────────────────────────────────────────────────┐
  │  KEY: isPrimary=true                                   │
  │  VALUE: { "com.android.settings/SettingsListener",     │
  │           "com.trusted.app/TrustedListener" }          │
  └────────────────────────────────────────────────────────┘
  INVARIANT HOLDS: runtime ⊆ approved ✓

---

MANAGED SERVICES STATE — AFTER EXPLOIT (DESYNC):

  mApproved (persisted allowlist, userId=0):   <-- UNCHANGED
  ┌────────────────────────────────────────────────────────┐
  │  KEY: isPrimary=true                                   │
  │  VALUE: { "com.android.settings/SettingsListener",     │
  │           "com.trusted.app/TrustedListener" }          │
  └────────────────────────────────────────────────────────┘

  mEnabledServicesForCurrentProfiles (runtime, userId=0):  <-- POISONED
  ┌────────────────────────────────────────────────────────┐
  │  KEY: isPrimary=true                                   │
  │  VALUE: { "com.android.settings/SettingsListener",     │
  │           "com.trusted.app/TrustedListener",           │
  │           "com.evil.listener/ListenerSvc"  <-- INJECTED │
  │         }                                              │
  └────────────────────────────────────────────────────────┘
  INVARIANT BROKEN: runtime ⊄ approved ✗

  rebindServices() trusts runtime set → binds ListenerSvc →
  grants BIND_NOTIFICATION_LISTENER_SERVICE privilege

Patch Analysis

The fix introduces an explicit approval membership check immediately upon entry to setPackageOrComponentEnabled(), before any mutation of the runtime state map. The patch ensures the invariant runtime ⊆ approved is enforced at write time rather than relying on callers to pre-validate.

// BEFORE (vulnerable) — ManagedServices.java pre-patch:
void setPackageOrComponentEnabled(String pkgOrComponent, int userId,
        boolean isPrimary, boolean enabled, boolean userSet) {

    ComponentName cn = ComponentName.unflattenFromString(pkgOrComponent);

    // No approval membership check here.
    // Proceeds directly to mutate mEnabledServicesForCurrentProfiles.

    ArraySet targetSet = getOrCreateEnabledSet(isPrimary, userId);
    if (enabled) {
        targetSet.add(pkgOrComponent);
    } else {
        targetSet.remove(pkgOrComponent);
    }
    rebindServices(false, userId);
}


// AFTER (patched) — March 2026 Security Bulletin fix:
void setPackageOrComponentEnabled(String pkgOrComponent, int userId,
        boolean isPrimary, boolean enabled, boolean userSet) {

    ComponentName cn = ComponentName.unflattenFromString(pkgOrComponent);

    // PATCH: validate membership in mApproved before mutating runtime state.
    // If enabling, the component/package MUST already be in the approved set.
    if (enabled && !isPackageOrComponentAllowed(pkgOrComponent, userId)) {
        Slog.w(TAG, "setPackageOrComponentEnabled: " + pkgOrComponent
                + " not in approved set for user " + userId + ", ignoring.");
        return;  // <-- early exit prevents desync
    }

    ArraySet targetSet = getOrCreateEnabledSet(isPrimary, userId);
    if (enabled) {
        targetSet.add(pkgOrComponent);
    } else {
        targetSet.remove(pkgOrComponent);
    }
    rebindServices(false, userId);
}

// isPackageOrComponentAllowed() (new helper, also patched in):
boolean isPackageOrComponentAllowed(String pkgOrComponent, int userId) {
    ArrayMap> approved = mApproved.get(userId);
    if (approved == null) return false;
    for (ArraySet approvedSet : approved.values()) {
        if (approvedSet.contains(pkgOrComponent)) return true;
        // Also check package prefix match for component entries
        ComponentName cn = ComponentName.unflattenFromString(pkgOrComponent);
        if (cn != null && approvedSet.contains(cn.getPackageName())) return true;
    }
    return false;
}

Detection and Indicators

Detection focuses on observing the desync condition at runtime within system_server. The following indicators suggest active exploitation or a device in a desynced state:

  • Logcat tag ManagedServices: Post-patch devices will emit the new Slog.w warning on blocked attempts. Pre-patch devices will be silent.
  • Anomalous notification listener bindings: adb shell dumpsys notification — inspect the Enabled listeners section for components not present in Allowed notification listeners in Settings.
  • Cross-reference mApproved vs runtime bound services: Any component bound via BIND_NOTIFICATION_LISTENER_SERVICE that lacks a corresponding entry in /data/system/notification_policy.xml is suspect.
  • SELinux audit logs: Binding a listener service from an unprivileged UID may generate avc: denied entries in dmesg depending on device policy — absence of such denials on a pre-patch device confirms successful exploitation.
// Audit command to detect desynced listener state:
$ adb shell dumpsys notification | grep -A 50 "Enabled listeners"
$ adb shell dumpsys notification | grep -A 50 "Allowed listeners"

// Desynced state: a component appears in "Enabled" but NOT in "Allowed"
// This is the smoking gun for CVE-2026-0034 exploitation.

Remediation

  • Apply the March 2026 Android Security Bulletin patches for frameworks/base immediately. OEM patch timelines vary; verify your build's SPL (Security Patch Level) is 2026-03-01 or later via Settings → About phone → Android security update.
  • Audit notification listener grants on managed/enterprise devices. MDM solutions should enumerate NotificationListenerService bindings via NotificationManager.getEnabledNotificationListeners() and flag any package not in the enterprise-approved list.
  • Defense in depth: Restrict the INotificationManager.setNotificationListenerAccessGranted() Binder endpoint to callers holding MANAGE_NOTIFICATION_LISTENERS — verify your device's SEPolicy enforces this. Some OEM customisations loosen this restriction.
  • Runtime detection: Instrument system_server via a custom log.tag.ManagedServices=VERBOSE property on canary fleet devices to catch the post-patch warning message, which would indicate active exploitation attempts against pre-patch devices in your environment.
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 →