CWE-926: Improper Export of Android Application Components
Overview
Android components (activities, services, broadcast receivers, content providers) are private by default, but an intent filter or an explicit android:exported="true" in AndroidManifest.xml can make one reachable from any other app on the device. The requirement to say so explicitly is keyed to what the app targets, not to the version of Android it runs on: once targetSdkVersion is 31 (Android 12) or higher, every activity, service and broadcast receiver with an intent filter must declare android:exported or the manifest merger fails the build. Content providers are outside that rule and follow their own default, which is false once targetSdkVersion is 17 or higher. Improper export happens when a component that handles sensitive data or privileged actions is left reachable - through a missing declaration that leaves an intent-filtered component exported by default, or an exported flag with no permission or caller check - so any installed app can launch it, bind to it, send it a broadcast, or query it.
For a scan finding, the source is an Intent or content provider query sent by another app on the device, and the sink is the component's entry point: onCreate(), onStartCommand(), onBind(), onReceive(), or a provider's query/insert/update/delete methods. What is missing between them is a manifest permission, a runtime caller check, or both.
OWASP Classification
A08:2025 - Software or Data Integrity Failures
Risk
High: Any app installed on the same device can invoke an exported component - performing a privileged action without authenticating, reading or writing data through a content provider, or crashing the app by sending it input it does not expect.
Common Vulnerable Patterns
Component exported without permission protection
<!-- VULNERABLE - admin activity reachable by any app on the device -->
<activity
android:name=".AdminActivity"
android:exported="true">
<!-- No android:permission - any app can launch this -->
</activity>
<!-- VULNERABLE - content provider with no read/write restriction -->
<provider
android:name=".UserDataProvider"
android:authorities="com.example.userdata"
android:exported="true" />
<!-- Any app can query or write user data -->
Why this is vulnerable: android:exported="true" with no android:permission, android:readPermission, or android:writePermission means the component accepts calls from every app installed on the device, with nothing checking who is calling.
No caller validation in component code
// VULNERABLE - accepts intents from any source, no caller check
public class AdminActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String action = getIntent().getStringExtra("action");
executeAdminAction(action); // no validation of who sent this intent
}
}
Why this is vulnerable: Acting on intent data without checking who sent it means any app that can send the component an intent triggers the admin action. A component that is deliberately exported still has to verify the caller before doing privileged work.
Secure Patterns
Set export status explicitly and default to false
<!-- SECURE - internal-only activity -->
<activity
android:name=".SettingsActivity"
android:exported="false">
<intent-filter>
<action android:name="com.example.OPEN_SETTINGS" />
</intent-filter>
</activity>
<!-- SECURE - internal-only content provider -->
<provider
android:name=".InternalDataProvider"
android:authorities="com.example.internal"
android:exported="false" />
Why this works: android:exported="false" restricts the component to calls that originate from your own app (or apps sharing the same UID). Declaring the attribute explicitly - rather than relying on the default that applies while the app targets below API 31 - prevents an intent filter from silently exporting the component in a future refactor.
Protect exported components with signature-level permissions
<!-- SECURE - access limited to apps signed with the same certificate -->
<permission
android:name="com.example.permission.ADMIN_ACCESS"
android:protectionLevel="signature" />
<activity
android:name=".AdminActivity"
android:exported="true"
android:permission="com.example.permission.ADMIN_ACCESS" />
<provider
android:name=".SharedDataProvider"
android:authorities="com.example.shared"
android:exported="true"
android:readPermission="com.example.permission.READ_DATA"
android:writePermission="com.example.permission.WRITE_DATA" />
Why this works: protectionLevel="signature" restricts access to apps signed with the same certificate as your app - a trust boundary the OS enforces before your component code even runs. Content providers can set independent read and write permissions so a caller with read access cannot also write. Avoid normal or dangerous protection levels for sensitive components; both can be granted to unrelated apps.
Validate caller identity in code as defense in depth
Runtime checks are a second layer, not a replacement for manifest-level android:exported="false" or a signature permission. Use an any-match signer policy so key rotation does not break legitimate callers.
public class AdminActivity extends Activity {
private static final String TAG = "AdminActivity";
// SHA-256 fingerprints of certificates you trust for this caller.
private static final Set<String> ALLOWED_SIGNER_SHA256 = Set.of(
"REPLACE_WITH_REAL_SHA256_FINGERPRINT"
);
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// getCallingPackage() is only reliable when started via startActivityForResult.
String callingPackage = getCallingPackage();
if (callingPackage == null || !isSignedByAllowedSigner(callingPackage)) {
Log.w(TAG, "Unauthorized caller: " + callingPackage);
finish();
return;
}
String action = getIntent().getStringExtra("action");
if (action == null || action.isEmpty()) {
finish();
return;
}
executeAdminAction(action);
}
private boolean isSignedByAllowedSigner(String packageName) {
try {
PackageInfo info = getPackageManager().getPackageInfo(
packageName, PackageManager.GET_SIGNING_CERTIFICATES);
SigningInfo signingInfo = info.signingInfo;
if (signingInfo == null) return false;
Signature[] signatures = signingInfo.hasMultipleSigners()
? signingInfo.getApkContentsSigners()
: signingInfo.getSigningCertificateHistory();
MessageDigest md = MessageDigest.getInstance("SHA-256");
for (Signature sig : signatures) {
String fingerprint = toHex(md.digest(sig.toByteArray()));
if (ALLOWED_SIGNER_SHA256.contains(fingerprint)) return true;
}
return false;
} catch (Exception e) {
Log.e(TAG, "Signature check failed", e);
return false; // fail closed
}
}
private static String toHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
private void executeAdminAction(String action) {
// Perform privileged operation; also allowlist the action value itself.
}
}
For a bound Service, apply the same signer check to Binder.getCallingUid() resolved via PackageManager.getPackagesForUid() instead of getCallingPackage(), and throw a SecurityException from the binder method when the check fails rather than silently returning.
Why this works: Package name alone is not a trust boundary - any app can declare any package name in its manifest unless install-time verification blocks a collision. Checking the caller's signing certificate fingerprint against an allowlist ties the check to a cryptographic identity the caller cannot forge. The catch block denies access rather than falling through, so a failed lookup cannot turn into an allow.
Considerations
Verifying the caller does not make the payload trustworthy. A signature-level
permission establishes which app sent the Intent; it says nothing about the
values inside it. Extras, the data URI, and the action string all still arrive
from another process and can be malformed or deliberately hostile - and a
compromised or repackaged app holding the right signature sends them just as
easily. Validate the contents after the caller check, not instead of it.
A finding on android:useEmbeddedDex is not this weakness. Some scanners
report the absence of android:useEmbeddedDex="true" under CWE-926. That
setting (API 29+) tells the runtime to execute DEX code directly from the APK
instead of a locally compiled artifact, which helps mitigate on-device code
tampering on rooted devices. It does not affect component export, intent
resolution, or IPC access control. Treat it as unrelated integrity hardening and
record the finding as a false positive for this CWE, with that reason.
Testing
# List exported components
adb shell dumpsys package com.example.app | grep -A 5 "android:exported"
# Confirm an internal component rejects external launch attempts
adb shell am start -n com.example.app/.InternalActivity
# Expected: Permission Denial
# Confirm an exported component still requires its permission
adb shell am start -n com.example.app/.AdminActivity
# Expected: Permission Denial without the signature permission granted
# Query a content provider that should be internal-only
adb shell content query --uri content://com.example.internal/data
# Expected: Permission Denial
- Run
./gradlew lintand confirm noExportedReceiver,ExportedService, orExportedContentProviderwarnings remain. - Install an unsigned or differently-signed test app and confirm it cannot invoke protected components.
- After merging manifests (
app/build/intermediates/merged_manifests/), re-check the finalandroid:exportedvalues, since manifest merging from libraries can change them.
Common Pitfalls
- Checking only the calling package name (spoofable) instead of its signing certificate.
- Leaving a debug or test component exported in release builds.
- Using
normalordangerousprotectionLevelfor a component that handles sensitive data. - Assuming
android:exported="false"is unnecessary because "no intent filter is declared" - explicit is safer than relying on a default that differs by component type and by what the app targets: an activity, service or receiver is exported when it has an intent filter, while a provider defaults tofalsefromtargetSdkVersion17.