Skip to content

About

Resolves an Android app's externally reachable surface from a decoded manifest — exported components against targetSdk defaults, deeplink pattern expansion, provider and transport posture.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

9 Commits

Folders and files

Repository files navigation

android-attack-surface

tests python licence dependencies

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.

The attack surface is declared, not discovered

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.

Install

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

Usage

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.

Triage mode

--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.

Full report

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.

How exported is resolved

These are the rules the tool implements, and the API levels it models them at.

activity, activity-alias, service, receiver

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)

provider

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.

What I am and am not certain of

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 from targetSdkVersion alone 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 omits android:exported at targetSdk 31+. If you know, the test that pins this is tests/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 — a normal or dangerous custom permission gating something — and does not claim a specific platform's outcome.
  • pathPattern with an escaped dot. A backslash stops . from pairing with a following *. But in the literal branch of the algorithm the test is c != '.' without consulting whether the character was escaped, so \. appears to still match any single character. glob_match reproduces that, and tests/test_deeplinks.py::SimpleGlob::test_escaped_dot_still_matches_any_character pins it. I have not confirmed this corner against a device. The .* short-circuit, which is what AAS-022 reports, 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.

pathPattern semantics

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.

Findings

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.

Severity means reachability, not impact

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.

What this tool does not do

  • 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, a resources.arsc table and a ZIP/APK by their magic bytes and refuses each with exit 3 and a message telling you to run apktool d first.
  • It does not contact a device, open a socket, run a binary, or unzip anything. It reads files and writes to stdout. The adb commands in the report are printed for you to run; nothing in the package can run them. tests/test_no_network.py walks the AST of every module in the package and fails on an import of socket, ssl, urllib, http, subprocess, zipfile and the rest, on any eval/exec/popen/system call, and on any open() outside the single read-only call site in safexml.py.
  • It does not resolve resource references. android:exported="@bool/x", targetSdkVersion="@integer/y" and a codename such as minSdkVersion="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:permission that the application manifest does not show. Analyse the merged manifest from build/intermediates, or the one from a decoded release APK, which is already merged. A source AndroidManifest.xml from a Gradle module is the least complete thing you can feed it.
  • It does not read code. A component registered with Context.registerReceiver at 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.

XML safety

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_created patches expat.ParserCreate and asserts it is never called.
  • The scan blanks comments, CDATA and processing instructions with same-length, newline-preserving whitespace first, so a comment mentioning DOCTYPE is not a false refusal, a DOCTYPE hidden after a comment still is, and the line number in the message is the real one.
  • Parsing is xml.etree.ElementTree's TreeBuilder driven from xml.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, which ElementTree.parse does 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/.

Exit codes

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.

Authorisation

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.

Examples

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.

Tests

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.

Licence

MIT. See LICENSE.

About

Resolves an Android app's externally reachable surface from a decoded manifest — exported components against targetSdk defaults, deeplink pattern expansion, provider and transport posture.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages