Read Android binary XML (AXML) with the Python standard library alone.
An APK does not ship its manifest as readable XML. AndroidManifest.xml inside the
archive is compiled into Android's binary resource format, so anything that needs to
know what an app declares — exported components, permissions, intent filters, deep
links, cleartext policy — has to decode that format first.
import axmlite
for el in axmlite.from_apk("app.apk"):
if el.tag == "activity" and el.get("exported"):
print(el.attrs["name"], "is exported")$ python -m axmlite app.apk
<manifest package="com.example.bank" versionCode="42">
<uses-permission android:name="android.permission.READ_SMS" />
<application android:debuggable="true" android:usesCleartextTraffic="true">
<activity android:name=".Main" android:exported="true" />
<provider android:name=".Data" android:exported="false" />
</application>
</manifest>
$ python -m axmlite app.apk --json | jq '.[] | select(.tag=="uses-permission")'No dependencies. Python 3.8+.
It runs where pip install does not. Malware triage often happens on an isolated
VM, an air-gapped lab machine, or a customer's laptop you were handed ten minutes ago.
A tool that needs network access at that moment is a tool you do not have. axmlite
imports struct, zipfile and json. That is the whole dependency list, and it is
also why it is a single file you can copy into a repository or paste into a notebook.
It assumes the input is hostile. A manifest handed to an analyst is frequently malformed on purpose: string counts that overflow, offsets pointing outside the buffer, chunk sizes of zero that spin a naive loop forever. Parsers written against well-formed input crash, hang, or exhaust memory on exactly the samples you most want to read. Here, every length and offset taken from the file is validated against the buffer before use.
| Mode | Behaviour |
|---|---|
parse(data) |
Tolerant. Returns whatever was readable. Never raises. |
parse(data, strict=True) |
Raises AXMLError on the first inconsistency, and nothing else — no IndexError, no struct.error, no MemoryError. |
Both contracts are enforced by fuzzing, not by inspection: 3 000 random byte mutations plus every possible truncation point of a valid file.
This library exists because of a specific bug, and the test suite is shaped around not repeating it.
The AXML node layout is:
ResXMLTree_node offset 0
chunk header type, headerSize, size 8 bytes
lineNumber 4 bytes
comment 4 bytes
ResXMLTree_attrExt offset 16
ns, name 8 bytes
attributeStart, attributeSize, ... 12 bytes
attributes offset 16 + attributeStart
attributeStart is documented in AOSP's ResourceTypes.h as "byte offset from the
start of this structure" — this structure being ResXMLTree_attrExt, not the
chunk. Real manifests write 20, so attributes begin at offset 36.
An earlier version of this code measured from the chunk start and read at offset 20 —
sixteen bytes early. It returned zero attributes from every conformant manifest.
No exception, no warning: just an empty dictionary where exported, debuggable and
usesCleartextTraffic should have been. A manifest audit built on it would have
reported a clean app, always.
It survived because the round-trip test used an encoder written by the same author,
which wrote 36 to match. Encoder and parser were wrong in the same direction, so
they agreed, and the test passed.
The lesson is in the test layout:
| Layer | What it proves |
|---|---|
| Round-trip against the bundled encoder | The parser agrees with us. Cheap; weakest. |
| Byte-exact specification vectors | The parser agrees with the format. Offsets are written out with the AOSP struct in the comment, so a reviewer can check them by reading. |
| Hostile input and fuzzing | It survives data written to break it. |
There is also a test asserting that an encoder writing the wrong offset produces visibly wrong output — so the round-trip layer can never again quietly certify a mutually consistent error.
parse(data: bytes, *, strict: bool = False) -> list[Element]
parse_file(path, *, strict=False) -> list[Element]
from_apk(path, *, strict=False) -> list[Element] # reads AndroidManifest.xml
to_xml(elements, indent=" ") -> strElement is a dataclass in document order:
| Field | Meaning |
|---|---|
tag |
element name |
attrs |
attributes by local name — exported |
qualified |
attributes by prefixed name — android:exported |
path |
manifest/application/activity |
depth, line, namespace |
position and source line |
A manifest may declare both exported and android:exported on the same element.
Android honours only the namespaced one. A tool that flattens to local names sees
whichever happened to come last — which makes the pair a usable way to show one value
to an analysis tool and another to the platform. Keep attrs for convenience; reach
for qualified when the answer matters.
Typed values are decoded to Python: booleans to bool, integers to signed int,
floats, dimensions (16dip), fractions, colours (#ff0000ff), and references as
@0x7f010001.
What it does not do, deliberately:
- No resource resolution. A
@0x7f010001reference is returned as-is; mapping it to a string needsresources.arsc, which is a different format and a different problem. - No text nodes.
CDATAchunks are skipped. Manifests do not use them. - No writing. A reader should not carry a writer it does not need. The encoder used to exercise the parser lives in the test file.
- No APK integrity checks.
from_apkuseszipfile, which reads the central directory. An APK can carry a local header that disagrees with it — a known way to show one manifest to a tool and another to Android.axmlitedoes not pretend to resolve that ambiguity; if it matters to your threat model, compare the headers yourself.
If you need resource resolution, DEX parsing and a full APK model, use
androguard. axmlite is for the case
where you want one answer, quickly, with nothing installed.
python -m unittest discover -s tests22 tests, no dependencies, no network.
MIT.