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 debugger.
your
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
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 logcat and 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.app is 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:
bash
# --- Decompile / inspect ---# apktool — decodes resources + DEX to smali, and rebuildssudo 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:
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 keytool versions warn that S= should be ST= (State). It still works — use ST=poc if 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.
bash
# 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
Note:apktool ddecodesclasses.dex into human-editable smali and turns the binary manifest back into readable XML. That's the whole point — you can't edit a .dex by 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:
smali
.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:
smali
.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 v0 it was about to return) rather than inventing v9. If you ever do need a fresh register, raise the .locals count by one. A too-high register number is the usual reason apktool b fails.
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.
bash
# 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 b failing with brut.androlib resource errors? Decode with plain apktool 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.
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, .locals counts, the p0 return 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:
What is monkey, and what does that line mean?monkey is 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 of 1 and 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 shell
Run the command on the device, not your PC
monkey
The built-in event-generator tool
-p owasp.mstg.uncrackable1
Package filter — only touch this app, nothing else
-c android.intent.category.LAUNCHER
Category filter — only the app's launcher (home) activity
1
Generate exactly one event → just launch, then stop
Increase that 1 to, say, 500 and monkey starts hammering random input — useful for fuzzing, not what we want. The cleaner "just launch" alternative, if you know the activity, is adb 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:
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
bash
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:
java
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):
smali
.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):
smali
.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.
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:
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:
Note: The register that holds the plaintext (p0 here) and the .locals count 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 b fails. This injection pattern — "wrap the bytes in a String, Log.d it, 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:
bash
adb logcat -c
adb logcat -s CYWARX # -s filters to just our tag
text
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:
text
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.
$ 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):
smali
.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:
smali
.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:
xml
<!-- The login screen — the intended entry point --><activityandroid:name=".LoginActivity"android:exported="true"><intent-filter><actionandroid:name="android.intent.action.MAIN" /><categoryandroid:name="android.intent.category.LAUNCHER" /></intent-filter></activity><!-- The dashboard — exported, and with no auth check inside = the bug --><activityandroid:name=".DashboardActivity"android:exported="true" />
adb can list them for you too:
bash
# 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 set exported explicitly, so you'll see it spelled out. A truly internal screen should be exported="false".
Step 2 — launch the protected screen directly
Use the Activity Manager (am) to start the dashboard by name, with no login:
bash
adb shell am start -n 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:
bash
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 watch Intent extras / deep links: if a screen reads getIntent().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 and finish() — 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 Intent extras 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
<?xml version="1.0" encoding="utf-8"?><network-security-config><base-configcleartextTrafficPermitted="true"><trust-anchors><certificatessrc="system" /><certificatessrc="user" /><!-- now Burp's CA is trusted --></trust-anchors></base-config></network-security-config>
Then point the manifest at it. Before (decoded AndroidManifest.xml):
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:
smali
.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:
smali
.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, or objection -g com.app explore then android 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):
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:
<applicationandroid:allowBackup="true" <!--enables:adbbackup-fdata.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, and run-as then 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):
bash
# 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: zipalignthenapksigner 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 into smali/, smali_classes2/, smali_classes3/. If grep finds your method in smali_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 your return fires 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 ()Z that 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.
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.
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.
.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
FridaInterceptor (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 believe is 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.
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 the Log.d injection 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
text
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 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.
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.