Pico Bank picoMini by CMU-Africa Solution

Published: April 2, 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 1
    Decompile with jadx for readable Java
    Observation
    I noticed the challenge provided an APK file, which packages Android DEX bytecode rather than readable source, so I needed a decompiler like jadx to convert the binary DEX back into Java before any static analysis could begin.
    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 bytecode, not Java source. Smali is a low-level Dalvik assembly that is far harder to read than Java, and grep-for-credentials workflows are much more painful. jadx produces Java directly, which is what you want for quick static 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 2
    Find hardcoded credentials
    Observation
    I noticed the challenge description mentioned credentials hidden in the source code, which suggested grepping the decompiled Java and res/values/strings.xml for keywords like 'password', 'token', and 'otp_value' to surface any plaintext secrets.
    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 stores many hardcoded string constants in strings.xml, not in Java class files. The OTP value in this challenge lives there, not in a .java file. Running grep only over *.java misses resource files entirely; you need to also check the res/values/ directory that jadx decodes.

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

    The APK is a zip archive containing binary DEX bytecode and binary XML resources. grep without decompiling first only matches raw byte sequences, missing most strings that are encoded in DEX constant pools or Android binary XML format. You must run jadx first so that all strings appear as plain text that grep can match reliably.

    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 3
    Decode transaction amounts as binary strings
    Observation
    I noticed that the transaction amount fields in the decompiled source were all exactly 7 characters long and contained only 0s and 1s rather than decimal digits, which immediately suggested they were 7-bit binary representations of ASCII characters that needed int(b, 2) and chr() to 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 encodings, not 8-bit bytes. Padding with a leading zero still gives the right answer for standard ASCII characters (which all fit in 7 bits), but assuming 8-bit encoding leads you to add a zero and then wonder why values above 127 give garbage. Trust the 7-character length - int(b, 2) on the raw 7-character string is correct without padding.

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

    int(b, 10) treats '1110000' as the decimal number one million one hundred ten thousand, not as a binary number. The resulting chr() call will fail with a ValueError for any value that large. The second argument to int() is the base, and binary encoding requires 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 4
    POST the hardcoded OTP to /verify-otp
    Observation
    I noticed the decompiled NetworkActivity class referenced a /verify-otp endpoint and constructed a JSON body using the otp_value key found in strings.xml, which suggested sending that hardcoded OTP directly to the server with curl to retrieve 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 server expects a JSON body based on how the decompiled NetworkActivity constructs its request. Sending form-encoded data causes the server to return a 400 or an error about missing fields because it parses the body as JSON and finds no 'otp' key. You need -H 'Content-Type: application/json' and a JSON-formatted 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-side code violate the fundamental principle that secrets must never be distributed to untrusted parties. A client application runs on hardware the user controls, so any secret embedded in it is recoverable by static analysis regardless of encoding or obfuscation, because the code must decode it at runtime and therefore carries all the information needed to reverse the process. Real OTP security requires the server to generate and own the secret; the client only ever receives a challenge and submits a response, never the underlying seed.

Related reading

Want more picoMini by CMU-Africa writeups?

Useful tools for Reverse Engineering

What to try next