$ cat writeup.md…
$ cat writeup.md…
umasscybersec
Task: an Android clicker game hides its reward flow behind Java and JNI checks, while shipping several obvious fake native flags. Solution: decompile the APK, notice Java insists on UMASS format, then recover the real body from a cross-architecture XOR-obfuscated native string and rebuild the intended flag.
Hackers have taken over and corrupted your beloved Lego Clicker game, can you reclaim the top of the leaderboard? Note: There are fake flags throughout the challenge which should be obvious to tell based on contents
English summary: the challenge provides an Android APK for a fake clicker game. Reaching the top of the leaderboard triggers reward logic, but the real solution requires separating deliberate fake flags from the actual flag reconstruction path.
This was a mobile reversing challenge with a strong native component. The APK decompiles cleanly enough to expose the app structure, but the interesting flag logic sits behind JNI calls in liblegocore.so.
The key to the solve is not trusting the first flag-looking strings you see. The APK contains multiple decoys, and the Java layer gives the decisive constraint: the final accepted answer must look like UMASS{...}.
After unpacking the APK with apktool and jadx, the app structure immediately showed that the package name is com.example.LegoClicker and that the important native library is legocore.
Useful files and classes:
com.example.LegoClicker.RA — leaderboard activitycom.example.LegoClicker.SessionValidator — JNI entry points and library loadingcom.example.LegoClicker.FCA — native-backed dialog path with extra decoy behaviordefpackage/n0.java — result handling and final format checksSessionValidator.java is the first important hint:
System.loadLibrary(...)refreshTileMap, syncBrickCache, and validateBrickTokena(long j, long j2) resolves and invokes a native method by reflectionRA.java shows the intended user flow. If the player reaches the top of the leaderboard, the app calls native validation and then displays a reward string. That makes the leaderboard path look like the intended flag trigger.
The decisive Java-side condition appears in defpackage/n0.java:
if (strA != null && !strA.isEmpty() && strA.startsWith("UMASS{") && strA.endsWith("}") && strA.length() - 7 >= 3) {
That single check is enough to reject the obvious fake flags later found in native code. Even if native code returns something flag-shaped, the Java layer only accepts a UMASS{...} result.
During native reversing, two decoy flags appear as obvious traps:
BHREV{fAk3_flAG_wr0ng_s3ss10n} BHREV{f4k3_fl4g_n1c3_try_d3bugger}
Both are intentionally wrong for two reasons:
BHREV{...}, not UMASS{...}So these strings are useful only as confirmation that the binary is designed to waste time if you trust surface-level string extraction.
The JNI side is intentionally messy. The obvious native paths are corrupted or decoyed, and following them directly does not yield a valid final answer. Looking at only one architecture can also be misleading, because the same library is shipped for several ABIs:
arm64-v8aarmeabi-v7ax86x86_64The better approach is to compare all native builds and search for data that repeats across architectures. Repeated hidden data in every build is much more likely to be intentional challenge material than a single noisy disassembly branch.
The important finding is that all relevant native libraries contain the same hidden byte sequence at architecture-specific offsets. XORing that data with 0x4c decodes to the same plaintext every time.
Offsets:
apktool/lib/arm64-v8a/liblegocore.so at 0x141d5apktool/lib/armeabi-v7a/liblegocore.so at 0xee50apktool/lib/x86_64/liblegocore.so at 0x13df0Applying byte ^ 0x4c to 39 bytes at each location yields:
CTF{REDACTED}
This is the real flag body hidden in the native data, but it still uses the wrong prefix for the app's Java-side acceptance logic.
At this point there are two facts that must be reconciled:
CTF{REDACTED}UMASS{...}That means the intended answer is not the raw decoded string verbatim. The hidden native string gives the flag body, while the Java layer gives the correct wrapper format.
So the intended final flag is:
UMASS{REDACTED}
apktool and jadx.RA.java, SessionValidator.java, FCA.java, and defpackage/n0.java.UMASS{...} requirement.liblegocore.so files across architectures.0x4c.CTF{REDACTED}.#!/usr/bin/env python3 from pathlib import Path TARGETS = [ (Path("tasks/umasscybersec/Lego Clicker/apktool/lib/arm64-v8a/liblegocore.so"), 0x141D5), (Path("tasks/umasscybersec/Lego Clicker/apktool/lib/armeabi-v7a/liblegocore.so"), 0x0EE50), (Path("tasks/umasscybersec/Lego Clicker/apktool/lib/x86_64/liblegocore.so"), 0x13DF0), ] XOR_KEY = 0x4C HIDDEN_LEN = 39 def decode_at_offset(path: Path, offset: int) -> str: blob = path.read_bytes() enc = blob[offset:offset + HIDDEN_LEN] return bytes(b ^ XOR_KEY for b in enc).decode() def main() -> None: decoded = [] for path, offset in TARGETS: value = decode_at_offset(path, offset) decoded.append(value) print(f"{path}: {value}") assert len(set(decoded)) == 1, "architectures disagree" native_flag = decoded[0] body = native_flag.removeprefix("CTF{").removesuffix("}") final_flag = f"UMASS{{{body}}}" print(f"native hidden string: {native_flag}") print(f"final flag: {final_flag}") if __name__ == "__main__": main()
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar