Czy kod Kotlina jest naprawdę zaciemniony?
Staram się co tydzień obejrzeć jedną prezentację z Google I/O lub Android Dev Summit. Zawsze uczę się czegoś nowego, nawet w tematach, które już nieźle znam i właśnie dlatego podtrzymuję ten zwyczaj od kilku lat.
Oglądając prezentację o interoperacyjności Javy i Kotlina, natknąłem się na coś o generowaniu i obfuskacji kodu Kotlina, co mnie zaskoczyło.
Każda poważna aplikacja powinna używać jakiejś formy mimimalizacji kodu i narzędzia zaciemniania kodu. Dla Androida typowymi narzędziami są ProGuard i od jakiegoś czasu R8. Te narzędzia są w pełni kompatybilne z Kotlinem bez dodawania specjalnych reguł (poza jakimiś niszowymi przypadkami, które pominę w tym poście). To oznacza, że możesz uruchomić ProGuard na swojej kotlinowej apce i wszystko powinno działać, jak należy.
Ale czy Twój kod w Kotlinie jest naprawdę zaciemniony?
W poniższym przykładzie pokażę, że w rzeczywistości kod nie jest do końca obsufkowany.
Oto przykładowa klasa z:
- Publiczną funkcją z parametrami, które nie są nullami
- Publiczną funkcję rozszerzającą
- Publiczną zmienną lateinit.
// note: this code is for example purposes, i know its not the right why to do things :)
class SomeClass {
lateinit var importantVar: String
fun funcWithParams(importantString: String, importantList: List<Int>) {
}
fun String.importantExtensionFunc() {
}
}
Przeanalizujmy zdekompilowany kod Javy, który wyprodukował kompilator Kotlina dla tej klasy:
// removed some of the irrelevant code for simplicity
public final class SomeClass {
@NotNull public String importantVar;
@NotNull
public final String getImportantVar() {
String var10000 = this.importantVar;
if (var10000 == null) {
Intrinsics.throwUninitializedPropertyAccessException("importantVar");
}
return var10000;
}
public final void funcWithParams(@NotNull String importantString, @NotNull List importantList) {
Intrinsics.checkParameterIsNotNull(importantString, "importantString");
Intrinsics.checkParameterIsNotNull(importantList, "importantList");
}
public final void importantExtensionFunc(@NotNull String $this$importantExtensionFunc) {
Intrinsics.checkParameterIsNotNull($this$importantExtensionFunc, "$this$importantExtensionFunc");
}
}
Zauważ, że wygenerowany kod odwołuje się do klasy zwanej Intrisics
.
Tego rodzaju wywołania weryfikują różnego rodzaju warunki stawiane przez Kotlina, np. to, że parametry nie powinny być nullem albo powinny zostać zainicjalizowane przed dostępem do nich.
Teraz zbudujmy zoptymalizowany i zobfuskowany APK (używając ProGuard), zobaczmy, co jest w środku (używam do tego świetnego Bytecode Viewer) i przeanalizujmy kod jeszcze raz:
public final class a {
public String a;
public final String a() {
String var1 = this.a;
if (var1 == null) {
b.b("importantVar");
}
return var1;
}
public final void a(String var1) {
b.b(var1, "$this$importantExtensionFunc");
}
public final void a(String var1, List var2) {
b.b(var1, "importantString");
b.b(var2, "importantList");
}
public final void b() {
String var1 = this.a;
if (var1 == null) {
b.b("importantVar");
}
this.a(var1);
}
}
Widzisz tu problem? Nazwy zmiennych i metod rozszerzających nadal tu są!
Te nazwy mogą ułatwić zrozumienie naszej logiki biznesowej dla kogoś z zewnątrz. Te informacje mogą zostać użyte w niecny sposób. Nie jest to wielki problem z punktu widzenia bezpieczeństwa, niemniej jednak jest to pewnego rodzaju zagrożenie.
Rozwiązanie
Reguły ProGuard mogą być użyte do czegoś więcej niż tylko do zapobiegania zaciemniania i usuwania pewnych partii kodu. Mogą być również użyte, by usunąć kod.
Dodanie następującej reguły ProGuard do pliku proguard-rules.pro
będzie skutkowało usunięciem wszystkich wymienionych wywołań klasy Intrisincs
.
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
public static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String);
public static void checkFieldIsNotNull(java.lang.Object, java.lang.String);
public static void checkFieldIsNotNull(java.lang.Object, java.lang.String, java.lang.String);
public static void checkNotNull(java.lang.Object);
public static void checkNotNull(java.lang.Object, java.lang.String);
public static void checkNotNullExpressionValue(java.lang.Object, java.lang.String);
public static void checkNotNullParameter(java.lang.Object, java.lang.String);
public static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
public static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String);
public static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String);
public static void throwUninitializedPropertyAccessException(java.lang.String);
}
-assumenosideeffects:
... w kroku optymalizacji ProGuard może usunąć wywołania do wymienionych metod, jeżeli stwierdzi, że zwracane wartości nie są używane. Ma to zastosowanie tylko, jeżeli wykonywany jest krok optymalizacji.
By włączyć optymalizacje w ProGuard, nazwa domyślnego pliku powinna zostać zmieniona z proguard-android.txt na proguard-android-optimize.txt w pliku build.gradle aplikacji.
Ten krok nie jest potrzebny przy użyciu R8.
Podsumowanie
O ile zauważy się problem, to rozwiązanie jest dość proste. Co więcej, gdy poszukałem informacji o tym problemie, to natknąłem się na kilka innych wpisów na blogach, ale nie rozumiem, czemu nie ma o tym nawet wzmianki w oficjalnej dokumentacji Kotlina.
Tego typu sprawdzenia negatywnie wpływają zarówno na bezpieczeństwo i na wydajność. Dlatego moim zdaniem powinny zostać wyłączone z przy tworzeniu aplikacji w trybie release.
Sugerowałbym pozostawienie wywołań do Intrisics w przypadku buildów debug, dzięki czemu szybko można by się dowiedzieć czy coś jest nie tak i usunąłbym je z buildów gotowych do dostarczenia na produkcję.
Oryginał tekstu w języku angielskim przeczytasz tutaj.