Деобфускация в уме: Часть 1 - (не)Уникальный опыт

Деобфускация в уме: Часть 1


Если вам на секунду показалось, что название статьи вам смутно знакомо, то вам не показалось.

Очень часто под рукой не оказывается ни отладчика, ни дизассемблера, ни даже компилятора, чтобы набросать хотя бы примитивный трассировщик. Разумеется, что говорить о взломе современных защитных механизмов в таких условиях просто смешно, но что делать если жизнь заставляет?

Касперски Крис

Я задумал этот цикл статей, как попытку составить некий справочник того, как в android приложениях написанных на Kotlin, могут выглядеть разные куски кода обфусцированные с помощью Proguard.

/img/deobfuscation-in-the-mind/kdpv.png



Сейчас, когда код android приложений все чаще пишется на языке Kotlin, очень важно построить этот мостик понимания происходящего чтобы упростить исследование android приложений. Почему именно Kotlin? Да потому что резульаты выхлопа его компилятора в обфусцированном и позже декомпилированном виде могут выглядеть весьма непривычно или вовсе ужасно по сравнению с кодом на Java. Например вот так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public final z m0(o0.h hVar, Integer num) {
    o0.h hVar2 = hVar;
    if ((num.intValue() & 11) == 2 && hVar2.x()) {
        hVar2.C();
    } else {
        q2 c11 = f2.c(null, true, null, hVar2, 10);
        hVar2.e(1157296644);
        boolean Q = hVar2.Q(c11);
        Object f11 = hVar2.f();
        if (Q || f11 == h.a.f22121b) {
            f11 = new f9.b(c11);
            hVar2.J(f11);
        }
        hVar2.N();
        f9.b bVar = (f9.b) f11;
        a0<? extends t4.p>[] a0VarArr = {bVar};
        hVar2.e(-312215566);
        Context context = (Context) hVar2.q(y.f2611b);
        Object[] copyOf = Arrays.copyOf(a0VarArr, 1);
        n nVar = n.E;
        o oVar = new o(context);
        l<Object, Object> lVar = w0.m.f29682a;
        t4.u uVar = (t4.u) ip.c.g(copyOf, new w0.n(nVar, oVar), new u4.p(context), hVar2, 4);
        for (int i11 = 0; i11 < 1; i11++) {
            uVar.f26868v.a(a0VarArr[i11]);
        }
        hVar2.N();
        hVar2.e(-492369756);
        Object f12 = hVar2.f();
        if (f12 == h.a.f22121b) {
            f12 = oa0.d.b(hVar2);
        }
        hVar2.N();
        u0 u0Var = (u0) f12;
        View view = (View) hVar2.q(y.f2615f);
        hj0.g.a(a.o.h(hVar2, -1964315366, new ru.vk.store.app.d(uVar, bVar, db0.i.i(MainActivity.this.F().M(), hVar2), u0Var)), hVar2, 6);
        ck0.d.b(uVar, new e(this.F, uVar, MainActivity.this), hVar2, 8);
        eb0.b.b(new f(MainActivity.this.F()), hVar2, 0);
        MainActivity mainActivity = MainActivity.this;
        ck0.d.a(mainActivity, mainActivity.F().f25379g, c11, u0Var, uVar, new g(MainActivity.this.F()), hVar2, 35912);
        g0.c(uVar, new h(MainActivity.this, uVar), hVar2);
        g0.c(z.f18979a, new i(view), hVar2);
    }
    return z.f18979a;
}

Инструменты вроде Jadx способны немного улучшить результат, но большую часть работы все равно придется проделать руками:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public final C12058z mo245m0(InterfaceC14454h interfaceC14454h, Integer num) {
    InterfaceC14454h interfaceC14454h2 = interfaceC14454h;
    if ((num.intValue() & 11) == 2 && interfaceC14454h2.mo8729x()) {
        interfaceC14454h2.mo8813C();
    } else {
        C9610q2 m12065c = C9481f2.m12065c(null, true, null, interfaceC14454h2, 10);
        interfaceC14454h2.mo8767e(1157296644);
        boolean mo8785Q = interfaceC14454h2.mo8785Q(m12065c);
        Object mo8765f = interfaceC14454h2.mo8765f();
        if (mo8785Q || mo8765f == InterfaceC14454h.C14455a.f41447b) {
            mo8765f = new C7151b(m12065c);
            interfaceC14454h2.mo8799J(mo8765f);
        }
        interfaceC14454h2.mo8791N();
        C7151b c7151b = (C7151b) mo8765f;
        AbstractC18586a0<? extends C18633p>[] abstractC18586a0Arr = {c7151b};
        interfaceC14454h2.mo8767e(-312215566);
        Context context = (Context) interfaceC14454h2.mo8743q(C1627y.f4476b);
        Object[] copyOf = Arrays.copyOf(abstractC18586a0Arr, 1);
        C19471n c19471n = C19471n.f52526E;
        C19472o c19472o = new C19472o(context);
        InterfaceC20914l<Object, Object> interfaceC20914l = C20915m.f56887a;
        C18643u c18643u = (C18643u) C10349c.m11442g(copyOf, new C20918n(c19471n, c19472o), new C19473p(context), interfaceC14454h2, 4);
        for (int i = 0; i < 1; i++) {
            c18643u.f50669v.m5462a(abstractC18586a0Arr[i]);
        }
        interfaceC14454h2.mo8791N();
        interfaceC14454h2.mo8767e(-492369756);
        Object mo8765f2 = interfaceC14454h2.mo8765f();
        if (mo8765f2 == InterfaceC14454h.C14455a.f41447b) {
            mo8765f2 = C14774d.m8447b(interfaceC14454h2);
        }
        interfaceC14454h2.mo8791N();
        C13158u0 c13158u0 = (C13158u0) mo8765f2;
        View view = (View) interfaceC14454h2.mo8743q(C1627y.f4480f);
        C9156g.m12294a(C0024o.m21933h(interfaceC14454h2, -1964315366, new C17095d(c18643u, c7151b, C5389i.m15648i(MainActivity.this.m6431F().m18375M(), interfaceC14454h2), c13158u0)), interfaceC14454h2, 6);
        C3779d.m17842b(c18643u, new C17096e(this.f47312F, c18643u, MainActivity.this), interfaceC14454h2, 8);
        C6402b.m14527b(new C17097f(MainActivity.this.m6431F()), interfaceC14454h2, 0);
        MainActivity mainActivity = MainActivity.this;
        C3779d.m17843a(mainActivity, mainActivity.m6431F().f47316g, m12065c, c13158u0, c18643u, new C17098g(MainActivity.this.m6431F()), interfaceC14454h2, 35912);
        C5494g0.m15505c(c18643u, new C17099h(MainActivity.this, c18643u), interfaceC14454h2);
        C5494g0.m15505c(C12058z.f35214a, new C17100i(view), interfaceC14454h2);
    }
    return C12058z.f35214a;
}

После деобфускации стало чуть-чуть получше, но выглядит все равно отвратительно. И самое забавное здесь то, что исходного кода на Kotlin было прямо кратно меньше. Но дело конечно не только в Kotlin. Приложения используют различные библиотеки, которые тоже вносят свой вклад в усложнение структуры кода. Вы видели когда-нибудь во что превращается например LiveData?

// Было
private val data = MutableLiveData<String>().apply { postValue("hello") }

// Стало
boolean z;
C0600q c0600q = new C0600q();
synchronized (c0600q.f2704a) {
    if (c0600q.f2709f == LiveData.f2703k) {
        z = true;
    } else {
        z = false;
    }
    c0600q.f2709f = "hello";
}
if (z) {
    C1391a.m1124k().m1123l(c0600q.f2713j);
}

А ведь это только одна концепция. Корутины, rx-цепочки, внедрение зависимостей и еще сотни вещей, которыми разработчики счастливо обмазываются сходив на очередную конференцию ;) Но во всем можно разобраться. Главное - иметь хороший план.

Основные положения

Общий подход к исследованию декомпилированного кода, по моему скромному мнению, базируется на двух повторяющихся действиях: переход по ссылкам (find usages/go to declaration) и определение тех или иных структур языка на основании имеющихся в голове шаблонов кода. Именно составление каталога таких шаблонов и является целью всего цикла статей. Сколько их будет я не знаю. Дорога возникает под ногами идущего.

Мой сетап для компиляции и декомпиляции приложений выглядит так:

  • AndroidStudio: Build #AI-221.6008.13.2211.9514443 (Runtime version: 11.0.15+0-b2043.56-8887301)
  • AGP: 7.4.1
  • Kotlin: 1.8.20-Beta
  • Gradle: 7.5
  • Jadx: dev (master:305d4f4f)
  • Деобфускация: Отключена для получении более чистого результата

proguard-rules.pro

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
-optimizationpasses 5
-allowaccessmodification
-dontpreverify
-renamesourcefileattribute SourceFile
-keepattributes Exceptions,InnerClasses,Signature,Deprecated,SourceFile,LineNumberTable,*Annotation*
-keepdirectories
-keepclassmembernames class * {
    java.lang.Class class$(java.lang.String);
    java.lang.Class class$(java.lang.String, boolean);
}
-keepclassmembers enum * {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}
-keepattributes SourceFile,LineNumberTable
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.view.View {
    public <init>(android.content.Context);
    public <init>(android.content.Context, android.util.AttributeSet);
    public <init>(android.content.Context, android.util.AttributeSet, int);
    public void set*(...);
}
-keepclasseswithmembers class * {
    public <init>(android.content.Context, android.util.AttributeSet);
}
-keepclasseswithmembers class * {
    public <init>(android.content.Context, android.util.AttributeSet, int);
}
-keepclassmembers class * extends android.app.Activity {
   public void *(android.view.View);
}
-keepclassmembers enum * {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}

-renamesourcefileattribute ''
-keepattributes SourceFile,LineNumberTable
-keepattributes Signature
-keepattributes *Annotation*
-dontnote
-dontwarn

-keep public class com.example.app.** {
    public protected *;
}

-keep public interface com.example.app.** {
    public protected *;
}

-keep public enum com.example.app.** {
    public protected *;
}

Не стоит воспринимать этот конфиг как некий супер референс или единственно правильную для всех случаев реализацию. Это просто пример, который я накидал из своих соображений и который дает мне достаточный уровень обфускации чтобы это было интересно.

Самые базовые структуры языка

Стоит сказать, что некоторые базовые структуры языка Kotlin вообще не имеет смысла разбирать как что-то самостоятельное. Из-за оптимизаций и прочего инлайнинга они попадают в приложение уже во вполне понятном виде и какого-либо влияния специфики языка в них не прослеживается. Яркий пример - шаблонные строки:

// Было
Log.d("DEBUG", "string: ${Date().time}")

// Стало
Log.d("DEBUG", "string: " + new Date().getTime());

Поэтому подобные вещи я буду сознательно пропускать чтобы не раздувать статьи сверх необходимого. Также, в некоторых искусственных примерах, я буду использовать значения, которые нельзя вычислить на этапе компиляции чтобы избежать их инлайнинга.

Условные выражения (Conditional expressions)

// Было
val myvar = if (Date().time < 1337) {
    val d1 = Date().time
    d1 + 1337
} else {
    val d2 = Date().time
    d2 - 1337
}

Log.d("debug", myvar.toString())
// Стало
long time;
if (new Date().getTime() < 1337) {
    time = new Date().getTime() + 1337;
} else {
    time = new Date().getTime() - 1337;
}
Log.d("debug", String.valueOf(time));

Все довольно предсказуемо, а встроенные в компилятор оптимизации делают код даже чуточку понятнее, чем он был написан изначально.

Цикл for (for loop)

// Было
val items = listOf("apple", "banana", "kiwifruit")
for (item in items) {
    println(item)
}
// Стало
List<String> asList = Arrays.asList("apple", "banana", "kiwifruit");
d.c(asList, "asList(this)");
for (String str : asList) {
    Log.d("debug", str);
}

// Было
val items = listOf("apple", "banana", "kiwifruit")
for (item in items) {
    println(item)
}
// Стало
List asList = Arrays.asList("apple", "banana", "kiwifruit");
d.c(asList, "asList(this)");
int size = asList.size();
for (int i4 = 0; i4 < size; i4++) {
    Log.d("debug", "item at " + i4 + " is " + ((String) asList.get(i4)));
}

Тут уже все несколько интереснее. Из непонятного сразу - вызов d.c(asList, "asList(this)");. Но если заглянуть в сам метод, то картина становится гораздо яснее:

public static void c(Object obj, String str) {
    if (obj != null) {
        return;
    }
    NullPointerException nullPointerException = new NullPointerException(str.concat(" must not be null"));
    e(nullPointerException);
    throw nullPointerException;
}

Если инстанс объекта существует, то все ок. Иначе будет выброшено NPE. Откуда это взялось и почему это важно? Это довольно типичная для скомпилированного кода на Kotlin конструкция и ее совершенно точно нужно запомнить. В самом языке такие штуки называются Intrinsics и живут здесь.

Вооружившись этим знанием, можно средствами студии отрефакторить(Refactor->Rename) d в Intrinsics, а c в checkNotNull и эти изменения применятся ко всему проекту, что позволит больше не думать над этими строчками в разных местах. Особо радикальные реверсеры могут даже удалить все упоминания этого класса из декомпилированного кода.

Цикл while (while loop)

// Было
val items = listOf("apple", "banana", "kiwifruit")
var index = 0
while (index < items.size) {
    Log.d("debug", "item at $index is ${items[index]}")
    index++
}
// Стало
List asList = Arrays.asList("apple", "banana", "kiwifruit");
d.c(asList, "asList(this)");
for (int i4 = 0; i4 < asList.size(); i4++) {
    Log.d("debug", "item at " + i4 + " is " + ((String) asList.get(i4)));
}

Нет, это не копипаста из прошлого примера. Несмотря на наличие в Java самостоятельной конструкции while (condition) { ... }, Kotlin в этом случае, все равно делает цикл for. А как насчет do-while?

// Было
val items = listOf("apple", "banana", "kiwifruit")
var index = 0

do {
    Log.d("debug", "item at $index is ${items[index]}")
    index++
} while (index < items.size)
// Стало
List asList = Arrays.asList("apple", "banana", "kiwifruit");
d.c(asList, "asList(this)");
int i4 = 0;
do {
    Log.d("debug", "item at " + i4 + " is " + ((String) asList.get(i4)));
    i4++;
} while (i4 < asList.size());

Тут компилятор не стал изобретать велосипед и вспомнил маму Джаву.

Выражение when (when expression)

// Было
fun describe(obj: Any): String =
    when (obj) {
        1          -> "One"
        "Hello"    -> "Greeting"
        is Long    -> "Long"
        !is String -> "Not a string"
        else       -> "Unknown"
    }
// Стало
public static final String describe(Object obj) {
    d.d(obj, "obj");
    if (d.a(obj, 1)) {
        return "One";
    }
    if (d.a(obj, "Hello")) {
        return "Greeting";
    }
    if (obj instanceof Long) {
        return "Long";
    }
    if (!(obj instanceof String)) {
        return "Not a string";
    }
    return "Unknown";
}

Из интересного здесь можно выделить вызов d.a(obj, ...), который догадливый читатель уже определил как метод класса Intrinsics. Осталось понять, что это за метод. Проваливаемся вовнутрь и понимаем, что это areEqual:

// Декомпилированный код
public static boolean a(Object obj, Object obj2) {
    return obj == null ? obj2 == null : obj.equals(obj2);
}

// Исходный код
public static boolean areEqual(Object first, Object second) {
    return first == null ? second == null : first.equals(second);
}

Диапазоны (Ranges)

// Было
val x = 10
val y = Date().time
if (x in 1..y+1) {
    Log.d("debug", "fits in range")
}
// Стало
long time = new Date().getTime() + 1;
long j4 = 10;
boolean z3 = false;
if (1 <= j4 && j4 <= time) {
    z3 = true;
}
if (z3) {
    Log.d("debug", "fits in range");
}

// Было
for (x in 1..Date().time) {
    Log.d("", x.toString())
}
// Стало
long time = new Date().getTime();
if (1 <= time) {
    long j4 = 1;
    while (true) {
        Log.d("", String.valueOf(j4));
        if (j4 != time) {
            j4++;
        } else {
            return;
        }
    }
}

Тут Kotlin внезапно вспоминает про существование цикла while в Java и счастливо его использует.


// Было
for (x in 1..Date().time step 2) {
    Log.d("debug", x.toString())
}
Log.d("debug", "stub")
for (x in Date().time downTo 0 step 3) {
    Log.d("debug", x.toString())
}
// Стало
long w3 = m.w(1L, new Date().getTime(), 2L);
long j4 = 1;
if (1 <= w3) {
    while (true) {
        Log.d("debug", String.valueOf(j4));
        if (j4 == w3) {
            break;
        }
        j4 += 2;
    }
}
Log.d("debug", "stub");
long time = new Date().getTime();
long w4 = m.w(time, 0L, -3L);
if (w4 > time) {
    return;
}
while (true) {
    Log.d("debug", String.valueOf(time));
    if (time != w4) {
        time -= 3;
    } else {
        return;
    }
}

А вот и плата за “сахар” =) Выглядит уже не так приятно, хотя разобраться что происходит - вполне можно. Основные вопросы здесь вызывает конструкция m.w(long, long, long);, которая, при ближайшем рассмотрении оказывается еще одним внутренним механизмом языка Kotlin для работы с прогрессиями и живет здесь :

// Декомпилированный код
public static final long w(long j4, long j5, long j6) {
    int i4 = (j6 > 0L ? 1 : (j6 == 0L ? 0 : -1));
    if (i4 > 0) {
        if (j4 < j5) {
            long j7 = j5 % j6;
            if (j7 < 0) {
                j7 += j6;
            }
            long j8 = j4 % j6;
            if (j8 < 0) {
                j8 += j6;
            }
            long j9 = (j7 - j8) % j6;
            if (j9 < 0) {
                j9 += j6;
            }
            return j5 - j9;
        }
        return j5;
    } else if (i4 < 0) {
        if (j4 > j5) {
            long j10 = -j6;
            long j11 = j4 % j10;
            if (j11 < 0) {
                j11 += j10;
            }
            long j12 = j5 % j10;
            if (j12 < 0) {
                j12 += j10;
            }
            long j13 = (j11 - j12) % j10;
            if (j13 < 0) {
                j13 += j10;
            }
            return j5 + j13;
        }
        return j5;
    } else {
        throw new IllegalArgumentException("Step is zero.");
    }
}

// Исходный код
internal fun getProgressionLastElement(start: Int, end: Int, step: Int): Int = when {
    step > 0 -> if (start >= end) end else end - differenceModulo(end, start, step)
    step < 0 -> if (start <= end) end else end + differenceModulo(start, end, -step)
    else -> throw kotlin.IllegalArgumentException("Step is zero.")
}

Коллекции (Collections)

С коллекциями мы уже начинали разбираться, когда изучали циклы, поэтому тут будет чуть более сложный пример с лямда- функциями над коллекцией:

// Было
val fruits = listOf("banana", "avocado", "apple", "kiwifruit")
fruits
    .filter { it != "apple" }
    .forEach { Log.d("debug", it) }
// Стало
List asList = Arrays.asList("banana", "avocado", "apple", "kiwifruit");
d.c(asList, "asList(this)");
ArrayList arrayList = new ArrayList();
for (Object obj : asList) {
    if (!d.a((String) obj, "apple")) {
        arrayList.add(obj);
    }
}
Iterator it = arrayList.iterator();
while (it.hasNext()) {
    Log.d("debug", (String) it.next());
}

Могло быть и хуже. Ничего такого с чем бы мы до этого не встречались. Все те же знакомые вызовы d.c() и d.a() и привычные java-унижения с итераторами =) Даже лямбда красиво заинлайнилась, превратившись в обычный if. Всегда бы так…

Проверка и автоматическое приведение типов (Type checks and automatic casts)

// Было
fun getStringLength(obj: Any): Int? {
    if (obj !is String) return null

    // `obj` is automatically cast to `String` in this branch
    return obj.length
}
// Стало
public static final Integer getStringLength(Object obj) {
    d.d(obj, "obj");
    if (!(obj instanceof String)) {
        return null;
    }
    return Integer.valueOf(((String) obj).length());
}

// Было
fun getStringLength(obj: Any): Int? {
    // `obj` is automatically cast to `String` on the right-hand side of `&&`
    if (obj is String && obj.length > 0) {
        return obj.length
    }

    return null
}
// Стало
public static final Integer getStringLength(Object obj) {
    d.d(obj, "obj");
    if (obj instanceof String) {
        String str = (String) obj;
        if (str.length() > 0) {
            return Integer.valueOf(str.length());
        }
        return null;
    }
    return null;
}

Вызов d.d(Object, String) нам до этого не встречался. Конечно же это еще один Intrinsic. В декомпилированном виде это 3 метода скомпилированные в один, а сама функция d() это checkNotNullParameter().

// Декомпилированный код
 public static void d(Object obj, String str) {
     if (obj == null) {
         StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
         String name = d.class.getName();
         int i4 = 0;
         while (!stackTrace[i4].getClassName().equals(name)) {
             i4++;
         }
         while (stackTrace[i4].getClassName().equals(name)) {
             i4++;
         }
         StackTraceElement stackTraceElement = stackTrace[i4];
         String className = stackTraceElement.getClassName();
         String methodName = stackTraceElement.getMethodName();
         NullPointerException nullPointerException = new NullPointerException("Parameter specified as non-null is null: method " + className + "." + methodName + ", parameter " + str);
         e(nullPointerException);
         throw nullPointerException;
     }
 }

// Исходный код
public static void checkNotNullParameter(Object value, String paramName) {
    if (value == null) {
        throwParameterIsNullNPE(paramName);
    }
}

private static void throwParameterIsNullNPE(String paramName) {
    throw sanitizeStackTrace(new NullPointerException(createParameterIsNullExceptionMessage(paramName)));
}

private static String createParameterIsNullExceptionMessage(String paramName) {
    StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();

    String thisClassName = Intrinsics.class.getName();
    int i = 0;
    // Skip platform frames such as Thread.getStackTrace.
    while (!stackTraceElements[i].getClassName().equals(thisClassName)) i++;
    // Skip all frames of this class such as createParameterIsNullExceptionMessage, throwParameterIsNullNPE, checkNotNullParameter.
    while (stackTraceElements[i].getClassName().equals(thisClassName)) i++;
    // This frame is our caller.
    StackTraceElement caller = stackTraceElements[i];
    String className = caller.getClassName();
    String methodName = caller.getMethodName();

    return "Parameter specified as non-null is null: method " + className + "." + methodName + ", parameter " + paramName;
}

Декомпилированный код здесь даже как-то понятнее. Если объект null, но таковым быть не должен, то покажи красивую ошибку.

Что будет дальше

Мы рассмотрели самые базовые конструкции и уже сейчас видно, что сахар Kotlin-а в разных случаях работает по разному. Иногда он помогает, иногда мешает. Но при последовательном подходе всегда можно докопаться до истины и понять что делает тот или иной код. Дальше будем разбирать уже более сложные конструкции и выдуманных примеров станет меньше. Я планирую брать типовой код для решения тех или иных android задач из различных open source приложений, и думаю это сделает мое исследование более релевантным существующей действительности.

Всем легкого реверса! 😎


Смотрите также