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.
Setup
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.
Step 1
Decompile with jadx for readable JavaObservationI 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.bashjadx -d pico_bank_src/ picobank.apkWhat 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.
Step 2
Find hardcoded credentialsObservationI 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.bashgrep -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),BuildConfigfields (populated from Gradle properties),res/values/strings.xml, raw assets, and native libraries (searchable withstrings 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.
Step 3
Decode transaction amounts as binary stringsObservationI 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.pythonpython3 -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 stringbas a base-2 integer (e.g.int("1110000", 2)gives 112), thenchr(112)gives'p'. Python's built-inint(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.
Step 4
POST the hardcoded OTP to /verify-otpObservationI 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.bashcurl -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.xmldefeats the entire purpose: anyone who decompiles the APK learns the "secret" immediately and can bypass authentication without a valid account.The
res/values/strings.xmlfile 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.