Bytecode reference

September 15, 2026 · View on GitHub

Smali reading/writing aid, obfuscation survival rules, and fingerprint debugging. Companion docs: fingerprint reference (writing fingerprints), reverse engineering workflow (finding targets).

The Android bytecode specification is authoritative for opcode semantics, instruction formats, and register limits. Third-party smali cheat sheets and opcode tables may contain transcription or operand-order errors; use them only as navigation aids and verify injected code with the assembler/build step. Do not add diagnostic snippets that log passwords, keys, tokens, or plaintext cryptographic data.

Type descriptors

SmaliJava
Vvoid
Zboolean
B / S / C / Ibyte / short / char / int
J / Dlong / double (2 registers each)
Ljava/lang/String;String (L…; = object)
[I, [Ljava/lang/String;int[], String[] ([ = array)

Method header format: .method <access> <name>(<params>)<return>, e.g. .method public static isPremium(Lcom/app/User;)Z.

Registers

  • .registers N = total (v0–vN-1); .locals N = locals only, params are p*.
  • Non-static: p0 = this, p1… = params. Static: p0… = params.
  • Wide types (J, D) consume two consecutive registers — count carefully when injecting const/return sequences.
  • move-result* must immediately follow its invoke-*.

Common opcodes

const/4 v0, 0x0          # int 0 / false      const/4 v0, 0x1   # int 1 / true
const-string v0, "text"  # String constant    const-class v0, Lcom/app/Foo;
return-void              # void               return v0         # int/bool/float
return-wide v0           # long/double        return-object v0  # object/String
move-result v0           # capture invoke return (object: move-result-object)
invoke-virtual {v0, v1}, Lcom/app/Foo;->bar(I)V
invoke-static {v0}, Lcom/app/Foo;->check(Z)Z
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
iget v0, p0, Lcom/app/Foo;->count:I           # get field
iput v0, p0, Lcom/app/Foo;->count:I           # set field
sget-object v0, Lcom/app/Tier;->PRO:Lcom/app/Tier;  # static field
if-eqz v0, :label        # == 0 / false / null      if-nez v0, :label  # != 0
goto :label              # unconditional
new-instance v0, Lcom/app/Foo;

Injected snippets

# allow / deny a boolean gate
const/4 v0, 0x1
return v0
# skip a void method
return-void
# return a premium enum constant
sget-object v0, Lcom/app/Tier;->PRO:Lcom/app/Tier;
return-object v0
# consult an extension, then branch
invoke-static { }, Lcom/example/extension/MyPatch;->isEnabled()Z
move-result v0
if-eqz v0, :continue
return-void
:continue
nop

Replace the example extension path with the real class for the app being patched.

Obfuscation: what survives

R8/ProGuard rename app classes, methods, and fields nearly every release — prefer stable anchors over those names (see the fingerprint rules):

SurvivesWhyFingerprint field
Return / parameter typesPart of the signaturereturnType, parameters
Access flagsStructuralaccessFlags
SDK class + method namesNot covered by app obfuscation rulesmethodCall(definingClass, name)
String constantsKept as-is (unless DexGuard/string encryption)string(…) / strings
Literals, opcodes, call orderLogic flow preservedliteral, opcode, filter order

Check the level with uvx apkid app.apk:

OutputDifficultyStrategy
compiler: d8EasyStandard fingerprints
compiler: r8 / obfuscator: proguardNormalSDK calls + signature + strings
obfuscator: dexguardHardStrings may be encrypted — opcode/method-call patterns instead
packer: *Very hardDEX encrypted at rest — dump the decrypted DEX first

Fallbacks for heavy obfuscation: "L" as a parameter type (matches any object), classFingerprint via a stable anchor (toString with readable content, e.g. Kotlin data-class output), SDK-call-only filters without strings.

Recover real Kotlin names to find targets (never to match on, except under the version-pinned exception in the fingerprint rules): builds that retain @DebugMetadata(c="com.foo.Bar$…") / @Metadata(d2={…Lcom/foo/Bar;…}) strings can be mapped with scripts/recover_kotlin_names.py (see recover Kotlin names). Coverage is best-effort and lower when metadata is stripped or encrypted. jadx --deobf only invents synthetic names; metadata recovery restores the developer-written ones when the metadata survived.

Fingerprint debugging

When a fingerprint stops matching, work through this order:

  1. Version drift? aapt dump badging — confirm the APK is the version the fingerprint was written for.
  2. Read smali fresh. Find the class across all DEX dirs (find smali/ -name … | xargs rg -l <sdk-call>), read the method, and compare field-by-field: return type (the descriptor after ) in the header), access flags (exact — public static ≠ public static final), full parameter descriptors (SDK package paths move, e.g. …/purchases/CustomerInfo → …/purchases/models/CustomerInfo), filter order vs instruction order.
  3. R8 code motion? Small methods get inlined into callers or split — patch the caller / the split method instead.
  4. Too broad / too narrow? Several matches → add a stable filter; zero matches → drop the most fragile filter. A few stable filters beat many brittle ones.
  5. Null-safe probe. During development, use methodOrNull with a logged skip so one dead fingerprint doesn't abort the whole execute block; hard-fail only in the final patch.

Incremental technique: start from returnType alone, add access flags, then params, then filters one by one until the match is unique — you'll find the lying field fast.

Evidence bar: runtime gates SHOULD have two independent confirmations — a static smali quote plus one dynamic observation (see dynamic confirmation). Pure static gates may ship on smali evidence alone; anything else static-only is draft status, not a release patch.

Also remember: instructionMatches requires filters; strings matches method-level (not class-level) content — use classFingerprint when you need the class first.