Patching APKs: Smali, DEX, Rebuild, and Signing
A practical, in-depth tutorial for editing Android app code and producing a
working, installable APK β plus the theory of why the toolchain looks the way
it does.
Legal / ethical note. Only patch APKs you own, that you built yourself, or
that you have explicit permission to modify. Repackaging someone else's app
breaks its signature, its update path, and usually its terms of service, and
can be illegal depending on where you live. This document is about learning
how Android packaging works, debugging your own builds, instrumenting test
builds, and reverse engineering for interoperability.
Table of contents
- The 60-second mental model
- What is actually inside an APK
- DEX in depth
- Smali in depth
- DEX vs Smali: two views of the same thing
- Why both exist, and which is easier to understand
- Toolchain setup
- Workflow A β the standard apktool loop, step by step
- Worked example: patching a boolean check
- Workflow B β dex-only surgery (no resource rebuild)
- Workflow C β patching the DEX binary directly
- Signing in depth
- Fast paths and build modes
- Troubleshooting
- Verification checklist
- Limits, anti-tamper, and reality checks
- Command cheat sheet
1. The 60-second mental model
An APK is a ZIP file. Inside it are resources, a manifest, native libraries,
assets β and one or more classes*.dex files containing all the compiled
Kotlin/Java code.
Kotlin/Java source
β kotlinc / javac
βΌ
JVM .class files (stack-based bytecode, one file per class)
β d8 (or R8 for release builds)
βΌ
classes.dex (register-based Dalvik bytecode, all classes in one file)
β zip + aapt2 (resources) + apksigner
βΌ
app.apk
Smali is not a compilation target. It is a textual rendering of an existing
DEX file, produced by a disassembler (baksmali) and consumed by an assembler
(smali). Editing smali is editing the DEX, just in a form you can read.
classes.dex ββbaksmaliβββΆ smali/ (readable text)
β² β
βββββββββsmaliββββββββββββββ (edit in between)
apktool wraps exactly this: apktool d = unzip + decode resources + baksmali;apktool b = smali + aapt2 + zip. That is the whole trick.
2. What is actually inside an APK
app.apk (ZIP)
βββ AndroidManifest.xml binary XML (AXML), not text
βββ resources.arsc compiled resource table (binary)
βββ classes.dex code (classes2.dex, classes3.dex β¦ for multidex)
βββ res/ compiled resource files (layouts, drawablesβ¦)
βββ lib/<abi>/lib*.so native libraries
βββ assets/ raw files shipped verbatim
βββ META-INF/ v1 (JAR) signature files β MANIFEST.MF, *.SF, *.RSA
Two consequences that drive everything else:
AndroidManifest.xmlandres/are compiled, not plain text. You cannot
edit them with a text editor; you need a decoder (apktool) and a rebuilder
(aapt2). That is why "just unzip and rezip" so often produces an APK that
won't install or crashes on startup.META-INF/is a signature over the ZIP's contents. Any change to any
byte invalidates it. So the moment you edit a DEX, the app is unsigned from
Android's point of view, and you must re-sign before installing.
apktool d keeps the untouched originals in a folder called original/
(notably original/META-INF and original/AndroidManifest.xml). apktool b -c
(--copy-original) can reuse them.
3. DEX in depth
3.1 What DEX is
DEX ("Dalvik Executable") is the bytecode container that Android Runtime (ART)
loads. It is a single flat file holding every class in the app, with all
strings, types, prototypes, fields, and methods interned into shared,
indexed tables. Cross-class references become 16-bit/32-bit integer indices
instead of names, which is what makes it compact.
It is an instruction set architecture, like x86 or ARM β not a
programming language. There is no syntax, no expressions, no scoping. There is
only a byte stream plus a documented layout.
Note: DEX is not JVM bytecode. .class files are stack-based; DEX is
register-based. d8 performs that translation. This is why a .class file and
a .dex file for the same class look nothing alike, and why you cannot just
rename one to the other.
3.2 File layout
The first 0x70 bytes are a fixed header, then six index tables, then a data
section:
βββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββ
β header β 0x00 β 0x6F (112 bytes) β
βββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββ€
β string_ids β offsets to MUTF-8 strings β
β type_ids β indices into string_ids (type descriptors) β
β proto_ids β method signatures (return type + params) β
β field_ids β {class, type, name} triples β
β method_ids β {class, proto, name} triples β
β class_defs β one per class: where its data lives β
βββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββ€
β data β string bytes, type lists, code items, debug β
β β info, annotations, static values, map_list β
βββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββββββ
Header fields worth memorizing:
| Offset | Field | Meaning |
|---|---|---|
0x00 |
magic |
dex\n035\0 β¦ dex\n040\0 (format version) |
0x08 |
checksum |
Adler-32 of bytes from 0x0C to EOF |
0x0C |
signature |
SHA-1 of bytes from 0x20 to EOF |
0x20 |
file_size |
total file length |
0x24 |
header_size |
always 0x70 |
0x28 |
endian_tag |
0x12345678 little-endian |
0x34 |
map_off |
offset of the map list (section directory) |
0x38β0x3F |
string_ids_size/off |
string table |
0x40β0x47 |
type_ids_size/off |
type table |
0x48β0x4F |
proto_ids_size/off |
prototype table |
0x50β0x57 |
field_ids_size/off |
field table |
0x58β0x5F |
method_ids_size/off |
method table |
0x60β0x67 |
class_defs_size/off |
class table |
0x68β0x6F |
data_size/off |
data section |
The checksum and signature fields are why a hand-edited DEX is rejected
until you recompute them. Everything from 0x0C onward is covered by the
checksum, and everything from 0x20 onward is covered by the signature β and
the checksum field (at 0x08) is itself inside the signed region, so the order
matters: signature first, then checksum.
3.3 Code items
Each method body is a code_item: register count, in/out argument sizes, try
blocks, debug info offset, instruction count in 16-bit code units, and the
instruction stream. Instructions are 1β5 code units long, variable length, and
operate on registers named v0, v1, β¦:
const/4 v0, 0x1 # 1 code unit (2 bytes)
invoke-virtual {v0}, Ljava/lang/Object;->toString()Ljava/lang/String; # 3 code units
Because instruction length depends on the encoded value (const/4 vs const/16
vs const), inserting or growing code changes byte offsets everywhere after it.
That is the central difficulty of editing DEX directly, and the reason a
textual assembler exists.
3.4 Hard limits
| Limit | Value | Consequence |
|---|---|---|
| Methods referenced per DEX | 65,535 (method_ids are 16-bit) |
64K method limit β multidex |
| Registers per method | 65,535 (16-bit count) | enormous methods only |
| String index (non-jumbo) | 65,535 | assembler switches to /jumbo |
| Fields referenced per DEX | 65,535 | same class of problem |
| Instruction index range | signed 16-bit branch offsets | huge methods fail to assemble |
classes.dex, classes2.dex, β¦ each have their own 64K budget. apktool decodes
them to smali/, smali_classes2/, smali_classes3/, β¦ and reassembles each
directory back into the matching DEX name.
4. Smali in depth
4.1 What smali is
Smali is the assembly syntax for DEX. Every DEX instruction has a smali
mnemonic with the same name (invoke-virtual, iget-object, const-string),
and every reference is written symbolically instead of as an index:
invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
Same instruction, but now you can read the class, the method, the parameter
type, and the return type. That is the entire value proposition.
Smali is lossless in the forward direction: baksmali β text β smali
gives you a semantically equivalent DEX. It is not guaranteed byte-identical
(the string pool and code-item ordering can be rearranged), but the class
structure and behaviour are preserved. This round-trip property is why apktool
can rebuild real apps at all.
4.2 Type descriptors
You must be able to read these; they appear everywhere:
| Descriptor | Type |
|---|---|
V |
void |
Z |
boolean |
B |
byte |
S |
short |
C |
char |
I |
int |
J |
long |
F |
float |
D |
double |
[I |
int[] |
[[B |
byte[][] |
Ljava/lang/String; |
String |
Lcom/example/Foo; |
Foo (dots β slashes, wrapped in Lβ¦;) |
4.3 Anatomy of a smali file
.class public final Lcom/example/Checker;
.super Ljava/lang/Object;
.source "Checker.java"
# static field with a constant value
.field private static final TAG:Ljava/lang/String; = "Checker"
# instance field
.field private licenseKey:Ljava/lang/String;
.method public constructor <init>()V
.registers 1
.line 12
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
return-void
.end method
.method public isLicensed()Z
.registers 2
.line 20
iget-object v0, p0, Lcom/example/Checker;->licenseKey:Ljava/lang/String;
if-nez v0, :cond_0
const/4 v0, 0x0
return v0
:cond_0
const/4 v0, 0x1
return v0
.end method
.method public static log(Ljava/lang/String;)V
.registers 2
.param p0, "msg" # Ljava/lang/String;
const-string v0, "Checker"
invoke-static {v0, p0}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
return-void
.end method
Line by line:
.class/.superβ class declaration and superclass..sourceβ the original source file name from debug info (optional)..fieldβ field declaration; initializers use= value..methodβ¦.end methodβ one method body..registers Nβ total register slots used by the method, including the
registers that hold the parameters..locals Nβ alternative: N locals only, with parameters namedp0β¦pN.
With.locals 2and one parameter, that parameter isp0=v2; with.registers 3the same method hasp0=v2too.p0isthisin instance
methods..param p0, "msg"β parameter debug name (cosmetic, optional)..line 20β debug line mapping (cosmetic, optional).:cond_0β a label; branch targets use:name.#β comment.
4.4 Registers and instruction selection
- Registers hold 32 bits each.
long/doubleoccupy two consecutive
registers (v0+v1, or a wide pair starting at an even register when an
instruction requires it). - The assembler chooses encodings:
const/4(1 unit, β8..7),const/16
(2 units),const(3 units),const-wide*for 64-bit,const-stringvsconst-string/jumbo(string index > 65,535). - Method calls have two forms:
invoke-virtual {v0, v1, v2}, β¦(up to 5
arguments in a 35c encoding) andinvoke-virtual/range {v0 .. v5}, β¦for
larger argument counts or contiguous ranges. - You may overwrite a register with a different type mid-method; DEX is not
typed per register, but the ART verifier tracks types along each path and
will reject code where a register is read with the wrong type. This is where
hand-written smali usually breaks.
4.5 Control flow, payloads, exceptions
if-eqz v0, :cond_next
goto :goto_0
:cond_next
packed-switch v1, :pswitch_data_0
:pswitch_0
# case 0 body
.catch Ljava/lang/Exception; {:try_start_0 .. :try_end_0} :catch_0
:try_start_0
invoke-static {}, Lcom/example/Native;->init()V
:try_end_0
goto :goto_1
:catch_0
move-exception v0
invoke-static {v0}, Landroid/util/Log;->getStackTraceString(Ljava/lang/Throwable;)Ljava/lang/String;
:goto_0
return-void
:pswitch_data_0
.packed-switch 0x0
:pswitch_0
.end packed-switch
Notes:
- Labels are positional; a branch may only target a label that exists.
- Switch and array payloads live at the end of the method and are referenced by
label (.packed-switch,.sparse-switch,.array-data). .catchdirectives must appear before the protected region;:catch_0
handlers receive the exception viamove-exception.- There is no
for,while,ifwith expressions β only compares, branches,
and gotos. That is deliberate: it maps 1:1 onto the instruction stream.
4.6 Annotations, interfaces, inner classes
.annotation runtime Ljava/lang/Deprecated;
.end annotation
.annotation system Ldalvik/annotation/InnerClass;
accessFlags = 0x8
name = "Builder"
.end annotation
These appear as .annotation blocks before fields/methods/classes or at the end
of the class. @Override, @Nullable, and Kotlin metadata are all annotations
here β the Kotlin metadata blob is how decompilers recover Kotlin constructs.
4.7 What smali is not
- Not typed-checked at assembly time beyond syntax and register arity. The
assembler will happily emit code the verifier rejects. - Not a language with scoping, expressions, or types per variable.
- Not stable as a source format β you cannot feed smali to a compiler to get
Java back.jadxdoes that (approximately), from DEX. - Not the only text form:
dexdump -dprints a similar listing, andbaksmali --code-offsetsannotates byte offsets, but neither reassembles.
5. DEX vs Smali: two views of the same thing
| Aspect | DEX | Smali |
|---|---|---|
| Nature | binary container + ISA | text assembly syntax |
| Human-readable | no (hex + spec) | yes |
| Editable in a text editor | no | yes |
| Contains resource info | no | no |
| Reassembles to a runnable artifact | n/a (it is the artifact) | yes, via smali |
| Size | compact, indexed, mmap-friendly | much larger text |
| References | numeric indices into tables | symbolic names |
| Debug info | offset tables | .line, .local, .param |
| Produced by | d8 / R8 |
baksmali |
| Consumed by | ART | smali (assembler) |
| Round-trip fidelity | β | semantic, not byte-identical |
| Typical tool | dexdump, dexlib2, ART |
apktool, baksmali/smali, jadx fallback |
The relationship is exactly like .o/ELF versus .s assembly: one is what the
machine runs, one is what humans and patch tools manipulate. DEX is the
canonical form; smali is a projection of it.
6. Why both exist, and which is easier to understand
Why DEX exists
- Density. One shared string/type/method pool for the entire app removes
the per-class duplication that.classfiles have, and most cross-references
are 16-bit indices. Result: much smaller downloads. - Load speed. ART can
mmapa DEX, verify it, and resolve references by
index. No per-class file parsing, no classpath scanning. - Register-based ISA. Fewer, denser instructions than a stack machine:
add-int v0, v1, v2instead ofiload_1; iload_2; iadd; istore_0. That
means less interpreter work and smaller code on battery-constrained devices. - Verification and optimization. A single file with explicit tables is
easy to validate ahead of time and to AOT-compile (dex2oat).
Why smali exists
- DEX is not editable by hand. Variable-length instructions, LEB128
encoding, offset tables, checksums β changing one instruction can shift
thousands of bytes. A textual assembler does the bookkeeping. - Decompilers are lossy. Java decompilation (jadx, CFR, Procyon) guesses
at structure. Optimized, obfuscated, or synthetic bytecode often has no valid
Java form. Smali has a 1:1 instruction mapping, so a patch tool never has to
guess β it can rebuild exactly what was there. - Tooling needs a stable intermediate. apktool's whole design is
"decode to text, let the user edit, re-encode". Smali is that text format. - Debugging and teaching. Reading smali is how you learn what Kotlin
actually compiles to (lambdas,when, coroutines, inline functions).
Which is easier to understand?
Ordered by ease for a human:
- Decompiled Java/Kotlin (jadx) β easiest to understand intent, but you
cannot rebuild from it. It is a reading aid, not a build input. - Smali β easiest editable form. Symbolic names, one instruction per
line, labels instead of offsets, comments allowed. You can reason about it
with zero knowledge of the binary layout. - DEX β hardest. Requires the header spec, offset arithmetic, checksum
recomputation, and constant re-parsing of index tables. Only worth touching
for in-place same-size patches or when writing your own tool.
So: smali is the easiest thing to edit; Java is the easiest thing to read;
DEX is the only thing that actually runs. A good workflow uses all three:
jadx to understand, smali to change, DEX (via the assembler) to ship.
7. Toolchain setup
You need, at minimum:
| Tool | Why | Where |
|---|---|---|
| JDK 17+ | runs apktool, apksigner, keytool | apt install openjdk-17-jdk / Temurin |
apktool 2.9+ (2.11.x current) |
decode/rebuild APKs | https://apktool.org or your distro |
zipalign |
align before signing | Android SDK build-tools |
apksigner |
v1/v2/v3/v4 signing | Android SDK build-tools |
keytool |
generate a keystore | part of the JDK |
adb |
install/test | Android platform-tools |
jadx / jadx-gui |
read decompiled Java (optional) | https://github.com/skylot/jadx |
baksmali / smali jars |
standalone DEX β smali (optional) | https://github.com/baksmali/smali |
dexdump |
inspect a DEX header/instructions (optional) | build-tools |
Check what you have:
java -version
apktool --version
apksigner version
zipalign -h | head -3
Version gotchas:
- apktool 2.9.0+ uses aapt2 by default; 2.7.x needs
--use-aapt2
explicitly when rebuilding modern apps. If yourapktool --versionsays
2.7.x, add the flag. - If you get
UnsupportedClassVersionError, your JDK is too old for the
apktool/smali build β upgrade the JDK, do not downgrade the tool. - Distro packages are often years behind. You can always run a pinned release
jar instead; the CLI is identical:java -jar apktool_2.11.1.jar d app.apk -o decoded. apksignerfrom build-tools 30+ supports v2/v3/v4; very old build-tools only
do v1 throughjarsigner.
Locate the SDK tools if they are not on PATH:
export ANDROID_HOME="$HOME/Android/Sdk"
export PATH="$ANDROID_HOME/build-tools/35.0.0:$ANDROID_HOME/platform-tools:$PATH"
Working directory convention used below:
mkdir -p ~/work/patch && cd ~/work/patch
cp ~/Downloads/app.apk .
8. Workflow A β the standard apktool loop, step by step
This is the normal, safest path. It rebuilds resources and code, and it is what
you want 90% of the time.
apktool d app.apk -o decoded βββΆ decoded/ (edit smali in decoded/smali*)
β apktool b
βΌ
unsigned.apk
β zipalign
βΌ
aligned.apk
β apksigner
βΌ
signed.apk
β adb install
βΌ
device
Step 0 β Keep a pristine copy
cp app.apk app.original.apk
sha256sum app.apk app.original.apk
You will compare against this when something breaks. Never debug on your only
copy.
Step 1 β Decode
apktool d app.apk -o decoded
Useful flags:
| Flag | Effect |
|---|---|
-f / --force |
overwrite an existing output directory |
-s / --no-src |
skip DEX decoding (resources only) |
-r / --no-res |
skip resource decoding (code only, much faster) |
-p / --frame-path |
where framework files live |
--only-main-classes |
skip DEX files that are not classes*.dex |
What you get:
decoded/
βββ AndroidManifest.xml now readable XML
βββ apktool.yml metadata: versions, DEX names, flags
βββ res/ decoded resources
βββ smali/ classes.dex
βββ smali_classes2/ classes2.dex
βββ smali_classes3/ classes3.dex
βββ assets/
βββ lib/
βββ original/ untouched META-INF + manifest
If decoding fails on an OEM/system APK, install its framework first:
apktool if framework-res.apk
Step 2 β Find the code to patch
Three complementary approaches:
# 1. Search strings in smali (fast, exact)
grep -rn "isLicensed\|license" decoded/smali*/ | head -50
# 2. Search the decompiled Java for intent (jadx is read-only)
jadx -d jadx-out app.apk
grep -rn "isLicensed" jadx-out/ | head
# 3. Inspect the manifest for entry points and permissions
sed -n '1,80p' decoded/AndroidManifest.xml
Use jadx to understand, smali to locate the exact instructions. jadx-gui is
better for browsing; note that when jadx fails to decompile a method it prints
the smali instead β that is your bridge.
Step 3 β Read the smali before touching it
Open the file and find the method. Typical patterns to look for:
.method public isLicensed()Z
.registers 2
iget-object v0, p0, Lcom/example/Checker;->licenseKey:Ljava/lang/String;
invoke-virtual {v0}, Ljava/lang/String;->isEmpty()Z
move-result v0
xor-int/lit8 v0, v0, 0x1
return v0
.end method
Before editing, note: register count, which registers are inputs, whether there
are try/catch blocks, and whether the method returns a value or V. Write down
the original (or rely on git β see Step 4).
Step 4 β Put the decoded tree under version control
cd decoded && git init && git add -A && git commit -m "pristine decode" && cd ..
Now every patch is a reviewable diff, and reverting is trivial. This is the
single biggest quality-of-life improvement in this workflow.
Step 5 β Edit the smali
Minimal, surgical changes win. Common patterns:
Force a boolean true:
.method public isLicensed()Z
.registers 2
const/4 v0, 0x1
return v0
.end method
Force false: identical with 0x0.
Skip a void call:
.method public onCreate(Landroid/os/Bundle;)V
.registers 3
invoke-super {p0, p1}, Landroid/app/Activity;->onCreate(Landroid/os/Bundle;)V
return-void
.end method
Neutralise one instruction β replace with nops, matching the original
instruction's code-unit count, or you will corrupt the following bytes when
assembling by hand (the assembler handles it, but the semantics of labels and
try ranges still matter):
# before: invoke-static {}, Lcom/example/Analytics;->ping()V (3 code units)
nop
nop
nop
Add logging (bump .registers if you need a new register):
.method public isLicensed()Z
.registers 2
const-string v0, "PATCH"
const-string v1, "isLicensed() called"
invoke-static {v0, v1}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
const/4 v0, 0x1
return v0
.end method
Rules of thumb while editing:
- Keep
.registers/.localsconsistent with the highest register you use. - Preserve the return type. A
Zmethod mustreturn v0with 0/1; aV
method mustreturn-void. - Do not delete
.line/.localdirectives you do not need to delete β they
are harmless and keep diffs small. - If you add a register, initialise it (
const/4 vX, 0x0) before a branch that
might read it, or the verifier may complain about an uninitialised register. - Avoid changing method signatures or class names unless you also fix every
caller. - If a method is huge or uses
invoke-*on a register range, do not reorder
registers casually.
Step 6 β Rebuild
apktool b decoded -o unsigned.apk
Notes:
- With apktool 2.7.x and a modern app, add
--use-aapt2. - If aapt2 fails on resource errors,
--force-allforces a rebuild of all
resources (slower, sometimes succeeds where incremental fails). -c/--copy-originalcopies the originalAndroidManifest.xml,META-INF, andresources.arscfromdecoded/original/instead of
rebuilding them. Use it when you only changed smali and the resource
rebuild is causing trouble.- The output is unsigned. Do not install it yet.
Sanity-check the rebuilt DEX:
unzip -l unsigned.apk | grep -E 'classes.*dex|resources.arsc'
unzip -p unsigned.apk classes.dex > /tmp/check.dex
dexdump -f /tmp/check.dex | head -20 # header: checksum, signature, counts
Step 7 β Align
zipalign -p -f -v 4 unsigned.apk aligned.apk
Why this step exists:
zipalignmakes every uncompressed entry start at a 4-byte boundary so
ART canmmapthem directly instead of copying.-ppage-aligns uncompressed.sofiles (required forextractNativeLibs=false
and for API 23+ in practice).- Android 11+ (API 30) additionally requires
resources.arscto be stored
uncompressed and 4-byte aligned. apktool writes it stored;zipalign
fixes the offset. apksignermust run afterzipalign: the v2+ signature covers the whole
file, so any later byte move destroys it.
Verify alignment:
zipalign -c -v 4 aligned.apk | tail -5
If you ever see Failure: resources.arsc not stored uncompressed, the APK was
rezip'd with a tool that compressed it; rebuild with apktool rather than
fighting zip.
Step 8 β Create a signing key (once)
keytool -genkeypair -v \
-keystore my-release.keystore \
-alias mykey \
-keyalg RSA -keysize 2048 -validity 10000 \
-storepass changeit -keypass changeit \
-dname "CN=Test, OU=Dev, O=Personal, L=Nowhere, ST=None, C=US"
Keep this file. Every future build of "your" version of the app must use the
same key, otherwise Android treats it as a different app and refuses to update
over the installed one.
Step 9 β Sign
apksigner sign \
--ks my-release.keystore \
--ks-key-alias mykey \
--ks-pass pass:changeit \
--key-pass pass:changeit \
--out signed.apk \
aligned.apk
That default enables v1 (for API < 24 compatibility), v2, and v3 in one pass.
Explicit control, if you need it:
apksigner sign \
--ks my-release.keystore --ks-key-alias mykey \
--v1-signing-enabled true \
--v2-signing-enabled true \
--v3-signing-enabled true \
--v4-signing-enabled true \
--min-sdk-version 21 \
--out signed.apk aligned.apk
Version semantics:
| Scheme | Covers | Notes |
|---|---|---|
| v1 (JAR) | each ZIP entry via META-INF/*.SF/*.RSA |
needed for API < 24; slow, per-file |
| v2 | whole APK bytes, in the ZIP central directory area | required for API 30+ installs, fast verify |
| v3 | like v2 plus key rotation | API 28+ |
| v4 | separate .idsig file |
incremental install (adb); not inside the APK |
Do not mix jarsigner and apksigner on the same output; if you must sign
with jarsigner (very old toolchains), sign, then zipalign, and accept v1-only.
Step 10 β Verify the signature
apksigner verify --verbose --print-certs signed.apk
Expected output shows Verifies, the scheme list (v1, v2, v3), and your
certificate's SHA-256. If it says DOES NOT VERIFY, you signed before
aligning, edited the APK after signing, or the keystore/alias is wrong.
Step 11 β Install and test
adb install -r signed.apk
If it fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE orsignatures do not match previously installed version, uninstall the original
first (this wipes app data):
adb uninstall com.example.app
adb install signed.apk
Then watch the runtime:
adb logcat -c
adb shell am start -n com.example.app/.MainActivity
adb logcat -s AndroidRuntime:E ActivityManager:I
A java.lang.VerifyError or ClassNotFoundException here almost always means
your smali is structurally wrong (register mismatch, bad label, wrong return
type), not that signing failed.
Step 12 β Diff and iterate
cd decoded && git diff && cd ..
Keep patches small and one-concern-at-a-time so a failure is easy to bisect.
9. Worked example: patching a boolean check
Suppose jadx shows:
public boolean isPro() {
return this.purchaseToken != null && this.purchaseToken.length() > 10;
}
and the class is com.example.billing.Entitlement.
1. Locate it in smali:
grep -rn "isPro" decoded/smali*/com/example/billing/
Open decoded/smali/com/example/billing/Entitlement.smali:
.method public isPro()Z
.registers 2
iget-object v0, p0, Lcom/example/billing/Entitlement;->purchaseToken:Ljava/lang/String;
if-eqz v0, :cond_0
invoke-virtual {v0}, Ljava/lang/String;->length()I
move-result v0
const/16 v1, 0xa
if-le v0, v1, :cond_0
const/4 v0, 0x1
return v0
:cond_0
const/4 v0, 0x0
return v0
.end method
2. Patch the branch. The cheapest change is the constant at:cond_0:
:cond_0
const/4 v0, 0x1
return v0
3. Rebuild, align, sign, verify, install:
apktool b decoded -o unsigned.apk
zipalign -p -f 4 unsigned.apk aligned.apk
apksigner sign --ks my-release.keystore --ks-key-alias mykey \
--ks-pass pass:changeit --key-pass pass:changeit \
--out signed.apk aligned.apk
apksigner verify --verbose --print-certs signed.apk
adb install -r signed.apk
4. Confirm at runtime. Compare with the original behaviour: launch the app,
exercise the gated feature, and check logcat. If the app still gates, either
the check is duplicated elsewhere (search for the field name, not the method
name) or the value is validated server-side (in which case client patching is
the wrong layer).
A useful debugging trick: temporarily insert a log line inside the method (see
Step 5) and confirm from logcat that your new code is actually the code being
executed. That distinguishes "patch not applied" from "patch applied but logic
elsewhere".
10. Workflow B β dex-only surgery (no resource rebuild)
When you only change code, rebuilding resources is unnecessary work and a common
source of breakage. Three options.
B1. apktool with --no-res
apktool d app.apk -o code_only --no-res
# edit code_only/smali*/...
apktool b code_only -o unsigned.apk -c
--no-res (decode-only flag) skips resource decoding; -c copiesoriginal/resources.arsc and original/AndroidManifest.xml verbatim instead of
rebuilding them. Result: your resources are bit-identical to the original, so
nothing can regress there. apktool.yml remembers where the originals are β
keep the original/ folder.
B2. baksmali/smali directly (no apktool at all)
Useful when you want zero changes outside the DEX, or when apktool chokes on
resources.
mkdir work && cd work
unzip ../app.apk -d unpacked
# disassemble every DEX
for f in unpacked/classes*.dex; do
baksmali d "$f" -o "smali_$(basename "$f" .dex)"
done
# ... edit smali_classes2/... as usual ...
# reassemble
smali a smali_classes -o unpacked/classes.dex
smali a smali_classes2 -o unpacked/classes2.dex
smali a smali_classes3 -o unpacked/classes3.dex
# repack: resources.arsc MUST stay uncompressed
cd unpacked
zip -X -0 ../patched.apk resources.arsc
zip -X -r ../patched.apk . -x 'META-INF/*' 'resources.arsc'
cd ..
zipalign -p -f 4 patched.apk aligned.apk
apksigner sign --ks ~/my-release.keystore --ks-key-alias mykey \
--out signed.apk aligned.apk
Caveats for B2:
zipwill compressresources.arscby default, which breaks API 30+
installs. Repack withresources.arscstored (zip -X -0 β¦), or letapktool bdo the packaging.- Strip or replace
META-INF/β the old signature is now invalid and, worse, a
stale v1 signature can make verification fail in confusing ways. - The ZIP must not contain the old
classes*.dexalongside new ones; overwrite
in place. smali a(assemble) accepts a directory tree, not a single file.
B3. Programmatic rewriting (dexlib2 / R8 / d8)
If you need to do this repeatedly or structurally (rename classes, inject a new
class, rewrite call sites), use dexlib2 (the library under baksmali/smali) orDexRewriter. It parses the DEX into an object model, lets you replaceMethodImplementations, and writes a new DEX with all offsets, string pools,
and checksums recomputed. This is the only sane way to make size-changing
edits programmatically.
For adding new Java/Kotlin code, the normal route is: compile the new class withjavac/kotlinc, convert it with d8 new.jar --output out/, and merge the
resulting classes.dex into the app (or add it as classesN.dex). apktool can
then decode it if you drop it into the APK. Remember the 64K method budget and
that new references must exist at runtime.
11. Workflow C β patching the DEX binary directly
Sometimes you must patch in place: a size-preserving tweak, a hex-editor-only
environment, or a tool that only accepts a DEX blob.
11.1 When it works
Only when the patch is the same number of bytes: flipping a constant that
fits the same encoding, changing a branch target within range, or swapping a
register number. Example: const/4 v0, 0x0 β const/4 v0, 0x1 is one changed
nibble in a 2-byte instruction.
Find the instruction with dexdump -d (method names plus instructions) or,
better, baksmali d --code-offsets, which annotates every instruction with its
byte offset inside the DEX. Note the offset, then edit those bytes:
dexdump -d classes.dex | grep -n -A4 "isPro"
xxd -s 0x1234 -l 16 classes.dex
11.2 Fix the header or nothing will load
After any byte change:
#!/usr/bin/env python3
"""Recompute DEX checksum + signature after an in-place patch."""
import hashlib, struct, sys, zlib
path = sys.argv[1]
data = bytearray(open(path, "rb").read())
assert data[:4] == b"dex\n", "not a dex file"
# 1) SHA-1 over everything after the 32-byte magic+checksum+signature area
data[12:32] = hashlib.sha1(bytes(data[32:])).digest()
# 2) Adler-32 over everything after the checksum field
struct.pack_into("<I", data, 8, zlib.adler32(bytes(data[12:])) & 0xFFFFFFFF)
open(path, "wb").write(data)
print("fixed", path)
Then confirm:
dexdump -f classes.dex | head -8 # checksum/signature should be populated
11.3 Why this is fragile
- Instructions are variable length. Inserting a byte shifts every subsequent
offset,code_item.insns_size, try/catch ranges,map_listoffsets, and the
string/type tables' base addresses. A naive hex insert produces a DEX that
parses as garbage. file_sizein the header must match the actual length.- Branch offsets are relative and range-limited; moving code can put a target
out of range. - The ART verifier re-checks types, so a byte-level change that "looks right"
can still throwVerifyError. - Multidex: you must patch the right
classesN.dex, and references across DEX
files are resolved at runtime.
Conclusion: direct DEX patching is a scalpel for same-size edits. For
anything else, use smali (Workflow A/B) or dexlib2.
12. Signing in depth
12.1 What a signature proves
Android does not care who signed the APK; it cares that the signature is
self-consistent and that updates use the same key as the installed app.
Consequences:
- Any modification invalidates the signature. There is no "edit and keep the
signature" shortcut (that would break the cryptography). - A repackaged app cannot be installed over the original (different key).
- Apps that check their own signature (
PackageManager.getPackageInfowithGET_SIGNATURES/GET_SIGNING_CERTIFICATES, Play Integrity, SafetyNet,
certificate pinning) will notice immediately and may refuse to run. That is a
server/attestation problem, not a packaging problem. - Play Store distribution requires the original upload key.
12.2 The v1/v2/v3/v4 scheme in one paragraph each
v1 (JAR signing). META-INF/MANIFEST.MF lists SHA-256 digests of every
entry; CERT.SF signs the manifest; CERT.RSA holds the signer's certificate
and signature. Verification is per-entry, which is slow and vulnerable to
"master key"-style ZIP parsing bugs. Still required for API < 24.
v2. A signature block embedded just before the ZIP central directory,
covering the entire APK (all bytes, including the central directory). Fast,
whole-file verification; detects any modification. Required for installs on
Android 11+ (targetSdk 30+) in practice.
v3. Same idea as v2 with a key-rotation proof, allowing an app to change
signing keys across versions. Android 9+.
v4. A detached .idsig file used for incremental/streaming installs viaadb; it is not part of the APK and is not required for normal distribution.
12.3 The one ordering rule
build β zipalign β apksigner β (never modify again)
Signing hashes bytes; alignment moves bytes. Reverse the order and v2/v3 fail.
If you must re-zipalign a signed APK, re-sign it afterwards.
12.4 v1-only fallback (legacy)
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore my-release.keystore -storepass changeit \
unsigned.apk mykey
zipalign -p -f 4 unsigned.apk aligned.apk # then re-sign if you aligned after
apksigner is strictly better; keep this only for very old SDK levels or old
CI images.
12.5 Verifying what is actually in the file
apksigner verify --verbose --print-certs signed.apk
unzip -l signed.apk | grep META-INF # v1 artifacts
apksigner verify reports which schemes verified and the certificate digest.
Compare the digest to what the app's own integrity check expects β if they
differ, self-checks will fail by design.
13. Fast paths and build modes
| Goal | Command |
|---|---|
| Full decode (resources + code) | apktool d app.apk -o out |
| Code only, fast | apktool d app.apk -o out --no-res |
| Resources only, fast | apktool d app.apk -o out --no-src |
| Rebuild | apktool b out -o unsigned.apk |
| Rebuild with aapt2 (old apktool) | apktool b out -o unsigned.apk --use-aapt2 |
| Rebuild keeping original manifest/arsc | apktool b out -o unsigned.apk -c |
| Force full resource rebuild | apktool b out -o unsigned.apk --force-all |
| Debug build | apktool b out -o unsigned.apk -d |
Practical guidance:
- Changing one method in
smali_classes2and nothing else?--no-res+-c
is the fastest reliable path. - Changing
AndroidManifest.xmlorres/? Full decode/rebuild; never hand-edit
binary XML. - apktool output "W: Could not decode attr value" or similar warnings usually
mean an OEM framework mismatch β install the device framework and retry. - If
apktool bproduces an APK that installs but crashes instantly withResources$NotFoundException, theresources.arscrebuild went wrong; try-cto reuse the original, and if that works, keep it.
14. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Invalid file: classes.dex during decode |
corrupted/odd DEX | re-extract from APK; check unzip -t |
brut.androlib.AndrolibException: Could not decode arsc |
resource table version | use newer apktool; install framework |
| aapt2 errors on build after resource edits | broken XML/attr | revert resource edits, use -c, or --force-all |
INSTALL_PARSE_FAILED_NO_CERTIFICATES |
not signed / v1 missing on old API | sign with apksigner, enable v1 |
INSTALL_FAILED_UPDATE_INCOMPATIBLE |
different signing key | adb uninstall first |
INSTALL_FAILED_INVALID_APK / resources.arsc alignment |
compressed or misaligned arsc | rebuild with apktool + zipalign |
java.lang.VerifyError at launch |
bad smali (registers/types/labels) | fix method; run dexdump; compare with original |
ClassNotFoundException after patch |
added/moved a class that nothing can load | check class name/descriptor and DEX placement |
| Build works, app behaves unchanged | patched the wrong copy / dead code / server-side check | grep for all occurrences; add a log line to confirm execution |
apksigner verify fails after editing |
signed then modified, or aligned after signing | rebuild and re-sign in the correct order |
zipalign: Unable to open |
wrong path/typo | use absolute paths |
UnsupportedClassVersionError |
JDK too old for the tool | upgrade JDK |
15. Verification checklist
Before you call a patch "done":
-
apksigner verify --verbosereportsVerifiesand the schemes you expect. -
zipalign -c -v 4 signed.apkpasses. -
unzip -l signed.apkshows the expectedclasses*.dexset and a storedresources.arsc. -
dexdump -fon the rebuilt DEX shows a consistent header (checksum,
signature, sizes). - The app installs cleanly on a clean device/emulator (no leftovers).
-
logcatshows noVerifyError/ClassNotFoundException/Resources$NotFound. - The specific behaviour you changed actually changed.
- The rest of the app still works (smoke test the main flows).
- You can revert:
git diffin the decoded tree, or restoreapp.original.apk.
16. Limits, anti-tamper, and reality checks
- Signature checks in code.
GET_SIGNATURES,GET_SIGNING_CERTIFICATES,
certificate pinning, and server-side attestation (Play Integrity/SafetyNet)
are common. Patching them is another patch, and it escalates quickly.
Server-side validation cannot be patched on the client at all. - Obfuscation. R8/ProGuard renames classes to
a.a.a; smali is still
readable as instructions but names are meaningless. Use behaviour (strings,
API calls, control flow) rather than names. - Native code. Logic in
lib/*/lib*.sois not smali; patching it requires
native RE (IDA/Ghidra/radare2) and often invalidates more invariants. - Integrity of resources. Some apps hash their own resources or DEX at
startup; any rebuild trips it. - Split APKs / App Bundles.
base.apkplussplit_config.*.apk. apktool
can decode a split, but installs need all splits and matching signatures.
Bundles must be converted (bundletool build-apks --mode=universal) before
patching. - Legality and ToS. See the note at the top. Modify your own software.
- Updates. A patched app cannot receive Play updates; the store's signature
will not match. Keep an unmodified copy.
17. Command cheat sheet
# ---------- decode / build ----------
apktool d app.apk -o decoded
apktool d app.apk -o decoded --no-res # code only
apktool b decoded -o unsigned.apk
apktool b decoded -o unsigned.apk --use-aapt2 # apktool < 2.9
apktool b decoded -o unsigned.apk -c # reuse original manifest/arsc
# ---------- dex <-> smali ----------
baksmali d classes.dex -o smali_out
smali a smali_out -o classes.dex
# ---------- inspect ----------
dexdump -f classes.dex | head -20
dexdump -d classes.dex | less
unzip -l app.apk
# ---------- align / sign / verify ----------
zipalign -p -f 4 unsigned.apk aligned.apk
zipalign -c -v 4 aligned.apk
keytool -genkeypair -v -keystore my-release.keystore -alias mykey \
-keyalg RSA -keysize 2048 -validity 10000
apksigner sign --ks my-release.keystore --ks-key-alias mykey \
--out signed.apk aligned.apk
apksigner verify --verbose --print-certs signed.apk
# ---------- install / debug ----------
adb install -r signed.apk
adb uninstall com.example.app
adb logcat -c && adb logcat -s AndroidRuntime:E
# ---------- integrity of your patch ----------
cd decoded && git diff
sha256sum app.apk app.original.apk signed.apk
Closing summary
- DEX is the binary, register-based bytecode ART executes; it exists for
density, fast loading, and verifiability. It is not meant to be edited by
hand. - Smali is the textual assembly view of DEX β symbolic, labelled,
round-trippable. It exists because decompiled Java is lossy and binary DEX is
not human-editable. It is the easiest editable representation. - apktool turns an APK into editable smali + XML/resources and back. It
does not sign. - The reliable loop is: decode β understand (jadx) β patch (smali) β
rebuild (apktool) β align (zipalign) β sign (apksigner) β verify β install β
test. - Editing the DEX directly is possible but only safe for same-size patches, and
always requires recomputing the checksum and SHA-1 signature in the header. - Re-signing is mandatory after every change, changes the app's identity, and
is detected by any app that checks its own signature.
Need an Android Developer or a full-stack website developer?
I specialize in Kotlin, Jetpack Compose, and Material Design 3. For websites, I use modern web technologies to create responsive and user-friendly experiences. Check out my portfolio or get in touch to discuss your project.


Comments (0)
All comments are reviewed before they are published.
Be the first to comment!