An APK does not ship its manifest as readable XML. The 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.
It is a mundane building block. It is also where an audit can lie without ever crashing.
The symptom
A home-grown parser returned apps with no attributes at all. Not an error, not an exception: an empty dictionary where exported, debuggable and usesCleartextTraffic should have been.
A manifest audit built on it would have returned the same verdict for every app: clean. An app with an unprotected exported activity, another allowing cleartext HTTP to a banking backend — same reassuring conclusion, because the code was reading nothing.
A silent tool is not a green light. A tool that answers "nothing to report" when it examined nothing is worse than no tool at all: it manufactures confidence.
The cause
The layout of a binary XML node is this:
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 a "byte offset from the start of this structure". Everything hangs on which structure: it is ResXMLTree_attrExt, not the chunk. Real manifests write 20 there, so attributes begin at offset 36.
The code measured from the chunk start and read at offset 20. Sixteen bytes early. What sits there is perfectly valid data — the header — so nothing flagged the mistake. The attribute count read from that position was zero, and a loop that runs zero times raises nothing.
Why the tests missed it
This is the interesting part, and the only one that generalises.
The test suite rested on a round trip: an encoder wrote a manifest, the parser read it back, the two were compared. The test passed.
It passed because the encoder had been written by the same person, on the same day, from the same misreading of the specification. It wrote 36 where the parser read 36. Both were wrong in the same direction, so they agreed.
A round-trip test never proves that code conforms to a format. It proves that the code is consistent with itself. On a format you invented, that is enough. On a format someone else defined — AOSP, an RFC, a banking standard — it is worth nothing.
What replaced it
Three layers, in increasing order of what they actually demonstrate:
| 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. |
The middle layer was the missing one. A byte-exact vector is a hand-written array of bytes whose expected output you know because you derived it from the specification, not from your own code.
Alongside it sits a test asserting that an encoder writing the wrong offset produces visibly wrong output. In other words: the round-trip layer can never again quietly certify a shared error.
A manifest is hostile data
One last point, which outlives this particular bug. 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 for well-formed input crash, hang, or exhaust memory on exactly the samples you most want to read. Every length and offset taken from the file has to be validated against the real buffer size before use — and that contract is verified by fuzzing, not by inspection.
The code is public: github.com/rolandsanou/axmlite. It reads Android binary XML with the Python standard library alone, for the isolated triage machine where pip install is not an option.