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