Smali⚓
Reading and patching Dalvik bytecode (Smali) for Android application analysis and modification.
What Is Smali?⚓
When Android compiles your Java/Kotlin source it produces .dex (Dalvik Executable) files — a compact binary format the Android Runtime executes. Smali is the human-readable text representation of that bytecode.
Think of it like Python's .pyc bytecode. Python source compiles to .pyc; you can't easily read .pyc raw — but tools like dis show you the bytecode as text. Smali is the same idea for Android:
Python: .py source → .pyc bytecode → dis.dis() shows human-readable text
Android: .java source → .dex bytecode → Smali is that human-readable text
apktool d app.apk disassembles every .dex into .smali files. You can edit those files and apktool b rebuilds the .dex. The roundtrip is lossless.
Why bother with Smali instead of just reading jadx output?
- jadx may fail to decompile certain obfuscated methods; Smali always works
- jadx shows approximate Java — Smali shows the actual instructions that run
- Patching happens at the Smali level; you need to read it to know what you are changing
- Some bugs only make sense at the bytecode level (register reuse, branch targets)
Types⚓
Every type in Smali is written as a descriptor — a compact string. Think of them like Python type annotations, just much shorter.
| Descriptor | Java type | Python equivalent |
|---|---|---|
V |
void |
None (no return value) |
Z |
boolean |
bool |
B |
byte |
int (small, −128 to 127) |
S |
short |
int (medium) |
C |
char |
str (single character) |
I |
int |
int |
J |
long (64-bit) |
int (big number) |
F |
float |
float |
D |
double (64-bit) |
float (higher precision) |
Ljava/lang/String; |
String |
str |
Ljava/util/List; |
List |
list |
[I |
int[] |
List[int] |
[Ljava/lang/String; |
String[] |
List[str] |
[[I |
int[][] |
List[List[int]] |
Object types always start with L, use / instead of ., and end with ;:
Array types prefix with [ once per dimension:
int[] → [I (like List[int])
String[] → [Ljava/lang/String; (like List[str])
int[][] → [[I (like List[List[int]])
Wide types (J and D) are 64-bit and always occupy two consecutive registers. If v2 holds a long, v3 is also consumed — you cannot use v3 independently.
Method Signatures⚓
A method signature in Smali fully describes where a method lives, its name, its parameters, and its return type. You see these in invoke-* calls and when searching smali files for a method to patch.
Compare a Python function definition with its Smali reference:
Both describe the same method — same class, name, parameters, return type — just in different formats.
Anatomy⚓
Lcom/example/app/Utils;->isRooted(Ljava/lang/String;I)Z
│──────────────────────│ │──────│ │──────────────────│ │
class (where) name parameters return
| Part | Smali | Python equivalent |
|---|---|---|
| Class | Lcom/example/app/Utils; |
the class the method belongs to |
-> |
separator (always literal) | . as in obj.method |
| Name | isRooted |
function name |
| Parameters | (Ljava/lang/String;I) |
(build_tag: str, flags: int) |
| Return type | Z |
-> bool |
Parameters are listed one after another inside () with no commas or spaces between them.
Full Method Declaration⚓
Breaking down .method public isRooted(Ljava/lang/String;I)Z:
| Part | Meaning |
|---|---|
public |
Access modifier — also private, protected, static, final |
isRooted |
Method name |
Ljava/lang/String; |
First parameter: String buildTag |
I |
Second parameter: int flags |
Z |
Return type: boolean |
Common Method Modifiers⚓
.method public myMethod()V # normal instance method
.method public static myMethod()V # static — like @staticmethod in Python
.method private myMethod()V # private — not accessible outside class
.method public constructor <init>()V # constructor — like __init__
.method static constructor <clinit>()V # runs at class load — like module-level code
.method public final myMethod()V # cannot be overridden
.method public synchronized myMethod()V # holds a thread lock while running
Reading a Call Site⚓
When you see an invoke-* line during patching, read the method reference after it like a Python function call:
Think of it as:
v1is the object (self)v2,v3are the arguments — two Strings- Return type
Z(bool) — captured withmove-result v0
Registers⚓
Registers are the named boxes that hold values in a method — exactly like variables in Python. Instead of choosing names like x or count, you get a fixed set of numbered boxes: v0, v1, v2…
Register Naming⚓
| Name | Meaning | Python analogy |
|---|---|---|
v0, v1, v2 … |
Local registers — general scratch space | local variables |
p0 |
this in instance methods; first param in static methods |
self |
p1, p2 … |
Method parameters in order | function arguments |
p registers are just aliases for the high end of the register file. If .registers 5 with 2 params (p0 = this, p1 = arg), then p0 = v3 and p1 = v4 internally. You never need to worry about this — just use p0 for this and p1, p2… for parameters.
Declaring Register Count⚓
You must have exactly one of these on every method — apktool won't assemble without it. Two choices, use one or the other, never both:
.registers 4 # total = locals + parameters (including p0/this)
.locals 2 # just the local count — apktool calculates total for you
Which number do you put?
.locals N— count only your localvregisters. Parameters (p0,p1…) are not counted here; apktool adds them automatically. This is the easiest to use when patching because you don't have to think about how many parameters there are..registers N— the full total: everyvand everypregister combined.
Concrete example
Method checkPin(String inputPin) on an instance:
p0 = this, p1 = inputPin → 2 parameter slots
You use v0, v1, v2 as locals → 3 local slots
When reading existing smali files you'll see whichever form apktool used.
When patching and you add a new instruction that needs an extra register, just bump the number up by 1:
Wide Registers (long / double)⚓
long and double are 64-bit — too big for one register, so they spill into two consecutive slots called a wide pair. Refer to the pair by the lower number:
const-wide/16 v0, 0x0 # v0 AND v1 are both consumed — holds a long
long-to-int v0, v0 # narrow back to int; now fits in v0 alone
Never use v1 independently while v0 holds a wide value — you will corrupt it.
Fields⚓
Fields are variables that belong to an object (instance fields) or a class (static fields) — like attributes in Python.
Format: .field <modifiers> <name>:<descriptor>
Reading a Field (iget / sget)⚓
To read self.token in Python you just write self.token. In Smali you need an explicit instruction for every field read.
# Read instance field (i = instance)
iget-object v0, p0, Lcom/example/app/Session;->mToken:Ljava/lang/String;
# v0 = self.token
# Read static field (s = static)
sget-object v0, Lcom/example/app/Session;->sInstance:Lcom/example/app/Session;
# v0 = Session.sInstance
# Read boolean instance field
iget-boolean v0, p0, Lcom/example/app/Auth;->isLoggedIn:Z
# v0 = self.is_logged_in
# Read int instance field
iget v0, p0, Lcom/example/app/Config;->retryCount:I
# v0 = self.retry_count
Pick the right iget variant based on the field's type (the part after : in the field declaration):
| Instruction | Field type | Python type |
|---|---|---|
iget |
int, float |
int, float |
iget-wide |
long, double |
big number |
iget-object |
any object (L…;, arrays) |
object, str, list… |
iget-boolean |
boolean |
bool |
iget-byte |
byte |
small int |
iget-char |
char |
single-char str |
iget-short |
short |
medium int |
sget-* is the static version — same variants, no object register needed.
Writing a Field (iput / sput)⚓
Invoke — Calling Methods⚓
Every method call is an invoke-* instruction. The key difference from Python: getting the return value is a separate step using move-result.
Invoke Types⚓
Which invoke-* to use depends on how the method is called:
| Instruction | When to use | Python analogy |
|---|---|---|
invoke-virtual |
Normal instance method call (dispatched at runtime) | obj.method() |
invoke-static |
Static method — no object | ClassName.method() |
invoke-direct |
Constructor <init> or private method |
obj.__init__() |
invoke-interface |
Method declared on an interface | calling via an abstract type |
invoke-super |
Superclass version of the current method | super().method() |
Full Syntax⚓
For instance methods (virtual, direct, interface, super): first register is the object, then parameters left to right.
For static methods: only parameter registers, no object register.
Examples⚓
Static call:
Instance call:
Constructor — creating a new object:
# No args — split into allocate + init
new-instance v0, Ljava/lang/StringBuilder;
invoke-direct {v0}, Ljava/lang/StringBuilder;-><init>()V
# No move-result — constructors are void
# With argument
new-instance v0, Ljava/lang/StringBuilder;
const-string v1, "hello"
invoke-direct {v0, v1}, Ljava/lang/StringBuilder;-><init>(Ljava/lang/String;)V
Interface call:
Super call:
Retrieving the Return Value⚓
After any non-void invoke-*, immediately grab the result:
| Instruction | Use when return type is… |
|---|---|
move-result v0 |
int, float, boolean, byte, char, short |
move-result-wide v0 |
long, double |
move-result-object v0 |
any object (L…;, arrays) |
For void methods, skip move-result entirely.
/range Variants⚓
When a method takes more than 5 arguments, use the /range form to specify registers as a contiguous range:
Common Instructions⚓
Constants — Putting a Value into a Register⚓
const-wide loads a 64-bit value into a register pair:
Move — Copying a Value⚓
After an invoke-*, use move-result to grab the return value:
move-result v0 # grab int/float/bool return
move-result-object v0 # grab object return
move-result-wide v0 # grab long/double return
move-exception v0 # at start of a catch block — grab the caught exception
Return⚓
Arithmetic⚓
add-int v0, v1, v2 # v0 = v1 + v2
sub-int v0, v1, v2 # v0 = v1 - v2
mul-int v0, v1, v2 # v0 = v1 * v2
div-int v0, v1, v2 # v0 = v1 // v2
rem-int v0, v1, v2 # v0 = v1 % v2
and-int v0, v1, v2 # v0 = v1 & v2
or-int v0, v1, v2 # v0 = v1 | v2
xor-int v0, v1, v2 # v0 = v1 ^ v2
shl-int v0, v1, v2 # v0 = v1 << v2
shr-int v0, v1, v2 # v0 = v1 >> v2 (signed)
ushr-int v0, v1, v2 # v0 = v1 >>> v2 (unsigned — no Python equivalent)
Shorthand forms when one operand is the destination:
add-int/2addr v0, v1 # v0 += v1
mul-int/lit8 v0, v1, 0x2 # v0 = v1 * 2 (literal constant)
add-int/lit16 v0, v1, 0x64 # v0 = v1 + 100
The same operations exist for long (add-long), float (add-float), double (add-double).
Type Conversions — Casting⚓
Object / Array Instructions⚓
new-instance v0, Lcom/example/MyClass; # allocate (still need invoke-direct <init>)
new-array v0, v1, [I # v0 = new int[v1]
array-length v0, v1 # v0 = len(v1)
aget v0, v1, v2 # v0 = v1[v2] (int/float array)
aput v0, v1, v2 # v1[v2] = v0
aget-object v0, v1, v2 # v0 = v1[v2] (object array)
aput-object v0, v1, v2 # v1[v2] = v0 (object)
instance-of v0, v1, Ljava/lang/String; # v0 = isinstance(v1, String) → 1 or 0
check-cast v0, Ljava/lang/String; # v0 = (String) v0 (throws if wrong type)
Null Checks⚓
Control Flow⚓
This is what you edit most during patching. Every if, else, loop, and early return becomes a branch or jump in Smali.
Labels — Named Jump Targets⚓
Labels mark a point in the code that a branch instruction can jump to. They start with : and can be named anything.
Apktool generates names like :cond_0, :goto_0. You can use any :name you like.
goto — Unconditional Jump⚓
Jumps to a label unconditionally — no condition, always taken:
goto/16 and goto/32 are larger variants for long-distance jumps — apktool picks the right size.
if-* — Conditional Branches⚓
if-* jumps to a label when the condition is true and falls through when false.
Key difference from Python
Python's if runs the indented block when true. Smali's if-* jumps away when true and falls through when false. When reading Smali, flip your mental model: if-eqz means "if this is zero/false, skip to the label — otherwise keep going here."
Comparing a register to zero / null:
| Instruction | Jump if… | Python equivalent |
|---|---|---|
if-eqz v0, :label |
v0 == 0 or null |
if v0 == 0: |
if-nez v0, :label |
v0 != 0 or not null |
if v0 != 0: |
if-ltz v0, :label |
v0 < 0 |
if v0 < 0: |
if-lez v0, :label |
v0 <= 0 |
if v0 <= 0: |
if-gtz v0, :label |
v0 > 0 |
if v0 > 0: |
if-gez v0, :label |
v0 >= 0 |
if v0 >= 0: |
Comparing two registers:
| Instruction | Jump if… | Python equivalent |
|---|---|---|
if-eq v0, v1, :label |
v0 == v1 |
if v0 == v1: |
if-ne v0, v1, :label |
v0 != v1 |
if v0 != v1: |
if-lt v0, v1, :label |
v0 < v1 |
if v0 < v1: |
if-le v0, v1, :label |
v0 <= v1 |
if v0 <= v1: |
if-gt v0, v1, :label |
v0 > v1 |
if v0 > v1: |
if-ge v0, v1, :label |
v0 >= v1 |
if v0 >= v1: |
if/else — Side by Side⚓
# is_logged_in is in v0 (1 = True, 0 = False)
if-eqz v0, :else # if v0 == 0 (False): jump to :else
# --- if-body (is_logged_in was True, we didn't jump) ---
invoke-virtual {p0}, Lcom/example/app/Main;->loadDashboard()V
goto :end # skip the else-body
:else
# --- else-body ---
invoke-virtual {p0}, Lcom/example/app/Main;->showLogin()V
:end
return-void
The Smali pattern for if/else is always:
if-*— jumps to:elsewhen condition is false (inverted from Python)- If-body instructions
goto :end— skip past else-body:elselabel + else-body:endlabel
while / for Loops — Side by Side⚓
switch — packed-switch and sparse-switch⚓
Java switch on an int compiles to one of two forms:
packed-switch— consecutive values (0, 1, 2, 3…)sparse-switch— non-consecutive values
packed-switch v0, :pswitch_data # jump based on v0's value
# default (no case matched)
const-string v1, "unknown"
goto :end
:pswitch_0 # case 0
const-string v1, "zero"
goto :end
:pswitch_1 # case 1
const-string v1, "one"
goto :end
:end
return-object v1
:pswitch_data
.packed-switch 0x0 # first key = 0
:pswitch_0
:pswitch_1
.end packed-switch
Exception Handling — try / catch⚓
Reading Smali — Worked Example⚓
This walks through a complete method three ways: Python pseudocode (what it does), then the Smali annotated line by line so you can see exactly how each Python step maps.
The Method⚓
.method public checkPin(Ljava/lang/String;)Z
# def check_pin(self, input_pin: str) -> bool:
.registers 5
# p0 = self (this) ← the Lock object
# p1 = input_pin (String) ← the argument
# v0 = scratch (prefs → storedPin → return value)
# v1 = scratch ("pin" key literal)
# v2 = scratch ("0000" default → result of equals())
# stored_pin = self.prefs.getString("pin", "0000")
iget-object v0, p0, Lcom/example/app/Lock;->prefs:Landroid/content/SharedPreferences;
# v0 = self.prefs
const-string v1, "pin"
const-string v2, "0000"
invoke-interface {v0, v1, v2}, Landroid/content/SharedPreferences;->getString(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
# call: v0.getString(v1, v2)
move-result-object v0 # v0 = storedPin ← grab the String return value
# if stored_pin.equals(input_pin):
invoke-virtual {v0, p1}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z
# call: v0.equals(p1)
move-result v2 # v2 = True/False (1 or 0)
if-eqz v2, :cond_false # if v2 == 0 (False): jump to :cond_false
# (if equals() returned True we fall through to the next lines)
# self.is_unlocked = True
const/4 v2, 0x1
iput-boolean v2, p0, Lcom/example/app/Lock;->isUnlocked:Z
# p0.isUnlocked = 1 (True)
# return True
const/4 v0, 0x1
return v0
:cond_false
# return False
const/4 v0, 0x0
return v0
.end method
Register Map for This Method⚓
| Register | Role | Python name |
|---|---|---|
p0 |
this — the Lock object |
self |
p1 |
argument inputPin |
input_pin |
v0 |
prefs ref → storedPin → return value |
reused local |
v1 |
string literal "pin" |
"pin" |
v2 |
string "0000" → result of equals() |
stored_pin / result |
Smali Patching⚓
Step 1 — Find the Method in the Smali Files⚓
You need to find which .smali file and which .method block to edit. There are three ways.
From jadx — copy the class path
Open the APK in jadx. Navigate to the method you want to patch. The package path in jadx maps directly to the file path:
jadx shows: com.example.app.auth.LoginManager.checkPin()
smali file: smali/com/example/app/auth/LoginManager.smali
method: .method ... checkPin(...)
Replace . with /, add .smali, look in the smali/ folder apktool produced.
Text search across all smali files
After apktool d app.apk -o decoded/:
# Find by method name
grep -rl "checkPin" decoded/smali/
# Find by string literal that appears in the method
grep -rl '"wrong pin"' decoded/smali/
# Find by class short name
grep -rl "class.*LoginManager" decoded/smali/
# Narrow to method declaration
grep -n "\.method.*checkPin" decoded/smali/com/example/app/auth/LoginManager.smali
Find by string constant (most reliable)
If you can identify a string the target method uses (from the jadx source, Logcat output, or UI text), find it in smali:
grep -rl '"pin"' decoded/smali/
# Returns the exact smali file — the method using that string is nearby
Then open that file and search for the string within it to find the method block.
Cross-reference with line numbers (jadx + baksmali)
jadx shows Java line numbers that correspond to .line directives in smali. Search for .line 84 if jadx showed the problem at line 84:
Step 2 — Understand What to Change⚓
Before editing, read the method completely. Identify:
- Which register holds the value you care about
- Which branch instruction decides the path —
if-eqz,if-nez,if-eq, etc. - What the label names are (
:cond_0,:cond_1, etc.)
Common targets and their Smali signatures:
| Goal | What to find |
|---|---|
| Bypass boolean check | if-eqz / if-nez on the result of a compare or method call |
| Always return true | Any return v0 where v0 could be 0 |
| Skip a method call | invoke-* call you want to NOP out |
| Change a constant | const-string, const, const/4 loading a value |
| Flip a branch | Change if-eqz ↔ if-nez, if-lt ↔ if-ge, etc. |
Step 3 — Common Patches⚓
Always Return true⚓
Replaces the entire method body with an unconditional return 1 (true):
Always Return false⚓
Always Return void (stub out a method)⚓
Flip a Branch⚓
Original — skips the admin check when isAdmin is false:
invoke-virtual {p0}, Lcom/example/app/User;->isAdmin()Z
move-result v0
if-eqz v0, :not_admin # if false, skip
Patched — always enters the admin block regardless of isAdmin():
invoke-virtual {p0}, Lcom/example/app/User;->isAdmin()Z
move-result v0
if-nez v0, :not_admin # flipped: if TRUE, skip (never happens for real admin)
Or simpler — just force the register before the branch:
invoke-virtual {p0}, Lcom/example/app/User;->isAdmin()Z
move-result v0
const/4 v0, 0x1 # overwrite result with true regardless
if-eqz v0, :not_admin # branch is now never taken
NOP Out a Method Call⚓
Replace an invoke-* line with nop (each instruction is replaced by a single nop; apktool handles sizing):
Warning
Only NOP a void call. If the call returns a value that is used in move-result afterwards, you must also remove or patch the move-result — otherwise the register will hold garbage.
Change a String Constant⚓
# Original:
const-string v0, "https://api.example.com"
# Patched — point to your intercepting server:
const-string v0, "https://192.168.1.100:8080"
Skip a Block with goto⚓
Instead of understanding and flipping a complex branch, just insert a goto that jumps over the block you want to skip:
# Original:
invoke-static {}, Lcom/example/app/License;->verify()V # launches verification
... lots of code ...
:continue
# rest of method
# Patched — insert goto right before the verify call:
goto :continue
invoke-static {}, Lcom/example/app/License;->verify()V # now unreachable
...
:continue
Add a New Local Register⚓
If your patch needs an extra register (e.g. you want to call an additional method), increase .locals:
Step 4 — Rebuild, Sign, and Install⚓
# 1. Decompile
apktool d app.apk -o decoded/
# 2. Edit the target smali file
# e.g. decoded/smali/com/example/app/auth/LoginManager.smali
# 3. Rebuild
apktool b decoded/ -o patched.apk
# If apktool reports errors, check:
# - Register count covers all registers you use
# - Every invoke of a non-void method has a move-result
# - Labels referenced in branch instructions exist in the method
# - Wide-type registers are used in pairs
# 4. Generate a signing key (one-time — reuse for all your test APKs)
keytool -genkey -v \
-keystore test.keystore \
-alias testkey \
-keyalg RSA \
-keysize 2048 \
-validity 9999
# 5a. Sign with apksigner (preferred — supports v2/v3 signatures, Android 7+)
apksigner sign \
--ks test.keystore \
--ks-key-alias testkey \
--out patched_signed.apk \
patched.apk
# 5b. Sign with jarsigner (older — only v1 signatures, works on older devices)
jarsigner -keystore test.keystore patched.apk testkey
# 6. Verify the signature
apksigner verify --verbose patched_signed.apk
# 7. Install
adb install -r patched_signed.apk
# If install fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE:
# the original app is installed with a different signing cert — uninstall first
adb uninstall com.example.app
adb install patched_signed.apk
Common Build Errors and Fixes⚓
| Error | Cause | Fix |
|---|---|---|
Register count too small |
Added instructions use registers beyond .locals count |
Increase .locals N |
Expected move-result |
Non-void invoke-* not followed by move-result |
Add move-result vX or change method to void |
Label not found |
Branch target label typo or removed | Fix label name or add missing label |
INSTALL_FAILED_UPDATE_INCOMPATIBLE |
Installed cert differs from test cert | adb uninstall com.example.app first |
apktool b outputs warnings about resources |
Resource decoding quirks | Try apktool b --use-aapt2 decoded/ |
| App crashes at modified method | Logic error in patch — wrong register, bad branch | Verify with adb logcat and inspect the exception |