← Back to Writeups
HTBN/AReversing

Lego Clicker

XESXOR8/23/20266 min read
#reversing#htb#n/a

Lego Clicker

Platform: Umasscybersec | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-07 | Status: Solved Techniques: apk_decompilation, jni_reversing, decoy_flag_elimination, xor_string_recovery, cross_architecture_validation

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: umasscybersec | ID: 20260407_umasscybersec_lego_clicker
  • Tags: xor, android, apk, jadx, native_library, jni, fake_flag, mobile, apktool
  • Indicators: Android APK loads a JNI library named legocore, Java-side validation explicitly requires a UMASS{...} prefix, native code exposes obvious fake flags with the wrong BHREV prefix, the same suspicious byte sequence appears at fixed offsets across multiple architecture builds, a single-byte XOR mask reveals a clean flag body from hidden native data
  • Source: 20260407_umasscybersec_lego_clicker.md

Foothold

Vulnerability / Misconfiguration

  1. Apk_decompilation
  2. Jni_reversing
  3. Decoy_flag_elimination
  4. Xor_string_recovery
  5. Cross_architecture_validation
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • apk_decompilation
  • jni_reversing
  • decoy_flag_elimination
  • xor_string_recovery
  • cross_architecture_validation
  • Tags: xor, android, apk, jadx, native_library, jni, fake_flag, mobile, apktool

Original Writeup

<details><summary>Click to expand original content</summary>

Description

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.

Challenge Summary

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{...}.

Recon and Decompilation

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 activity
  • com.example.LegoClicker.SessionValidator — JNI entry points and library loading
  • com.example.LegoClicker.FCA — native-backed dialog path with extra decoy behavior
  • defpackage/n0.java — result handling and final format checks

SessionValidator.java is the first important hint:

  • it loads the native library with System.loadLibrary(...)
  • it exposes JNI methods such as refreshTileMap, syncBrickCache, and validateBrickToken
  • helper a(long j, long j2) resolves and invokes a native method by reflection

RA.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.

Fake-Flag Analysis

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:

  1. The prefix is BHREV{...}, not UMASS{...}
  2. The challenge description explicitly warns that fake flags exist

So these strings are useful only as confirmation that the binary is designed to waste time if you trust surface-level string extraction.

Native Analysis Summary

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-v8a
  • armeabi-v7a
  • x86
  • x86_64

The 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.

Decisive XOR Discovery

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 0x141d5
  • apktool/lib/armeabi-v7a/liblegocore.so at 0xee50
  • apktool/lib/x86_64/liblegocore.so at 0x13df0

Applying 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.

Final Flag Reasoning

At this point there are two facts that must be reconciled:

  1. Native hidden data cleanly decodes to CTF{REDACTED}
  2. Java validation clearly insists the returned result must match 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}

Solution

  1. Decompile the APK with apktool and jadx.
  2. Identify the important Java classes: RA.java, SessionValidator.java, FCA.java, and defpackage/n0.java.
  3. Follow the leaderboard reward flow into JNI.
  4. Notice the fake flag strings in native logic, but reject them because they do not satisfy the Java-side UMASS{...} requirement.
  5. Compare the shipped liblegocore.so files across architectures.
  6. Extract the repeated hidden byte sequence at the noted offsets.
  7. XOR each byte with 0x4c.
  8. Recover CTF{REDACTED}.
  9. Replace the wrapper with the format required by the Java validator to obtain the intended final flag.
#!/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()
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR