July 27, 2022

    Strings, Please! Eliminating Data Leaks Caused by Kotlin Assertions

    As Kotlin has become the go-to language to code Android applications, it is fundamental for developers to be aware of the security implications that come with its features.

    We already wrote about the importance of Kotlin metadata’s obfuscation and followed up with an overview of these new security challenges in our presentation during Droidcon London 2021, Keep Rules in the Age of Kotlin. In this blog post, we will take an in-depth look at one of them: Kotlin assertions leaking information.

    Null safety and Kotlin assertions

    Kotlin is a modern programming language with a type system designed to avoid null reference exceptions. Kotlin types can be either nullable or non-nullable:

    val nullable: String? = "foo" // nullable type String?
    val nonNullable: String = "foo" // non-nullable type String

    Property access and method calls on non-nullable objects are guaranteed to not generate a null pointer exception. On the other hand, Kotlin has a wide range of features to handle nullable objects in a convenient way, like the safe call operator?.or the elvis operator?:that provides an alternative result in case of null values.

    Nevertheless, there are some features in which Kotlin’s strict null-safety assumption is impractical. In these situations, the Kotlin compiler injects assertions in the code to keep a fail-fast behavior in case of unexpected null values.

    Kotlin-Java interoperability

    Kotlin is designed with Java interoperability in mind. Java and Kotlin compile to the same bytecode, Java code can be called almost seamlessly from Kotlin code, and vice versa.

    Nevertheless, unlike Kotlin, Java’s type system is not designed with null-safety in mind; Java references are always nullable and strict null-safety cannot be assumed for objects coming from Java code. Thus, platform types for which nullability is unknown are used by default for these references in Kotlin:

    val platform = SomeJavaClass.staticMethodReturningString() // platform type String!, can be either String or String?

    Java objects can also be assigned to nullable or non-nullable variables:

    val nullable: String? = SomeJavaClass.staticMethodReturningString()
    val nonNullable: String = SomeJavaClass.staticMethodReturningString()

    In the case of assignment to a nullable variable, no additional check is necessary to guarantee interoperability since Java references are always nullable. Conversely, when we assign the result of a Java call to a non-nullable variable, an assertion is generated by the Kotlin compiler in order to check that the reference is actually not null.

    This is a Java decompilation using jadx of the previous snippet:

    String nullable = SomeJavaClass.staticMethodReturningString();
    String nonNullable = SomeJavaClass.staticMethodReturningString();
    Intrinsics.checkNotNullExpressionValue(nonNullable, "staticMethodReturningString()");

    In this snippet, a call tocheckNotNullExpressionValuethat was not in our original code is present. This method, among others fromkotlin.jvm.internals.Intrinsics, is inserted by the Kotlin compiler in order to detect unexpected null values and throw an exception as early as possible.

    Lateinit properties

    Another case in which the Kotlin compiler inserts assertions not related to Java interoperability is the usage oflateinitproperties or variables. This modifier is used for non-nullable variables that can’t be, or are inconvenient, to initialize in the constructor. In this case, to preventNullPointerException, the compiler insertsthrowUninitializedPropertyAccessExceptionwhenever alateinitvariable is used.

    For example, if we have this code:

    private lateinit var lateString: String
    [...]
    foo(lateString)
    

    The resulting decompilation of the method call is:

    String str = this.lateString;
    if (str != null) {
        foo(str);
    } else {
        Intrinsics.throwUninitializedPropertyAccessException("lateString");
        throw null;
    }

    How assertions get leaky

    In the two decompiled snippets, we see that the name of the called Java method and of the accessed variable are used as an argument of the assertions inserted by the Kotlin compiler:

    Intrinsics.checkNotNullExpressionValue(nonNullable, "staticMethodReturningString()");
    Intrinsics.throwUninitializedPropertyAccessException("lateString");

    This is a convenient feature because the name of the program components are used to provide precise error messages that can help while debugging the code, but the security implications of adding these names into the assertions also needs to be taken into consideration.

    Let’s assume we have the following code in our application:

    val secretKey: String = getSecretKey()
    foo(secretKey)
    ...
    
    fun foo(secretKey:String) = secretKey.length

    In cases like this, you usually want to hide information about the name of program components from your code. Otherwise, as shown in the previous examples, it is pretty simple for a reverse engineer to decompile the code and search for these names and find the sensitive locations in the code. Code obfuscation is a typical way to hide this information without altering the code functionalities.

    There are some free and open source solutions that can help you obfuscate your code, most notably ProGuard® and R8. When decompiling apps built with these tools, we can see the resulting assertions:

    ProGuard_vs_R8_diagram-1

    As we can see, while the name of the classes, methods, and variables are obfuscated as expected, these tools do not obfuscate thegetSecretKey()andsecretKeystrings containing (respectively) the name of a method and of a parameter. Since the name of the called Java method is a parameter of the assertioncheckNotNullExpressionValueinjected by the Kotlin compiler, the real name of the method gets leaked even after the method itself has been obfuscated. The same happens for the call tocheckNotNullParameterinjected in the functionfoothat leaks the name of its parameter.

    It is very common in Android applications to write code that involves these assertions. For example, the usage of platform types or lateinit variables to be initialized in Android lifecycle calls. To get an idea about how prevalent they are, we can run AppSweep on a simple Android application (Android Studio’s Basic Activity template with our snippet added to theMainActivity):

    ProGuard_vs_R8_diagram-2Full ProGuard report | Full R8 report

     

    Fixing the leaky assert pipes

    This problem should however not overshadow how useful these assertions can be during the development process: discovering potential null values earlier and having more precise error messages is very valuable. That said, removing them in release builds should be taken into serious consideration.

    Let’s take a look at some possible solutions.

    Kotlin compiler

    It is possible to set some Kotlin compiler arguments in order to avoid inserting part of the assertions. For example we have seencheckNotNullExpressionValue, which can be removed via the-Xno-call-assertionsargument, andcheckNotNullParameter, which can be removed via the-Xno-param-assertionsargument.

    To set the-Xno-call-assertions,-Xno-receiver-assertions, and-Xno-param-assertions, your app-levelbuild.gradleshould contain something like this:

    kotlinOptions {
        	jvmTarget = '1.8'
        	freeCompilerArgs += [
                	'-Xno-call-assertions',
                	'-Xno-receiver-assertions',
                	'-Xno-param-assertions'
        	]
    }

    Nevertheless, while the assertions shown in the previous examples are no longer inserted by the compiler when these options are used, analyzing the decompiled code shows several other assertions are still present. For example, there is no argument to avoid addingthrowUninitializedPropertyExceptionassertions.

    Additionally, assertions in libraries, which are already compiled, won’t be removed this way. This can be seen in the AppSweep report of an APK compiled with these options:

    ProGuard_vs_R8_diagram__4

    Moreover, none of these options are officially documented in the Kotlin compiler documentation, and thus should be considered unsupported.

    Removing the assertions with shrinkers

    You can remove the Kotlin assertions from release builds with ProGuard or R8 by adding an-assumenosideeffectsrule for each assert function to your ProGuard configuration file:

    -assumenosideeffects class kotlin.jvm.internal.Intrinsics {
    	public static void checkNotNull(...);
    	public static void checkExpressionValueIsNotNull(...);
    	public static void checkNotNullExpressionValue(...);
    	public static void checkParameterIsNotNull(...);
    	public static void checkNotNullParameter(...);
    	public static void checkReturnedValueIsNotNull(...);
    	public static void checkFieldIsNotNull(...);
    	public static void throwUninitializedPropertyAccessException(...);
    	public static void throwNpe(...);
    	public static void throwJavaNpe(...);
    	public static void throwAssert(...);
    	public static void throwIllegalArgument(...);
    	public static void throwIllegalState(...);
    }

    Thanks to this rule, the shrinkers know they can remove the specified methods without any consequences in release builds. This can also help reduce the size of the resulting APK. If we analyze the resulting APKs with AppSweep, we can see no additional Kotlin assertions are found:

    ProGuard_vs_R8_diagram-3Full ProGuard report | Full R8 report

     

    Obfuscating the leaking strings with ProGuard

    An alternative is available only for ProGuard as long as the-dontobfuscaterule is not specified (also requires the-keepkotlinmetadatarule to be specified for ProGuard versions before 7.2.2). In this case, the assertions are not removed, but all the strings in the assertions are replaced with empty strings. This might be a good option if you want to keep the assertions in order to keep the fail-fast behavior in production environments without leaking the name of the variables.

    Nevertheless, the exception messages you will get in case of unexpected null values will not be as convenient as before since they will not contain the name of the variable. This is the decompiled result of the same example in this case:

    String a5 = a.a();
    l.b(a5, "");
    a(a5);

    foo:

    public final int a(String str) {
        l.d(str, "");
        return str.length();
      }

    There are also no more Kotlin assertions findings reported by AppSweep:

    ProGuard_vs_R8_diagram__5

    Conclusion

    In this blog, we illustrated how Kotlin handles the lack of null-safety coming from Java code or unitializedlateinitvariables thanks to the insertion of assertions at compile time that guarantee a fail-fast behavior in the case of unexpected null references.

    While these assertions are great to help writing good code, they can potentially help in reverse engineering your application if kept in release builds since they leak the original name of program components. Telling the Kotlin compiler to not add these assertions might look like a valid option, but this is not recommended; this approach only partially works and the parameters to do so are not officially supported.

    Nonetheless, shrinkers, such as ProGuard and R8, allow removing these calls easily or, if needed, ProGuard’s obfuscation can be used to keep them without the problematic strings.

    Discover how Guardsquare provides industry-leading protection for mobile apps.

    Request Pricing

    Other posts you might be interested in