Android APK¶
Level: 201 · for anyone with a hex editor open
One line: An APK is a ZIP, and a ZIP is read from the end: the loader finds the end-of-central-directory record in the last 22 bytes, follows it to the central directory, and only then reaches classes.dex — and every filename inside is IBM code page 437 unless one bit in its header says UTF-8.
What the loader reads¶
An Android package is a ZIP archive with a fixed set of members: AndroidManifest.xml (binary XML, not text), classes.dex and any classes2.dex, classes3.dex … that follow (DEX), resources.arsc, and the META-INF/ signature files. The ZIP format is PKWARE's APPNOTE ↗, and its structure is three kinds of record, all little-endian:
| record | signature | where | what it holds |
|---|---|---|---|
| local file header | PK\3\4 |
in front of each member's bytes | flags, method, CRC, sizes, and the filename |
| central directory header | PK\1\2 |
one per member, all together near the end | the same again, plus the offset of the local header |
| end of central directory | PK\5\6 |
the last 22 bytes, plus a comment | the count of entries, and the size and offset of the central directory |
A reader starts at the end. It finds PK\5\6, reads the central directory's offset, reads the directory, and reaches each member through the offset stored there. The PK\3\4 at offset 0 that file(1) tests for is the first member's local header, which happens to be at the front; it is not an index and nothing in the format promises it is at offset 0 at all.
In Python¶
Verified output of android_apk_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. A ZIP IS READ FROM THE END
------------------------------------------------------------------------
file size 476 last 22 bytes 50 4b 05 06 00 00 00 00 03 00 03 00 b8 00 00 00 0e 01 00 00 00 00
signature b'PK\x05\x06' 0x06054b50
entries 3
central dir 184 bytes at offset 270
comment 0 bytes
offset 0 b'PK\x03\x04' a local file header -- the FIRST entry, not the index
offset 270 b'PK\x01\x02' the central directory, which is the index
A reader seeks to the end, finds this record, and follows its
offset to the central directory; the local headers at the front
are reached from there. PK\3\4 at offset 0 is what file(1) tests,
and it is a convention, not what a ZIP reader uses.
2. SO BYTES IN FRONT DO NOT BREAK IT
------------------------------------------------------------------------
24 bytes of shell script, then the ZIP opens: ['classes.dex', 'AndroidManifest.xml', 'assets/café.txt']
the ZIP, then 13 bytes of junk opens: ['classes.dex', 'AndroidManifest.xml', 'assets/café.txt']
Self-extracting archives and installers are exactly a program with
a ZIP appended, and every reader that starts from the end handles
them. The offsets inside are relative to the archive, so zipfile
measures where the archive begins and corrects them.
3. A FILENAME'S CODE PAGE IS ONE BIT IN ITS HEADER
------------------------------------------------------------------------
at 0 flags 0x0000 bit 11 clear name bytes 63 6c 61 73 73 65 73 2e 64 65 78
at 153 flags 0x0000 bit 11 clear name bytes 41 6e 64 72 6f 69 64 4d 61 6e 69 66 65 73 74 2e 78 6d 6c
at 218 flags 0x0800 bit 11 set name bytes 61 73 73 65 74 73 2f 63 61 66 c3 a9 2e 74 78 74
zipfile reads ['classes.dex', 'AndroidManifest.xml', 'assets/café.txt']
bit 11 cleared ['classes.dex', 'AndroidManifest.xml', 'assets/café.txt']
Same name bytes, 63 61 66 c3 a9, in both files. With bit 11 set
they are UTF-8 and read back as café; with it clear the APPNOTE
says IBM code page 437, and c3 a9 is two characters there. Python
sets the bit only when a name is not ASCII, which is why the
first two entries carry 0x0000: for ASCII the two tables agree.
4. WHAT THE APK LOADER LOOKS FOR
------------------------------------------------------------------------
/classes.dex found
/classes2.dex absent -- the loader stops here
/classes3.dex absent -- the loader stops here
Ghidra asks the ZIP for /classes.dex, then /classes2.dex, and so on
until one is missing, and loads each as a DEX program. Everything
else in the archive -- the manifest, the resources, the signature
block -- is a file it can list and does not read.
Read from the end¶
Section 2 above puts 24 bytes of shell script in front of the archive, and zipfile opens it — every self-extracting archive and every installer is a program with a ZIP appended, and a reader that starts from the end never notices the program. Section 1 has the mechanism: the last 22 bytes name the central directory, and the central directory names the members, so the front of the file is the last thing consulted. The offsets inside are relative to where the archive begins, which a reader has to measure; Python's zipfile does, silently.
It is the opposite discipline from every other format on this list. ELF, PE and the rest are read from offset 0 forward; a ZIP is read from the end backward, which is why the two questions "what is at offset 0" and "what does the ZIP contain" can have different answers, and why an APK can be prefixed with a signature block or a bootstrapper and still install.
One bit decides the code page¶
Section 3 is the encodings lesson. A filename in a ZIP is bytes, and APPNOTE's Appendix D says which characters they are: IBM code page 437 — the MS-DOS table — unless bit 11 of the general-purpose flags is set, in which case UTF-8. The bit is per member, in both the local and the central header. Python's zipfile sets it only when a name is not pure ASCII, so classes.dex carries flags 0x0000 and assets/café.txt carries 0x0800, and the same five bytes 63 61 66 c3 a9 read back as café with the bit and caf├⌐ without it — c3 is a box-drawing character in CP437 and a9 is a ⌐.
That is mojibake with a switch, and the switch is younger than the format: bit 11 arrived in APPNOTE 6.3.0 in 2006, seventeen years after the first ZIP. Archives made before then, or by tools that never learned the bit, hold names in whatever code page the writer's machine used, labelled as 437. Android's own tools write UTF-8 and set the bit; a .jar, which is the same container, may not.
What Ghidra checks¶
ApkLoader ↗, in the FileFormats module, first asks a PkzipRecognizer whether the first bytes are a ZIP's, then mounts the archive as a filesystem and looks up /classes.dex, then /classes2.dex and so on until one is missing; each is handed to the DEX loader as a separate program. The name it shows is Android APK. It never reads the manifest, the resources or the signing block, and it does not read the central directory itself — the ZIP filesystem does, from the end, as section 1 describes.
If you are coming from Python or ABAP¶
Python. zipfile does everything above: ZipFile(io.BytesIO(data)) on an archive with a prefix, ZipInfo.flag_bits & 0x800 to see the bit, and since 3.11 ZipFile(path, metadata_encoding='cp1252') to override the code page the flag-less names are decoded in — the default is cp437, exactly per APPNOTE. ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) pins the timestamp, which is what makes the archive's bytes the same on every run; without it the program would have written the clock into the file.
ABAP. (Not machine-checked — CI cannot run ABAP.) cl_abap_zip reads and writes ZIP archives, and the encoding of a member name is a place to be careful: the class works in xstrings, and a name that is not ASCII has to be UTF-8 bytes with the flag set for a modern reader, or CP437 bytes without it for an old one — and nothing in the class name says which it wrote. Test the archive with an outside tool before shipping it, and verify any code-page number against the system.
Try it¶
xxd -s -22 app.apkon any APK. Read the entry count and the central-directory offset out of the last 22 bytes, thenxxd -s <offset> -l 4.unzip -l app.apk | grep classes. Count the.dexfiles; that is how many programs Ghidra's loader would make.zipinfo -v app.apk | grep -i -B4 'unicode', or in Python printflag_bits & 0x800for every member. Find one with a non-ASCII name.cat /bin/echo app.apk > prefixed.apkand open the result withzipfile. Thenfile prefixed.apk: the two disagree about what the file is.- Make a ZIP with a name like
Łódź.txtin an old tool or with the flag cleared, and open it in three different unzippers. Count the spellings.
See also¶
- DEX — what the loader takes out of the archive
- Mojibake —
caf├⌐, and why the bytes are not the problem - Code pages — CP437, the table a flag-less ZIP name is in
- Byte order and the BOM — the other one-bit-or-three-byte switch that decides an encoding
- File type is four questions —
file'sPK\3\4rule, which tests the front of a format that is read from the back - GDT — a ZIP with no end-of-central-directory record at all, which the reader above cannot open and Java's stream reader can
- APPNOTE 6.3.9 ↗ — the record layouts, and Appendix D on filenames