I Patched an Android APK to Show How Fragile Client-Side Checks Can Be

Featured in Android Weekly · #739
I Patched an Android APK to Show How Fragile Client-Side Checks Can Be

When people talk about reverse-engineering resistance on Android, the conversation often jumps straight to obfuscation.

Obfuscation matters. R8 matters. Naming things less helpfully can raise the cost of analysis. But in most practical reviews, that is not where the first useful finding comes from.

The first useful finding is usually much simpler: a hardcoded secret, a readable asset, a client-side check that can be patched, or a dynamic loading path that trusts a file it should never execute.

That is why I like the reverse-engineering lab in the Android Security Training project. It does not treat resilience as a checklist item. It gives you a secure build and a deliberately vulnerable one, then lets you compare the difference directly. You can open the APK. You can inspect the code. You can patch the vulnerable path. And then you can see what changes when the secure build refuses the same shortcuts.

GitHub - LethalMaus/AndroidSecurityTraining

The Vulnerable Build Gives Attackers Too Much to Work With

The first vulnerable example is intentionally blunt:

private val SUPER_SECRET_API_KEY = "sk_live_REPLACE_ME_123456"

override fun getHardcodedSecret(): String = SUPER_SECRET_API_KEY

override fun readLeakyAsset(context: Context): String = try {
    context.assets.open("sensitive.txt").use { it.readBytes().toString(Charsets.UTF_8) }
} catch (t: Throwable) {
    "Asset missing: ${t.message}"
}
View code on GitHub Gist

It shows two common mistakes at once: secrets compiled into the APK and sensitive material shipped in assets/.

Neither one requires advanced reversing. If a key appears as a string in the app, JADX can usually find it. If a file ships in assets/, unzip or apktool can usually extract it. That is the point of the demo. The problem is not that an attacker has a magic tool. The problem is that the build packaged information that should not have been there in the first place.

This is why I still like showing the mechanics plainly. Many developers already know they should not ship secrets in a client app. What changes the conversation is seeing how little work it takes to recover them once they do.

Patching an APK Is Less Dramatic Than It Sounds

The tamper-check part of the lab is useful for the same reason. The vulnerable app can be decoded, patched, rebuilt, re-signed, and installed again.

The workflow is ordinary:

./gradlew :app:assembleVulnReDebug
apktool d app/build/outputs/apk/vulnRe/debug/app-vuln-re-debug.apk
# patch the relevant smali branch
apktool b app-vuln-re-debug
apksigner sign --ks debug.keystore app-vuln-re-debug/dist/app-vuln-re-debug.apk
adb install -r app-vuln-re-debug/dist/app-vuln-re-debug.apk
View code on GitHub Gist

That matters because “someone could patch this” can sound abstract until you do it once. If a critical decision exists only on the client and can be changed by flipping a branch, it is fragile by design.

Client-side checks can still be useful. They can add friction, detect obvious tampering, and improve telemetry. But they should not be the only thing standing between an attacker and a sensitive server-side decision.

The Secure Build Is Stronger Because It Uses Policy

The secure helper takes a different shape:

override fun verifyExpectedSignature(context: Context): Boolean {
    val actual = runCatching { signingCertSha256B64(context) }.getOrNull() ?: return false
    val expected = EXPECTED_CERT_DIGEST_B64 // Release signing certificate SHA-256 (Base64 NO_WRAP)
    return expected.isNotBlank() && actual == expected
}

override suspend fun tryDynamicDexLoad(context: Context, dexOrJarPath: String): String {
    val path = if (dexOrJarPath.equals("self", ignoreCase = true)) context.packageCodePath else dexOrJarPath
    return "Dynamic code loading is blocked by policy in secure builds (requested='$path')."
}
View code on GitHub Gist

It computes the app signing certificate digest at runtime and compares it with the expected release signing digest. That is a more meaningful identity check than trusting a package name or assuming the installed app is still the one you shipped.

It also refuses dynamic code loading in secure builds.

I like that decision because it is clear. The secure flavor does not try to make untrusted runtime code loading “mostly okay.” It says the feature is not part of the secure policy.

There is still nuance here. Runtime signature verification is useful, but it does not replace server-side authorization or backend trust decisions. Obfuscation is useful, but it does not fix hardcoded secrets. The secure build is better because the controls line up with the risk instead of decorating it.

Dynamic Code Loading Becomes a Liability Quickly

The vulnerable dynamic loading path is the most interesting part of the lab:

override suspend fun tryDynamicDexLoad(context: Context, dexOrJarPath: String): String {
    return runCatching {
        // Interpret input as a simple file name located in the app's external files directory,
        // except for the special keyword 'self' which loads the current APK.
        val input = dexOrJarPath.trim().trim('"', '\'')
        val isSelf = input.equals("self", ignoreCase = true)

        val src: File = if (isSelf) {
            File(context.packageCodePath)
        } else {
            val baseRoot = context.getExternalFilesDir(null) ?: context.filesDir
            val nameOnly = File(input).name // guard against paths; keep only the last segment
            File(baseRoot, nameOnly)
        }

        if (!src.exists()) return@runCatching "Dynamic load failed: File not found at ${src.absolutePath}"
        if (!src.isFile) return@runCatching "Dynamic load failed: Not a file: ${src.absolutePath}"

        // If loading external dex/jar by name, copy it into internal code cache first
        val loadFile: File = if (!isSelf) {
            val nameOnly = src.name
            val internalDex = File(context.codeCacheDir, nameOnly)
            runCatching {
                src.inputStream().use { input ->
                    internalDex.outputStream().use { output ->
                        input.copyTo(output)
                    }
                }
            }.onFailure { ioErr ->
                return@runCatching "Dynamic load failed: IOException while copying to internal cache: ${ioErr.message} (src=${src.absolutePath})"
            }
            internalDex.setReadable(true, true)
            internalDex.setWritable(false, true)
            internalDex
        } else src

        val optDir = File(context.codeCacheDir, "dexopt").apply { mkdirs() }
        val cl = DexClassLoader(loadFile.absolutePath, optDir.absolutePath, null, context.classLoader)

        // Self-load demo
        if (isSelf) {
            val appBuildConfig = runCatching { cl.loadClass("dev.jamescullimore.android_security_training.BuildConfig") }.getOrNull()
            if (appBuildConfig != null) {
                val appIdField = runCatching { appBuildConfig.getField("APPLICATION_ID") }.getOrNull()
                val versionNameField = runCatching { appBuildConfig.getField("VERSION_NAME") }.getOrNull()
                val appId = runCatching { appIdField?.get(null) as? String }.getOrNull()
                val versionName = runCatching { versionNameField?.get(null) as? String }.getOrNull()
                return@runCatching "Loaded self APK via DexClassLoader: BuildConfig{APPLICATION_ID=$appId, VERSION_NAME=$versionName} (path=${src.absolutePath})"
            }
        }

        // Preferred demo: dev.training.dynamic.Hello.greet()
        val tryPreferred = runCatching {
            val k = cl.loadClass("dev.training.dynamic.Hello")
            val m = k.getDeclaredMethod("greet")
            m.invoke(null) as? String
        }.getOrNull()
        if (tryPreferred != null) return@runCatching "Loaded dev.training.dynamic.Hello.greet(): ${tryPreferred} (path=${src.absolutePath})"

        // Keep failure simple now
        "Dynamic load failed: ClassNotFound for dev.training.dynamic.Hello.greet() (path=${src.absolutePath})"
    }.getOrElse { err -> "Dynamic load failed: ${err.javaClass.simpleName}: ${err.message}" }
}
View code on GitHub Gist

The helper accepts a file name, looks in the app-specific external files directory, copies the DEX or JAR into the internal code cache, and loads it with DexClassLoader. It also supports a self mode to load the app APK itself for demonstration.

That is a good teaching example because the trust boundary is visible. A file that originates outside the app’s trusted code path is copied into a place where it can be executed with the app’s privileges. Even though the demo trims path input down to a file name, the larger policy problem remains: the vulnerable build accepts runtime code from a location the secure build should not trust.

The hands-on version is straightforward. Build a minimal dev.training.dynamic.Hello class, convert it to DEX, push it into the app-specific external directory, and enter dynamic.dex in the vulnerable UI. If the class resolves, the app executes code that was not part of the original build.

That is exactly why “we only use DexClassLoader for one small internal feature” should make you slow down. If runtime code loading is genuinely required, it needs strong provenance checks, strict allowlisting, and a threat model that survives hostile input. Most apps are better off not doing it.

What I Would Check in a Real Review

The lab examples are small, but the review questions transfer directly to production apps.

I would start with the APK itself, not only the source tree. Search the decompiled output for URLs, API keys, feature flags, tokens, and words like secret, private, debug, admin, and internal. Then inspect assets/, res/raw/, and bundled configuration files. If sensitive material is there, the app has already lost control of it.

After that, I would look for client-side decisions that are too valuable to trust locally. Premium access, account state, fraud checks, feature entitlements, and backend authorization should not depend on a branch inside the APK. The client can collect signals and improve the experience, but the server should make the decision that matters.

Finally, I would search for runtime loading APIs and plugin-like behavior. DexClassLoader, PathClassLoader, native library loading, script engines, and downloaded configuration that changes behavior all deserve a closer look. The question is not only whether the code compiles. The question is who controls the bytes that eventually run.

A Better Secure Pattern

The safer pattern is usually less exciting than the attack.

Do not put long-lived secrets in the app. Treat obfuscation as friction, not as secrecy. Move sensitive authorization to the backend. If the app needs to prove its own identity to a server, combine normal authentication with signing identity checks, Play Integrity or another attestation signal where appropriate, and server-side enforcement.

For dynamic code, the default answer should be no. If there is a real business need, keep the loading path narrow, verify provenance cryptographically, avoid writable external locations, and fail closed when validation is missing or ambiguous.

Closing Thoughts

The reverse-engineering lab works because it stays grounded. It does not pretend obfuscation is useless, and it does not pretend obfuscation is enough.

The first line of defence is more basic: do not ship secrets in the APK, do not treat assets as hidden storage, do not rely on patchable client checks for decisions that matter, and do not execute untrusted code at runtime unless you can defend the entire loading path.

If the vulnerable APK gives away the useful parts before anyone even opens smali, the reversing was not too advanced. The build was too generous.


I Patched an Android APK to Show How Fragile Client-Side Checks Can Be was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.

Back to all articles