An offline analyser for the attack surface an Android app declares. It reads a
decoded AndroidManifest.xml and reports which components any other app on the
device can reach, why each one is reachable, and the exact command that reaches
it.
An Android app's attack surface is declared, not discovered. There is no
scan that finds it. Every <activity>, <service>, <receiver> and
<provider> the manifest marks as exported is an IPC entry point that any
other installed app can address with an intent, and the manifest is the only
place that fact is recorded.
The trouble is that the manifest records it badly, in three specific ways.
The default is not stable. android:exported can be true, false, or
absent — and absent means different things depending on the component type,
on whether the component declares an <intent-filter>, and on
targetSdkVersion. An activity with an intent filter and no explicit
android:exported is exported when the app targets API 30 and is a build error
when it targets 31. A <provider> with no explicit android:exported is
exported below API 17 and private at 17 and above, and the intent filter makes
no difference to it at all. So the same missing attribute in the same file
resolves in opposite directions for two components sitting ten lines apart.
That is what examples/subtle-AndroidManifest.xml is: a manifest with no
android:exported="true" anywhere, no debuggable flag, backup off, every
permission signature-level, and three components any app on the device can
drive.
A guard can be undone elsewhere. android:exported="false" on a provider
is undone by a grantUriPermissions="true" that lets the provider hand a
per-URI grant to whoever asks. A careful android:permission on an activity is
undone by an <activity-alias> pointing at it that declares no permission of
its own. A readPermission with no matching writePermission makes a provider
easier to write to than to read. None of those are visible from the line that
looks like the control.
The matchers are not the matchers you think. android:pathPattern is not a
regular expression and not a shell glob. It is
PatternMatcher.PATTERN_SIMPLE_GLOB, in which * quantifies only the single
character before it and the matcher returns a match the moment it reaches an
unescaped .* — so everything after that point in the pattern is never
evaluated. /note/.*/edit is not "any one segment, then /edit". It accepts any
path beginning /note/. Meanwhile the <data> elements inside one
<intent-filter> are not a list of URIs: the platform collects every scheme,
every host and every path rule into three independent sets and matches one of
each, so six <data> elements routinely register eight URI patterns, most of
which nobody wrote down.
A reviewer reading a manifest top to bottom sees attributes. This tool computes the reachable set.
No dependencies, Python 3.11 or newer.
git clone https://github.com/Young-Syre/android-attack-surface
cd android-attack-surface
pip install .
Or run it straight out of the tree with no install at all:
python -m android_attack_surface examples/vulnerable-AndroidManifest.xml
android-attack-surface AndroidManifest.xml
android-attack-surface AndroidManifest.xml --surface
android-attack-surface AndroidManifest.xml --format json --fail-on high
android-attack-surface AndroidManifest.xml \
--network-security-config res/xml/network_security_config.xml \
--backup-rules res/xml/backup_rules.xml \
--file-provider-paths res/xml/filepaths.xml
Get the input with apktool:
apktool d app.apk -o decoded
android-attack-surface decoded/AndroidManifest.xml \
--network-security-config decoded/res/xml/network_security_config.xml
The three optional files are the resources the manifest names but does not
contain, and each changes the answer: the network security config decides
whether cleartext is allowed and which CAs are trusted, the backup rules decide
what allowBackup actually hands over, and a FileProvider's <paths>
resource decides which directories an exported provider can be made to read.
Without them the tool reports what the manifest says and says it could not see
the rest.
--surface prints only the reachable set, grouped by how hard it is to reach.
On a large app this is the step that tells you where to spend the afternoon.
Run against examples/subtle-AndroidManifest.xml:
$ python -m android_attack_surface examples/subtle-AndroidManifest.xml --surface
package: com.example.quietleak
minSdk: 23 targetSdk: 30 effective: 30
components: 6 exported: 4 unguarded: 4
KIND NAME LINE GUARD RESOLVED BY URIS
-------- ----------------- ---- ----- -------------------- ----
# no permission guard -- reachable by any app
activity .MainActivity 37 - explicit-true 0
activity .WebViewActivity 45 - filter-default-pre31 0
receiver .ConfigReceiver 60 - filter-default-pre31 0
service .ExportJobService 53 - filter-default-pre31 0
Three of those four say filter-default-pre31: nothing in that manifest asked
for them to be exported. The <provider> in the same file, with the same
missing attribute, is correctly absent from the table.
The report leads with the reachable set rather than the file order, because file order is how manifest review goes wrong.
$ python -m android_attack_surface examples/vulnerable-AndroidManifest.xml \
-n examples/network_security_config.xml \
-b examples/backup_rules.xml \
-p examples/filepaths.xml --min-severity high
============================================================================================
android-attack-surface
============================================================================================
manifest examples/vulnerable-AndroidManifest.xml
package com.example.insecurenotes
sdk min=19, target=30, effective=30
network config examples/network_security_config.xml
backup rules examples/backup_rules.xml
provider paths examples/filepaths.xml
components 14 declared, 14 exported, 11 exported with no guard
findings 3 critical, 27 high
Each finding carries the line it came from and the command that reaches it:
[CRIT] AAS-015 Exported provider with no read or write guard
provider .NotesProvider (line 165)
provider .NotesProvider is exported with no android:permission, readPermission or
writePermission.
- android:exported="true" is declared.
- authority: com.example.insecurenotes.provider
reach it with:
$ adb shell content query --uri content://com.example.insecurenotes.provider/
$ adb shell content insert --uri 'content://com.example.insecurenotes.provider/<table>' --bind key:s:value
And where a matcher is the problem, it explains the matcher:
[HIGH] AAS-022 pathPattern accepts more than it appears to
activity .DeeplinkActivity (line 75)
activity .DeeplinkActivity: pathPattern "/note/.*/edit" matches more than it appears
to.
- everything after the '.*' at offset 6 is never evaluated: the matcher returns a
match as soon as it reaches '.*', so '/edit' is not enforced and the filter accepts
any path beginning '/note/'
- for example https://notes.example/note/anything is accepted
- for example https://*.cdn.notes.example/note/anything is accepted
- for example http://notes.example/note/anything is accepted
[INFO] AAS-028 Deeplink filter cross-product exceeds what was written
activity .DeeplinkActivity (line 75)
activity .DeeplinkActivity: the filter's 6 <data> element(s) expand to 8 URI
patterns.
- schemes: https, http
- authorities: notes.example, *.cdn.notes.example
- paths: pathPattern=/note/.*/edit, pathPrefix=/share
- accepts https://notes.example<pathPattern:/note/.*/edit>
- accepts https://notes.example/share<...>
- accepts https://*.cdn.notes.example<pathPattern:/note/.*/edit>
- accepts https://*.cdn.notes.example/share<...>
- accepts http://notes.example<pathPattern:/note/.*/edit>
- accepts http://notes.example/share<...>
- accepts http://*.cdn.notes.example<pathPattern:/note/.*/edit>
- accepts http://*.cdn.notes.example/share<...>
The hardened counterpart exits 0:
$ python -m android_attack_surface examples/hardened-AndroidManifest.xml ; echo $?
...
components 9 declared, 4 exported, 2 exported with no guard
findings 3 info
...
0
It is not finding-free, and that is deliberate. The two public entry points —
the launcher activity and a verified App Link handler — are still reported,
under AAS-044, because they are still startable by an explicit intent from
any app with attacker-chosen extras. What "hardened" buys is that every one of
those exposures is stated in the manifest rather than inherited from a default.
These are the rules the tool implements, and the API levels it models them at.
android:exported |
<intent-filter> |
effective targetSdk |
result |
|---|---|---|---|
true |
any | any | exported (explicit-true) |
false |
any | any | not exported (explicit-false) |
| absent | none | any | not exported (no-filter-default) |
| absent | present | ≤ 30 | exported (filter-default-pre31) |
| absent | present | ≥ 31 | build error (filter-missing-explicit-31plus) |
android:exported |
effective targetSdk |
result |
|---|---|---|
true |
any | exported (explicit-true) |
false |
any | not exported (explicit-false) |
| absent | ≤ 16 | exported (provider-default-pre17) |
| absent | ≥ 17 | not exported (provider-default-17plus) |
The effective targetSdkVersion is targetSdkVersion if declared, otherwise
minSdkVersion, otherwise 1. That fallback is itself reported (AAS-054),
because it silently selects the oldest and most permissive defaults in both
tables.
The RESOLVED BY column in --surface and the export_decision.rule field in
JSON output name the row that decided each component, so you can check the
tool's reasoning rather than trusting it.
The brief for this tool was that version boundaries get misstated, so here is where my confidence sits.
Confident, and tested at every boundary: the intent-filter default for
activities, services and receivers; the API 31 requirement to declare
android:exported explicitly on an intent-filtered activity, service or
broadcast receiver; the provider default flipping at API 17; the effective-SDK
fallback chain; cleartext traffic becoming default-denied at API 28.
Modelled as stated, with the limit stated:
- The API 31 requirement and
<provider>. Google documents the explicit-export requirement for activities, services and broadcast receivers that declare intent filters. Providers are not named. I therefore resolve a provider's default fromtargetSdkVersionalone at every level, and an intent filter on a provider does not change the answer. I have not verified what current build tooling does with an intent-filtered provider that omitsandroid:exportedattargetSdk31+. If you know, the test that pins this istests/test_exported.py::ProviderDefaults::test_provider_default_ignores_intent_filters. - Custom permission squatting (
AAS-006). Permission names are first-come-first-served, and an app that defines a name first owns it. I model API 21 as the level at which a conflicting definition from a differently-signed app fails to install rather than being silently accepted, and the finding says so in those terms. Handling here has changed more than once across releases, so the rule reports the condition — anormalordangerouscustom permission gating something — and does not claim a specific platform's outcome. pathPatternwith an escaped dot. A backslash stops.from pairing with a following*. But in the literal branch of the algorithm the test isc != '.'without consulting whether the character was escaped, so\.appears to still match any single character.glob_matchreproduces that, andtests/test_deeplinks.py::SimpleGlob::test_escaped_dot_still_matches_any_characterpins it. I have not confirmed this corner against a device. The.*short-circuit, which is whatAAS-022reports, I am confident about and it is tested directly.PROTECTED_BROADCASTS. A curated subset of AOSP's protected-broadcast list, not a copy of it. A broadcast action missing from the list is not evidence it is unprotected. The rule it drives (AAS-008) is sound either way, because its point is that an exported receiver accepts an explicit intent regardless of what its filters say.
Not modelled: pathAdvancedPattern (API 26+) is a different, richer
matcher; it is recognised and reported but not simulated, and the overlap check
returns "no overlap" rather than guessing when it meets one. pathSuffix (API
31+) is treated as a plain suffix test.
android:pathPattern uses PatternMatcher.PATTERN_SIMPLE_GLOB. The rules, each
with the test that asserts it in tests/test_deeplinks.py:
| behaviour | example |
|---|---|
| The whole path must match, not a prefix | /pay does not match /payment |
. matches any one character |
/a.c matches /abc and /a/c |
* quantifies only the character before it |
/a* matches /a, /aaaa — not /ab |
* is not a wildcard on its own |
/x/* does not match /x/anything |
.* returns a match immediately |
/note/.*/edit matches /note/anything |
…so text after .* is never evaluated |
/note/.*/edit matches /note/x/delete |
The .* must still be reached |
/note/.*/edit does not match /note/ |
A trailing X* cannot match zero |
/a* does not match / |
No backtracking after X* |
/a*a does not match /aa |
The last three are consequences of the algorithm's shape rather than of its
documentation: the main loop needs a character left in the value to consume the
next pattern token at all, and the single special case at the end of the
function only covers a trailing .*.
glob_match() in android_attack_surface/deeplinks.py is a direct port, so the
URI expansion in the report and the warnings about it come from one model
rather than two.
| ID | Name | Severity | Mechanism |
|---|---|---|---|
| AAS-001 | Exported component with no permission guard | high | Any app can start or bind it with an explicit intent, no permission, no user interaction. |
| AAS-002 | Exported only by the intent-filter default | medium | Exported because it has a filter and targetSdk < 31. Nothing in the manifest says it was intended. |
| AAS-003 | Provider exported by the pre-API-17 default | high | No android:exported and effective targetSdk < 17, where a provider defaults to exported. |
| AAS-004 | Intent filter without explicit exported at targetSdk 31+ | medium | A combination that fails the build, so no platform default applies; treated as reachable. |
| AAS-005 | Exported component guarded by a weak protection level | high | The guard's base level is normal (granted at install) or dangerous (one prompt away). |
| AAS-006 | Custom permission open to squatting | medium | A normal/dangerous custom permission; the name belongs to whichever app defines it first. |
| AAS-007 | Guard permission not declared in this manifest | info | The guard's protectionLevel cannot be resolved offline. |
| AAS-008 | Exported receiver reachable by explicit intent | medium | Protected-broadcast filters cover only the implicit path; an explicit intent bypasses filter matching. |
| AAS-009 | Exported activity-alias weaker than its target | high | An unguarded alias is a second door onto a guarded activity. |
| AAS-010 | grantUriPermissions with no path-permission | high | A per-URI grant can be issued for any path under the authority, around any read guard. |
| AAS-011 | Provider write permission weaker than read | high | The provider is easier to write to than to read. |
| AAS-012 | Provider declared multiprocess | low | One provider instance per calling process, so in-process checks are per-caller. |
| AAS-013 | Exported provider on a guessable authority | medium | Reaching the authority does not require reading the manifest. |
| AAS-014 | FileProvider root reaches outside app storage | high | A <paths> entry grants the filesystem root or shared storage, so URIs can be minted for files the app does not own. |
| AAS-015 | Exported provider with no read or write guard | critical | Directly queryable and writable with adb shell content. |
| AAS-020 | Custom scheme with no host | medium | Any app can register the same bare scheme; there is no ownership proof for one. |
| AAS-021 | http/https filter without autoVerify | medium | A web-intent handler, not a verified App Link: no assetlinks.json check, chooser shown. |
| AAS-022 | pathPattern accepts more than it appears to | high | Simple-glob returns on .*, so the rest of the pattern is never evaluated. |
| AAS-023 | Wildcard deeplink host | medium | Every name under the wildcard reaches the component, including a subdomain an attacker takes over. |
| AAS-024 | Overlapping deeplink filters | medium | Which component handles a link is decided by install order and chooser state, not the manifest. |
| AAS-025 | Filter mixes http and https across data elements | low | The cross product registers the plaintext variant of every host and path in the filter. |
| AAS-026 | Deeplink host is not lowercase | low | Matching is against the lowercased authority, so the filter is dead. |
| AAS-027 | Path rules declared with no host | low | Path attributes are only consulted when a host is present, so they are inert. |
| AAS-028 | Deeplink filter cross-product exceeds what was written | info | Schemes, hosts and paths are independent sets, so the filter registers URIs nobody wrote. |
| AAS-030 | Cleartext traffic permitted | medium | Plaintext HTTP is allowed, so any network position can read and rewrite the traffic. |
| AAS-031 | Network security config permits cleartext | medium | cleartextTrafficPermitted="true" re-enables plaintext for its scope. |
| AAS-032 | debug-overrides block present | high | Applies whenever android:debuggable is true, so it is a ready-made interception path. |
| AAS-033 | Network config trusts the user CA store | high | src="user" trusts what the device owner installed, which is what a proxy installs. |
| AAS-034 | Domain config weakens its parent | medium | The nearest domain-config wins, so the exception applies and the stricter rule does not. |
| AAS-035 | Backup enabled | medium | Private data can be pulled off the device with adb backup, without root. |
| AAS-036 | Backup rules include a sensitive or whole domain | high | Backup extraction yields sharedpref/database contents, which is where tokens live. |
| AAS-037 | Application declared debuggable | critical | Any local user can attach a debugger and run code in the app's uid, defeating everything else. |
| AAS-038 | Application declared testOnly | medium | A development build, which commonly carries debug configuration. |
| AAS-039 | meta-data key names a credential | medium | Manifest meta-data is world-readable through PackageManager. |
| AAS-040 | Task-hijackable exported activity | high | singleTask/singleInstance with a known affinity lets another app place its activity in this task. |
| AAS-041 | allowTaskReparenting enabled | medium | The activity moves into the task of whichever app next owns its affinity. |
| AAS-042 | Exported activity not excluded from recents | low | A snapshot is retained in the recents list. |
| AAS-043 | Exported activity with a launcher filter and a guard | info | A guard on a launcher activity does not reduce reachability from the launcher. |
| AAS-044 | Public entry point by design | info | A launcher activity or verified App Link handler, which cannot be guarded — still untrusted input. |
| AAS-050 | Dangerous permission requested | info | A runtime permission, listed so the reachable set reads against what the app can already do. |
| AAS-051 | sharedUserId declared | high | Collapses the uid boundary with every app sharing the id; compromise of one is compromise of all. |
| AAS-052 | Component runs in a globally-named process | medium | A process name with no leading : is shared with other apps declaring it. |
| AAS-053 | QUERY_ALL_PACKAGES requested | low | Defeats the API 30 package-visibility restrictions. |
| AAS-054 | targetSdkVersion not declared | medium | The platform falls back to minSdkVersion, then 1, selecting the oldest defaults. |
| AAS-055 | Notable permission requested | low | A permission that widens the app's reach, worth reading alongside the exported set. |
| AAS-900 | XML refused before parsing | high | A DOCTYPE, entity or markup declaration. The body is never parsed. |
| AAS-901 | Input is not decoded XML | info | A compiled binary resource or an archive. Decode it with apktool d first. |
--list-rules prints this table from the catalogue the code uses, and
tests/test_catalogue.py fails if the two drift apart.
A severity here is a statement about how many parties can reach something, not
about what they get when they do. AAS-015 is critical because any app can
read and write the provider; whether that matters depends on what is in it.
Read the reachable set first and the severities second.
- It does not read binary (compiled)
AndroidManifest.xml. An APK's manifest is compiled AXML, and a half-working binary XML parser is worse than an honest boundary. The tool detects compiled AXML, aresources.arsctable and a ZIP/APK by their magic bytes and refuses each with exit 3 and a message telling you to runapktool dfirst. - It does not contact a device, open a socket, run a binary, or unzip
anything. It reads files and writes to stdout. The
adbcommands in the report are printed for you to run; nothing in the package can run them.tests/test_no_network.pywalks the AST of every module in the package and fails on an import ofsocket,ssl,urllib,http,subprocess,zipfileand the rest, on anyeval/exec/popen/systemcall, and on anyopen()outside the single read-only call site insafexml.py. - It does not resolve resource references.
android:exported="@bool/x",targetSdkVersion="@integer/y"and a codename such asminSdkVersion="S"need the compiled resource table. They are treated as not stated, which routes them through the default-resolution rules, and the raw value is kept in JSON output so you can see what it was. - It does not merge manifests. Gradle's manifest merger folds in library
manifests, and a library can add an exported component or an
android:permissionthat the application manifest does not show. Analyse the merged manifest frombuild/intermediates, or the one from a decoded release APK, which is already merged. A sourceAndroidManifest.xmlfrom a Gradle module is the least complete thing you can feed it. - It does not read code. A component registered with
Context.registerReceiverat runtime is not in the manifest and so is not in this report. Neither is the question of whether an exported component validates its intent extras — the tool tells you what is reachable, not what happens next. - It does not know your threat model. An exported activity is a finding here even when exporting it is the whole point of the feature.
The input is an attacker-controlled file from somebody else's APK, so:
- A
DOCTYPE, an<!ENTITY>, any other markup declaration, or a reference to any entity beyond the five XML predefines and numeric character references is refused before the parser sees the document body. An expansion bomb is never expanded and an external entity is never resolved, because the refusal happens with no parser in play at all —tests/test_safexml.py::Refusals::test_refusal_happens_before_the_parser_is_createdpatchesexpat.ParserCreateand asserts it is never called. - The scan blanks comments, CDATA and processing instructions with same-length,
newline-preserving whitespace first, so a comment mentioning
DOCTYPEis not a false refusal, aDOCTYPEhidden after a comment still is, and the line number in the message is the real one. - Parsing is
xml.etree.ElementTree'sTreeBuilderdriven fromxml.parsers.expat, which is what ElementTree does internally. The entity and external-reference handlers are simply never installed. Driving expat directly is also what makes line numbers available, whichElementTree.parsedoes not provide. - Input above
--max-bytes(16 MiB by default) is refused unread past the limit. A decoded manifest is tens of kilobytes.
Fixtures for each case live in examples/refused/.
| code | meaning |
|---|---|
| 0 | analysis completed; nothing at or above --fail-on |
| 1 | analysis completed; at least one finding at or above --fail-on |
| 2 | usage error (bad arguments, unknown rule id) |
| 3 | input refused: unsafe XML, binary AXML, an archive, malformed XML, or an unreadable file |
--fail-on defaults to medium. --min-severity controls what is printed
and --fail-on controls the exit code; they are independent, so
--min-severity high --fail-on info prints the serious findings and still
fails on anything at all.
The adb shell am, adb shell content and adb backup commands this tool
generates reach into a running application. Run them only against a device you
own, or one you have written authorisation to test, and only against an app
in scope for that authorisation. Reading a manifest is analysis; sending an
intent is interacting with a system. The tool deliberately cannot run them for
you.
| file | what it is |
|---|---|
examples/vulnerable-AndroidManifest.xml |
Deliberately wrong in roughly fifteen ways: unguarded exports, a hostless custom scheme, a .* pathPattern, cleartext, backup on, debuggable, sharedUserId. |
examples/hardened-AndroidManifest.xml |
The same app with every export decision stated. Exits 0. |
examples/subtle-AndroidManifest.xml |
targetSdk 30, no exported="true" anywhere, no obvious flags — and three components any app can drive. |
examples/network_security_config.xml |
Ships <debug-overrides>, a cleartext exception, a user trust anchor and overridePins. |
examples/backup_rules.xml |
Includes the whole sharedpref domain. |
examples/filepaths.xml |
A <root-path path=""> granting the filesystem root. |
examples/refused/ |
Billion laughs, external entity, parameter entity, a bare entity reference, and synthetic compiled AXML. |
Every example is fabricated. Package names are under com.example.*, hosts are
under the reserved .example TLD, and no real application's manifest was read
or copied in building any of them.
python -m unittest discover -s tests -t . -v
367 tests covering every row of both exported tables and every version
boundary in them, every rule in the catalogue firing and not firing, the
simple-glob matcher against each of its documented quirks, the <data> cross
product, the overlap heuristic, every XML refusal, the CLI's output shapes and
exit codes, and the no-network claim. CI runs the suite on Python 3.11, 3.12
and 3.13 and then exercises the CLI end to end against the example manifests.
MIT. See LICENSE.