APK Code Tampering & Smali Patching: A Bug Bounty Guide (2026)

Most mobile apps still make the same mistake the web made twenty years ago: they trust the client. They check isPremium() on the device. They decide "this phone is rooted, so deny access" on the device. They hide the real secret behind a check that runs — on the device. Every one of those decisions lives in code that ships to you, sits on your phone, and runs in your debugger.
Code tampering is the craft of rewriting those decisions. You decompile the app to its bytecode, change the logic that says "no" into logic that says "yes", repackage it, and run your version. Done well, it's not vandalism — it's a controlled experiment that proves the app was relying on the one thing an attacker fully controls. And on a bug bounty program, that proof is often the entire report.
This guide is the deep version, and it's built so you can follow along command-for-command. We'll go from "what is a smali file" to one complete, end-to-end solve of a single deliberately-vulnerable app — every command, every patch, the real output, and adb logcat where it earns its place. Then we cover the anti-tamper defenses you'll meet on real targets and how to turn the result into a finding a triager pays for instead of a "won't fix — modified client" close.
Note: Everything here is for apps you are authorized to test — bug bounty programs that include the mobile app in scope, your own builds, or the deliberately vulnerable training target we use below. Tampering with apps you don't have permission to test, or distributing modified APKs, is illegal in most jurisdictions. Read the legal section before you touch a real target.
Why APK tampering is a bug bounty goldmine
Tampering is not the bug. Tampering is the technique that exposes the bug. The vulnerability is almost always one of these:
| What you find by tampering | The real weakness (OWASP Mobile) | Typical impact |
|---|---|---|
Flip isPremium() to true, paid features unlock | M3: Insecure Authentication/Authorization — entitlement enforced only on the client | Payment bypass, revenue loss |
| Patch out root/emulator detection, app runs anyway | M8: Security Misconfiguration / weak anti-tamper | Defeats the control; enables further testing |
| Remove SSL pinning, traffic becomes readable | Enables you to find the server-side bugs | IDOR, broken auth, secret leakage in the API |
| Hardcoded API key / secret in the bytecode | M10: Insufficient Cryptography / hardcoded secrets | Account takeover, backend abuse |
Change price=499 to price=1 and the server accepts it | Server trusts a client-supplied amount | Direct financial impact — high severity |
Notice the pattern. The two best outcomes are at the top and bottom. The middle ones (root/pin bypass) are enablers — they let you reach the network and storage layers where the high-severity bugs live. A program that pays for "no SSL pinning" alone is rare; a program that pays for "I removed pinning, then found an IDOR that leaks every user's address" is normal. Tampering is how you get from the locked front door to the rooms worth reporting.
The other reason it's worth your time: a huge fraction of apps still enforce business logic on the client. Loyalty points, free trials, feature gates, "verified" badges, KYC steps you can skip — these are all decisions an app could enforce server-side and often doesn't. The client is the cheapest place to put a check and the worst place to trust it.
What you'll learn
By the end you'll be able to:
- Read enough Smali (Android's bytecode assembly) to find and change a decision.
- Run the full loop: decode → patch → rebuild → zipalign → sign → install, and know why each step exists.
- Recognize the categories of things that can be tampered, with the exact opcode patterns for each.
- Work one complete solve against OWASP UnCrackable-Level1 — bypass its root detection by static patching, leak its "secret" through
adb logcatand Frida, and force its success path — with real inputs and outputs you can reproduce. - Defeat the defenses you'll meet on real targets — signature verification, integrity/CRC checks, root and Frida detection — and know which one (Play Integrity) you can't smali your way around, and why that's fine.
- Write the result up as a bug bounty report that survives triage.
This is a code-tampering deep dive. If you want the broader workflow — Burp interception, full Frida instrumentation, the whole OWASP Mobile Top 10 — those live in our companion field manuals: the Mobile App Pentesting Lab Setup (emulator, Burp, Frida from scratch) and the Android App Bug Bounty Guide. This post is about changing the app itself.
Jargon, fast
New to mobile hacking? Here's every term in this guide in one line. Skip it if you already know them.
| Term | Plain meaning |
|---|---|
| APK | The Android app file you install — technically just a ZIP archive |
| DEX | classes.dex — the app's compiled code, packed inside the APK |
| Smali | The human-readable form of DEX bytecode — this is what you edit |
| apktool | Unpacks an APK into smali and repacks it back |
| jadx | Decompiles the app into readable Java (you read here, patch in smali) |
| Frida | Tool that changes an app's behavior live in memory, no rebuild |
| Burp (Suite) | The proxy you route the app's traffic through to read/modify API calls |
| IDOR | Insecure Direct Object Reference — change an ID, see another user's data |
| Root / Magisk | Full admin control of an Android device; Magisk is the usual rooting tool |
| Emulator / AVD | A virtual Android phone on your PC (Android Virtual Device) |
| Triage / triager | The person who reviews your bug report and decides severity and payout |
First, the legal line
- Only test what you're allowed to test. A bug bounty scope that lists
com.example.appis your authorization for that package. No scope, no authorization. - Tamper on a test device/emulator, with a test account. Never on production data or other users' accounts.
- Don't distribute the modified APK. Repackaging a paid or copyrighted app and sharing it is a separate crime (and gets you nothing on a bounty). Your patched build is a disposable proof-of-concept that lives on your test phone and dies there.
- Our target here is designed to be cracked — OWASP's UnCrackable-Level1. Practising on it is the intended use.
When in doubt, read the program's policy. "Reverse engineering / decompilation permitted for in-scope mobile apps" is the line you're looking for.
What "code tampering" actually means
There are two ways to change how an app behaves. Keep them straight — they show up in different report sections and the program cares about the difference.
| Static tampering (this guide) | Dynamic instrumentation (Frida/objection) | |
|---|---|---|
| What you change | The APK on disk — its bytecode, resources, manifest | The app's memory at runtime |
| Persistence | Permanent; the patched APK behaves that way forever | Lasts only while your script is attached |
| Needs root? | No — you install your own signed build | Usually root or a repackaged Frida gadget |
| Survives reboot? | Yes | No (re-attach each run) |
| Best for | Proving "the control is bypassable by a modified client" | Fast experimentation, calling internal methods, dumping data |
| The catch | You must re-sign, and beat any anti-tamper/signature check | App keeps its original signature; integrity checks pass |
Static tampering is the purest demonstration of client-side trust: you hand the triager an APK that is the app, modified, and it works. It's also the harder path when the app defends itself, because your changed bytecode breaks the original signature. Most real assessments use both — tamper to prove the trust boundary is broken, hook to move fast. In the solve below we do mostly static, and bring in Frida exactly where it earns its place.
Building the lab
Before any commands, here's the whole toolkit in one glance — five things, and what each is for. If you understand these five roles, every command later makes sense.
| You need | Tool we use | Its job |
|---|---|---|
| A decompiler | apktool + jadx | Turn the APK back into editable code (smali) and readable Java |
| A rebuilder | apktool | Put your edited code back into an APK |
| A signer | keytool + zipalign + apksigner | Make a key, then re-sign the rebuilt APK so it installs |
| A device | emulator or rooted phone + adb | Run and observe the app |
| A target | OWASP UnCrackable-Level1 | A legal, self-contained app built to be cracked |
Here's how to install all of it:
# --- Decompile / inspect ---
# apktool — decodes resources + DEX to smali, and rebuilds
sudo apt install apktool # or grab the latest jar from apktool.org
# jadx — DEX to readable Java (read here, patch in smali)
sudo apt install jadx # jadx-gui for the clickable version
# --- Rebuild / sign (from the Android SDK build-tools) ---
# zipalign + apksigner live in $ANDROID_HOME/build-tools/<version>/
sdkmanager "build-tools;35.0.0"
export PATH=$PATH:$ANDROID_HOME/build-tools/35.0.0
# --- Device ---
sudo apt install adb
# Use an emulator (Android Studio AVD, x86_64, Google APIs — no Play = easy root)
# or a rooted physical device (Magisk).
# --- Runtime, for the spots where hooking beats patching ---
pip install frida-tools objection
A one-time signing key you'll reuse for every PoC:
keytool -genkey -v -keystore poc.keystore -alias poc \
-keyalg RSA -keysize 2048 -validity 10000 \
-storepass cywarx -keypass cywarx \
-dname "CN=poc, OU=poc, O=poc, L=poc, S=poc, C=US"
About the signing key — what this is and why you need it. keytool is a small program that ships with Java (have Java? you have keytool). It creates a keystore — a password-protected file that holds a signing key. You need one because Android refuses to install an app unless it's signed, and rebuilding with apktool b throws away the developer's original signature. You don't have their key, so you make your own here and sign your patched build with it. Run this once and reuse poc.keystore forever. Each flag, in plain words:
| Flag | Plain meaning |
|---|---|
-genkey | Create a new key (the action) |
-v | Verbose — show what it's doing |
-keystore poc.keystore | The file to save the key in (you point apksigner at this later) |
-alias poc | A nickname for the key inside the file |
-keyalg RSA -keysize 2048 | Key type and strength — the standard Android accepts |
-validity 10000 | Valid for 10,000 days (~27 years) so it never "expires" |
-storepass / -keypass | Passwords for the file and the key (throwaway for a PoC) |
-dname "CN=poc, …" | The owner identity written into the certificate — for a PoC, poc everywhere is fine; Android doesn't check it's "real" |
Note: Some
keytoolversions warn thatS=should beST=(State). It still works — useST=pocif you want it perfectly clean.
The target — OWASP UnCrackable-Level1 (owasp.mstg.uncrackable1). It's a self-contained crackme: it refuses to run on a rooted device, then asks for a secret string. No backend, no account — perfect for a reproducible solve.
# Grab it from the OWASP MASTG crackmes (https://mas.owasp.org/crackmes/)
wget https://raw.githubusercontent.com/OWASP/mastg/master/Crackmes/Android/Level_01/UnCrackable-Level1.apk
adb install UnCrackable-Level1.apk
adb shell pm list packages | grep -i uncrack
# package:owasp.mstg.uncrackable1
Anatomy of an APK
An APK is just a ZIP. unzip -l UnCrackable-Level1.apk shows the parts that matter for tampering:
| Entry | What it is | Why you care |
|---|---|---|
classes.dex, classes2.dex… | Compiled Dalvik bytecode — all the app's logic | This is what becomes smali when you decode; where you patch logic |
AndroidManifest.xml | Binary-encoded app config | android:debuggable, networkSecurityConfig, exported components |
resources.arsc | Compiled resources (strings, layouts) | Feature flags, hardcoded strings, config references |
res/ | Layouts, drawables, raw certs | Pinned certs sometimes live in res/raw/ |
lib/<abi>/*.so | Native code (NDK) | Some checks (root, pinning) hide here — harder, needs Frida/Ghidra |
assets/ | Bundled files | JS bundles (React Native), Flutter libapp.so, config blobs |
META-INF/ | The signature | apktool b strips this; you re-sign it yourself |
Note:
apktool ddecodesclasses.dexinto human-editable smali and turns the binary manifest back into readable XML. That's the whole point — you can't edit a.dexby hand, but you can edit smali and let apktool reassemble it.
Two non-Java cases to recognize early, because smali patching won't help: React Native logic lives in assets/index.android.bundle (JavaScript — patch the JS), and Flutter compiles to native lib/.../libapp.so (Ghidra/reFlutter territory). If jadx shows you almost no app code and a giant .so, you're in one of those worlds. UnCrackable-Level1 is plain Java/smali, so we're in the easy lane.
A practical crash course in Smali
Smali is the assembly form of Dalvik bytecode. You don't need to write it from scratch — you need to read it and change small things surgically. Here's the 20% that covers 90% of patches.
Registers and types. Methods use local registers v0, v1, … and parameter registers p0, p1, … (p0 is this for instance methods). Types are single letters: Z=boolean, I=int, V=void, J=long, and objects are Lpackage/Class;.
A method header tells you what it returns — and that's usually all you need:
.method public isPremiumUser()Z # the trailing Z = returns boolean
.registers 2
iget-boolean v0, p0, Lcom/app/User;->premium:Z
return v0
.end method
The handful of opcodes you'll actually touch:
| Smali | Meaning | The patch you'll make |
|---|---|---|
const/4 v0, 0x0 | put 0 (false) into v0 | change 0x0→0x1 to force true |
const/4 v0, 0x1 | put 1 (true) into v0 | change 0x1→0x0 to force false |
return v0 | return the value in v0 | force v0 first, then return |
if-eqz v0, :label | jump if v0 == 0 | change to if-nez to invert |
if-nez v0, :label | jump if v0 != 0 | change to if-eqz to invert |
invoke-virtual {…}, …->isRooted()Z | call a method | follow with move-result v0, then overwrite v0 |
move-result v0 | grab the returned value | overwrite v0 right after to ignore the real result |
The single most useful patch — the "early return" trick. This is the move you'll use over and over, so read this slowly — it answers what to add, what to remove, and where.
You add two lines at the very top of the method (right under the .locals/.registers line, before any other instruction). You delete nothing. Here's a root check, with the two added lines marked:
.method private isDeviceRooted()Z
.registers 4
const/4 v0, 0x0 # <-- ADD line 1: put 0 (false) in v0
return v0 # <-- ADD line 2: return right now
# ↓↓↓ everything below is the ORIGINAL code — LEAVE IT EXACTLY AS-IS ↓↓↓
# it still sits in the file, but it never runs (see "two returns" below)
const-string v1, "su"
# ...the rest of the real detection logic, untouched...
.end method
To force true instead (e.g. an isPremium() / isLicensed() / signature check), the only change is line 1 — use const/4 v0, 0x1. For a method that returns nothing ()V, like an SSL pinner), the one added line is return-void.
"But now there are two return statements — is that a bug?" No — and this is the whole trick. In smali (just like an early return in Java), the method stops at the first return it reaches. Your return v0 on line 2 runs first, so the method exits immediately and hands back your value. The original code below — including its own return — is unreachable, so it simply never executes. The Android verifier is completely fine with unreachable code after a return; you do not need to delete it. Leaving it there is actually safer: you're not risking a mistake by cutting lines, and the diff is tiny and obvious.
So, to be crystal clear:
| Question | Answer |
|---|---|
| What do I add? | Two lines: const/4 v0, 0x0 (or 0x1) then return v0 — or just return-void for a )V method |
| What do I remove? | Nothing. Leave the entire original method body in place |
| Where do I add them? | At the very top, immediately after .locals/.registers, before the first real instruction |
| Can I keep everything else the same? | Yes — .locals, registers, the original code: all unchanged |
Two returns now — problem? | No. Execution hits your return first and exits; the rest is dead code and is ignored |
When the gate is a branch rather than a method you control, flip the condition instead — if-nez v0, :label ↔ if-eqz v0, :label.
Note: Don't introduce a register the method never declared. Reuse one that's already in play (like the
v0it was about to return) rather than inventingv9. If you ever do need a fresh register, raise the.localscount by one. A too-high register number is the usual reasonapktool bfails.
Workflow tip: read in Java, patch in smali. Open the app in jadx-gui, find the method by name (isRooted, verify, isPremium), understand the logic in readable Java, then make the equivalent edit in the corresponding smali/.../Foo.smali file.
The tampering workflow, end to end
Every static tamper, regardless of target, is the same six steps. Memorize this loop — the solve below is just this loop, twice.
# 1. DECODE — APK to smali + decoded resources/manifest
apktool d target.apk -o target_src
# 2. PATCH — edit the smali / manifest / resources
nano target_src/smali/com/app/SecurityCheck.smali
# 3. REBUILD — reassemble smali back into an unsigned APK
apktool b target_src -o target_patched.apk
# 4. ZIPALIGN — align before signing (required by apksigner)
zipalign -p -f 4 target_patched.apk target_aligned.apk
# 5. SIGN — apply YOUR key (v1+v2+v3 schemes)
apksigner sign --ks poc.keystore --ks-pass pass:cywarx \
--out target_signed.apk target_aligned.apk
# 6. INSTALL — your build replaces the original
adb uninstall com.app.target # original signature won't match yours
adb install target_signed.apk
Why each step is non-optional: decode turns un-editable .dex into editable smali; rebuild strips META-INF/, leaving the APK unsigned (Android refuses to install unsigned apps); zipalign must come before signing with apksigner (aligning after invalidates the v2/v3 signature); sign makes it installable with your key; uninstall first because Android blocks installing an app whose signature differs from the one already present.
Note:
apktool bfailing withbrut.androlibresource errors? Decode with plainapktool d, and if a stubborn resource won't reassemble, re-run the build with--use-aapt2. "method too large" is usually a warning, not a failure.
What can actually be tampered
This is the mental catalog. When you open an app, you're hunting for one of these. Each row is a class of patch you can make — and the solve below exercises the first three rows directly.
| Category | What it looks like in code | The tamper |
|---|---|---|
| Boolean security gates | isRooted(), isEmulator(), isDebuggerConnected(), isFridaPresent() | Force the method to return 0x0 |
| Entitlement checks | isPremium(), hasSubscription(), isLicensed(), verify() | Force return 0x1 |
| Conditional jumps | if-eqz/if-nez gating a feature or an error screen | Invert the condition or NOP the branch |
| Constants & limits | free-trial count, max retries, price, score thresholds | Change the literal (const/16 v0, 0x3 → 0x270f) |
| String comparisons | hardcoded PIN/password/secret in equals() | Read the constant directly, or force the compare result |
| Hardcoded secrets | API keys, HMAC keys, encryption keys as string literals | Extract them (no patch needed — it's already a leak) |
| SSL pinning | OkHttp CertificatePinner, TrustManager, checkServerTrusted | Remove the pinning class / inject a permissive network_security_config |
| Manifest flags | android:debuggable, allowBackup, usesCleartextTraffic, exported | Flip to true to open the app up for analysis |
| Integrity / anti-tamper | self-signature check, DEX CRC compare, PackageManager.getSignatures | Patch the verifier to always pass |
| Feature flags | BuildConfig.DEBUG, enableInternalMenu, featureX = false | Flip to expose hidden/dev functionality |
Some of these are findings on their own (hardcoded secrets, a cleartext-allowing config). Most are enablers that get you to the network/storage where the real findings are. Keep that distinction in your head — it decides whether you report the tamper or what the tamper revealed.
The full solve: OWASP UnCrackable-Level1
One app, start to finish. Every command and smali patch below was run against the real APK (apktool 2.10.0, Android build-tools 34); on-device output (adb/Frida) is shown as it appears on a rooted device/emulator.
The solve at a glance — here's the whole journey in six steps before we go deep. Read this once, then follow the detailed version below.
| Step | What we do | The point |
|---|---|---|
| 0 | Install the app, watch it detect root and quit | See the defense work |
| 1 | apktool d + jadx — find the three root-check methods | Locate the decision |
| 2 | Edit the smali so each check returns "false" | Disarm the defense |
| 3 | apktool b → zipalign → apksigner → adb install | Rebuild and run our version |
| 4 | Inject a log line / use Frida to print the decrypted secret | Read a "protected" value |
| 5 | Force the verify method to always pass | Bypass the gate entirely |
The pattern (decode → find → patch → rebuild → sign → install) is the same for every app you'll ever tamper. UnCrackable-Level1 is just a clean place to learn it.
Note: Class and method names below (
sg.vantagepoint.a.c,sg.vantagepoint.a.a,.localscounts, thep0return register) are the actual values in the current UnCrackable-Level1 APK. If you grab a different build, re-read the methods in jadx and adjust — the technique is identical, only the names move.
Step 0 — see the defense fire
Install the stock APK on a rooted emulator/device and launch it. It detects root and bails:
adb install UnCrackable-Level1.apk
adb shell monkey -p owasp.mstg.uncrackable1 -c android.intent.category.LAUNCHER 1
What is
monkey, and what does that line mean?monkeyis a tool built into every Android device — its full name is the UI/Application Exerciser Monkey. The name comes from the "infinite monkey" idea: it normally fires a stream of random taps, swipes, and key presses at an app to stress-test it for crashes. We're not stress-testing here — we're abusing one handy side effect: with an event count of1and a launcher filter, monkey simply opens the app's main screen. It's the easiest way to start an app from the terminal when you know the package name but not the exact activity name. Reading it left to right:
Piece Meaning adb shellRun the command on the device, not your PC monkeyThe built-in event-generator tool -p owasp.mstg.uncrackable1Package filter — only touch this app, nothing else -c android.intent.category.LAUNCHERCategory filter — only the app's launcher (home) activity 1Generate exactly one event → just launch, then stop Increase that
1to, say,500and monkey starts hammering random input — useful for fuzzing, not what we want. The cleaner "just launch" alternative, if you know the activity, isadb shell am start -n owasp.mstg.uncrackable1/sg.vantagepoint.uncrackable1.MainActivity(am= Activity Manager). Both work; monkey is shorter when you only have the package name.
A dialog reads "Root detected!"; tapping OK calls System.exit(0) and the app closes. Confirm the lifecycle in logcat — clear the buffer, launch, and watch the process die:
adb logcat -c # clear
adb logcat | grep -i "uncrackable\|AndroidRuntime\|ActivityManager"
I ActivityManager: START u0 {cmp=owasp.mstg.uncrackable1/sg.vantagepoint.uncrackable1.MainActivity}
I ActivityManager: Displayed owasp.mstg.uncrackable1/sg...MainActivity
I Process : Sending signal. PID: 7421 SIG: 9 <-- System.exit(0) after the dialog
That Sending signal … SIG: 9 is the app killing itself the moment it sees root. Our job is to make that decision never trigger.
Step 1 — decode and locate the check
apktool d UnCrackable-Level1.apk -o uncrackable1
jadx-gui UnCrackable-Level1.apk & # read the logic here
In jadx, open sg.vantagepoint.uncrackable1.MainActivity. onCreate does the equivalent of:
if (c.a() || c.b() || c.c()) { // three root indicators
a("Root detected!"); // dialog -> System.exit(0)
}
if (b.a(getApplicationContext())) { // debuggable check
a("App is debuggable!");
}
The three root checks live in sg.vantagepoint.a.c as a(), b(), c(). Each returns a boolean (true = "rooted"); the app trips if any is true. Here's what each one actually does in the real APK:
| Method | .locals | What it checks |
|---|---|---|
a() | 7 | Looks for a su binary in every directory on $PATH |
b() | 2 | Checks Build.TAGS for test-keys (a custom-ROM tell) |
c() | 7 | Looks for known root files (/system/app/Superuser.apk, daemonsu, …) |
Step 2 — patch the root detection in smali
The fix is the same for all three: make each method return false immediately, so its real detection logic never runs. You add two lines at the top and leave everything else alone. Here's a() before and after so you can see exactly what changes.
Original — uncrackable1/smali/sg/vantagepoint/a/c.smali, method a() as it ships (this is the real su-on-$PATH search):
.method public static a()Z
.locals 7
const-string v0, "PATH"
invoke-static {v0}, Ljava/lang/System;->getenv(Ljava/lang/String;)Ljava/lang/String;
move-result-object v0
const-string v1, ":"
invoke-virtual {v0, v1}, Ljava/lang/String;->split(Ljava/lang/String;)[Ljava/lang/String;
move-result-object v0
# ...loops over each PATH dir, returns 0x1 (true) if a "su" file exists...
return v2
.end method
Modified — two new lines at the very top (everything below is now dead code; apktool doesn't mind):
.method public static a()Z
.locals 7
const/4 v0, 0x0 # <-- ADDED: put 0 (false) in v0
return v0 # <-- ADDED: return it now; the real check never runs
const-string v0, "PATH"
invoke-static {v0}, Ljava/lang/System;->getenv(Ljava/lang/String;)Ljava/lang/String;
# ...original detection logic, now unreachable...
return v2
.end method
That's the entire change: const/4 v0, 0x0 then return v0, inserted right after the .locals line. Do the identical two-line insert at the top of b() and c(). Keep each method's own .locals value (a and c are 7, b is 2) — you're not adding registers, just reusing v0, so the count never needs to change.
(Optional: the same two-line insert kills the debuggable check in sg/vantagepoint/a/b.smali — method a(Landroid/content/Context;)Z — so the "App is debuggable!" dialog never fires either.)
There's a tidier one-edit alternative if you'd rather touch a single file: in MainActivity.smali, the combined result feeds an if-eqz/if-nez before the call that shows the dialog. Flipping that one branch (if-nez v0, :cond → if-eqz v0, :cond) skips the exit path entirely. Patching the three source methods is cleaner and reusable; the branch flip is faster. Either works.
Step 3 — rebuild, sign, install
The standard loop:
apktool b uncrackable1 -o uncrackable1_patched.apk
zipalign -p -f 4 uncrackable1_patched.apk uncrackable1_aligned.apk
apksigner sign --ks poc.keystore --ks-pass pass:cywarx \
--out uncrackable1_signed.apk uncrackable1_aligned.apk
apksigner verify -v --print-certs uncrackable1_signed.apk # sanity check
adb uninstall owasp.mstg.uncrackable1
adb install uncrackable1_signed.apk
apksigner verify confirms all three signature schemes applied with your key:
Verifies
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
Number of signers: 1
Signer #1 certificate DN: CN=poc, OU=poc, O=poc, L=poc, ST=poc, C=US
…and adb install prints Success.
Launch it. No "Root detected" dialog — the app runs on the rooted device:
adb logcat -c
adb shell monkey -p owasp.mstg.uncrackable1 -c android.intent.category.LAUNCHER 1
adb logcat | grep -i "ActivityManager\|uncrackable"
I ActivityManager: Displayed owasp.mstg.uncrackable1/sg...MainActivity
# ...and no "Sending signal ... SIG: 9". The app stays alive.
You just proved the root check is a client-side speed bump, defeated with three one-line edits. Now the app shows a text box: "Enter the secret string." Time to get it.
Step 4 — recover the secret via logcat
The secret isn't stored in plaintext — it's an AES-encrypted blob the app decrypts at runtime and compares to your input. The decryption happens in sg.vantagepoint.a.a.a(byte[] key, byte[] enc), which returns the decrypted bytes. We'll make the app log that value to logcat, then read it. This is pure tampering — we inject a Log.d call right where the plaintext exists.
Open uncrackable1/smali/sg/vantagepoint/a/a.smali. Here it is before we touch it — this is the real method that ships in the APK. It sets up an AES key, decrypts, and the plaintext bytes end up in register p0 (smali reuses the parameter register), which it returns:
.method public static a([B[B)[B
.locals 2
new-instance v0, Ljavax/crypto/spec/SecretKeySpec;
const-string v1, "AES/ECB/PKCS7Padding"
invoke-direct {v0, p0, v1}, Ljavax/crypto/spec/SecretKeySpec;-><init>([BLjava/lang/String;)V
const-string p0, "AES"
invoke-static {p0}, Ljavax/crypto/Cipher;->getInstance(Ljava/lang/String;)Ljavax/crypto/Cipher;
move-result-object p0
const/4 v1, 0x2
invoke-virtual {p0, v1, v0}, Ljavax/crypto/Cipher;->init(ILjava/security/Key;)V
invoke-virtual {p0, p1}, Ljavax/crypto/Cipher;->doFinal([B)[B
move-result-object p0 # <-- p0 now holds the DECRYPTED secret bytes
return-object p0
.end method
We do two things: bump .locals 2 → .locals 3 so we have a free scratch register (v0), then just before return-object p0, wrap the plaintext in a String and log it. Here's the modified method — the four <-- ADDED lines are the only difference:
.method public static a([B[B)[B
.locals 3 # CHANGED: was 2 — one extra register for our String
new-instance v0, Ljavax/crypto/spec/SecretKeySpec;
const-string v1, "AES/ECB/PKCS7Padding"
invoke-direct {v0, p0, v1}, Ljavax/crypto/spec/SecretKeySpec;-><init>([BLjava/lang/String;)V
const-string p0, "AES"
invoke-static {p0}, Ljavax/crypto/Cipher;->getInstance(Ljava/lang/String;)Ljavax/crypto/Cipher;
move-result-object p0
const/4 v1, 0x2
invoke-virtual {p0, v1, v0}, Ljavax/crypto/Cipher;->init(ILjava/security/Key;)V
invoke-virtual {p0, p1}, Ljavax/crypto/Cipher;->doFinal([B)[B
move-result-object p0
new-instance v0, Ljava/lang/String; # <-- ADDED
invoke-direct {v0, p0}, Ljava/lang/String;-><init>([B)V # <-- ADDED: bytes -> String
const-string v2, "CYWARX" # <-- ADDED: our log tag
invoke-static {v2, v0}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I # <-- ADDED: print it
return-object p0
.end method
Note: The register that holds the plaintext (
p0here) and the.localscount are app-specific — always read the real method first and match them. If you invent a register the method never declared, or forget to raise.locals,apktool bfails. This injection pattern — "wrap the bytes in a String,Log.dit, continue unchanged" — is the single most useful trick for pulling decrypted values, keys, and tokens out of any app at the exact moment they exist in cleartext.
Rebuild → sign → install again (same loop as Step 3), then watch logcat while you type anything into the box and tap Verify:
adb logcat -c
adb logcat -s CYWARX # -s filters to just our tag
D CYWARX : I want to believe
There's the secret: I want to believe. The app decrypted it to compare against your guess, and we logged it on the way through. Type it into the app:
Enter the secret string: I want to believe
[ VERIFY ]
-> "Success! This is the correct secret."
Step 4b — the same secret, with Frida (no repackage)
If you'd rather not rebuild, hook the decrypt at runtime. This is the faster path for experimentation and the one to reach for when the value you want is buried in an obfuscated method.
// secret.js — frida -U -f owasp.mstg.uncrackable1 -l secret.js
Java.perform(function () {
const Log = Java.use("android.util.Log");
const aes = Java.use("sg.vantagepoint.a.a");
// a(byte[] key, byte[] cipher) -> byte[] plaintext
aes.a.overload("[B", "[B").implementation = function (key, cipher) {
const out = this.a(key, cipher); // run the original
const secret = Java.use("java.lang.String").$new(out);
console.log("[*] Decrypted secret: " + secret); // Frida console
Log.d("CYWARX", "secret=" + secret); // also to logcat
return out;
};
});
$ frida -U -f owasp.mstg.uncrackable1 -l secret.js
____
/ _ | Frida 16.x - A world-class dynamic instrumentation toolkit
| (_| |
Spawned `owasp.mstg.uncrackable1`. Resuming main thread!
[*] Decrypted secret: I want to believe
Same answer, no repackaging — proof that whether you tamper the file or hook memory, a client-side secret is a secret you can read.
Step 5 — skip the secret entirely (force the success path)
For the report, it's worth showing the entitlement-style patch too: the method that decides pass/fail can simply be told to always pass. The check is sg.vantagepoint.uncrackable1.a.a(Ljava/lang/String;)Z — it decrypts the secret, compares it to your input with String.equals, and returns that result. Force it to return true and any input is "correct."
Original — the method ends by comparing and returning the real result (p0 holds the equals outcome):
.method public static a(Ljava/lang/String;)Z
.locals 5
# ...decrypt the secret into v0, then compare it to the user's input p0...
new-instance v1, Ljava/lang/String;
invoke-direct {v1, v0}, Ljava/lang/String;-><init>([B)V
invoke-virtual {p0, v1}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z
move-result p0
return p0 # <-- returns true only if the input matched
.end method
Modified — short-circuit to "always true" with the same two-line pattern from Step 2:
.method public static a(Ljava/lang/String;)Z
.locals 5
const/4 p0, 0x1 # <-- ADDED: force "correct"
return p0 # <-- ADDED: return now, comparison never runs
.end method
Now any input pops "Success!" — the exact move you'd use against an isPremium() or isLicensed() gate on a real app, where the prize isn't a dialog but a paid feature.
That's the whole discipline in one app: a security gate bypassed by patching booleans, a "protected" secret read straight out of memory, and a success check forced open — none of which the server ever got a say in.
Bonus attack: open the dashboard without logging in
Here's a bug you'll find on real apps that needs zero code patching — just one adb command. Many apps put the login before the dashboard in the navigation flow, but the dashboard screen itself never checks "is this user actually logged in?" It just assumes you could only have arrived by logging in. So if you launch that screen directly, it opens — logged in or not.
In Android terms, every screen is an Activity. If a sensitive Activity is exported (reachable from outside the app) and doesn't re-check the session, you can start it straight from the shell and skip the login entirely.
What kind of vulnerability is this?
This is an authentication/authorization bypass through an exposed Android component — the same "the client decides who's allowed in" failure as the rest of this guide, just exploited through the component system instead of smali.
| Label | Where it maps |
|---|---|
| OWASP Mobile Top 10 | M3: Insecure Authentication/Authorization (and M8: Security Misconfiguration for the export) |
| OWASP MASVS | MASVS-AUTH (auth enforced per-screen) and MASVS-PLATFORM (component exposure) |
| CWE | CWE-862 Missing Authorization · CWE-306 Missing Authentication for a Critical Function · CWE-926 Improper Export of Android Application Components |
Step 1 — find the activities and which are exported
Read the decoded AndroidManifest.xml (or open it in jadx). Every <activity> is a screen; the android:exported flag and any <intent-filter> tell you what's reachable from outside the app:
<!-- The login screen — the intended entry point -->
<activity android:name=".LoginActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- The dashboard — exported, and with no auth check inside = the bug -->
<activity android:name=".DashboardActivity" android:exported="true" />
adb can list them for you too:
# Dump every activity the app declares (look for exported="true")
adb shell dumpsys package <pkg> | grep -i activity
Note:
android:exported="true"means any other app on the device can launch that screen. Since Android 12 every component with an<intent-filter>must setexportedexplicitly, so you'll see it spelled out. A truly internal screen should beexported="false".
Step 2 — launch the protected screen directly
Use the Activity Manager (am) to start the dashboard by name, with no login:
adb shell am start -n com.example.app/.DashboardActivity
Starting: Intent { cmp=com.example.app/.DashboardActivity }
If the dashboard appears — populated, logged-in, usable — you've bypassed authentication. A more capable tool for the same job is drozer, which enumerates and launches exported components for you:
drozer console connect
run app.package.attacksurface com.example.app # counts exported activities/services/etc.
run app.activity.info -a com.example.app # lists exported activities
run app.activity.start --component com.example.app com.example.app.DashboardActivity
Often the login screen exists only to gate the UI flow, while every screen behind it trusts that the flow was followed. Direct launch breaks that assumption.
Step 3 — the part that decides the severity
Opening the screen is the demo. Whether it's a real, payable bug depends on one question — the same one that runs through this whole guide: does the server still require a valid session?
| What you see after the direct launch | Real impact | Severity |
|---|---|---|
| Dashboard loads real data (the API served it with no valid token) | True auth bypass — anyone can read another context's data | High / Critical |
Screen opens but data calls return 401/403 (server enforces auth) | UI shell only; cosmetic | Low / Informative |
You can pass Intent extras like is_admin=true and the screen trusts them | Privilege escalation via untrusted input | High |
| Exported screen performs an action (transfer, delete) without re-auth | Unauthorized action | High |
So after the screen opens, watch your proxy (Burp): if the activity fetches data and the server hands it over without a session token, that's your finding. If the server says 401, the client gate was cosmetic and the backend is doing its job — note it and move on.
Note: The same exposure applies to the other exported components — Services, Broadcast Receivers, and especially Content Providers (direct
content://queries can dump a private database). Activities are just the most visual example. And watchIntentextras / deep links: if a screen readsgetIntent().getBooleanExtra("isAdmin", false)and trusts it, you control that value.
How they should have fixed it (your remediation section)
- Check the session inside every protected screen. In
onCreate, if there's no valid auth token, redirect to login andfinish()— never assume the user "must have logged in already." - Set
android:exported="false"on any Activity/Service/Receiver/Provider that isn't deliberately meant for other apps. Export is opt-in, not a default to leave on. - Never trust
Intentextras or deep-link parameters for authorization decisions (isAdmin,userId,role). Treat them as attacker-controlled input. - Enforce auth on the server for every request. Even a perfectly gated client is bypassable; the backend must reject any request without a valid session — that's the control that actually holds.
Real-world tamper recipes
The solve taught the loop. These are the four tampers you'll reach for most on real bug bounty targets — each with the actual before/after and the steps. Same six-step loop every time (decode → patch → rebuild → zipalign → sign → install); only the edit changes.
Recipe 1 — strip SSL pinning to read the API
This is the most valuable tamper for bounty, because the high-severity bugs (IDOR, broken auth, leaked secrets) live in the API traffic, and pinning is the wall hiding it. Two ways; try the easy one first.
Approach A — inject a Network Security Config (no smali). Since Android 7, apps trust only system CAs and ignore your Burp certificate. Tell the app to also trust user-installed CAs.
Create src/res/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" /> <!-- now Burp's CA is trusted -->
</trust-anchors>
</base-config>
</network-security-config>
Then point the manifest at it. Before (decoded AndroidManifest.xml):
<application android:label="@string/app_name" android:icon="@mipmap/ic_launcher">
After — one new attribute:
<application android:label="@string/app_name" android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config">
Rebuild → sign → install, install Burp's CA on the device, and traffic from apps that pin via the default trust store is now readable.
Approach B — neutralize an OkHttp CertificatePinner (smali). Apps that pin in code ignore the network config. Find CertificatePinner in jadx; its check() method throws an exception on mismatch. Make it do nothing.
Original — okhttp3/CertificatePinner.smali, the check method that enforces the pin:
.method public check(Ljava/lang/String;Ljava/util/List;)V
.locals 6
# ...builds the cert chain, compares to the pinned hashes,
# throws SSLPeerUnverifiedException if they don't match...
throw v0
.end method
Modified — replace the body with an immediate return-void so it never checks or throws:
.method public check(Ljava/lang/String;Ljava/util/List;)V
.locals 6
return-void # <-- ADDED: pinning disabled — accept any certificate
.end method
Note: When pinning is obfuscated, spread across several
TrustManagers, or in native code, stop hand-patching and use Frida:frida -U -f com.app -l frida-multiple-unpinning.js, orobjection -g com.app explorethenandroid sslpinning disable. Same result, no rebuild.
Once traffic is visible, that's where the report is: change an id in a request and watch for IDOR, replay another account's token, look for secrets in responses. Pinning removal is just the key to the door.
Recipe 2 — change business logic (trials, prices, flags)
Apps often store limits and entitlements as plain constants. You change the number.
Free-trial counter — Before (3 uses left, const/16 v0, 0x3):
const/16 v0, 0x3 # remainingUses = 3
iput v0, p0, Lcom/app/Trial;->remainingUses:I
After — give yourself ~10,000 uses (0x270f = 9999):
const/16 v0, 0x270f # <-- CHANGED: remainingUses = 9999
iput v0, p0, Lcom/app/Trial;->remainingUses:I
A price field — Before (499):
const/16 v0, 0x1f3 # amount = 499
After (1):
const/4 v0, 0x1 # <-- CHANGED: amount = 1
Note: Changing a price locally is only a bug if the server accepts the tampered amount. After the edit, complete the purchase with Burp open: if the backend charges 1 instead of 499, that's a critical finding; if it recomputes the price server-side and rejects you, the client value was cosmetic. Same rule as always — the server is the judge.
Recipe 3 — open the app up via the manifest
Three one-line manifest edits make analysis dramatically easier. Before:
<application
android:allowBackup="false"
android:debuggable="false"
android:usesCleartextTraffic="false" >
After:
<application
android:allowBackup="true" <!-- enables: adb backup -f data.ab <pkg> -->
android:debuggable="true" <!-- enables: run-as <pkg>, jdb, drozer hooks -->
android:usesCleartextTraffic="true" > <!-- lets HTTP through to your proxy -->
What each unlocks:
| Edit | What it gives you |
|---|---|
debuggable="true" | The app becomes debuggable even on a non-rooted phone: adb shell run-as <pkg> reads its private /data/data/<pkg>/ files; you can attach jdb / drozer |
allowBackup="true" | adb backup -f out.ab <pkg> extracts the app's data to your PC (then unpack with abe) |
usesCleartextTraffic="true" | Forces HTTP to be allowed, so cleartext requests reach Burp |
Note:
debuggable="true"is one of the highest-leverage tampers — it turns any phone into a debugging device for that one app, no root needed, andrun-asthen hands you the private data directory.
Recipe 4 — run Frida on a non-rooted phone (gadget injection)
Frida normally needs root. objection repackages the APK with the Frida gadget baked in, so you can hook on a stock phone — itself a tampering technique (it modifies and re-signs the app):
# Injects frida-gadget into the APK, rebuilds and re-signs it for you
objection patchapk --source app.apk
# Install the patched build it produced
adb install app.objection.apk
# The gadget loads on launch — attach with no root:
objection explore
# or: frida -U Gadget -l hook.js
Under the hood objection runs the same loop you've been doing by hand (decode → add the gadget .so + a loadLibrary call → rebuild → sign). It's the bridge between static tampering and runtime hooking when you don't have a rooted device.
When the rebuild breaks: errors and fixes
You will hit these. Here's every common failure and the one-line fix, so a red error never stops you.
| Error you see | Cause | Fix |
|---|---|---|
INSTALL_FAILED_UPDATE_INCOMPATIBLE | The original app is installed, signed with a different key | adb uninstall <pkg> first, then install your build |
INSTALL_FAILED_VERSION_DOWNGRADE | Your rebuild's versionCode looks older | adb install -r -d your.apk (-d allows downgrade) |
INSTALL_FAILED_INVALID_APK / "not signed" | You forgot to sign, or zipaligned after signing | Re-run: zipalign then apksigner sign |
apktool b → brut.androlib ... AndrolibException (resources) | A resource won't reassemble | Rebuild with apktool b src --use-aapt2 |
Unsigned short value out of range / method too large | A method exceeds Dalvik limits after editing | You added too much — keep injections tiny; move logic to a new method |
Invalid register / verify error at runtime | You used a register the method never declared | Reuse an existing register, or raise the .locals count |
| App installs but instantly crashes | Your smali is malformed or you broke a try/catch range | adb logcat *:E — the VerifyError/exception names the class; recheck that file |
| Patched code does nothing | The real check is in another classes2.dex / classes3.dex, or in native .so | Search all dex folders (smali/, smali_classes2/…); if it's native, switch to Frida |
Note (multidex): big apps split code across
classes.dex,classes2.dex,classes3.dex— apktool decodes these intosmali/,smali_classes2/,smali_classes3/. Ifgrepfinds your method insmali_classes2/, patch it there; editing the wrong dex folder is the usual reason "my patch did nothing."
The bypasses you'll keep hitting
UnCrackable-Level1 has no anti-tamper, so our re-signed APK ran fine. Real targets fight back. Below are the defenses you'll actually meet, each with the real Java it compiles from, the original smali, and the one-line patch — because once you see that they're all the same early-return trick from the crash course, none of them is scary.
How to read every "Modified" block below. Each one shows the two lines you add at the very top of the method (right after
.locals). You don't delete the original code — it stays in the file and simply never runs, because yourreturnfires first. So "Modified" = original method, with these two lines inserted on top, never "replace the whole method with only these." When a block shows just two lines, picture the rest of the original method still sitting below them, untouched.
Note: The class/method names below are representative (real apps obfuscate them to
a.b.c()), but the opcodes and the patch are exactly what you'll edit. You find the method by behavior — a()Zthat runs right before the app exits or hides a feature — not by name.
For each defense below you get: the file it lives in, the complete Original method, and the complete Modified method. In every Modified version the only change is the two lines added at the top — every other line is byte-for-byte identical to the Original. Compare them line by line and you'll see the rest never changes.
1. Signature verification (the wall that stops your repackage)
The most important one: after you re-sign with your key, the app sees its signing certificate changed and refuses to run. The tell in jadx is getPackageInfo(... GET_SIGNATURES) / getSigningCertificates, with the hash compared to a hardcoded constant.
📄 File: smali/com/app/security/SignatureChecker.smali
Original — the complete method as it ships (gets the cert hash, compares it to the developer's constant, returns the result):
.method public static isSignatureValid(Landroid/content/Context;)Z
.locals 2
invoke-static {p0}, Lcom/app/security/SignatureChecker;->getCertHash(Landroid/content/Context;)Ljava/lang/String;
move-result-object v0
const-string v1, "9f86d081884c7d659a2feaa0c55ad015"
invoke-virtual {v0, v1}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z
move-result v0
return v0
.end method
Modified — the exact same method, with only the two <-- ADDED lines on top. Everything from invoke-static down is unchanged:
.method public static isSignatureValid(Landroid/content/Context;)Z
.locals 2
const/4 v0, 0x1 # <-- ADDED line 1: force "valid"
return v0 # <-- ADDED line 2: return now
invoke-static {p0}, Lcom/app/security/SignatureChecker;->getCertHash(Landroid/content/Context;)Ljava/lang/String;
move-result-object v0
const-string v1, "9f86d081884c7d659a2feaa0c55ad015"
invoke-virtual {v0, v1}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z
move-result v0
return v0
.end method
If the check is obfuscated or called from many places, hook the one source method with Frida instead of chasing every call site:
Java.perform(function () {
const Sec = Java.use("com.app.security.SignatureChecker");
Sec.isSignatureValid.overload("android.content.Context").implementation = function (ctx) {
return true; // always "valid"
};
});
2. Integrity / CRC checks
A checksum of classes.dex computed at startup and compared to a number baked into the build. Change the dex → checksum changes → app bails. Your CRC question, answered: you do not delete the body — you add the same two lines at the top and keep the rest, exactly like every other example.
📄 File: smali/com/app/security/IntegrityCheck.smali
Original — the complete method (computes the dex CRC, compares it to the expected long, branches):
.method public static verifyDex(Landroid/content/Context;)Z
.locals 5
invoke-static {p0}, Lcom/app/security/IntegrityCheck;->computeDexCrc(Landroid/content/Context;)J
move-result-wide v0
const-wide v2, 0x8a3f19c2L
cmp-long v4, v0, v2
if-nez v4, :cond_tampered # not equal -> tampered
const/4 v0, 0x1
return v0
:cond_tampered
const/4 v0, 0x0
return v0
.end method
Modified — same method, two lines added on top; the entire CRC computation below is kept and simply never runs:
.method public static verifyDex(Landroid/content/Context;)Z
.locals 5
const/4 v0, 0x1 # <-- ADDED line 1: pretend the checksum matched
return v0 # <-- ADDED line 2: return now
invoke-static {p0}, Lcom/app/security/IntegrityCheck;->computeDexCrc(Landroid/content/Context;)J
move-result-wide v0
const-wide v2, 0x8a3f19c2L
cmp-long v4, v0, v2
if-nez v4, :cond_tampered
const/4 v0, 0x1
return v0
:cond_tampered
const/4 v0, 0x0
return v0
.end method
Note: If they only hash a resource you didn't modify, you may not even trip this — patch the dex, leave that file alone, and the check passes on its own.
3. Root detection (the one from the solve, library edition)
Most apps don't hand-roll it like UnCrackable; they call the RootBeer library. The patch is identical — force the result false.
📄 File: smali/com/app/security/RootCheck.smali
Original — the complete method:
.method public isDeviceRooted(Landroid/content/Context;)Z
.locals 2
new-instance v0, Lcom/scottyab/rootbeer/RootBeer;
invoke-direct {v0, p1}, Lcom/scottyab/rootbeer/RootBeer;-><init>(Landroid/content/Context;)V
invoke-virtual {v0}, Lcom/scottyab/rootbeer/RootBeer;->isRooted()Z
move-result v0
return v0
.end method
Modified — same method, two lines on top; the RootBeer calls stay below and never run:
.method public isDeviceRooted(Landroid/content/Context;)Z
.locals 2
const/4 v0, 0x0 # <-- ADDED line 1: "not rooted"
return v0 # <-- ADDED line 2: return now
new-instance v0, Lcom/scottyab/rootbeer/RootBeer;
invoke-direct {v0, p1}, Lcom/scottyab/rootbeer/RootBeer;-><init>(Landroid/content/Context;)V
invoke-virtual {v0}, Lcom/scottyab/rootbeer/RootBeer;->isRooted()Z
move-result v0
return v0
.end method
(No-patch alternative on a real device: Magisk's DenyList hides root from the app entirely.)
4. Emulator detection
Checks tell-tale Build fields or ro.kernel.qemu. Same boolean, same patch.
📄 File: smali/com/app/security/EmulatorCheck.smali
Original — the complete method (Build.FINGERPRINT.contains("generic")):
.method public static isEmulator()Z
.locals 2
sget-object v0, Landroid/os/Build;->FINGERPRINT:Ljava/lang/String;
const-string v1, "generic"
invoke-virtual {v0, v1}, Ljava/lang/String;->contains(Ljava/lang/CharSequence;)Z
move-result v0
return v0
.end method
Modified — same method, two lines on top:
.method public static isEmulator()Z
.locals 2
const/4 v0, 0x0 # <-- ADDED line 1: "not an emulator"
return v0 # <-- ADDED line 2: return now
sget-object v0, Landroid/os/Build;->FINGERPRINT:Ljava/lang/String;
const-string v1, "generic"
invoke-virtual {v0, v1}, Ljava/lang/String;->contains(Ljava/lang/CharSequence;)Z
move-result v0
return v0
.end method
(Or sidestep it completely by testing on a physical device.)
5. Debugger / Frida detection
Scans /proc/self/maps for frida, watches TCP port 27042, or calls Debug.isDebuggerConnected().
📄 File: smali/com/app/security/DebugCheck.smali
Original — the complete method (reads /proc/self/maps, returns whether it contains frida):
.method public static isFridaPresent()Z
.locals 2
invoke-static {}, Lcom/app/security/DebugCheck;->readProcMaps()Ljava/lang/String;
move-result-object v0
const-string v1, "frida"
invoke-virtual {v0, v1}, Ljava/lang/String;->contains(Ljava/lang/CharSequence;)Z
move-result v0
return v0
.end method
Modified — same method, two lines on top:
.method public static isFridaPresent()Z
.locals 2
const/4 v0, 0x0 # <-- ADDED line 1: "no Frida/debugger here"
return v0 # <-- ADDED line 2: return now
invoke-static {}, Lcom/app/security/DebugCheck;->readProcMaps()Ljava/lang/String;
move-result-object v0
const-string v1, "frida"
invoke-virtual {v0, v1}, Ljava/lang/String;->contains(Ljava/lang/CharSequence;)Z
move-result v0
return v0
.end method
For Frida specifically you can also dodge instead of patch: rename the gadget, use a non-default port, or inject frida-gadget. OWASP UnCrackable-Level3 is the practice target — it stacks anti-tamper and Frida detection on top of root, so you patch the integrity check and evade the Frida scan.
When you can't just patch a boolean
The three above all reduced to "force 0x0 or 0x1." These don't — recognize them so you don't waste time:
| Defense | Why a boolean patch won't do it | What actually works |
|---|---|---|
| Obfuscation (ProGuard/R8/DexGuard) | Names become a.a.a() — annoying, but opcodes are unchanged, so patching still works; reading is the hard part | Find methods by string searches (URLs, library names like okhttp3), jadx cross-refs, and signatures (a ()Z right before an exit). DexGuard string-encryption/flattening → lean on Frida |
Native (.so) checks | The check is in compiled C/C++, not smali — there's no boolean in the dex to flip | Reverse the .so in Ghidra/IDA, or hook the native function with Frida's Interceptor.attach |
| Packers / runtime DEX | The real classes.dex is encrypted and only decrypted in memory at launch; apktool d shows a stub | Dump the decrypted dex from memory with frida-dexdump, then analyze that |
| Play Integrity / SafetyNet | The verdict is signed by Google and checked on the vendor's server — you can't forge it client-side | Nothing client-side; if the server enforces it properly, this control holds (and that's the correct design to note in your report) |
The throughline: static tampering wins against client-side, Java-layer checks — and almost all of them collapse to the same two-line boolean patch. It loses against server-attested controls and (without extra tooling) against native and packed code. Knowing which wall you're at is half the skill.
Tamper vs. hook: when to use which
You saw both in the solve — smali for root, your choice of smali-or-Frida for the secret. Quick decision table:
| Situation | Reach for |
|---|---|
| Prove "a modified client bypasses this control" for a report | Static tamper — hand them a working APK |
| One boolean to flip, clean Java-layer app | Static tamper — fastest, persistent |
| Check is obfuscated / duplicated across many call sites | Frida hook the single source method |
| Check is in native code | Frida Interceptor (smali can't reach it) |
| You want to call an internal method or dump a decrypted value | Frida / objection |
| Rapid experimentation, many tweaks per minute | Frida — no rebuild loop |
| App has aggressive anti-tamper but no Frida detection | Tamper (re-sign), patching the integrity check |
| App has Frida detection but weak anti-tamper | Tamper — avoids the Frida scan entirely |
They compose. A common real flow: statically patch the integrity/signature check so a repackaged build runs, embed a Frida gadget for the parts you want to poke at runtime, and you've got a self-contained PoC that needs no rooted device at all.
Turning a tamper into a real bug bounty report
This is where most beginners lose the bounty. "I decompiled the app and unlocked premium" gets closed as Informative — modified client, not a vulnerability unless you frame the server-side trust failure. The triager's mental model: the user controls their device, so client-side changes are expected; the question is whether your control did something the server should have prevented.
The test for "is this actually a bug":
- Did tampering cause the server to do something it shouldn't (return paid data, accept a manipulated price, leak another user's records)? → Real vulnerability, often high severity.
- Did tampering only change local UI/behavior with no server impact? → Usually low/informative, unless the unlocked thing has standalone value (a hidden admin screen that talks to the backend, an exposed secret).
- Did tampering reveal a hardcoded secret (API key, signing key, third-party token)? → Real finding on its own — the leak doesn't need server cooperation. (Our
I want to believeis a toy; a real AES key or API token recovered the same way is a report.) - Did removing pinning let you find a server bug (IDOR/auth/secret)? → Report the server bug; pinning removal is just methodology.
Severity, roughly:
| Scenario | Likely severity |
|---|---|
| Client-supplied price/amount accepted by server | Critical / High |
| Hardcoded backend secret enabling account/data access | High |
| Premium entitlement not re-checked server-side | High / Medium |
| IDOR or broken auth found after un-pinning | per the bug (often High) |
| Root/emulator/debugger detection bypass, no further impact | Low / Informative |
| Missing anti-tamper / re-signable app, no further impact | Low / Informative |
Write the report around impact, not technique. Lead with what an attacker gains, show the tamper as the reproduction step, and prove the server angle with the actual request/response. A clean before/after — original app blocks you, patched app + server response grants you — is the most persuasive artifact you can attach.
Remediation (for your report)
Programs reward reports that tell them how to fix it. The honest answer for every client-side bypass is the same root cause and the same cure:
- Enforce every security decision server-side. Entitlements, prices, quantities, KYC/verification status, role checks — the server must re-validate, never trust a client claim. This is the one fix that actually matters.
- Treat the client as untrusted. Assume the APK will be decompiled and every check bypassed. Client-side root/tamper detection is a speed bump for telemetry, not a control for enforcement.
- Use attestation properly. Play Integrity / hardware-backed attestation, with the verdict validated on the server — never read as a client boolean. This is the one control you can't smali around, because the verdict is signed by Google and checked server-side.
- Don't ship secrets in the app. No API keys, signing keys, or HMAC secrets in the bytecode, resources, or
.so. Anything in the APK is public — as theLog.dinjection above demonstrates, it takes seconds to read. Move secrets to the backend; scope and rotate the unavoidable ones. - Defense in depth for the controls you do keep. Native checks, integrity verification tied to server-issued nonces, and obfuscation raise the cost — but make clear in the report they're cost, not a substitute for server enforcement.
Report template
Title: [Component] — [Impact] via client-side control bypass (APK tampering)
Severity: [Critical/High/Medium] — justified by server-side impact
Summary:
The Android app enforces [entitlement/price/auth/integrity] on the client.
By repackaging the APK and patching [method], an attacker [gains X]. The
backend does not re-validate [Y], so the change is accepted server-side.
Affected:
- Package: com.example.app (versionCode N, from Play Store)
- Endpoint(s): POST /api/v1/... (if server-side impact)
Steps to reproduce:
1. apktool d app.apk -o src
2. Patch <file>:<method> — <const/4 v0, 0x1 ; return v0> (screenshot)
3. apktool b src ; zipalign ; apksigner sign ; adb install
4. Launch app, perform [action]
5. Observe server response granting [X] (attach request/response)
Impact:
[What an attacker gains — money, data, access. Tie to real users.]
Proof of concept:
- Original app: [blocked / 403 / feature locked] (screenshot)
- Patched app + server response: [granted] (request + response)
- Diff of the smali change (before/after)
Remediation:
Enforce [decision] server-side; do not trust the client. [Specifics.]
Every command explained
If any command above was unfamiliar, here's the plain-English version of each one — what it is, what it does, and why we run it in this workflow. Skim the group you need.
adb — Android Debug Bridge (your cable to the device)
| Command | What it does | Why we use it |
|---|---|---|
adb devices | Lists connected devices/emulators | Confirm the phone/emulator is actually attached before anything else |
adb install app.apk | Copies an APK to the device and installs it | Get the target (or our patched build) onto the device |
adb uninstall <pkg> | Removes an installed app by package id | Android won't replace an app signed with a different key, so we delete the original before installing our re-signed one |
adb shell pm list packages | Asks the device's Package Manager for installed app ids | Find the exact package name (e.g. owasp.mstg.uncrackable1) |
adb shell monkey -p <pkg> -c android.intent.category.LAUNCHER 1 | The monkey event-generator, told to fire just one event into the app's launcher activity | Start the app from the terminal using only its package name (no need to know the activity) |
adb shell am start -n <pkg>/<activity> | am (Activity Manager) starts a specific activity by name | The precise "just launch" command when you do know the activity name |
adb logcat | Streams the device's live system log | Watch what the app does — crashes, our injected log lines, lifecycle events |
adb logcat -c | Clears the log buffer | Start from a clean slate so old noise doesn't confuse you |
adb logcat -s CYWARX | Shows only lines tagged CYWARX (-s = silence the rest) | Filter straight to the secret our injected Log.d prints |
apktool — decode and rebuild the APK
| Command | What it does | Why we use it |
|---|---|---|
apktool d app.apk -o src | Decodes the APK: classes.dex → editable smali, binary manifest → readable XML, into folder src | You can't hand-edit .dex; smali is the editable form |
apktool b src -o patched.apk | Builds the edited src folder back into an APK | Turn your smali changes back into an installable app |
apktool b src --use-aapt2 | Same, using the newer resource compiler | Fallback if a resource refuses to rebuild |
Note:
apktool bstrips theMETA-INF/signature folder, so the rebuilt APK is unsigned — that's why the next group (zipalign + sign) always follows it.
Build-tools — align and sign (from the Android SDK)
| Command | What it does | Why we use it |
|---|---|---|
keytool -genkey ... -keystore poc.keystore | Creates a self-signed RSA key in a keystore file (JDK tool) | You need a signing key; a throwaway one is fine for a PoC |
zipalign -p -f 4 in.apk out.apk | Aligns the ZIP entries to 4-byte boundaries (-f overwrite, -p for .so libs) | Required before apksigner, and it lets Android memory-map resources efficiently |
apksigner sign --ks poc.keystore --out signed.apk aligned.apk | Signs the APK with your key using the v1/v2/v3 schemes | An unsigned APK can't be installed; this makes it installable |
apksigner verify -v --print-certs signed.apk | Checks the signature and prints which schemes pass + your cert details | Sanity check that signing worked before you push it to the device |
Note: Order matters —
zipalignfirst, thenapksigner. Aligning after signing would break the v2/v3 signature.
Inspection / proof tools
| Command | What it does | Why we use it |
|---|---|---|
jadx-gui app.apk | Decompiles the app to readable Java in a clickable UI | Understand the logic and find the method to patch (you read Java, then edit smali) |
unzip -l app.apk | Lists the files inside the APK | See its parts (classes.dex, manifest, lib/, assets/) — an APK is just a ZIP |
file app.apk | Identifies a file's type | Confirm the download is a real APK, not an error page |
sha256sum app.apk | Prints the file's SHA-256 hash | Pin the exact build you tested so results are reproducible |
base64 -d | Decodes Base64 to raw bytes | The app stores its ciphertext as Base64; we decode it to feed the decryptor |
openssl enc -d -aes-128-ecb -K <hexkey> | Decrypts AES-128-ECB data with a given key | Recover the secret offline, proving it without even launching the app |
Frida / objection — runtime hooking (the no-repackage path)
| Command | What it does | Why we use it |
|---|---|---|
frida-ps -Uai | Lists apps on the USB device (-U USB, -a apps, -i installed) | Find the target's id/process for hooking |
frida -U -f <pkg> -l hook.js | Spawns the app (-f) and injects your script (-l) over USB (-U) | Change behavior in memory at runtime — no rebuild or re-sign needed |
objection -g <pkg> explore | Opens an interactive runtime session for the app | Use built-in one-liners like android root disable / android sslpinning disable |
Flag quick-reference (the short options you'll see most): -o = output path, -f = force/overwrite (or "spawn file" in Frida), -U = USB device, -l = load script, -c = clear (logcat) or category (monkey), -s = silence-all-but-this-tag (logcat), -d/-b = decode/build (apktool), --ks = keystore.
Cheat sheet
The loop:
apktool d app.apk -o src
# ...edit smali / manifest / res...
apktool b src -o patched.apk
zipalign -p -f 4 patched.apk aligned.apk
apksigner sign --ks poc.keystore --ks-pass pass:cywarx --out signed.apk aligned.apk
adb uninstall <pkg> && adb install signed.apk
Smali patches you'll reuse:
# Force a boolean method FALSE (root/emulator/tamper detector)
const/4 v0, 0x0
return v0
# Force a boolean method TRUE (premium/license/integrity)
const/4 v0, 0x1
return v0
# Make a void check a no-op (SSL pinner / TrustManager)
return-void
# Invert a gate
if-nez v0, :label -> if-eqz v0, :label
# Bump a constant (free-trial count, score, etc.)
const/16 v0, 0x3 -> const/16 v0, 0x270f
# Leak a decrypted value/key/token to logcat at the moment it exists
new-instance v1, Ljava/lang/String;
invoke-direct {v1, v0}, Ljava/lang/String;-><init>([B)V
const-string v2, "CYWARX"
invoke-static {v2, v1}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
Find the check (jadx search terms):
root: su, isRooted, RootBeer, test-keys, Superuser, magisk
emulator: isEmulator, ro.kernel.qemu, Build.FINGERPRINT, generic
debugger: isDebuggerConnected, TracerPid, debuggable
frida: frida, 27042, /proc/self/maps, gum-js-loop
entitle: isPremium, isPro, subscribed, entitlement, license, verify
pinning: CertificatePinner, checkServerTrusted, X509TrustManager, sslSocketFactory
integrity: getSignatures, GET_SIGNING_CERTIFICATES, CRC32, MessageDigest, verifyIntegrity
secrets: apiKey, secret, password, BEGIN PRIVATE KEY, Bearer, https://
adb + Frida one-liners:
adb logcat -c # clear the log buffer
adb logcat -s CYWARX # show only your injected tag
adb shell monkey -p <pkg> -c android.intent.category.LAUNCHER 1 # launch app
frida-ps -Uai # list installed apps
frida -U -f <pkg> -l hook.js # spawn + load script
objection -g <pkg> explore # then: android root disable / sslpinning disable
Frequently asked questions
Is APK tampering or decompiling an app illegal?
Only when you're not authorized. It's legal and expected on apps whose bug bounty program lists the mobile app in scope, on your own builds, and on deliberately vulnerable training apps like the one in this guide. What crosses the line is testing an app you have no permission for, or distributing a modified APK. Unsure? Look for a "reverse engineering permitted" clause in the program's policy.
Do I need a rooted phone to tamper an APK?
No. Static tampering installs your own re-signed build, which runs on any device — no root required. You only need root (or a repackaged Frida gadget) for runtime hooking with Frida. An emulator running a Google APIs image (not the Play image) is the easiest rooted lab.
What's the difference between apktool and jadx — do I need both?
Yes, they do opposite jobs. jadx decompiles to readable Java so you can understand the app and find the right method — but you can't reliably rebuild from it. apktool decodes to smali, which is less pretty but rebuildable. So you read in jadx and patch in apktool's smali.
When should I patch the smali vs. hook with Frida?
Patch (static) when you want a permanent, installable proof for a report, or for a clean one-boolean flip. Hook (Frida) for fast experimentation, obfuscated or duplicated checks, native code, or to call internal methods and dump values. Most real work uses both — see Tamper vs. hook.
My patched app won't install or instantly crashes — why?
Three usual causes: you didn't re-sign (Android rejects unsigned APKs), the original is still installed with a different key (adb uninstall first), or your smali is malformed (adb logcat *:E shows the VerifyError). The full table of errors and fixes is in When the rebuild breaks.
Can I bypass Google Play Integrity (SafetyNet) by patching the APK?
No — and that's by design. The verdict is produced and signed by Google Play services and verified on the app's server, so you can't forge it on the device. Patching the client call doesn't help if the backend checks it properly. If an app gates a feature on a client-read integrity boolean you win; if it validates a signed token server-side, that's correct design.
Is "I modified the app to unlock premium" a valid bug bounty finding?
Only if the server honored the change — served paid data, accepted a tampered price, leaked another user's records. A purely local UI unlock with no server impact is usually closed as informative. Always follow the action to the network and prove the server-side trust failure. See Turning a tamper into a real report.
Keep learning
- Related guides on this site — Mobile App Pentesting Lab Setup · Android App Bug Bounty Guide · iOS App Bug Bounty Guide.
- OWASP MASTG — the Tampering & Reverse Engineering chapter and the UnCrackable crackmes (do Level 2 and 3 next — native code, anti-tamper, and Frida detection).
- OWASP MASVS — the RESILIENCE requirements are exactly the controls you're testing; map findings to them in reports.
- More practice targets — DIVA, InsecureBankv2, InsecureShop.
- Tools — Apktool, jadx, Frida, objection.
The whole discipline reduces to one sentence: anything decided on the client is something you can change, so anything that must be true has to be enforced on the server. Code tampering is how you prove an app forgot that — and the proof, framed around server-side impact, is the report that pays.
Authorized testing only. Practise on the deliberately vulnerable apps above, test real targets only within a program's scope, and never distribute modified APKs.
Related articles
Comments(0)
Sign in to join the conversation.
- Be the first to comment.
