Skip to content
All articlesMobile Pentesting

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

Cywarx 19 Jul 2026 63 min read
Share
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 tamperingThe real weakness (OWASP Mobile)Typical impact
Flip isPremium() to true, paid features unlockM3: Insecure Authentication/Authorization — entitlement enforced only on the clientPayment bypass, revenue loss
Patch out root/emulator detection, app runs anywayM8: Security Misconfiguration / weak anti-tamperDefeats the control; enables further testing
Remove SSL pinning, traffic becomes readableEnables you to find the server-side bugsIDOR, broken auth, secret leakage in the API
Hardcoded API key / secret in the bytecodeM10: Insufficient Cryptography / hardcoded secretsAccount takeover, backend abuse
Change price=499 to price=1 and the server accepts itServer trusts a client-supplied amountDirect 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.

TermPlain meaning
APKThe Android app file you install — technically just a ZIP archive
DEXclasses.dex — the app's compiled code, packed inside the APK
SmaliThe human-readable form of DEX bytecode — this is what you edit
apktoolUnpacks an APK into smali and repacks it back
jadxDecompiles the app into readable Java (you read here, patch in smali)
FridaTool 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
IDORInsecure Direct Object Reference — change an ID, see another user's data
Root / MagiskFull admin control of an Android device; Magisk is the usual rooting tool
Emulator / AVDA virtual Android phone on your PC (Android Virtual Device)
Triage / triagerThe person who reviews your bug report and decides severity and payout
  • 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 changeThe APK on disk — its bytecode, resources, manifestThe app's memory at runtime
PersistencePermanent; the patched APK behaves that way foreverLasts only while your script is attached
Needs root?No — you install your own signed buildUsually root or a repackaged Frida gadget
Survives reboot?YesNo (re-attach each run)
Best forProving "the control is bypassable by a modified client"Fast experimentation, calling internal methods, dumping data
The catchYou must re-sign, and beat any anti-tamper/signature checkApp 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 needTool we useIts job
A decompilerapktool + jadxTurn the APK back into editable code (smali) and readable Java
A rebuilderapktoolPut your edited code back into an APK
A signerkeytool + zipalign + apksignerMake a key, then re-sign the rebuilt APK so it installs
A deviceemulator or rooted phone + adbRun and observe the app
A targetOWASP UnCrackable-Level1A 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:

FlagPlain meaning
-genkeyCreate a new key (the action)
-vVerbose — show what it's doing
-keystore poc.keystoreThe file to save the key in (you point apksigner at this later)
-alias pocA nickname for the key inside the file
-keyalg RSA -keysize 2048Key type and strength — the standard Android accepts
-validity 10000Valid for 10,000 days (~27 years) so it never "expires"
-storepass / -keypassPasswords 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.

# 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:

EntryWhat it isWhy you care
classes.dex, classes2.dexCompiled Dalvik bytecode — all the app's logicThis is what becomes smali when you decode; where you patch logic
AndroidManifest.xmlBinary-encoded app configandroid:debuggable, networkSecurityConfig, exported components
resources.arscCompiled resources (strings, layouts)Feature flags, hardcoded strings, config references
res/Layouts, drawables, raw certsPinned certs sometimes live in res/raw/
lib/<abi>/*.soNative code (NDK)Some checks (root, pinning) hide here — harder, needs Frida/Ghidra
assets/Bundled filesJS bundles (React Native), Flutter libapp.so, config blobs
META-INF/The signatureapktool b strips this; you re-sign it yourself

Note: apktool d decodes classes.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:

.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:

SmaliMeaningThe patch you'll make
const/4 v0, 0x0put 0 (false) into v0change 0x00x1 to force true
const/4 v0, 0x1put 1 (true) into v0change 0x10x0 to force false
return v0return the value in v0force v0 first, then return
if-eqz v0, :labeljump if v0 == 0change to if-nez to invert
if-nez v0, :labeljump if v0 != 0change to if-eqz to invert
invoke-virtual {…}, …->isRooted()Zcall a methodfollow with move-result v0, then overwrite v0
move-result v0grab the returned valueoverwrite 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:

QuestionAnswer
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, :labelif-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.

# 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.

CategoryWhat it looks like in codeThe tamper
Boolean security gatesisRooted(), isEmulator(), isDebuggerConnected(), isFridaPresent()Force the method to return 0x0
Entitlement checksisPremium(), hasSubscription(), isLicensed(), verify()Force return 0x1
Conditional jumpsif-eqz/if-nez gating a feature or an error screenInvert the condition or NOP the branch
Constants & limitsfree-trial count, max retries, price, score thresholdsChange the literal (const/16 v0, 0x30x270f)
String comparisonshardcoded PIN/password/secret in equals()Read the constant directly, or force the compare result
Hardcoded secretsAPI keys, HMAC keys, encryption keys as string literalsExtract them (no patch needed — it's already a leak)
SSL pinningOkHttp CertificatePinner, TrustManager, checkServerTrustedRemove the pinning class / inject a permissive network_security_config
Manifest flagsandroid:debuggable, allowBackup, usesCleartextTraffic, exportedFlip to true to open the app up for analysis
Integrity / anti-tamperself-signature check, DEX CRC compare, PackageManager.getSignaturesPatch the verifier to always pass
Feature flagsBuildConfig.DEBUG, enableInternalMenu, featureX = falseFlip 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.

StepWhat we doThe point
0Install the app, watch it detect root and quitSee the defense work
1apktool d + jadx — find the three root-check methodsLocate the decision
2Edit the smali so each check returns "false"Disarm the defense
3apktool bzipalignapksigneradb installRebuild and run our version
4Inject a log line / use Frida to print the decrypted secretRead a "protected" value
5Force the verify method to always passBypass 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:

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? 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:

PieceMeaning
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 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:

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.localsWhat it checks
a()7Looks for a su binary in every directory on $PATH
b()2Checks Build.TAGS for test-keys (a custom-ROM tell)
c()7Looks 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.

Originaluncrackable1/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, :condif-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 (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:

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.

LabelWhere it maps
OWASP Mobile Top 10M3: Insecure Authentication/Authorization (and M8: Security Misconfiguration for the export)
OWASP MASVSMASVS-AUTH (auth enforced per-screen) and MASVS-PLATFORM (component exposure)
CWECWE-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 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:

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 launchReal impactSeverity
Dashboard loads real data (the API served it with no valid token)True auth bypass — anyone can read another context's dataHigh / Critical
Screen opens but data calls return 401/403 (server enforces auth)UI shell only; cosmeticLow / Informative
You can pass Intent extras like is_admin=true and the screen trusts themPrivilege escalation via untrusted inputHigh
Exported screen performs an action (transfer, delete) without re-authUnauthorized actionHigh

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 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.

Originalokhttp3/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, 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):

    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:

EditWhat 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):

# 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 seeCauseFix
INSTALL_FAILED_UPDATE_INCOMPATIBLEThe original app is installed, signed with a different keyadb uninstall <pkg> first, then install your build
INSTALL_FAILED_VERSION_DOWNGRADEYour rebuild's versionCode looks olderadb install -r -d your.apk (-d allows downgrade)
INSTALL_FAILED_INVALID_APK / "not signed"You forgot to sign, or zipaligned after signingRe-run: zipalign then apksigner sign
apktool bbrut.androlib ... AndrolibException (resources)A resource won't reassembleRebuild with apktool b src --use-aapt2
Unsigned short value out of range / method too largeA method exceeds Dalvik limits after editingYou added too much — keep injections tiny; move logic to a new method
Invalid register / verify error at runtimeYou used a register the method never declaredReuse an existing register, or raise the .locals count
App installs but instantly crashesYour smali is malformed or you broke a try/catch rangeadb logcat *:E — the VerifyError/exception names the class; recheck that file
Patched code does nothingThe real check is in another classes2.dex / classes3.dex, or in native .soSearch 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.

📄 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:

DefenseWhy a boolean patch won't do itWhat actually works
Obfuscation (ProGuard/R8/DexGuard)Names become a.a.a() — annoying, but opcodes are unchanged, so patching still works; reading is the hard partFind 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) checksThe check is in compiled C/C++, not smali — there's no boolean in the dex to flipReverse the .so in Ghidra/IDA, or hook the native function with Frida's Interceptor.attach
Packers / runtime DEXThe real classes.dex is encrypted and only decrypted in memory at launch; apktool d shows a stubDump the decrypted dex from memory with frida-dexdump, then analyze that
Play Integrity / SafetyNetThe verdict is signed by Google and checked on the vendor's server — you can't forge it client-sideNothing 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:

SituationReach for
Prove "a modified client bypasses this control" for a reportStatic tamper — hand them a working APK
One boolean to flip, clean Java-layer appStatic tamper — fastest, persistent
Check is obfuscated / duplicated across many call sitesFrida hook the single source method
Check is in native codeFrida Interceptor (smali can't reach it)
You want to call an internal method or dump a decrypted valueFrida / objection
Rapid experimentation, many tweaks per minuteFrida — no rebuild loop
App has aggressive anti-tamper but no Frida detectionTamper (re-sign), patching the integrity check
App has Frida detection but weak anti-tamperTamper — 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.

Severity, roughly:

ScenarioLikely severity
Client-supplied price/amount accepted by serverCritical / High
Hardcoded backend secret enabling account/data accessHigh
Premium entitlement not re-checked server-sideHigh / Medium
IDOR or broken auth found after un-pinningper the bug (often High)
Root/emulator/debugger detection bypass, no further impactLow / Informative
Missing anti-tamper / re-signable app, no further impactLow / 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

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)

CommandWhat it doesWhy we use it
adb devicesLists connected devices/emulatorsConfirm the phone/emulator is actually attached before anything else
adb install app.apkCopies an APK to the device and installs itGet the target (or our patched build) onto the device
adb uninstall <pkg>Removes an installed app by package idAndroid 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 packagesAsks the device's Package Manager for installed app idsFind the exact package name (e.g. owasp.mstg.uncrackable1)
adb shell monkey -p <pkg> -c android.intent.category.LAUNCHER 1The monkey event-generator, told to fire just one event into the app's launcher activityStart 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 nameThe precise "just launch" command when you do know the activity name
adb logcatStreams the device's live system logWatch what the app does — crashes, our injected log lines, lifecycle events
adb logcat -cClears the log bufferStart from a clean slate so old noise doesn't confuse you
adb logcat -s CYWARXShows only lines tagged CYWARX (-s = silence the rest)Filter straight to the secret our injected Log.d prints

apktool — decode and rebuild the APK

CommandWhat it doesWhy we use it
apktool d app.apk -o srcDecodes the APK: classes.dex → editable smali, binary manifest → readable XML, into folder srcYou can't hand-edit .dex; smali is the editable form
apktool b src -o patched.apkBuilds the edited src folder back into an APKTurn your smali changes back into an installable app
apktool b src --use-aapt2Same, using the newer resource compilerFallback if a resource refuses to rebuild

Note: apktool b strips the META-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)

CommandWhat it doesWhy we use it
keytool -genkey ... -keystore poc.keystoreCreates 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.apkAligns 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.apkSigns the APK with your key using the v1/v2/v3 schemesAn unsigned APK can't be installed; this makes it installable
apksigner verify -v --print-certs signed.apkChecks the signature and prints which schemes pass + your cert detailsSanity check that signing worked before you push it to the device

Note: Order matters — zipalign first, then apksigner. Aligning after signing would break the v2/v3 signature.

Inspection / proof tools

CommandWhat it doesWhy we use it
jadx-gui app.apkDecompiles the app to readable Java in a clickable UIUnderstand the logic and find the method to patch (you read Java, then edit smali)
unzip -l app.apkLists the files inside the APKSee its parts (classes.dex, manifest, lib/, assets/) — an APK is just a ZIP
file app.apkIdentifies a file's typeConfirm the download is a real APK, not an error page
sha256sum app.apkPrints the file's SHA-256 hashPin the exact build you tested so results are reproducible
base64 -dDecodes Base64 to raw bytesThe 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 keyRecover the secret offline, proving it without even launching the app

Frida / objection — runtime hooking (the no-repackage path)

CommandWhat it doesWhy we use it
frida-ps -UaiLists 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.jsSpawns 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> exploreOpens an interactive runtime session for the appUse 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

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.