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 1Decompile with jadx for readable Java
ObservationThe 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.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, 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.
Step 2Find hardcoded credentials
ObservationThe 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.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 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),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 3Decode transaction amounts as binary strings
ObservationThe 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.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, 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 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 4POST the hardcoded OTP to /verify-otp
ObservationThe 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.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 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.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.