jadx showed a pile of if (v == 0) … else if and a dead xor on the same register. apktool smali was an if-eqz / if-eq chain plus a junk block that never runs. It is not a commercial packer. It is a 40-line lab method that imitates the flattening I keep seeing in small SDKs. Goal: recover the switch the author meant.

Lab APK

1com.lab.smali.flow
2  Gate.java          // source I compiled, then ran a tiny obfuscator over
3  lib/arm64-v8a/libgate.so   // one native, only used for the crash section

Intended Java before the obfuscator:

 1package com.lab.smali.flow;
 2
 3public final class Gate {
 4    public static boolean accept(int code, String token) {
 5        if (token == null || token.length() < 8) return false;
 6        switch (code % 5) {
 7            case 0: return token.charAt(0) == 'l';
 8            case 1: return token.charAt(0) == 'a';
 9            case 2: return token.charAt(0) == 'b';
10            case 3: return token.length() == 8;
11            default: return false;
12        }
13    }
14}

The obfuscator (a 30-line Python rewriter I keep in labs/smali_flow/, not shipped) did three things:

  1. Replaced packed-switch with an if-eqz / if-eq chain.
  2. Inserted xor-int vX, p0, p0 / if-eqz opaque predicates (p0^p0 is always 0).
  3. Split the length check into two branches that both return false.

No control-flow flattening VM. No DexGuard. If a sample has a real dispatcher + encrypted I-string, this note does not unpack it.

apktool smali

1apktool d flow.apk -o flow_d
2# I/Baksmali: flow_d/smali/com/lab/smali/flow/Gate.smali
 1.class public final Lcom/lab/smali/flow/Gate;
 2.super Ljava/lang/Object;
 3.source "Gate.java"
 4
 5.method public static accept(ILjava/lang/String;)Z
 6    .locals 5
 7    .param p0, "code"    # I
 8    .param p1, "token"    # Ljava/lang/String;
 9
10    if-nez p1, :fail
11
12    invoke-virtual {p1}, Ljava/lang/String;->length()I
13    move-result v0
14
15    if-eqz v0, :fail          # length==0 → fail
16    const/16 v1, 0x8
17    if-lt v0, v1, :fail       # length<8 → fail
18
19    xor-int v2, p0, p0        # opaque: always 0
20    if-nez v2, :dead          # v2==0 → fall through
21    goto :live
22
23    :dead
24    const/4 v3, 0x1
25    return v3                 # junk; predicate is false
26
27    :live
28    rem-int/lit8 v1, p0, 0x5
29
30    if-eqz v1, :c1            # v1==0 ?
31    goto :case0
32
33    :c1
34    const/4 v2, 0x1
35    if-eq v1, v2, :c2
36    goto :case1
37
38    :c2
39    const/4 v2, 0x2
40    if-eq v1, v2, :c3
41    goto :case2
42
43    :c3
44    const/4 v2, 0x3
45    if-eq v1, v2, :c4
46    goto :case3
47
48    :c4
49    goto :fail                # default: 4, and any surprise
50
51    :case0
52    const/4 v2, 0x0
53    invoke-virtual {p1, v2}, Ljava/lang/String;->charAt(I)C
54    move-result v2
55    const/16 v3, 0x6c         # 'l'
56    if-eq v2, v3, :fail
57    const/4 v2, 0x1
58    return v2
59
60    :case1
61    const/4 v2, 0x0
62    invoke-virtual {p1, v2}, Ljava/lang/String;->charAt(I)C
63    move-result v2
64    const/16 v3, 0x61         # 'a'
65    if-eq v2, v3, :fail
66    const/4 v2, 0x1
67    return v2
68
69    :case2
70    const/4 v2, 0x0
71    invoke-virtual {p1, v2}, Ljava/lang/String;->charAt(I)C
72    move-result v2
73    const/16 v3, 0x62         # 'b'
74    if-eq v2, v3, :fail
75    const/4 v2, 0x1
76    return v2
77
78    :case3
79    const/16 v2, 0x8
80    if-eq v0, v2, :fail
81    const/4 v2, 0x1
82    return v2
83
84    :fail
85    const/4 v2, 0x0
86    return v2
87.end method

Read it as a graph, not as a story. The listing above is the fixed APK: xor-int produces 0, if-nez v2, :dead does not jump, goto :live runs the dispatcher.

The first rewriter shipped if-eqz v2, :dead instead. if-eqz jumps when the register is zero, so :dead ran every time and :live was skipped. jadx showed return true right after the length check. Runtime agreed: Gate.accept(0, "lab_AAAA") returned true without looking at code % 5. That build is kept as flow-dead.apk. It is the kind of bug obfuscators ship. Do not trust an opaque predicate until you evaluate the opcode.

jadx on the fixed APK

 1public static boolean accept(int code, String token) {
 2    if (token == null) return false;
 3    int v0 = token.length();
 4    if (v0 == 0) return false;
 5    if (v0 < 8) return false;
 6    if ((code ^ code) != 0) {
 7        return true;          // still emitted; never taken
 8    }
 9    int v1 = code % 5;
10    if (v1 == 0) {
11        return token.charAt(0) == 'l';
12    }
13    if (v1 == 1) {
14        return token.charAt(0) == 'a';
15    }
16    if (v1 == 2) {
17        return token.charAt(0) == 'b';
18    }
19    if (v1 == 3) {
20        return v0 == 8;
21    }
22    return false;
23}

jadx already folded the if-eqz chain into if (v1 == N). It did not emit a switch. That is fine. A switch is the note I write, not a requirement of the decompiler.

Reconstruct a packed-switch

Same method, same cases, written the way dx would have if I had left the original switch alone:

 1    rem-int/lit8 v1, p0, 0x5
 2    packed-switch v1, :pswitch_data_0
 3    goto :fail
 4
 5    :pswitch_0
 6    # case 0, 'l'
 7    ...
 8    :pswitch_1
 9    ...
10    :pswitch_2
11    ...
12    :pswitch_3
13    ...
14
15    :pswitch_data_0
16    .packed-switch 0x0
17        :pswitch_0
18        :pswitch_1
19        :pswitch_2
20        :pswitch_3
21    .end packed-switch

.packed-switch 0x0 means keys 0,1,2,3 are contiguous. Key 4 falls out to goto :fail, which is the default. sparse-switch would list keys explicitly; I would use it if the cases were 0, 7, 19.

How I decide it was a switch:

  1. One register, compared to consecutive small integers.
  2. Each arm returns or joins at a single label.
  3. The discriminant is a cheap arithmetic (rem-int, and-int) of a method argument.

If the comparisons are against hashes of strings, it is still a dispatch, but I do not force packed-switch syntax onto it.

Call site in MainActivity

 1.method protected onClick(Landroid/view/View;)V
 2    .locals 3
 3    invoke-virtual {p0}, Lcom/lab/smali/flow/MainActivity;->readCode()I
 4    move-result v0
 5    invoke-virtual {p0}, Lcom/lab/smali/flow/MainActivity;->readToken()Ljava/lang/String;
 6    move-result-object v1
 7    invoke-static {v0, v1}, Lcom/lab/smali/flow/Gate;->accept(ILjava/lang/String;)Z
 8    move-result v2
 9    invoke-virtual {p0, v2}, Lcom/lab/smali/flow/MainActivity;->show(Z)V
10    return-void
11.end method
1adb shell am start -n com.lab.smali.flow/.MainActivity
2# UI: code=0, token=lab_AAAA  → true  (case 0, charAt(0)=='l')
3#     code=1, token=lab_AAAA  → false (wants 'a')
4#     code=3, token=lab_AAAA  → false (length 8 only; this token is 8? lab_AAAA is 8 → true)

lab_AAAA is 8 chars. code=3 returns true on the fixed APK. Frida on the Java method, length and prefix only:

 1Java.perform(function () {
 2  const G = Java.use('com.lab.smali.flow.Gate');
 3  G.accept.implementation = function (code, token) {
 4    const n = token ? token.length() : 0;
 5    const pre = n >= 4 ? token.substring(0, 4) : '';
 6    const rc = this.accept(code, token);
 7    console.log('[Gate] code=' + code + ' len=' + n + ' prefix=' + pre + ' rc=' + rc);
 8    return rc;
 9  };
10});
1[Gate] code=0 len=8 prefix=lab_ rc=true
2[Gate] code=1 len=8 prefix=lab_ rc=false
3[Gate] code=3 len=8 prefix=lab_ rc=true

Native crash (same APK, different button)

Gate.nCopy copies the token into 16 bytes. Not part of the dispatch; it exists so this note has a tombstone.

1JNIEXPORT void JNICALL
2Java_com_lab_smali_flow_Gate_nCopy(JNIEnv *env, jclass c, jstring s) {
3    char buf[16];
4    const char *u = (*env)->GetStringUTFChars(env, s, NULL);
5    strcpy(buf, u);
6    (*env)->ReleaseStringUTFChars(env, s, u);
7}

40 As:

1F DEBUG  : ABI: 'arm64-v8a'
2F DEBUG  : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x[REDACTED]
3F DEBUG  :   #00 pc 00000000000010f4  libgate.so (Java_com_lab_smali_flow_Gate_nCopy+0x24)
1==3904==ERROR: AddressSanitizer: stack-buffer-overflow
2WRITE of size 41
3    #1 Java_com_lab_smali_flow_Gate_nCopy gate.c:12

What I file

  • Gate.accept is a 5-way dispatch on code % 5, hidden as if-eqz/if-eq.
  • Opaque xor-int is dead; confirm the opcode (eqz vs nez) before deleting it.
  • Equivalent form: packed-switch keys 0–3, default fail.
  • Not an unpack of a commercial packer. No encrypted opcode stream.

Commands appendix

1apktool d flow.apk -o flow_d
2less flow_d/smali/com/lab/smali/flow/Gate.smali
3jadx flow.apk
4frida -U -f com.lab.smali.flow -l gate.js --no-pause
5adb logcat -b crash -d | tail -30