Back to Blog

APK Signing Schemes V1–V4 and Why Hardening Breaks Them

7 min read·2026-08-12

What each signing scheme actually protects, how Janus-style attacks worked, and why a hardened APK must always be re-signed last.

Android signing is not one mechanism, it is four stacked ones, and each generation fixed a blind spot in the previous. V1 (JAR signing) hashes every file individually into META-INF/MANIFEST.MF, then signs a digest of that manifest. Because it signs files rather than the byte stream, anything outside the manifest — ZIP comments, padding, files not listed — is unprotected. That gap is exactly what CVE-2017-13156 (Janus) exploited: prepend a DEX to a legitimately signed APK and the ZIP parser sees a valid package while ART happily executes the attacker's code.

V2 fixed this by signing the whole byte stream. It inserts an APK Signing Block immediately before the ZIP Central Directory and splits the remaining sections into 1MB chunks, hashing each with SHA-256 and then hashing the chunk digests. Chunking makes verification parallelisable — a big win on hundreds-of-megabyte packages — and leaves the ZIP structure untouched, because ZIP readers locate the Central Directory from the EOCD at the end and never look at the block. V3 keeps that structure and adds a proof-of-rotation lineage so a leaked key can be replaced without losing the ability to update. V3.1 (Android 13) adds an extension slot that old devices ignore; V4 is a separate .apk.idsig file with an fs-verity Merkle tree used only for incremental installation.

The version matrix matters in practice. Below Android 7.0 only V1 is understood. From 7.0 V2 is preferred. Android 11+ can reject V1-only packages in some paths, and Android 13+ tooling expects V3.1 awareness. The safe baseline for a release build remains V1 + V2 + V3, with V4 generated for you when you ship an AAB through Play. Always verify with `apksigner verify --verbose --print-certs` in CI — a package that silently dropped a scheme is an install-time landmine, not a build warning.

Hardening collides with all of this in a specific way: every packer re-signs. dpt-shell writes its own debug certificate, Abdal-DroidGuard writes another, and each stage leaves stale signature material behind. Running apksigner on top of that residue either fails or produces a package whose V2 block no longer matches the bytes it protects. The correct order is therefore strict: strip old signatures (zip_clean.py), align (zipalign), then sign V1+V2+V3 and verify. Signing is always the last stage, never a middle one.

One trap is worth calling out because it looks like a random crash: dpt-shell's `-vs` flag turns on runtime signature verification inside the shell. That check runs against the certificate the shell itself applied, not the certificate you finally ship, so enabling it alongside a final re-sign makes the app abort at launch. This is why VALLUM's safe mode deliberately omits `-vs`. If you want runtime signature checking, implement it yourself against your own release fingerprint rather than letting the packer check against its debug key.

Finally, remember what signing does not buy you. Signing proves integrity and authorship to the platform, but an attacker who repackages your app simply signs it with their own key and distributes it through a third-party store — the platform is perfectly happy. Detecting that case requires an in-app fingerprint check against your known release certificate, hardened so it cannot be NOP'd out. That check belongs in native code, behind anti-tamper, which is precisely why VALLUM couples SignSeal to the shell rather than shipping it as a library call.