CTF Write‑up: Android Reverse (TFCCTF 2025)

JNI anti‑analysis + native check bypass using hot‑patching and Frida hooks.

Android Reversing Security

Overview

The challenge ships an APK with two native libraries. We first inspected the Java side with JADX to understand how the app wires into the native code and how inputs are passed.

Java side exploration

A quick look at AndroidManifest.xml shows the app registers a broadcast receiver: com.example.oxidized_intentions.TicketReceiver. Further inspection of TicketReceiver reveals code that calls the native function getFlag().

getFlag() receives four arguments: context, stringExtra, PART_J, and i & 255. The return value is then shown to the user via a Toast.

The stringExtra is assigned from the broadcast Intent. The code performs a cumulative XOR on stringExtra and stores the result in i. The security‑sensitive checks happen in the native side. Searching the Java sources shows loadLibrary("oxi") is invoked in the Native class constructor.

AndroidManifest.xml in JADX
AndroidManifest.xml
TicketReceiver class decompiled
TicketReceiver class

Native side exploration

After loading, JNI methods are resolved dynamically via RegisterNatives(). We could hook RegisterNatives to enumerate function signatures and their addresses, but it wasn’t necessary here.

Triggering the receiver with a dummy seed immediately shows a hint on screen:

adb shell am broadcast -n com.example.oxidized_intentions/.TicketReceiver --es seed "DUMMY"
# App shows: FAKE{wrong_seed}

We loaded the native library into Ghidra. By locating the above string, we found cross‑references that landed us inside getFlag().

Early in the function, a seed sanity check exists. The decompilation shows a data region DAT_001047e0 that memcmp() compares against. From this we recovered the valid seed:

seed = "fe2o3rust"
Seed check in Ghidra
Ghidra seed checks
Seed value in rodata
Seed value in rodata

Sending that seed:

adb shell am broadcast -n com.example.oxidized_intentions/.TicketReceiver --es seed "fe2o3rust"
# App shows: FAKE{2152411021524119}

This still isn’t the real flag (it doesn’t match the TFCCTF{} format). Inside getFlag() we observed time‑related calculations implementing S‑boxes and permutations to derive the real flag. One option would be to fully reverse the algorithm and substitute static values, but that’s slow.

Instead, there is an if on a global HACKER variable that, when set to 1, follows a code path yielding the genuine flag. There’s also another preceding check we can bypass.

Conditions to be bypassed
Conditions to be bypassed

Solution

We hot‑patch the native code after it’s loaded: (1) set HACKER to 1, and (2) NOP the conditional branch that blocks the real‑flag path.

'use strict';

const LIBRARY = "liboxi.so";
const TARGET_BRANCH = 0x11de0; // b.ne -> NOP

(function wait() {
  const base = Module.findBaseAddress(LIBRARY);
  if (!base) return setTimeout(wait, 50);

  try {
    // Set global HACKER = 1
    Module.getExportByName(LIBRARY, "HACKER").writeU32(1);

    // NOP the conditional branch
    Memory.patchCode(base.add(TARGET_BRANCH), 4, code => {
      const w = new Arm64Writer(code); w.putNop(); w.flush();
    });
    console.log(`[+] ${LIBRARY} patched @ ${base}`);
  } catch (e) {
    console.log(`[!] Patch failed: ${e}`);
  }
})();

Run Frida and spawn the target app:

# on device/emulator
./data/local/tmp/frida-server &

# on host
frida -U -f com.example.oxidized_intentions -l ./solution.js --no-pause

# send the correct seed
adb shell am broadcast -n com.example.oxidized_intentions/.TicketReceiver --es seed "fe2o3rust"

# flag
TFCCTF{167e3ce3c65387c6e981c31c39ac7839}