< ALL NOTES

The bug that emptied every Android manifest

3 min read

A sixteen-byte offset error in Android's binary XML format returned zero attributes from every conformant app. No exception, no warning — just an audit calling things clean that it had never read.

androidmalware analysisbinary formattesting

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:

LayerWhat it proves
Round trip against the bundled encoderThe parser agrees with us. Cheap, weakest.
Byte-exact specification vectorsThe 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 fuzzingIt 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.

OP_CENTER
STATUS: ENCRYPTED
SYS_BOOT.exeDOSSIER.exeREGISTRY.exeNOTES.logSERVICES.exeFORMATIONS.dirCV_DATA.dirSECURE_CONNECT