← back to posts

1 - GhostMode: Reversing a Native Android CTF Library

Overview

GhostMode was the workshop’s first foray into native code. The APK itself was thin — the interesting logic lived entirely in libctflib.so, a stripped ELF64 shared library loaded at runtime. The challenge name already telegraphed the approach: you were not meant to find the flag through static analysis alone.

The fake flag was there to remind you of that.

First Look — The Stub and the Taunt

Decompiling the APK with JADX produced a small FlagManager class with a single JNI method:

1public native String revealFlag(int pin);

The implementation was entirely in the native library. Loading libctflib.so into IDA and finding the exported symbol Java_com_example_ctf_1dynamicbypass_FlagManager_revealFlag revealed the structure immediately.

The first thing the function does is call sub_860(a3) — a validator for the PIN passed from Java. If the validator returns false, the function returns a hardcoded string from .rodata:

1"CTF{y0u_c4nt_r34d_m3_fr0m_st4t1c_4n4lys1s}"

That is not the flag. That is the challenge author telling you to keep going.

Reversing the PIN Validator — sub_860

sub_860 takes the PIN as an unsigned 32-bit integer and performs a series of nibble-level checks. The decompiled logic translates to:

  • The value must be ≤ 0xFFFF (16-bit range)
  • (pin & 0xF0F) must equal 0x307 — low nibble of the low byte must be 7, low nibble of the high byte must be 3
  • The four nibbles must sum to 0xE (14)
  • (pin >> 8) ^ (pin & 0xFF) must equal 0x24
  • The second nibble (bits 4–7) must equal 3

Working through the constraints:

  • digit0 = 7, digit2 = 3 (from the mask check)
  • digit1 = 3 (from the final check)
  • digit3 = 14 − 7 − 3 − 3 = 1

Packed together: 0x1337. Verification confirms (0x13 ^ 0x37) = 0x24. The PIN is 4919 — the hexspeak 0x1337 — and the comment left in the decompiled stub already hinted at this: “Value passed to function in APK is 4919”.

The Key Derivation — sub_8C0

With the PIN validated, the function calls sub_8C0(0x1337) to derive a single key byte v7. The function performs a short arithmetic sequence:

1ecx = pin >> 8;         // 0x13
2eax = ecx ^ pin;        // 0x13 ^ 0x1337 = 0x1324
3edx = eax ^ 0x7E;       // 0x1324 ^ 0x7E  = 0x135A
4ecx = ecx + pin;        // 0x13 + 0x1337  = 0x134A
5bit = (eax >> 7) & 1;   // (0x1324 >> 7) = 38, bit = 0
6result = (1 ^ bit) * edx | bit * ecx;

With bit = 0: result = 1 * 0x135A | 0 = 0x135A. Truncated to a byte: v7 = 0x5A = 90.

Flag Decryption — The Loop

The function then allocates 37 bytes and runs an 18-iteration loop, pulling characters alternately from two 18-byte arrays (byte_5B0 and byte_590) stored in .rodata:

1byte_5B0 = { 0xBA, 0xC1, 0x29, 0x65, 0xBC, 0xFC, 0xC7, 0x13,
2              0x5A, 0xA8, 0xCE, 0x0F, 0x32, 0xB8, 0xEC, 0x27, 0x46, 0x17 };
3byte_590  = { 0xCE, 0xDB, 0x17, 0x55, 0xC3, 0xE7, 0x3F, 0x38,
4              0xB9, 0xA1, 0x01, 0x0C, 0x7D, 0xDC, 0xE9, 0x4C, 0x5D, 0xBD };

Each iteration uses a counter v10 that starts at −1044 and increments by 58 per step, wrapping to zero after exactly 18 steps:

1v9[2*i]   = v7 ^ byte_5B0[i] ^ ((v10 - 73) & 0xFF);
2v9[2*i+1] = v7 ^ byte_590[i] ^ ((v10 - 44) & 0xFF);

The interleaved XOR of v7, the array bytes, and the counter-derived byte produces a 36-character string:

1CTF{dynam1c_byp4ss_1s_th3_w4y_t0_g0}

Why the Fake Flag Works as a Teaching Moment

The fake flag is an elegant design choice. A naive grep over the binary or a quick JADX pass both surface the string immediately — and both tell you exactly nothing useful about the actual mechanism. The challenge forces you to read the conditional: the string is only returned on the failure path of sub_860.

The real flag requires understanding the bit-level PIN constraints, the key derivation, and the loop structure. Each step is self-contained and small — a good introduction to native Android reversing before the workshop moved into harder territory.

Key Techniques

TechniqueWhere
JNI native bridgeFlagManager.revealFlag(int) delegates entirely to the .so
Fake flag as decoyHardcoded in .rodata; returned only on PIN validation failure
Nibble-level PIN constraintsub_860 checks bit patterns, nibble sums, and a XOR condition
Key byte derivationsub_8C0 produces a single byte used as a XOR key for decryption
Counter-keyed XOR loop18-iteration loop interleaves two arrays with a wrapping counter
series
BotConf 2026 Android Workshop