HTB: FlappyFlopper Challenge
FlappyFlopper - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Name | FlappyFlopper |
| Category | Misc (Android / Reverse Engineering) |
| Difficulty | Medium |
| Author | d3vn0mi |
Description
No one has got 10000 score yet! Are you able to do so?
The challenge ships a Unity/IL2CPP Android game (a Flappy Bird clone). The description implies the flag is gated behind reaching a score of 10,000 — but as the analysis below shows, that number is a red herring.
Solution
Artifact Recovery
The staged APK in the challenge workspace was a 0-byte placeholder. The real archive was sitting one directory up as a password-protected zip:
# Locate the actual archive (not the 0-byte staged copy)ls -la /out/*.zip
# It's password protected — HTB's standard archive password unlocks it7z x -phackthebox /out/<session-uuid>.zip# -> yields the real ~19MB APKRecon
The APK turned out to be a Unity 2023.2.11f1 game built with IL2CPP, targeting arm64. A first sweep for the flag came up empty everywhere obvious:
# Unzip the APK to inspect its contents directlyunzip -o -q game.apk -d apk && cd apk
# Search compiled metadata for interesting stringsstrings -n 4 assets/bin/Data/Managed/Metadata/global-metadata.dat \ | grep -oiE "[A-Za-z0-9_]*(flag|Flappy|Flopper|Score|Bird)[A-Za-z0-9_]*"
# Search serialized Unity asset bundle for the flag (via UnityPy)python3 - <<'EOF'import UnityPyenv = UnityPy.load('assets/bin/Data/data.unity3d')for o in env.objects: # inspect object types/data for anything flag-shaped passEOF
# Search the DEX and native library toostrings -n 5 classes.dex | grep -iE "flag|score|htb|secret"strings -n 8 lib/arm64-v8a/libil2cpp.so | grep -aiE "flag|congrat|winner|reward|secret|htb\{|10000"None of these turned up an HTB{REDACTED} string — not in global-metadata.dat literals, not in data.unity3d, not in classes.dex, and not in libil2cpp.so strings. The actual game logic is small: just 7 classes (BirdControl, GameMain, PipeSpawner, Score, and a few others), meaning the flag had to be reconstructed at runtime rather than stored as plain text.
Building a Mini IL2CPP Dumper
No Il2CppDumper binary was available in the environment, so a minimal one was hand-rolled to map native code back to the C# method table.
1. Parsing global-metadata.dat (v29):
Struct sizes for this metadata version weren’t reliably documented, so they were derived empirically — by checking that computed offsets divided evenly and stayed in range, rather than trusting assumed struct sizes:
import struct
d = open('assets/bin/Data/Managed/Metadata/global-metadata.dat', 'rb').read()
# Header giving section offsets/sizes for MethodDefinition table etc.lo, ls, do, ds = struct.unpack_from('<4i', d, some_header_offset)
# First guess of MethodDefinition = 36 or 40 bytes produced out-of-range# methodStart values once compared against the code pointer table size.# Confirmed the correct struct size is 32 bytes for metadata v29:METHOD_DEFINITION_SIZE = 322. Resolving .data.rel.ro via relocations:
The key obstacle: IL2CPP’s global data pointers (e.g. the Il2CppCodeGenModule table) live in .data.rel.ro, but on disk those pointer slots are all zero — they only become valid at runtime after the dynamic linker applies relocations. Direct pointer scanning against the raw file therefore found nothing.
from elftools.elf.elffile import ELFFile
elf = ELFFile(open('lib/arm64-v8a/libil2cpp.so', 'rb'))
# Apply all R_AARCH64_RELATIVE relocations from .rela.dyn to reconstruct# the "live" pointer values that would exist after the loader runs.# 170,943 relocations total for this binary.for reloc in rela_dyn_entries: addend = reloc.addend write_qword_at(image, reloc.r_offset, base_addr + addend)Once the relocated image was reconstructed, scanning for the Il2CppCodeGenModule structure became possible: it’s the unique qword in the relocated data pointing at the C string "Assembly-CSharp.dll". That struct yielded:
methodPointerCount = 27methodPointers -> array of native function addresses for Assembly-CSharp classesFrom there, cross-referencing MethodDefinition entries against this pointer array gave working native-address ↔ C#-method mappings for the whole assembly.
The Win Condition
With method addresses resolved, Score.UpdateScoreText disassembled to:
; Score.UpdateScoreText @ 0x9ad568ldr w8, [x0, #0x30] ; load this->scorecmp w8, #0x3e7 ; compare against 999 (0x3e7)b.le <print_number> ; if score <= 999, just print the number... ; ...otherwise, fall through to String.Join(secret_array)This confirms the actual gate is score ≥ 1000, not 10,000 as the challenge description suggests — the description is intentionally misleading.
Extracting the Flag
Score..cctor (the static constructor) populates a string[28] array from 28 IL2CPP “metadata usage” slots. Each slot’s on-disk initial value is an encoded token of the form:
token = 0xA0000000 | (2 * idx + 1)Decoding idx against the string-literal table for each of the 28 slots resolved to 28 single-character string literals. Reading them back in store order (the constructor writes them at obj+0x20, obj+0x28, obj+0x30, … — one qword apart) and concatenating spells out the flag:
# Pseudocode for the final reconstruction stepchars = []for idx in range(28): token = 0xA0000000 | (2 * idx + 1) literal = resolve_string_literal(token) # single character chars.append(literal)
flag = ''.join(chars)print(flag) # -> HTB{REDACTED}HTB{REDACTED}Key Steps
- Recover the real APK from the password-protected staged archive (
7z x -phackthebox). - Rule out the flag being stored as plain text anywhere (metadata, asset bundle, DEX, native strings).
- Build a minimal IL2CPP metadata parser for v29, verifying struct sizes empirically (
MethodDefinition= 32 bytes). - Apply
R_AARCH64_RELATIVErelocations from.rela.dynto reconstruct the real.data.rel.ropointer values, since on-disk values are zero. - Locate
Il2CppCodeGenModuleforAssembly-CSharp.dllto recover the method pointer array. - Disassemble
Score.UpdateScoreTextto find the real score threshold (999, not 9999). - Decode the 28 metadata-usage tokens in
Score..cctorinto single-character literals and concatenate them in store order to recover the flag.
Tools Used
7z— extracting the password-protected APK archiveunzip— unpacking the APKUnityPy— inspecting the serialized Unity asset bundle (data.unity3d)pyelftools(ELFFile) — parsinglibil2cpp.soELF structure and relocation tablescapstone— AArch64 disassembly of native IL2CPP-compiled methodsstrings/grep— initial text sweeps across metadata, DEX, and native librarypython3— custom IL2CPP metadata parser and relocation-resolution scripts
Key Learnings
- Staged 0-byte artifacts can hide a real, password-protected archive nearby — always check for a sibling
.zipbefore assuming an artifact is broken. - IL2CPP binaries store
.data.rel.ropointers as zero on disk. Any pointer-scanning approach against the raw file will silently find nothing untilR_AARCH64_RELATIVErelocations from.rela.dynare applied to reconstruct the runtime-valid addresses. - Don’t trust assumed struct sizes for metadata formats. When a documented struct size produces out-of-range offsets, derive the real size empirically (e.g. via divisibility/range checks) rather than debugging downstream symptoms.
- Challenge descriptions can be deliberately misleading. The advertised “10000 score” was actually a 1000-point (
0x3e7) threshold in the disassembled comparison — always verify win conditions against the actual code path, not the prompt text. - Flags built from many small runtime-assembled pieces (here, 28 single-character IL2CPP string literals reconstructed via metadata-usage tokens) require reproducing the constructor’s exact write order to reassemble correctly.