There is a very good way to learn Android security: take a small intentionally vulnerable application and stop pretending that security is magic.

Look at the APK. Look at the manifest. Look at the code. Look at the logs. Trigger the app through adb. Check what is stored locally. Then connect every finding to a real-world mistake developers can make.

That is the point of this article.

Today we are looking at VulnBankLab, an intentionally vulnerable Android training app built with Jetpack Compose.

Project link:

The app imitates a small fake banking workflow: login, dashboard, transactions, transfer, settings, and a security information screen. It is not supposed to be secure. It is a lab. The whole idea is to make unsafe Android patterns visible and easy to explain.

Educational note: only test apps you own, apps designed for training, or apps where you have explicit permission. This walkthrough is based on a deliberately vulnerable lab project.


What We Are Going To Analyze

The repository already documents the intended weak points. The app contains unsafe defaults on purpose:

AreaWhat is intentionally vulnerableWhy it is useful for training
ManifestActivities are exported, app is debuggable, deep link is exposedTeaches Android attack surface review
AuthenticationDefault credentials are hardcodedShows how secrets can be recovered from APKs
Local storageUsername, password and token are stored in SharedPreferencesDemonstrates insecure local persistence
LoggingCredentials, tokens and transfer data are written to LogcatShows why debug logging matters
Deep linksTransfer screen is reachable via vuln://transferDemonstrates auth bypass through external entry points
ValidationTransfer form accepts weak input pathsShows why UI validation and backend validation both matter
Root / emulator checksDetection is intentionally weakShows why client-side checks are not a security boundary

Suggested Toolset

You do not need an enormous lab to start. One emulator or test phone, Android Studio, and a few APK analysis tools are enough.

ToolLinkUse in this walkthrough
Android Studiodeveloper.android.com/studioBuild, install, run the app, inspect Logcat
ADBIncluded with Android SDK Platform ToolsLaunch activities, trigger deep links, inspect app data
JADXgithub.com/skylot/jadxDecompile APK to Java-like source, inspect strings, manifest and code
Apktoolibotpeaches.github.io/ApktoolDecode resources, manifest and Smali; rebuild APKs when needed
PulseAPK Coregithub.com/deemoun/PulseAPK-CoreGUI workflow for APK decompilation, Smali analysis, building and patching
PulseAPKgithub.com/deemoun/PulseAPKWindows GUI frontend for apktool, analysis and APK rebuild/sign workflow
MobSFgithub.com/MobSF/Mobile-Security-Framework-MobSFAutomated static and dynamic mobile security assessment
Fridafrida.reRuntime instrumentation and method hooking
Objectiongithub.com/sensepost/objectionRuntime mobile exploration powered by Frida

For this particular application, I would start with the simplest flow:

  1. Build or download the APK.
  2. Open it in JADX or decompile it with Apktool / PulseAPK Core.
  3. Review AndroidManifest.xml.
  4. Search for credentials, tokens and API URLs.
  5. Run the app and watch Logcat.
  6. Trigger the exported screens with adb.
  7. Inspect local storage after login.
  8. Discuss fixes.

Project Overview

The app package is:

com.training.vulnerablebank

The repository describes the app as a deliberately vulnerable Android training app built with Jetpack Compose. The current screens include:

ScreenPurposeSecurity angle
LoginActivityLogin form with pre-filled credentialsHardcoded credentials, logging, weak session assumptions
DashboardActivityFake balance and navigationSession validation assumptions
TransactionsActivityHardcoded/sample transaction listLocal data handling discussion
TransferActivityTransfer form and deep link targetAuth bypass, input validation, logging
SecurityInfoActivityLists embedded issuesGood for explaining the lab intentionally
SettingsActivitySettings screenAlso exported, so part of attack surface
HiddenFeatureActivityHidden feature screenGood for direct activity launch demo

The important part: this app is deliberately built as a learning target, not a production template.


Finding 1: Debuggable Build

The manifest contains:

<application
    android:debuggable="true"
    ...>

For a lab, this is useful. For production, it is a serious mistake.

A debuggable Android application is easier to inspect and manipulate. It can make runtime analysis simpler and can also make it easier to use tools that attach to the app process.

CheckExpected result in this app
Open AndroidManifest.xmlandroid:debuggable="true" is present
Install and run debug buildApp behaves like an intentionally inspectable lab target
Discuss production fixMake sure release builds are not debuggable

How to fix in a real app

In production:

android:debuggable="false"

Better: do not manually set it in the manifest at all unless you have a very clear reason. Let Gradle build types control it.

Also make sure CI/CD never signs and distributes a debug build as a release build.


Finding 2: All Activities Are Exported

The manifest declares multiple activities with:

android:exported="true"

That includes LoginActivity, DashboardActivity, TransactionsActivity, TransferActivity, SecurityInfoActivity, SettingsActivity, and HiddenFeatureActivity.

Exported activities can be launched by other apps or through ADB. Some activities must be exported, for example the launcher activity or legitimate external entry points. But exporting every internal screen is usually a bad sign.

ActivityExported?Why it matters
LoginActivityYesExpected for launcher, but still should be controlled
DashboardActivityYesInternal screen can be opened directly
TransactionsActivityYesInternal data screen can be opened directly
TransferActivityYesTransfer flow becomes externally reachable
SecurityInfoActivityYesInternal security info screen is externally reachable
SettingsActivityYesSettings surface is externally reachable
HiddenFeatureActivityYesHidden screen can be discovered and opened

ADB activity launch examples

Use only in your own lab environment:

adb shell am start -n com.training.vulnerablebank/.DashboardActivity
adb shell am start -n com.training.vulnerablebank/.TransactionsActivity
adb shell am start -n com.training.vulnerablebank/.TransferActivity
adb shell am start -n com.training.vulnerablebank/.SecurityInfoActivity
adb shell am start -n com.training.vulnerablebank/.HiddenFeatureActivity

The key question is not only “does it open?”

The real question is:

Does the screen enforce its own authorization check when opened directly?

If the screen assumes the user came from the correct previous screen, that is fragile. Attackers and testers do not have to follow your intended UI path.

How to fix in a real app

ProblemBetter approach
Internal activities exportedSet android:exported="false" unless external access is required
Sensitive screens rely on navigation flowEnforce session checks inside each sensitive screen
Deep links open privileged flowsValidate authentication and intent parameters before showing sensitive UI
Hidden screens rely on obscurityRemove them or protect them properly

The transfer screen has an intent filter for:

vuln://transfer

That means the transfer activity can be opened through a deep link.

Command:

adb shell am start -W -a android.intent.action.VIEW -d "vuln://transfer"

This is one of the best demo points in the app because it is visual and easy to understand. You can show the user flow like this:

  1. Start from a clean app state.
  2. Do not log in.
  3. Trigger the deep link from ADB.
  4. Watch the transfer screen open.
  5. Explain why sensitive screens must validate the session themselves.
TestExpected lab behavior
Trigger vuln://transfer without loginTransfer screen opens
Check LogcatApp logs that transfer activity opened without auth checks
Try submitting dataTransfer flow attempts to process input

Why this matters

Deep links are not just navigation shortcuts. They are external entry points.

If an app exposes deep links, each deep link must be treated like an API endpoint:

  • Is the user authenticated?
  • Is the user allowed to perform this action?
  • Are input parameters validated?
  • Can another app trigger the same flow?
  • Is the destination screen safe to open directly?

How to fix in a real app

For a real banking-style app, the transfer screen should do something like this:

if (!sessionManager.isAuthenticated()) {
    startActivity(Intent(this, LoginActivity::class.java))
    finish()
    return
}

But do not stop there. Client-side session checks are a UX and local safety layer. Real transfer authorization must also happen server-side.


Finding 4: Hardcoded Credentials and API Secrets

The app contains hardcoded values:

const val HARDCODED_USERNAME = "admin"
const val HARDCODED_PASSWORD = "password123"
const val DEFAULT_AUTH_TOKEN = "dev-session-token-abc123"
const val HARDCODED_API_URL = "https://api.vulnbanklab.training/internal"
const val HARDCODED_API_KEY = "sk_test_vulnbanklab_unsafe_key"

Again, this is intentional for the lab. It gives us an easy static analysis target.

ValueTypeRisk in a real app
adminUsernamePredictable default account
password123PasswordStatic credential recoverable from APK
dev-session-token-abc123TokenFake session secret stored in client code
https://api.vulnbanklab.training/internalAPI URLEnvironment and internal endpoint disclosure
sk_test_vulnbanklab_unsafe_keyAPI keyStatic API key recoverable from APK

How to discover this

With JADX:

  1. Open the APK.
  2. Search for password123.
  3. Search for sk_test.
  4. Search for api. or token.
  5. Open the class where the values are defined.

With command-line tools, you can also use strings-style checks after unpacking or decompiling:

grep -R "password123\|sk_test\|auth_token\|api" ./decompiled-app

Why this matters

Anything shipped inside an APK should be treated as recoverable.

Obfuscation can slow someone down, but it does not turn a client-side secret into a safe secret. If the app needs the value to run, a motivated tester can usually recover it.

How to fix in a real app

Bad patternBetter pattern
Static admin credentials in the appUse real authentication with server-side verification
API keys embedded in the APKUse backend-mediated access or scoped public keys only
Long-lived token shipped with appIssue short-lived tokens after authentication
Trusting client-held secretsMove trust decisions to the server

Finding 5: Credentials and Tokens Stored in Plaintext SharedPreferences

The app saves the username, password and token into SharedPreferences:

preferences.edit()
    .putString(KEY_USERNAME, user.username)
    .putString(KEY_PASSWORD, user.password)
    .putString(KEY_AUTH_TOKEN, user.authToken)
    .apply()

The preferences file name is:

private const val PREFS_NAME = "vuln_bank_prefs"

And the keys are:

private const val KEY_USERNAME = "username"
private const val KEY_PASSWORD = "password"
private const val KEY_AUTH_TOKEN = "auth_token"

This is a classic Android security teaching point.

Lab workflow

  1. Install the app.
  2. Log in.
  3. Inspect the app data directory.

If the app is debuggable, you can often use run-as:

adb shell run-as com.training.vulnerablebank
cd shared_prefs
ls
cat vuln_bank_prefs.xml

Expected finding:

Stored itemExpected weakness
UsernameStored as plaintext
PasswordStored as plaintext
Auth tokenStored as plaintext
Balances / transactionsStored locally as JSON-like data

Why this matters

Local storage is not automatically safe. On rooted devices, compromised devices, debug builds, backups, and misconfigured environments, local app data can become accessible.

Also, storing passwords locally is usually a design smell. Most apps should store a session token or refresh token, not the user’s raw password. Even then, sensitive tokens should be protected properly.

How to fix in a real app

ProblemBetter approach
Password stored locallyDo not store raw passwords
Token stored in plaintextUse encrypted storage where appropriate
Sensitive data included in backupsReview backup rules and data extraction rules
App trusts local account balanceTreat local values as display/cache only; verify server-side

For Android, look at Jetpack Security and EncryptedSharedPreferences where appropriate. But remember: encrypted local storage is not a replacement for server-side authorization.


Finding 6: Sensitive Data Written To Logcat

The app writes sensitive values to Logcat.

Login flow:

Log.d("VulnBankLab", "Logging in with username=$username password=$password")

Preferences manager:

Log.d(TAG, "Saving plaintext credentials username=${user.username} password=${user.password}")
Log.d(TAG, "Saving auth token=${user.authToken}")

Transfer flow:

Log.d("VulnBankLab", "Transfer activity opened without authentication checks")
Log.d("VulnBankLab", "Transfer requested to=$recipient amount=$amount")

Logcat demo

adb logcat | grep -i "VulnBankLab\|PreferencesManager"

Then perform:

  1. Login.
  2. Transfer screen open.
  3. Transfer submission.

Expected visible evidence:

ActionLog output risk
LoginUsername and password appear in logs
Save userCredentials and auth token appear in logs
Open transferAuth bypass hint appears in logs
Submit transferRecipient and amount appear in logs

Why this matters

Logs are useful during development. But logs should not contain credentials, tokens, session IDs, personal data, payment data, or anything that would be dangerous if exposed.

Even when modern Android restricts log access between apps, Logcat leakage is still a real problem in debug builds, internal builds, rooted environments, shared test devices, crash reports, and careless diagnostics.

How to fix in a real app

Bad loggingBetter logging
password=$passwordNever log passwords
token=$tokenNever log tokens
Full transfer detailsLog event IDs or sanitized metadata only
Debug logs in releaseStrip or disable debug logs in production

A safer log might look like:

Log.d("BankApp", "Login attempt submitted")

Not perfect, but much better than dumping credentials.


Finding 7: Weak Root and Emulator Detection

The app contains a weak root/emulator check. It checks a list of common su paths and some emulator indicators.

Example paths:

"/system/bin/su"
"/system/xbin/su"
"/sbin/su"
"/data/adb/magisk"

Example emulator indicators:

"generic"
"sdk_gphone"
"emulator"
"goldfish"
"ranchu"
"genymotion"
"simulator"

The code itself says this is intentionally weak for demo purposes.

Check typeWeakness
File path checksCan miss modern hiding techniques or alternative paths
Build string checksCan be spoofed or changed
Client-side resultCan be patched or hooked
UI warning onlyDoes not enforce real security controls

Defensive lesson

Root detection can be part of risk scoring, anti-tampering, fraud detection, or policy enforcement. But sensitive operations must still be protected server-side.

For example:

RiskBetter control
Device is rootedAdd risk score, require stronger verification, limit risky actions
App is instrumentedDetect suspicious runtime conditions, but do not rely only on local checks
Transfer is sensitiveValidate authorization and transaction rules on the backend

Finding 8: Missing or Weak Input Validation In Transfer Flow

The README says the transfer flow is intentionally missing validation for arbitrary recipient and amount values. In the current implementation, the storage layer rejects amount <= 0, but the UI still accepts weak input and the activity itself is externally reachable.

The transfer screen accepts free-form text:

var recipient by rememberSaveable { mutableStateOf("") }
var amount by rememberSaveable { mutableStateOf("") }

And submits raw strings:

onClick = { onSubmit(recipient, amount) }

The submit function parses the amount:

val parsedAmount = amount.toDoubleOrNull()

This gives you a good teaching point: validation may exist in one layer, but the flow can still be poorly designed.

Inputs to try

InputWhat to observe
Empty recipientShould be rejected cleanly
Unknown recipientShould not leak too much detail
Non-numeric amountShould show proper validation error
Negative amountShould be rejected
Very large amountShould be rejected if balance is insufficient
Decimal edge casesShould be handled consistently

Better validation model

For a real app, validation should happen in layers:

LayerResponsibility
UIFriendly feedback and basic formatting
ViewModel/domain layerBusiness rule validation
BackendFinal authorization and transaction validation
Audit layerRecord safe, non-sensitive event metadata

Never trust the client alone for money movement.


Suggested Static Analysis Workflow

Here is a simple workflow you can show on screen.

Option A: JADX

jadx-gui vulnerable-bank.apk

Then search for:

password123
sk_test
SharedPreferences
Log.d
android:exported
vuln://transfer
su
Magisk

Option B: Apktool

apktool d vulnerable-bank.apk -o vulnerable-bank-decoded

Then inspect:

cat vulnerable-bank-decoded/AndroidManifest.xml
 grep -R "password123\|sk_test\|Log.d\|SharedPreferences\|vuln" vulnerable-bank-decoded/

Option C: PulseAPK Core

PulseAPK Core is useful when you want a GUI workflow around APK analysis.

Project:

PulseAPK Core is especially useful for workshops because it reduces the number of terminal commands beginners need to remember.


Suggested Dynamic Testing Workflow

Dynamic testing means we run the app and observe behavior.

StepCommand / actionWhat to prove
Install appadb install app-debug.apkApp is ready on test device
Watch logs`adb logcatgrep -i “VulnBankLab|PreferencesManager”`
Trigger deep linkadb shell am start -W -a android.intent.action.VIEW -d "vuln://transfer"Transfer screen can be opened externally
Launch activityadb shell am start -n com.training.vulnerablebank/.DashboardActivityExported internal screens can be opened directly
Inspect storageadb shell run-as com.training.vulnerablebankPlaintext preferences can be inspected in debug context

This is a strong bridge for QA engineers moving toward AppSec. It feels like testing, because it is testing. The difference is the test objective: instead of asking “does it work?”, you ask “can this be abused?”


Vulnerability Summary Table

#FindingEvidenceImpactReal-world fix
1Debuggable buildandroid:debuggable="true"Easier runtime inspection and tamperingEnsure release builds are not debuggable
2Exported activitiesAll activities use android:exported="true"Internal screens can be launched externallyExport only required entry points
3Deep link auth bypassvuln://transfer opens transfer screenSensitive flow reachable without normal navigationValidate session in destination activity
4Hardcoded credentialsadmin / password123Credentials recoverable from APKServer-side auth, no static secrets
5Hardcoded API key/tokenStatic token and sk_test... keySecret extraction from clientBackend-mediated secrets, scoped keys
6Plaintext SharedPreferencesPassword/token saved as stringsLocal data exposureAvoid storing passwords; encrypt sensitive tokens
7Sensitive Logcat outputCredentials and token loggedData leakage in debug/log pipelinesSanitize logs, strip debug logs in release
8Weak root/emulator checksPath and build-string checks onlyEasy bypass or false confidenceTreat as risk signal, not security boundary
9Weak transfer validationFree-form transfer input and external entryBad transaction handling patternsValidate in UI, domain layer and backend

What This Teaches QA Engineers

This lab is especially useful for QA engineers because the workflow is not alien.

You already know how to:

  • read requirements,
  • inspect behavior,
  • test edge cases,
  • write bug reports,
  • use logs,
  • think in flows,
  • question assumptions.

Mobile security testing adds a different angle:

Normal QA questionSecurity testing version
Does login work?Can login be bypassed?
Does transfer work?Can transfer be opened directly?
Does the app save state?Does it save sensitive data unsafely?
Are errors logged?Are secrets logged?
Does the screen validate input?Can malformed input reach sensitive logic?
Does the app detect root?Does it rely too much on root detection?

That is why QA people can move into AppSec faster than they think. The mindset is already close. You just need different targets and different tools.


Safe Bug Report Examples

If you want to turn this into training tasks, here are example bug report titles.

Bug titleSeverity for labNotes
Internal activities are exported and can be launched directlyHighEspecially dashboard, transfer and hidden feature screens
Transfer screen can be opened through deep link without authenticationHighClear auth bypass demo
Application is built with android:debuggable=trueMedium / HighDepends on release context
Credentials are hardcoded in client codeHighEasy static extraction
API key and auth token are hardcoded in client codeHighStatic secret leakage
Password and token are stored in plaintext SharedPreferencesHighLocal sensitive data exposure
Credentials and auth token are printed to LogcatHighSensitive logging issue
Root detection is weak and implemented only client-sideMediumGood anti-tampering discussion
Transfer form lacks strong validation and authorization modelHighImportant for financial flows

Final Thoughts

VulnBankLab is not trying to be subtle. That is a good thing.

For teaching Android security, subtle examples are often worse. Beginners need to see the pattern clearly first:

  • The manifest exposes too much.
  • The app logs too much.
  • The app stores too much.
  • The app trusts the client too much.
  • The app assumes navigation equals authorization.

Once those ideas click, you can move to harder targets, obfuscation, Frida hooks, SSL pinning, native libraries, anti-tampering, and real-world mobile testing methodology.

But the basics matter.

A surprising number of serious mobile findings still start with boring questions:

  • What is exported?
  • What is logged?
  • What is stored?
  • What is hardcoded?
  • What can be opened directly?
  • What does the app trust that it should not trust?

That is why a small vulnerable banking app is a good training target.

Start there. Build the habit. Then go deeper.