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:
- Replaced
packed-switchwith anif-eqz/if-eqchain. - Inserted
xor-int vX, p0, p0/if-eqzopaque predicates (p0^p0is always 0). - 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:
- One register, compared to consecutive small integers.
- Each arm returns or joins at a single label.
- 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.acceptis a 5-way dispatch oncode % 5, hidden asif-eqz/if-eq.- Opaque
xor-intis dead; confirm the opcode (eqzvsnez) before deleting it. - Equivalent form:
packed-switchkeys 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