Kotlin Multiplatform¶
Kotlin Multiplatform (KMP) allows developers to write shared business logic in Kotlin that compiles to platform-native output -- standard JVM/DEX bytecode on Android, native binaries on iOS, and JavaScript for web. On Android, KMP code compiles to the same DEX bytecode as any regular Kotlin Android app, making it indistinguishable at the binary level from a standard native app. This is the core reverse engineering challenge: there are no unique runtime markers, custom VMs, or bundled interpreters to identify. The framework is developed by JetBrains and reached stable status in November 2023.
Architecture¶
Shared Code Model¶
KMP projects use a hierarchical source set structure where common business logic lives in commonMain and platform-specific implementations live in platform source sets:
| Source Set | Purpose |
|---|---|
| commonMain | Shared business logic, data models, networking, serialization |
| androidMain | Android-specific implementations (platform APIs, UI bindings) |
| iosMain | iOS-specific implementations (not relevant to Android RE) |
| commonTest | Shared test code |
At compile time, the Kotlin compiler merges commonMain and androidMain into standard Kotlin/JVM bytecode, which then goes through D8/R8 to produce DEX. The resulting APK contains no trace of the multiplatform source structure.
Expect/Actual Pattern¶
KMP's primary mechanism for platform abstraction is the expect/actual pattern. Common code declares an expect function or class, and each platform provides an actual implementation:
In source, this looks like a common interface with platform-specific backends. After compilation to DEX, the expect declarations disappear entirely -- they exist only at compile time. The decompiled output shows only the concrete actual implementations as regular Kotlin classes and functions.
Compilation Pipeline¶
commonMain (Kotlin) ──┐
├──> Kotlin/JVM Compiler ──> .class files ──> D8/R8 ──> DEX
androidMain (Kotlin) ─┘
The output is identical to a standard Kotlin Android project. No intermediate bytecode formats, no embedded interpreters, no bundled scripts.
Identification¶
KMP apps produce standard DEX bytecode with no definitive fingerprint. Identification relies on indirect signals rather than definitive markers.
| Indicator | Confidence | Details |
|---|---|---|
| Ktor classes in DEX | High | io.ktor.client.* is the standard KMP HTTP client |
| kotlinx.serialization | Medium | kotlinx.serialization.* classes are common in KMP projects |
| kotlinx.coroutines | Low | Used widely in all Kotlin projects, not KMP-specific |
| Multi-target library packages | Medium | Presence of org.jetbrains.kotlinx.* utility libraries |
| Lack of framework markers | N/A | No React Native, Flutter, or Xamarin markers in an app that has iOS counterpart |
Quick check for Ktor (strongest KMP signal):
What You Will NOT Find¶
Unlike Flutter (libflutter.so), React Native (libhermes.so), or Xamarin (libmonosgen.so), KMP leaves no native libraries, bundled runtimes, or asset files that betray its origin. The DEX bytecode is structurally identical to a hand-written Kotlin Android app.
Identification is Probabilistic
There is no single artifact that definitively identifies a KMP app. The best approach is to look for a combination of common KMP libraries (Ktor, kotlinx.serialization, kotlinx.datetime) alongside the absence of other cross-platform framework markers.
Analysis¶
Standard Jadx Workflow¶
Since KMP compiles to normal DEX bytecode, the standard Android reverse engineering workflow applies without modification:
The decompiled output is standard Kotlin (displayed as Java by jadx, or as Kotlin with --decompile-kotlin flag). All KMP shared code appears as regular classes and functions.
Identifying Former commonMain Code¶
Code that originated in commonMain often exhibits these patterns in decompiled output:
| Pattern | Indicator |
|---|---|
| Platform abstraction wrappers | Classes that wrap simple platform calls (e.g., PlatformProvider.getDeviceId()) |
| Interface + single implementation | An interface with exactly one Android implementation, suggesting expect/actual origin |
| Ktor networking throughout | All HTTP calls using Ktor rather than OkHttp/Retrofit directly |
| kotlinx.serialization annotations | @Serializable data classes with generated Companion serializers |
| kotlinx.datetime usage | kotlinx.datetime.Instant instead of java.time.* |
Data Class Serialization¶
KMP apps heavily use kotlinx.serialization for JSON handling. The compiler plugin generates serializer companion objects for each @Serializable data class:
These generated serializers contain the complete field mapping, making it straightforward to reconstruct API request/response models even in R8-obfuscated builds.
Ktor Client Networking¶
Ktor is the standard HTTP client in KMP projects because it supports all KMP targets. On Android, Ktor uses OkHttp as its underlying engine by default.
Ktor Architecture on Android¶
The key classes in decompiled output:
| Class | Purpose |
|---|---|
io.ktor.client.HttpClient |
Main client entry point |
io.ktor.client.engine.okhttp.OkHttpEngine |
Android HTTP engine (wraps OkHttp) |
io.ktor.client.request.HttpRequestBuilder |
Request construction |
io.ktor.client.plugins.contentnegotiation.* |
JSON serialization plugin |
io.ktor.client.plugins.auth.* |
Authentication plugin (Bearer, Basic) |
Intercepting Ktor Traffic¶
Since Ktor uses OkHttp on Android, standard OkHttp interceptor hooks work:
Java.perform(function() {
var OkHttpClient = Java.use("okhttp3.OkHttpClient");
var Builder = Java.use("okhttp3.OkHttpClient$Builder");
Builder.build.implementation = function() {
var client = this.build();
console.log("[Ktor/OkHttp] Client built with interceptors: " + client.interceptors());
return client;
};
});
Ktor SSL Pinning¶
Ktor on Android delegates TLS to OkHttp, so standard OkHttp certificate pinner bypasses apply:
Java.perform(function() {
var CertPinner = Java.use("okhttp3.CertificatePinner");
CertPinner.check.overload("java.lang.String", "java.util.List").implementation = function(hostname, peerCertificates) {
console.log("[Ktor] Bypassed certificate pin for: " + hostname);
};
});
Some KMP apps configure Ktor-level pinning through io.ktor.client.engine.okhttp.OkHttpConfig. This still resolves to OkHttp's CertificatePinner at runtime.
Hooking Strategy¶
Standard Kotlin/Java Hooks¶
KMP compiles to standard DEX, so all Frida Java hooks work without special handling:
Java.perform(function() {
var TargetClass = Java.use("com.example.shared.NetworkRepository");
TargetClass.fetchUserData.implementation = function(userId) {
console.log("[KMP] fetchUserData called with: " + userId);
var result = this.fetchUserData(userId);
console.log("[KMP] fetchUserData returned: " + result);
return result;
};
});
Coroutine-Aware Hooking¶
KMP shared code uses Kotlin coroutines extensively. Suspend functions compile to methods with an extra Continuation parameter:
Java.perform(function() {
var ApiService = Java.use("com.example.shared.ApiService");
ApiService.getProfile.overload("java.lang.String", "kotlin.coroutines.Continuation").implementation = function(token, continuation) {
console.log("[KMP] getProfile called with token: " + token);
return this.getProfile(token, continuation);
};
});
Enumerating Shared Module Classes¶
To find classes originating from the shared KMP module, enumerate classes matching the shared module package:
Java.perform(function() {
Java.enumerateLoadedClasses({
onMatch: function(className) {
if (className.indexOf("shared") !== -1 || className.indexOf("common") !== -1) {
console.log("[KMP Shared] " + className);
}
},
onComplete: function() {}
});
});
KMP shared modules typically compile to a package like com.example.shared.* or com.example.common.*, though this is entirely developer-configurable.
Hooking kotlinx.serialization¶
Intercept JSON deserialization to capture API responses:
Java.perform(function() {
var Json = Java.use("kotlinx.serialization.json.Json");
var JsonKt = Java.use("kotlinx.serialization.json.internal.StreamingJsonDecoder");
JsonKt.decodeSerializableValue.implementation = function(deserializer) {
var result = this.decodeSerializableValue(deserializer);
console.log("[KMP Serialization] Decoded: " + result.toString());
return result;
};
});
Shared Code Patterns¶
Repository Pattern¶
KMP projects frequently use a repository pattern where shared code defines repositories that abstract data sources:
In decompiled output, look for classes ending in Repository or DataSource in the shared package. These contain the core business logic and API call definitions.
Multiplatform Settings / Key-Value Storage¶
KMP apps often use multiplatform-settings for key-value storage. On Android, this wraps SharedPreferences:
Java.perform(function() {
var SharedPrefs = Java.use("android.app.SharedPreferencesImpl");
SharedPrefs.getString.implementation = function(key, defValue) {
var value = this.getString(key, defValue);
console.log("[KMP Settings] getString(" + key + ") = " + value);
return value;
};
});
Koin Dependency Injection¶
Koin is the dominant DI framework in KMP projects (Dagger/Hilt are Android-only). In decompiled output, look for org.koin.core.* classes and module definitions that reveal the app's dependency graph and service architecture.
RE Difficulty Assessment¶
| Aspect | Assessment |
|---|---|
| Code format | Standard DEX bytecode |
| Identification | Difficult -- no unique markers |
| Decompilation | Full -- standard jadx output |
| String extraction | Standard -- all strings in DEX string pool |
| Control flow recovery | Full -- standard Kotlin compiled code |
| Hooking | Standard Frida Java hooks |
| Patching | Standard smali patching workflow |
| Obfuscation ceiling | R8/ProGuard, DexGuard, same as any native Android app |
| Overall difficulty | Easy (once identified) |
The irony of KMP from a reverse engineering perspective is that the identification step is the hardest part. Once you determine an app uses KMP, every standard Android analysis technique works without modification. There is no custom VM to understand, no bytecode format to decompile, and no bridge to intercept.