What is Not Malware?

The Troubles of Modern Android APK Forensics

Those of you familiar with me know I have done quite a bit of work in modern Android forensics, malware or otherwise. The quick TLDR for those of you in the back is the landscape of Android has changed (quite awhile ago actually). Historically (like before 2017) most Android applications were built like this. I’m probably not 100% right in the order, but that doesn’t really matter.

  1. You ran javac, and made java byte code ( .class files).
  2. You ran dx, the historical android dexer.
  3. Maybe you ran proguard, maybe not.
  4. Various Android SDK components would then pack and sign it as a ZIP.

In practice this was usually 1:1 with source code, optimization was minimal and at best you got some simple names “a’, “b”, “c”. Nowadays its a bit more complicated…

  1. You are probably going to be using kotlin, which is kotlinc.
  2. Kotlinc itself is going to compose everything into the K2 FIR and eventually IR representations.
  3. Within those representations are compiler plugins. Which have unrestricted access to source code. This is things like kotlinx serialization, Jetpack Compose, and a few other nice things.
  4. Kotlinc then generates .class files directly… And any java would also be compiled here, or linked appropriately (i.e. a library)
  5. D8, the modern dexer. Converts the .class to the dex format (the ART and Dalvik format)
  6. R8 engages. Which does what proguard did, but also inlines code and provides other optimizations.

The end result is well… A mess. Code lands everywhere, and its impossible to trace. Don’t believe me? This is onCreate from my app.


@Override // defpackage.qg4, defpackage.tq1, defpackage.sq1, android.app.Activity
    public final void onCreate(Bundle bundle) {
        byte b;
        z(bundle);
        gl3.a(this);
        rwb viewModelStore = getViewModelStore();
        pwb pwbVarT = xg8.t(this);
        vh7 vh7Var = new vh7(viewModelStore, pwbVarT, q24.f(this, pwbVarT));
        c69 c69Var = b69.a;
        np5 np5VarB = c69Var.b(p3a.class);
        String strF = np5VarB.f();
        if (strF == null) {
            h.j("Local and anonymous classes can not be ViewModels");
            return;
        }
        p3a p3aVar = (p3a) vh7Var.z(np5VarB, "androidx.lifecycle.ViewModelProvider.DefaultKey:".concat(strF));
        this.k0 = p3aVar;
        x02 x02VarW = w();
        String string = getString(R.string.server_disconnected_message);
        string.getClass();
        String string2 = getString(R.string.status_server_offline);
        string2.getClass();
        String string3 = getString(R.string.status_waiting_for_approval);
        string3.getClass();
        String string4 = getString(R.string.status_vehicle_connected);
        string4.getClass();
        String string5 = getString(R.string.status_no_vehicle);
        string5.getClass();
        String string6 = getString(R.string.status_working);
        string6.getClass();
        String string7 = getString(R.string.status_word_offline);
        string7.getClass();
        String string8 = getString(R.string.status_word_waiting);
        string8.getClass();
        String string9 = getString(R.string.status_word_failed);
        string9.getClass();
        String string10 = getString(R.string.status_word_connected);
        string10.getClass();
        String string11 = getString(R.string.status_word_idle);
        string11.getClass();
        aja ajaVar = new aja(string2, string3, string4, string5, string6, string7, string8, string9, string10, string11);
        lha lhaVar = x02VarW.e;
        lha lhaVar2 = x02VarW.d;
        lha lhaVar3 = x02VarW.g;
        byte b2 = 3;
        byte b3 = 2;
        final byte b4 = 0;
        p52 p52Var = null;
        if (p3aVar.t) {
            b = true;
        } else {
            p3aVar.t = true;
            b = true;
            ip8.C(new z94(new vp(lhaVar3, b3), new vs0(p3aVar, string, p52Var, (byte) 7), b3), d74.G(p3aVar));
            ip8.C(new z94(new vp(lhaVar2, b2), new k3a(p3aVar, p52Var, b3), b3), d74.G(p3aVar));
            ip8.C(new z94(lhaVar, new k3a(p3aVar, p52Var, b2), b3), d74.G(p3aVar));
            byte b5 = 4;
            ip8.C(new z94(rc7.d, new k3a(p3aVar, p52Var, b5), b3), d74.G(p3aVar));
            ip8.C(new z94(rc7.e, new k3a(p3aVar, p52Var, (byte) 5), b3), d74.G(p3aVar));
            ip8.C(new z94(new wp0(new m94[]{lhaVar3, lhaVar, lhaVar2, p3aVar.w}, new l3a(ajaVar, null), b5), new k3a(p3aVar, p52Var, (byte) 6), b3), d74.G(p3aVar));
        }
        p3a p3aVar2 = this.k0;
        if (p3aVar2 == null) {
            hh5.T("shell");
            throw null;
        }
        this.m0 = new ay(p3aVar2);
        p3a p3aVar3 = this.k0;
        if (p3aVar3 == null) {
            hh5.T("shell");
            throw null;
        }
        ip8.C(new z94(p3aVar3.i, new yr4(this, p52Var, b3), b3), rx9.e0(this));
        rwb viewModelStore2 = getViewModelStore();
        pwb pwbVarT2 = xg8.t(this);
        vh7 vh7Var2 = new vh7(viewModelStore2, pwbVarT2, q24.f(this, pwbVarT2));
        np5 np5VarB2 = c69Var.b(g55.class);
        String strF2 = np5VarB2.f();
        if (strF2 == null) {
            h.j("Local and anonymous classes can not be ViewModels");
            return;
        }
        g55 g55Var = (g55) vh7Var2.z(np5VarB2, "androidx.lifecycle.ViewModelProvider.DefaultKey:".concat(strF2));
        LruCache lruCache = g55Var.b;
        if (lruCache == null) {
            LruCache lruCache2 = new LruCache(((int) (Runtime.getRuntime().maxMemory() / 1024)) / 8);
            g55Var.b = lruCache2;
            lruCache = lruCache2;
        }
        qpa.i = lruCache;
        SharedPreferences sharedPreferencesQ = xg8.q(this);
        ts7 ts7Var = this.l0;
        sharedPreferencesQ.getClass();
        ts7Var.setValue(J(sharedPreferencesQ));
        p3a p3aVar4 = this.k0;
        if (p3aVar4 == null) {
            hh5.T("shell");
            throw null;
        }
        boolean z = sharedPreferencesQ.getBoolean("accessibility_pin_nav_bar", false);
        lha lhaVar4 = p3aVar4.j;
        Boolean boolValueOf = Boolean.valueOf(z);
        lhaVar4.getClass();
        lhaVar4.l(null, boolValueOf);
        p3aVar4.L();
        Object systemService = getSystemService("accessibility");
        systemService.getClass();
        AccessibilityManager accessibilityManager = (AccessibilityManager) systemService;
        this.n0 = accessibilityManager;
        p3a p3aVar5 = this.k0;
        if (p3aVar5 == null) {
            hh5.T("shell");
            throw null;
        }
        boolean zIsTouchExplorationEnabled = accessibilityManager.isTouchExplorationEnabled();
        lha lhaVar5 = p3aVar5.k;
        Boolean boolValueOf2 = Boolean.valueOf(zIsTouchExplorationEnabled);
        lhaVar5.getClass();
        lhaVar5.l(null, boolValueOf2);
        p3aVar5.L();
        AccessibilityManager accessibilityManager2 = this.n0;
        if (accessibilityManager2 == null) {
            hh5.T("accessibilityManager");
            throw null;
        }
        accessibilityManager2.addTouchExplorationStateChangeListener(this.o0);
        ?? r5 = b;
        uq1.a(this, new ft1(1900478328, r5, new ti6(this, b4)));
        this.d0.add(ip8.C(new z94(new vp(w().e, b2), new yr4(this, p52Var, r5 == true ? (byte) 1 : (byte) 0), b3), rx9.e0(this)));
        ArrayList arrayList = this.d0;
        d73.Companion.getClass();
        arrayList.add(ip8.C(new z94(d73.b, new wi6(this, p52Var, r5 == true ? (byte) 1 : (byte) 0), b3), rx9.e0(this)));
        boolean z2 = sharedPreferencesQ.getBoolean("start_server_on_launch", r5);
        this.e0 = z2;
        int i = sharedPreferencesQ.getInt("terms_version_acceptedv2", 0);
        if (x().b || i > r5) {
            if (this.e0 && j68.a(this, "android.permission.POST_NOTIFICATIONS") && j68.a(this, "android.permission.BLUETOOTH_CONNECT")) {
                G(this);
            }
            x().b = true;
        } else {
            y56 y56Var = new y56(this);
            View viewInflate = LayoutInflater.from(this).inflate(R.layout.legal_accept_dialog, (ViewGroup) null);
            ((Button) viewInflate.findViewById(R.id.termsButton)).setOnClickListener(new View.OnClickListener(this) { // from class: x56
                public final /* synthetic */ MainActivity b;

                {
                    this.b = this;
                }

                @Override // android.view.View.OnClickListener
                public final void onClick(View view) {
                    byte b6 = b4;
                    MainActivity mainActivity = this.b;
                    switch (b6) {
                        case 0:
                            mainActivity.startActivity(new Intent(mainActivity, (Class<?>) LegalActivity.class));
                            break;
                        default:
                            mainActivity.startActivity(new Intent(mainActivity, (Class<?>) PrivacyActivity.class));
                            break;
                    }
                }
            });
            final byte b6 = 1;
            ((Button) viewInflate.findViewById(R.id.privacyButton)).setOnClickListener(new View.OnClickListener(this) { // from class: x56
                public final /* synthetic */ MainActivity b;

                {
                    this.b = this;
                }

                @Override // android.view.View.OnClickListener
                public final void onClick(View view) {
                    byte b7 = b6;
                    MainActivity mainActivity = this.b;
                    switch (b7) {
                        case 0:
                            mainActivity.startActivity(new Intent(mainActivity, (Class<?>) LegalActivity.class));
                            break;
                        default:
                            mainActivity.startActivity(new Intent(mainActivity, (Class<?>) PrivacyActivity.class));
                            break;
                    }
                }
            });
            ie ieVar = (ie) y56Var.c;
            ieVar.s = viewInflate;
            ieVar.e = "Welcome to Gretio";
            ieVar.g = "To use this app you must accept the terms of service and the privacy policy.\n";
            y56Var.u("Accept", new ui6(this, sharedPreferencesQ, z2));
            vi6 vi6Var = new vi6(this, b4);
            ie ieVar2 = (ie) y56Var.c;
            ieVar2.l = "Exit";
            ieVar2.m = vi6Var;
            ieVar.n = false;
            y56Var.r();
        }
        this.d0.add(f10.N(rx9.e0(this), null, null, new wi6(this, p52Var, b4), 3));
    }

99.9% of people would look at this, have no idea where to go next. And give up.  What even is all this code? What is “shell”? Shells are bad. Where even is the app’s main logic?

I’ll give you a hint. It’s here

uq1.a(this, new ft1(1900478328, r5, new ti6(this, b4)));

What is that? Well at one point it was a jetpack compose entry point. Like this.

       setContent {
            GretioTheme {
                GretioApp(
                    // ...
                )
            }
        }

If we dive into that ‘function’ thing we see

package defpackage;

import com.surrealdev.max.MainActivity;

/* JADX INFO: compiled from: r8-map-id-f1bea1f2b20b2c262105114794125233f4b0d83914655aa2279739eb682e393c */
/* JADX INFO: loaded from: classes2.dex */
public final /* synthetic */ class ti6 implements ti4 {
    public final /* synthetic */ byte a;
    public final /* synthetic */ MainActivity b;

    public /* synthetic */ ti6(MainActivity mainActivity, byte b) {
        this.a = b;
        this.b = mainActivity;
    }

    @Override // defpackage.ti4
    public final Object invoke(Object obj, Object obj2) {
        byte b = this.a;
        qlb qlbVar = qlb.a;
        byte b2 = 1;
        switch (b) {
            case 0:
                bl4 bl4Var = (bl4) obj;
                int iIntValue = ((Integer) obj2).intValue();
                int i = MainActivity.q0;
                if (bl4Var.S(iIntValue & 1, (iIntValue & 3) != 2)) {
                    edc.d(ao7.O(737273718, new ti6(this.b, b2), bl4Var), bl4Var, 6);
                } else {
                    bl4Var.V();
                }
                return qlbVar;
            default:
                bl4 bl4Var2 = (bl4) obj;
                int iIntValue2 = ((Integer) obj2).intValue();
                int i2 = MainActivity.q0;
                if (bl4Var2.S(iIntValue2 & 1, (iIntValue2 & 3) != 2)) {
                    MainActivity mainActivity = this.b;
                    p3a p3aVar = mainActivity.k0;
                    if (p3aVar == null) {
                        hh5.T("shell");
                        throw null;
                    }
                    ay ayVar = mainActivity.m0;
                    if (ayVar == null) {
                        hh5.T("navigator");
                        throw null;
                    }
                    tia tiaVar = (tia) mainActivity.l0.getValue();
                    saa saaVar = mainActivity.b0;
                    boolean zI = bl4Var2.i(mainActivity);
                    Object objN = bl4Var2.N();
                    yu0 yu0Var = qw1.a;
                    if (zI || objN == yu0Var) {
                        n45 n45Var = new n45(1, mainActivity, MainActivity.class, "selectTab", "selectTab(Lcom/surrealdev/max/ui/shell/TopLevelTab;)V", 0, (byte) 8);
                        bl4Var2.l0(n45Var);
                        objN = n45Var;
                    }
                    ii4 ii4Var = (ii4) ((hq5) objN);
                    boolean zI2 = bl4Var2.i(mainActivity);
                    Object objN2 = bl4Var2.N();
                    if (zI2 || objN2 == yu0Var) {
                        objN2 = new uu1(mainActivity, (byte) 5);
                        bl4Var2.l0(objN2);
                    }
                    tl5 tl5Var = p3a.x;
                    ms4.a(p3aVar, ayVar, tiaVar, saaVar, ii4Var, (ii4) objN2, bl4Var2, 8);
                } else {
                    bl4Var2.V();
                }
                return qlbVar;
        }
    }
}

Which is a signature R8 optimization. It converts common functions into a single class and then switches on them. In the case of onCreate the integer is always 0 sooo it just takes the first path. This is faster, and a critical optimization for Jetpack compose. That was that final byte b4 = 0; you saw earlier. Did you pay attention?

This exercise was just to show the absurdities of the problem. The answer is I didn’t write most of this code, it is SDK code inlined by R8 for various optimization purposes. It is thus extremely hard to really know what I actually wrote, and what the app’s intended behavior actually is. In the case of malware its hard to know where the SDK begins and the malicious behavior begins.

So, what if we could remove this noise? Clear the SDK signals from the developer’s code so that we can focus on what they wrote. In the case of malware this is like asking ourselves not “What is Malware” but rather “What is not Malware?”.

I’ll just ask the AI lol

I meannnn yeah. But AI is going to give you a different answer every time. Good for one offs, bad for scale. Plus the AI is going to spin tokens on diving into SDKs to figure them out. That’s time the AI could instead spend actually reversing the developer’s code.

So if our goal is more efficient and reliable forensics, we need a better method to dissect applications.

Introducing EighthR

What is done can be undone. If instead of operating off methods, classes, and packages… What if we decompose the application in a new IR form and abuse that IR to undo the optimization that created it.

Better yet, what if we take this same concept and apply it to every common SDK (Compose, Androidx, etc…) and then map their IR signatures back into the target application.

And better yet, what if we made the system so good that it was 100% deterministic.

And even better yet. What if we made it so the application actually ran post de-obfuscation (ignoring like a signature validation).

I call this system “EighthR”, or 8R (the opposite of R8). But in practice simply undoing R8 is not enough we also need context, so a major system is known as The Forge, which takes SDK data to basically reverse engineer what the original code actually looked like.

IR Form

The first step of EighthR is converting some arbitrary dalvik format dex code back into its original IR components, and trying to sort out what R8 ‘probably’ did with the data.

An Example Problem

Let’s just focus on the IR for now. Let’s say we have a simple kotlin function, and let’s say you want to detect this function in a production application (maybe its malware)

package defpackage;
    class Counter(private val name: String) {
        private var count = 0
        
        fun bump(by: Int): String {
            if (by > 0) count += by
            return name + "=" + count
        }
    }

It’s corresponding dexdump is

defpackage.Counter.bump:(I)Ljava/lang/String;
  0000: if-lez v2, 0007
  0002: iget v0, v1, Ldefpackage/Counter;.count:I
  0004: add-int/2addr v0, v2
  0005: iput v0, v1, Ldefpackage/Counter;.count:I
  0007: new-instance v2, Ljava/lang/StringBuilder;
  0009: invoke-direct {v2}, Ljava/lang/StringBuilder;.<init>:()V
  000c: iget-object v0, v1, Ldefpackage/Counter;.name:Ljava/lang/String;
  000e: invoke-virtual {v2, v0}, Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  0011: const/16 v0, #int 61                        // '=' — kotlinc turned the 1-char string into a char
  0013: invoke-virtual {v2, v0}, Ljava/lang/StringBuilder;.append:(C)Ljava/lang/StringBuilder;
  0016: iget v1, v1, Ldefpackage/Counter;.count:I
  0018: invoke-virtual {v2, v1}, Ljava/lang/StringBuilder;.append:(I)Ljava/lang/StringBuilder;
  001b: invoke-virtual {v2}, Ljava/lang/StringBuilder;.toString:()Ljava/lang/String;
  001e: move-result-object v1
  001f: return-object v1

And our IR representation of that is

Ldefpackage/Counter;->bump(I)Ljava/lang/String;   (registers 3, ins 2: this = v1, by = v2)
     0  IfZ          { cond: Le, a: 2, target: 4 }
     1  InstanceGet  { Narrow, dst: 0, obj: 1, field: Counter.count:I }
     2  Binop        { Add, Int, dst: 0, a: 0, b: Reg(2) }
     3  InstancePut  { Narrow, src: 0, obj: 1, field: Counter.count:I }
     4  NewInstance  { dst: 2, ty: StringBuilder }
     5  Invoke       { Direct, StringBuilder.<init>()V, args: [2] }
     6  InstanceGet  { Object, dst: 0, obj: 1, field: Counter.name:Ljava/lang/String; }
     7  Invoke       { Virtual, StringBuilder.append(String), args: [2, 0] }
     8  Const        { dst: 0, value: Narrow(61) }
     9  Invoke       { Virtual, StringBuilder.append(C), args: [2, 0] }
    10  InstanceGet  { Narrow, dst: 1, obj: 1, field: Counter.count:I }
    11  Invoke       { Virtual, StringBuilder.append(I), args: [2, 1] }
    12  Invoke       { Virtual, StringBuilder.toString(), args: [2] }
    13  MoveResult   { Object, dst: 1 }
    14  Return       { Object, src: 1 }

The minified dex (like from Proguard) would be

a.a:(I)Ljava/lang/String;                           // Counter.bump
  0000: if-lez v2, 0007
  0002: iget v0, v1, La;.b:I                          // count
  0004: add-int/2addr v0, v2
  0005: iput v0, v1, La;.b:I
  0007: new-instance v2, Ljava/lang/StringBuilder;
  0009: iget-object v0, v1, La;.a:Ljava/lang/String;  // name
  000b: invoke-direct {v2, v0}, Ljava/lang/StringBuilder;.<init>:(Ljava/lang/String;)V   // R8 merged new+append(name)
  000e: const/16 v0, #int 61
  0010: invoke-virtual {v2, v0}, Ljava/lang/StringBuilder;.append:(C)Ljava/lang/StringBuilder;
  0013: iget v1, v1, La;.b:I
  0015: invoke-virtual {v2, v1}, Ljava/lang/StringBuilder;.append:(I)Ljava/lang/StringBuilder;
  0018: invoke-virtual {v2}, Ljava/lang/StringBuilder;.toString:()Ljava/lang/String;
  001b: move-result-object v1
  001c: return-object v1

But, if we run it in R8 we find the code doesn’t look like the above minification at all. Instead R8 has ‘outlined’ the code into different functions spread across classes. R8 made the determination that this transformation is more efficient.

  // Outline functions R8 synthesized (class b)
  Lb;->a(IILjava/lang/StringBuilder;)Ljava/lang/String;
     0  Binop       { Add, Int, dst: 0, a: 0, b: Reg(1) }             // the caller's "v + N"
     1  Invoke      { Virtual, StringBuilder.append(I), args: [2, 0] }
     2  Invoke      { Virtual, StringBuilder.toString(), args: [2] }
     3  MoveResult  { Object, dst: 0 }
     4  Return      { Object, src: 0 }

  Lb;->b(Ljava/lang/String;Ljava/lang/String;C)Ljava/lang/StringBuilder;
     0  Invoke      { Virtual, Object.getClass(), args: [0] }         // Kotlin's null check on k
     1  NewInstance { dst: 0, ty: StringBuilder }
     2  Invoke      { Direct, StringBuilder.<init>(String), args: [0, 1] }
     3  Invoke      { Virtual, StringBuilder.append(C), args: [0, 2] }
     4  Return      { Object, src: 0 }

    // What's left of one of those functions, inside its caller
    74  ArrayLength { dst: 7, array: 9 }                                   args.size
    75  Invoke      { Static, Lb;.b(String, String, C), args: [2, 2, 0] }   // outline
    76  MoveResult  { Object, dst: 2 }
    77  Invoke      { Static, Lb;.a(I, I, StringBuilder), args: [7, 3, 2] } // outline
    78  MoveResult  { Object, dst: 2 }
    79  StaticGet   { dst: 3, field: System.out }
    80  Invoke      { Virtual, PrintStream.println(Object), args: [3, 2] }

Based on this, we can see any signature based off the original code will not align with reality. Likewise matching an outline use will only target a specific scenario of R8 and could change the next compilation…. R8 could just as easily keep it as a function, inline its use entirely, or even remove the function entirely. It just depends on the usage within the application.

Thus the key question is: How can we abstract R8 in a way such that a signature can exist for all possible R8 paths? (where R8 did not delete unused code)

The IR Solution

To reverse R8 we need to think like R8, and that means operating on the IR directly.

  1. Identify possible outlines and inlines (the key R8 signature)
   83  ArrayLength { dst: 7, array: 12 }
    84  Move        { Object, dst: 8, src: 2 }       <┐ outline b's parameters
    85  Move        { Object, dst: 9, src: 2 }        │ bound to the call's arguments
    86  Move        { Single, dst: 10, src: 0 }      <┘
    87  Invoke      { Virtual, Object.getClass(), args: [8] }
    88  NewInstance { dst: 8, ty: StringBuilder }
    89  Invoke      { Direct, StringBuilder.<init>(String), args: [8, 9] }
    90  Invoke      { Virtual, StringBuilder.append(C), args: [8, 10] }
    91  Move        { Object, dst: 2, src: 8 }
    92  Binop       { Add, Int, dst: 7, a: 7, b: Reg(3) }          // outline a, back inline
    93  Invoke      { Virtual, StringBuilder.append(I), args: [2, 7] }
    94  Invoke      { Virtual, StringBuilder.toString(), args: [2] }
    95  MoveResult  { Object, dst: 7 }
    96  Move        { Object, dst: 2, src: 7 }
    97  StaticGet   { dst: 3, field: System.out }
    98  Invoke      { Virtual, PrintStream.println(Object), args: [3, 2] }
  1. Normalize what is common such that the signature aligns no matter what transformation R8 applies.
  ops:     ifz iget add iput new-instance iget invoke const invoke iget invoke invoke move-result return
  refs:    iget:?:I  iput:?:I  new:Ljava/lang/StringBuilder;  iget:?:Ljava/lang/String;
           D:Ljava/lang/StringBuilder;-><init>(Ljava/lang/String;)V
           V:Ljava/lang/StringBuilder;->append(C)Ljava/lang/StringBuilder;
           V:Ljava/lang/StringBuilder;->append(I)Ljava/lang/StringBuilder;
           V:Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
  nums:    61
  proto:   (I)Ljava/lang/String;

The same method, renamed differently or compiled differently always gives this exact fingerprint. Note bump is a very simple function which would collide, but a real SDK example would have been too harsh as an initial example.

The D8 version from the top has ()V + append(String), and R8 turned that into (String). But that’s okay! We only care about the R8 side of things.

Our 8R IR representation does other deobfuscation techniques as well, but they aren’t that important right now. For now let’s just focus on these signatures.

The Forge

Ok so, we have a good idea of what R8 ‘did’ for a given app. We’re not 100% certain, but that’s ok. We collect those signatures for later analysis. The next step is figuring out what these signature actually align to, and for that we use The Forge.

The Forge is all about taking SDKs and generating deterministic fingerprints directly from the SDK itself. Those fingerprints then go back into 8R’s IR to identify common code, even after it has been mangled by R8. It does so basically by just running R8 in all the common patterns we might see in an app. The output signatures will then align to what we just generated.

How do we know what SDK is? Well unless its obfuscated, the APK itself will tell you. Even if it was obfuscated you can simply ahead of time generate fingerprints. You don’t even need the exact SDK version for this to work its just ‘nice to have’. As long as we’re in the ball park it will give us results.

 

The basis of the Forge is really

  1. Grab the SDK.
  2. Compile the SDK (if not already shipped as an AAR/Jar).
  3. Run the SDK through R8 under various scenarios, where we have the mapping.
  4. Apply that mapping to generate fingerprints specific to that exact build.

Simple right? Let’s look at a core Jetpack Compose function: setContent


class ExampleActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent { // In here, we can call composables!
            MaterialTheme {
                Greeting(name = "compose")
            }
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

1. Grab the SDK

setContent lives in androidx.activity:activity-compose:1.13.0. Which we can get from Sonatype Maven Central.

2. Generate code that uses it

The Forge generates callers straight from the SDK’s public API, in several flavours:

  • keep the whole public API (the library on its own)
  • keep random samples of it, as if an app used just those parts
  • calls to Kotlin’s $default functions exactly as kotlinc emits them when you leave default arguments out.

That last one is how most apps call setContent.

setContent { … } leaves parent = null out. Here’s the bytecode the Forge generated for it:

public static void c2();
   0: invokestatic  gen/O.a:()Ljava/lang/Object;           // an opaque ComponentActivity
   3: checkcast     androidx/activity/ComponentActivity
   6: aconst_null                                          // parent: taken from the default
   7: invokestatic  gen/O.a:()Ljava/lang/Object;           // an opaque content lambda
  10: checkcast     kotlin/jvm/functions/Function2
  13: ldc_w         1                                      // mask: argument 0 uses its default
  16: aconst_null
  17: invokestatic  androidx/activity/compose/ComponentActivityKt.setContent$default:(…ILjava/lang/Object;)V

3. Run it through the app’s R8

Here’s what R8 did with setContent in that scenario:

androidx.activity.compose.ComponentActivityKt -> qr:
    1:33:void setContent(androidx.activity.ComponentActivity,androidx.compose.runtime.CompositionContext,kotlin.jvm.functions.Function2):55:55 -> a
    1:33:void setContent$default(androidx.activity.ComponentActivity,…,int,java.lang.Object):50 -> a
      # {"id":"com.android.tools.r8.residualsignature","signature":"(Lpr;Ldi0;)V"}

So the method qr.a(pr, di0) is setContent, specialized with its defaults. R8 removed the parent, mask and marker parameters, exactly as it did in Gretio.

4. Fingerprint it with the mapping

The scenario’s output goes through the same IR, the same rewrites and the same normalization 8R applies to an app, so names, registers and encodings don’t matter. The mapping (from R8) tells us what the method really is:

scenario:  qr.a(Lpr;Ldi0;)V      all=12fecb805b8c39ff
pack:      androidx/activity/compose/ComponentActivityKt;->setContent(ComponentActivity, CompositionContext, Function2)
            all=12fecb805b8c39ff  unique  scenarios: r1–r6, c1–c6, d1–d4

5. Back in the app

Using the same process in the outline section, EighthR lifts Gretio’s functions into IR space, undoes R8’s restructuring, and fingerprints uq1.a:

uq1.a(Ltq1;Lft1;)V      all=12fecb805b8c39ff

Same fingerprint, so uq1.a is setContent:

void setContent(ComponentActivity_…, ComposableLambdaImpl_…)

Then just apply the rename in the output.

 

Application Example

We rinse and repeat this process for all SDKs, not just Jetpack compose. The below function shows some internal “DTC” clear function for Gretio. It’s confusing what’s going on here.


public aj3(String str, Module module, x02 x02Var, un9 un9Var) {
    str.getClass();
    module.getClass();
    x02Var.getClass();
    this.b = str;
    this.c = module;
    this.d = x02Var;
    lha lhaVarM = ep6.m(ti3.a);
    this.e = lhaVarM;
    this.f = hib.j(lhaVarM);
    gx0 gx0VarN = ms2.n(-2, null, null, 6);
    this.g = gx0VarN;
    this.h = ik8.E(gx0VarN);
    f10.N(d74.G(this), null, null, new k73(this, false, null, (byte) 1), 3);
    this.i = x00.W0(new String[]{"CLEAR_ALL", "CLEAR_ALL_OBD"});
    this.j = "CLEAR_ALL";
    lha lhaVarM2 = ep6.m(Boolean.FALSE);
    this.k = lhaVarM2;
    this.l = hib.j(lhaVarM2);
}

After running it through EighthR, we see many of these fields are simply state flows and coroutine internals. We still don’t really know what it’s actually doing but we now have a much greater understanding of its internal mechanisms.


@Inlined(paramNullChecks = 3)
public Class_f39b(String str, Module module, Class_3d74 class_3d74, SavedStateHandle_ce10 savedStateHandle_ce10) {
    str.getClass();
    module.getClass();
    class_3d74.getClass();
    this.str_5bd2 = str;
    this.module_a0c6 = module;
    this.obj_6b6e = class_3d74;
    StateFlowImpl_d9b4 stateFlowImpl_d9b4M_6260 = Class_4f50d.m_6260(Class_c0fd.obj_650f);
    this.obj_9430 = stateFlowImpl_d9b4M_6260;
    this.obj_811c = Class_9ff2.asStateFlow(stateFlowImpl_d9b4M_6260);
    BufferedChannel_115c bufferedChannel_115cM_ab58 = Class_a8ea.m_ab58(-2, null, null, 6);
    this.obj_b582 = bufferedChannel_115cM_ab58;
    this.obj_70c9 = Class_9110.m_930e(bufferedChannel_115cM_ab58);
    Class_3e52.m_7ae1(Class_87aa.getViewModelScope(this), null, null, new Serializable_6a4b(this, false, null, (byte) 1), 3);
    this.set_8aba = Class_6e50.toSet(new String[]{"CLEAR_ALL", "CLEAR_ALL_OBD"});
    this.str_a5f6 = "CLEAR_ALL";
    StateFlowImpl_d9b4 stateFlowImpl_d9b4M_6261 = Class_4f50d.m_6260(Boolean.FALSE);
    this.obj_fe71 = stateFlowImpl_d9b4M_6261;
    this.obj_6bdc = Class_9ff2.asStateFlow(stateFlowImpl_d9b4M_6261);
}

Which would you rather read?

Is it a perfect original source code deobfuscator? No. But it doesn’t need to be. It just needs to provide enough information so that well known functions are outlined and do not burn analysis time. This deobfuscation is even useful for AI. To determine these fields are in fact stateflows the AI would have otherwise needed to dive deep into each function reference, and verify. But with the EighthR representation the AI can immediately see that a field is a stateflow. The AI’s attention then focuses on what it doesn’t know (the code the developer actually wrote).

EighthR helps answer the question: “What is Not Malware?”

With the rise of AI use in generating malware, concrete static analysis signals have become harder and harder to reliably achieve. When signals are present they are usually a compiler artifact rather than a real malicious signal. Which is bad. Flagging “Legit” code as behavior just means it’s only a matter of time before your detection flags a genuine false positive.

To prevent such false positives we must start to ask ourselves not “What is Malware“ but rather “What is Not Malware?“. And I have the perfect sample. I developed a CTF which while not malware, will look like malware. Meaning it must be a genuine true positive: https://github.com/Snipesy/pokedex-ctf

To make my own CTF harder I recompiled it from scratch with R8 in its most restrictive settings. I then put it through EighthR.

Now I have in the past said you should never try to “Deep Dive” Compose, and I still don’t think you should. But what EighthR gives us is enough pattern matching hints that doing a deep dive of Jetpack Compose is possible with some simple pattern matching skills.

The below video shows me finding the CTF’s DCL code (Dynamic Code Loading) behavior in less than 60 seconds, without AI, and without doing a single String search.

Now I am biased, I made this CTF. But still. Doing this without EighthR would have taken me over an hour.

My strategy is simple. Focus on the code the dev made, ignore the SDK code. With that, I was able to find the suspicious DCL code within 60 seconds. That is the power of answering “What is not malware?”, and is what I believe is the future of detection engineering especially in the age of AI.

 

Try EighthR

8R is open source on Github

https://github.com/Snipesy/8R

The motivation of the project is not to be “The best deobfuscator” but rather prove the methodology and serve as a concrete baseline.

Posted in Malware.