Can Someone Steal Your Code From an APK? What Reverse Engineering Really Exposes
Ever wondered what someone actually gets when they download your Android app and pull it apart? The uncomfortable truth: every APK can be decompiled. The reassuring truth: that…
Ever wondered what someone actually gets when they download your Android app and pull it apart? The uncomfortable truth: every APK can be decompiled. The reassuring truth: that does not mean your source code and project are up for grabs. The gap between those two statements is where all the interesting engineering lives.
This post walks through what a reverse engineer really sees inside a release APK, what modern obfuscation does and doesn't hide, and a practical, layered checklist to make reverse engineering (RE) so expensive that nobody bothers.
First, what actually ships inside an APK?
An APK is just a ZIP file. Rename it to .zip, unzip it, and you get a predictable structure. Each piece exposes a different amount of information — and some of it is readable with zero effort.
flowchart TD
A["app-release.apk"] -->|"unzip / apktool"| B["APK contents"]
B --> C["AndroidManifest.xml"]
B --> D["resources.arsc + res/"]
B --> E["classes.dex"]
B --> F["lib/*.so"]
B --> G["assets/"]
C -->|"aapt2 dump"| H["Permissions, components,<br/>package name, metadata"]
D -->|"readable as-is"| I["Every UI string & label"]
E -->|"jadx"| J["Decompiled Java"]
E -->|"strings"| K["Every string literal"]
J --> L{"Obfuscated?"}
L -->|"Yes (R8 release build)"| M["Gibberish names —<br/>logic still readable"]
L -->|"No (debug build)"| N["Near-original source"]The key insight from that diagram: the manifest, the resources, and every string literal are readable no matter what. Obfuscation only touches your code (the .dex). So before we even talk about decompilers, a huge amount leaks for free.
The 30-second attack: strings and jadx
You don't need to be an expert. Two tools cover 90% of a first pass:
strings(or a two-line Python script) dumps every text literal in the DEX. Log messages, JSON keys, URLs, custom protocol names, error text — all of it.jadxturns the DEX back into browsable Java in one click.
Here's the humbling part. This is a Kotlin data class you wrote:
data class WifiCredentials(
val ssid: String,
val password: String,
val security: String,
)Kotlin auto-generates a toString() for it. After R8 obfuscation, the class becomes d00 — but the generated toString still contains the literal field names, in plain text, in the DEX:
d00(ssid=…, password=…, security=…)So even with aggressive obfuscation, a strings dump hands the attacker your entire data model — field by field — and any value those objects print. That's a real, common leak, and it happens without anyone opening a decompiler.
What obfuscation does hide
Android's R8 (the successor to ProGuard) is genuinely good at what it's designed for. With shrinking and obfuscation enabled, it renames, repackages, and deletes:
// build.gradle.kts
android {
buildTypes {
release {
isMinifyEnabled = true // R8: shrink + obfuscate + optimize
isShrinkResources = true // drop unused resources
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
}And a few extra rules push it further:
# Flatten every renamable class into a single unnamed package,
# so the DEX no longer reveals your feature/module structure by name.
-repackageclasses ''
# Let R8 rename across package-private boundaries too.
-allowaccessmodification
# Strip all android.util.Log calls (and their string arguments) from release.
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int d(...);
public static int i(...);
public static int w(...);
public static int e(...);
}After this, a decompiler shows classes named a, b, d00, qr0 — the package hierarchy is gone, unused code is deleted, and your debug logs are stripped. An attacker sees the shape of the logic but has to rebuild all the meaning by hand.
What obfuscation doesn't hide
This is where most teams have a false sense of security. R8 does not:
| Not protected by R8 | Consequence |
|---|---|
AndroidManifest.xml | Permissions, components, package, metadata all readable |
resources.arsc / res/ | Every UI string, label, and asset name in the clear |
| String literals in code | Log text, JSON keys, protocol names, endpoints readable via strings |
Kotlin data class field names | Leaked through the generated toString() |
| Control flow | Decompiled logic reads linearly once names are guessed |
The headline: *plain strings can reconstruct most of your app's behaviour without any decompiling at all. Obfuscation hides names, not narrative*.
Layered defense: raise the cost, don't chase the impossible
You can't make an APK un-decompilable — it runs on a device you don't control. The realistic goal is defense in depth: stack cheap measures so the effort to understand or tamper with your app outweighs the payoff.
flowchart LR
subgraph BUILD["Build time"]
A["R8: rename +<br/>repackage + shrink"]
B["String encryption"]
C["Full v2 / v3 / v4 signing"]
end
subgraph RUN["Runtime"]
D["Root / debugger /<br/>Frida detection"]
E["Signature self-check"]
end
subgraph SRV["Server"]
F["Play Integrity /<br/>remote attestation"]
end
A --> B --> C --> D --> E --> F --> G(["Attacker cost ↑↑↑"])1. Sign with the full scheme set
Modern signature schemes prove the APK wasn't modified and re-signed. v3 carries a key-rotation lineage; v4 enables fast incremental installs.
signingConfigs {
create("release") {
// ...storeFile, passwords, alias...
enableV1Signing = true // legacy verifiers (pre-API 24)
enableV2Signing = true
enableV3Signing = true // key-rotation lineage
enableV4Signing = true
}
}2. Redact sensitive fields in toString()
Stop the free data-model leak. For any class that holds secrets, don't let the generated toString print them:
data class WifiCredentials(
val ssid: String,
val password: String,
) {
// Override so the password never appears in logs, crash traces, or the DEX
override fun toString() = "WifiCredentials(ssid=$ssid, password=***)"
}3. Encrypt string literals
This is the single highest-impact anti-RE step, because it defeats the strings attack. Sensitive constants are stored encrypted and decoded at runtime, so a raw dump shows ciphertext instead of your protocol and log messages. Commercial tools (DexGuard, DexProtector) automate it; you can also hand-roll it for a handful of critical constants, or move them into native code.
4. Detect a hostile runtime
If the app is running under a debugger, on a rooted device, or hooked by an instrumentation framework, react to it:
fun isTampered(): Boolean {
// Debugger attached?
if (android.os.Debug.isDebuggerConnected()) return true
// Common root binaries
val su = listOf("/system/xbin/su", "/system/bin/su", "/sbin/su")
if (su.any { java.io.File(it).exists() }) return true
// Instrumentation frameworks (Frida, Xposed) leave traces in memory maps
val maps = runCatching { java.io.File("/proc/self/maps").readText() }.getOrNull().orEmpty()
if (listOf("frida", "xposed").any { it in maps }) return true
return false
}Keep in mind: client-side checks like this can be patched out by a determined attacker. They raise the cost; they don't guarantee safety.
5. Verify integrity on a server you control
The only tamper check an attacker can't simply delete from your APK is one that runs off-device. Play Integrity API attests the app and device from Google's servers; your backend refuses to serve if attestation fails. This is the strongest layer because the decision isn't in the attacker's hands.
The one file that undoes everything: mapping.txt
When R8 obfuscates, it writes a mapping.txt that maps d00 back to your real class names (you keep it to de-obfuscate crash reports). If that file leaks, all of your obfuscation is instantly reversed.
app/build/outputs/mapping/release/mapping.txtTreat it like a private key: never commit it, never ship it, never put it anywhere a customer or the public can reach. This is the most common way teams quietly defeat their own hardening.
"But can they rebuild my whole project?"
Here's the reassuring part, and it's worth stating plainly. A release APK does not contain:
- Your original Kotlin source (decompilers emit Java, not your Kotlin)
- Comments, or real class / method / field names
- Your Gradle build scripts and module structure
- Editable resource source files (only the compiled
resources.arsc) - Your signing key
So a decompiler gives an attacker obfuscated Java that won't even compile into a working app. Turning that back into a buildable project means renaming thousands of symbols by hand, fixing decompiler errors, and rebuilding the entire project scaffold — that's a rewrite using the decompiled code as a hint, not a "clone my project" button. For the vast majority of apps, nobody will bother.
The catch: this only holds for release builds. A debug (un-minified) build decompiles back to near-original names. Never distribute one.
The practical checklist
flowchart TD
S(["Ship a release build?"]) --> A["✅ isMinifyEnabled = true"]
A --> B["✅ v2 + v3 signing, real key"]
B --> C["✅ Redact secrets in toString()"]
C --> D["✅ Encrypt sensitive strings"]
D --> E["✅ Runtime tamper checks"]
E --> F["✅ Server-side attestation"]
F --> G["🔒 Guard mapping.txt like a private key"]
G --> DONE(["RE is now economically pointless"])- [ ] Only ever distribute the release build (
isMinifyEnabled = true). Never a debug build. - [ ] Full signing (v2 + v3), with a real production key — not a dev/dummy keystore.
- [ ] No secrets in the binary — no API keys, passwords, or endpoints baked into code or resources.
- [ ] Redact
toString()on any class carrying sensitive data. - [ ] Encrypt string literals to defeat the
stringsattack (highest-impact single step). - [ ] Runtime checks for debugger / root / hooking frameworks.
- [ ] Server-side attestation (Play Integrity) for anything that must be trusted.
- [ ] Guard
mapping.txt— the file that undoes all your obfuscation.
Key takeaway
You cannot stop someone from decompiling an APK — anyone who promises "100% RE-proof" is selling something. What you can do is make the result worthless: obfuscated names that won't compile, encrypted strings that hide your logic, a tamper-aware runtime, and a server that refuses to trust a modified client.
Reverse engineering is a cost-vs-reward decision for the attacker. Do the cheap, layered work above, and you tip that math firmly in your favour — without ever chasing the myth of an unbreakable app.