Skip to main content

Pico Bank picoMini by CMU-Africa Solution

Reverse engineer a banking application to bypass its authentication and extract the flag.

Published: April 2, 2026Updated: August 13, 2026

Description

A banking APK hides credentials in its source code and encodes transaction amounts as binary strings. Decompile the app, find the OTP hardcoded in strings.xml and decode the binary amounts to recover the flag, then confirm by sending the OTP to the server.

Download the APK file.

Install jadx: sudo apt install jadx

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Decompile with jadx for readable Java
    Observation
    The challenge provides an APK, which packages Android DEX bytecode rather than readable source. A decompiler like jadx turns that DEX back into Java, which has to happen before any static analysis.
    jadx converts Android DEX bytecode to near-readable Java source. This is easier to analyze than raw smali.
    bash
    jadx -d pico_bank_src/ picobank.apk
    What didn't work first

    Tried: Unzip the APK and read classes.dex directly with a text editor or hexdump.

    DEX bytecode is binary, not human-readable text. You see raw bytes and Dalvik opcodes with no class names or string logic visible. jadx is needed to lift that bytecode back into Java source; without it you are reading an unstructured binary blob.

    Tried: Use apktool instead of jadx to decompile the APK.

    apktool decodes resources and produces smali, not Java. Smali is low-level Dalvik assembly and far harder to read, which makes grepping for credentials painful. jadx produces Java directly, which is what you want for quick analysis of string constants and control flow.

    Learn more

    jadx(Java Decompiler for Android) converts Android's DEX bytecode directly into Java source code, bypassing the smali intermediate representation. The output is not identical to the original source (variable names are often generic, and some constructs decompile differently), but it is close enough to understand the application's logic at a high level.

    jadx includes both a command-line tool (jadx) and a GUI (jadx-gui) with a class browser, search functionality, and cross-reference navigation. The GUI is particularly useful for larger apps where you need to trace method calls across multiple classes. For this challenge, the command-line tool dumps all classes to a directory for easy searching with grep.

    The decompiled Java source reveals class names, method signatures, string constants, API endpoints, and hardcoded values exactly as they appeared in the original code. This makes jadx the standard first step in Android security research and penetration testing - before running an app on a device, reviewing its decompiled source reveals the intended behavior, API structure, and any obvious security flaws.

  2. Step 2Find hardcoded credentials
    Observation
    The description mentions credentials hidden in the source. Grep the decompiled Java and res/values/strings.xml for keywords like 'password', 'token', and 'otp_value' to surface anything in plaintext.
    Search the decompiled source for hardcoded usernames, passwords, API keys, or tokens. Check LoginActivity and NetworkActivity classes.
    bash
    grep -r 'password\|secret\|token\|api_key' pico_bank_src/ --include='*.java'
    What didn't work first

    Tried: Only search Java files and skip res/values/strings.xml when hunting for credentials.

    Android keeps many hardcoded string constants in strings.xml rather than in Java class files, and the OTP here is one of them. Grep only over *.java and you miss resource files entirely, so check the res/values/ directory jadx decodes as well.

    Tried: Run grep without the -r flag directly against the apk file to find credential strings.

    The APK is a zip archive of binary DEX bytecode and binary XML resources. Grep it without decompiling and you match raw byte sequences, missing most strings held in DEX constant pools or binary XML. Run jadx first so every string is plain text grep can reach.

    Learn more

    Hardcoded credentials in mobile apps are a widespread vulnerability. Developers embed credentials directly in source code for convenience (testing, demo accounts, API integration) and forget to remove them before shipping. Because anyone can decompile an APK, these credentials are effectively public.

    Common locations to check in Android apps include: string constants in Activity classes (especially LoginActivity, NetworkActivity, ApiClient), BuildConfig fields (populated from Gradle properties), res/values/strings.xml, raw assets, and native libraries (searchable with strings libname.so). grep with -r (recursive) and -i (case-insensitive) covers the decompiled source directory quickly.

    The proper fix is to never ship credentials in app code. API keys should be fetched from the server after user authentication, stored in secure enclaves (Android Keystore), or replaced with short-lived tokens generated server-side. The OWASP Mobile Top 10 lists hardcoded credentials as M8: Security Misconfiguration.

  3. Step 3Decode transaction amounts as binary strings
    Observation
    The transaction amount fields are all exactly 7 characters long and contain only 0s and 1s, not decimal digits. Those are 7-bit binary ASCII characters, which int(b, 2) and chr() decode.
    The amounts stored in the app are 7-bit binary strings, not decimal integers. Each binary string (e.g. '1110000') represents one character. Convert each with int(b, 2) to get the decimal codepoint, then chr() to get the character. Joining the results reveals the hidden flag fragment.
    python
    python3 -c "amounts=['1110000','1101001','1100011','1101111',...]; print(''.join(chr(int(b,2)) for b in amounts))"
    What didn't work first

    Tried: Interpret the binary strings as 8-bit values by padding each one to 8 characters before converting.

    The amounts are 7-bit ASCII, not 8-bit bytes. Padding with a leading zero happens to work for standard ASCII, since it all fits in 7 bits, but assuming 8-bit encoding leaves you puzzling over why values above 127 come out as garbage. Trust the 7-character length: int(b, 2) on the raw string is correct with no padding.

    Tried: Try int(b, 10) instead of int(b, 2) to convert each amount string to a number.

    int(b, 10) reads '1110000' as the decimal number one million one hundred ten thousand, not as binary, and chr() then fails with a ValueError on a value that large. The second argument to int() is the base, and binary needs base 2.

    Learn more

    Storing strings as arrays of binary string literals is a step up from plain integer obfuscation. Instead of writing 112 (which grep easily flags as a suspicious constant), the developer writes "1110000"- a value that looks like a transaction amount to a casual reviewer but is actually the 7-bit binary representation of the character 'p' (decimal 112). The full ASCII table fits in 7 bits (0-127), so every printable character maps to a 7-character binary string.

    The two-step decoding is: int(b, 2) interprets the string b as a base-2 integer (e.g. int("1110000", 2) gives 112), then chr(112) gives 'p'. Python's built-in int(string, base) handles arbitrary bases from 2 to 36, making this a clean one-liner: ''.join(chr(int(b, 2)) for b in amounts).

    This binary-string encoding is more subtle than a raw integer array because the values superficially resemble plausible data. Static analysis tools that search for hardcoded strings will not flag them. However, any attacker who notices that all "amounts" are exactly 7 characters long and contain only 0 and 1 will immediately recognize the pattern.

  4. Step 4POST the hardcoded OTP to /verify-otp
    Observation
    The decompiled NetworkActivity references a /verify-otp endpoint and builds a JSON body from the otp_value key in strings.xml. So send that hardcoded OTP to the server with curl to get the server-side flag fragment.
    The app checks the OTP against the server by posting to /verify-otp with only the otp field. The OTP (9673 in the challenge) is stored in plaintext in res/values/strings.xml under the key otp_value. Send it directly with curl to get the server-side flag fragment.
    bash
    curl -X POST http://<host>:<PORT_FROM_INSTANCE>/verify-otp -H 'Content-Type: application/json' -d '{"otp":"9673"}'
    What didn't work first

    Tried: Send the OTP as a form-encoded body instead of JSON using curl -d 'otp=9673' without a Content-Type header.

    The decompiled NetworkActivity builds a JSON request, so that is what the server expects. Send form-encoded data instead and the server returns a 400 or complains about missing fields, because it parses the body as JSON and finds no 'otp' key. Use -H 'Content-Type: application/json' with a JSON body.

    Tried: POST to /login or /authenticate instead of /verify-otp with the OTP value.

    The decompiled source shows a specific endpoint path for OTP verification. Posting to the wrong route returns a 404 or an unrelated response. You must read the actual endpoint URL from the decompiled NetworkActivity or strings before crafting the curl request.

    Learn more

    One-time passwords are supposed to be generated fresh for each authentication attempt and verified server-side, making replay attacks impossible. Hardcoding an OTP in strings.xml defeats the entire purpose: anyone who decompiles the APK learns the "secret" immediately and can bypass authentication without a valid account.

    The res/values/strings.xml file is part of Android's compiled resource system. It is stored inside the APK as binary XML, but jadx decodes it back to human-readable XML during decompilation. Any value defined there - API base URLs, keys, OTP seeds, feature flags - is fully recoverable by any tool that can unzip an APK.

    The correct implementation sends a challenge to the user (e.g. via SMS or an authenticator app), the user replies with the OTP, and the server compares it against a short-lived server-generated value that is never sent to the client. The client app should never hold the OTP itself.

Interactive tools
  • Strings ExtractorPull printable text from any binary, library, or image. ASCII and UTF-16 detection, configurable minimum length, flag-like highlight, no command line needed.
  • Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.

Flag

Reveal flag

picoCTF{1_l13d_4b0ut_b31ng_s3cur3d_m0b1l3_l0g1n_...}

Flag combines a binary-decoded first half and a server OTP-response second half. The suffix hash (e.g. f59ef39a) varies per instance.

Key takeaway

Hardcoded credentials, OTPs, and API keys in client code break the basic rule that a secret must never be handed to an untrusted party. A client app runs on hardware the user controls, so any embedded secret is recoverable by static analysis whatever the encoding, because the code has to decode it at runtime and therefore carries everything needed to reverse the process. Real OTP security keeps the secret on the server: the client receives a challenge and submits a response, never the underlying seed.

Related reading

Useful tools for Reverse Engineering

Where to go next