Skip to content

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

  1. xxd -s -22 app.apk on any APK. Read the entry count and the central-directory offset out of the last 22 bytes, then xxd -s <offset> -l 4.
  2. unzip -l app.apk | grep classes. Count the .dex files; that is how many programs Ghidra's loader would make.
  3. zipinfo -v app.apk | grep -i -B4 'unicode', or in Python print flag_bits & 0x800 for every member. Find one with a non-ASCII name.
  4. cat /bin/echo app.apk > prefixed.apk and open the result with zipfile. Then file prefixed.apk: the two disagree about what the file is.
  5. Make a ZIP with a name like Łódź.txt in 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
  • Mojibakecaf├⌐, 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 questionsfile's PK\3\4 rule, 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