VEILGATE (ROCSC Bootcamp 2026) — Android Native RE with Control Flow Flattening

An Android license verifier that hides its validation logic behind three layers: RegisterNatives binding with no exported symbol, OLLVM-style control flow flattening in the native library, and AES-128-CBC decryption with a key split across two XOR'd arrays in .rodata. The goal is to recover the license key the app accepts.

1. Overview

NorthStar License Verifier v3.1.0 is a single-screen Android app. It takes a license key as input, sends it to a native library, and displays "License Accepted" or "License Rejected". The flag is CTF{sha256(license_key)}.

The challenge combines three techniques found in real commercial Android applications — TikTok, Genshin Impact, PUBG Mobile, and banking apps all use some variation of this stack:

  • RegisterNatives — the JNI binding is set up at runtime in JNI_OnLoad, so the standard Java_com_northstar_... export symbol does not exist in the .so
  • Control Flow Flattening (CFF) — the core verification function is compiled with an OLLVM-style obfuscation pass that replaces the natural control flow graph with a state-machine dispatcher
  • Split key material — the AES key is not stored as a single array; it is reconstructed at runtime by XOR'ing two separate byte arrays from .rodata

2. DEX analysis — finding the native call

Open NorthStarLicVerify.apk in JADX. com.northstar.licverify.MainActivity calls:

val ok = NativeLib.verifyLicense(key)

The NativeLib class:

object NativeLib {
    init { System.loadLibrary("northstar") }
    external fun verifyLicense(key: String): Boolean
}

verifyLicense() is declared external — the implementation lives in libnorthstar.so. The app takes user input, passes it to the native side, and shows the result. The license key never appears in the UI.

3. RegisterNatives — locating the real function

Extract lib/arm64-v8a/libnorthstar.so from the APK and check the exports:

nm --dynamic libnorthstar.so | grep " T "
JNI_OnLoad

No Java_com_northstar_licverify_NativeLib_verifyLicense — the binding is not done by naming convention. Instead, JNI_OnLoad calls RegisterNatives() to map the method at runtime.

Static approach (Ghidra)

In Ghidra, navigate to JNI_OnLoad. The function:

  1. Calls GetEnv to obtain JNIEnv*
  2. Calls FindClass("com/northstar/licverify/NativeLib")
  3. Builds a JNINativeMethod struct array: method name "verifyLicense", descriptor "(Ljava/lang/String;)Z", and a function pointer
  4. Calls RegisterNatives(env, cls, methods, 1)

The function pointer in the struct points to native_verify_license, which is a thin JNI wrapper around ns_verify_license — the real verification function.

Dynamic approach (Frida)

Interceptor.attach(Module.findExportByName(null, 'RegisterNatives'), {
    onEnter: function(args) {
        let count = args[3].toInt32();
        let methods = args[2];
        for (let i = 0; i < count; i++) {
            let name = methods.add(i * 24).readPointer().readCString();
            let sig  = methods.add(i * 24 + 8).readPointer().readCString();
            let fn   = methods.add(i * 24 + 16).readPointer();
            console.log('[RegisterNatives] ' + name + ' ' + sig + ' -> ' + fn);
        }
    }
});

Output:

[RegisterNatives] verifyLicense (Ljava/lang/String;)Z -> 0x7b1234 (native_verify_license)

4. Control flow flattening — deobfuscating the dispatcher

Load libnorthstar.so in Ghidra and navigate to ns_verify_license. The CFG is immediately recognizable as CFF — a star-shaped graph where every basic block jumps back to a central dispatcher node:

[init] -> [dispatcher loop] -> [case blocks] -> [back to dispatcher]

The function has 124 CFG nodes; the dispatcher has 8 incoming edges (the highest in-degree in the entire graph). Each block computes a new state value and branches back to the dispatcher's switch.

The state machine uses volatile uint32_t __sw as the dispatch variable, with constants like 0x3a9f2d71 (entry), 0x7c1e8b4a (XOR key reconstruction), 0x51c3a8d6 (AES decrypt), and so on. The volatile qualifier and optnone attribute prevent the compiler from optimizing the dispatcher away.

Deobfuscation with deflat.py + angr

python3 deflat.py libnorthstar.so <addr_of_ns_verify_license>

deflat.py uses angr to:

  1. Build the CFG and identify the dispatcher (node with highest in-degree)
  2. Symbolically execute each block to determine the next state constant
  3. Patch the binary with direct jumps, eliminating the dispatcher entirely

After deobfuscation, the logic is straightforward:

// reconstruct key: XOR two .rodata arrays
for (i = 0; i < 16; i++)
    actual_key[i] = s_key_a[i] ^ s_key_b[i];

// sanity check: key must not be all-zero
uint8_t acc = 0;
for (i = 0; i < 16; i++) acc |= actual_key[i];
if (acc == 0) return 0;

// AES-128-CBC decrypt the encrypted license key
out = malloc(sizeof(s_enc_lkey) + 1);
aes128_cbc_decrypt(actual_key, s_iv, s_enc_lkey, out, sizeof(s_enc_lkey));

// validate PKCS7 padding
pad = out[31];
// ...

// compare with user input
result = (strcmp(input, (char *)out) == 0) ? 1 : 0;
free(out);
return result;
The CFF pattern here mirrors what NCC Group documented in their 2022 analysis of TikTok's Android app. The same obfuscation is available through OLLVM and its successor O-MVLL (Open Obfuscator for LLVM by Build38). In real applications the dispatcher typically has dozens of incoming edges — 8 is on the lighter end, but enough to make manual reconstruction tedious without symbolic execution.

5. Key extraction and AES decryption

Three pieces of data are needed from .rodata, all found via Ghidra cross-references from the deobfuscated code:

KEY_A and KEY_B — two 16-byte arrays whose XOR produces the AES key:

KEY_A = bytes([0xe5,0xea,0xcd,0x4a,0xd6,0xb8,0x3a,0x7f,
               0x96,0x45,0xfd,0xe6,0x37,0x43,0x18,0x47])

KEY_B = bytes([0xab,0x85,0xbf,0x3e,0xbe,0xeb,0x4e,0x1e,
               0xe4,0x1a,0xb6,0x83,0x4e,0x71,0x28,0x75])

key = bytes(a ^ b for a, b in zip(KEY_A, KEY_B))
# key = b"NorthStar_Key202"

IV — the 16-byte initialization vector for CBC mode:

IV = bytes([0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,
            0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f,0x10])

s_enc_lkey — the 32-byte encrypted license key (two AES blocks, PKCS7 padded):

ENC_LKEY = bytes([
    0x49,0x37,0x72,0xc2,0xba,0xdf,0xbb,0x52,
    0x98,0xf4,0xd3,0x17,0xf8,0x5d,0xd5,0x45,
    0x36,0x33,0xd7,0xe3,0x05,0x19,0xea,0x88,
    0xa4,0x30,0x33,0x80,0xbd,0xd1,0xfa,0x9e,
])

Decrypt and unpad:

from Crypto.Cipher import AES

cipher = AES.new(key, AES.MODE_CBC, IV)
plain  = cipher.decrypt(ENC_LKEY)

pad = plain[-1]
license_key = plain[:-pad].decode()
print(license_key)
# NSLV-7X9K-A3R5-M2P8

The license key is NSLV-7X9K-A3R5-M2P8.

6. Solve script

Extracts the key material from the byte arrays, reconstructs the AES key, decrypts the license, and computes the flag:

from Crypto.Cipher import AES
import hashlib

KEY_A = bytes([0xe5,0xea,0xcd,0x4a,0xd6,0xb8,0x3a,0x7f,
               0x96,0x45,0xfd,0xe6,0x37,0x43,0x18,0x47])
KEY_B = bytes([0xab,0x85,0xbf,0x3e,0xbe,0xeb,0x4e,0x1e,
               0xe4,0x1a,0xb6,0x83,0x4e,0x71,0x28,0x75])
IV    = bytes([0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,
               0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f,0x10])
ENC   = bytes([0x49,0x37,0x72,0xc2,0xba,0xdf,0xbb,0x52,
               0x98,0xf4,0xd3,0x17,0xf8,0x5d,0xd5,0x45,
               0x36,0x33,0xd7,0xe3,0x05,0x19,0xea,0x88,
               0xa4,0x30,0x33,0x80,0xbd,0xd1,0xfa,0x9e])

key = bytes(a ^ b for a, b in zip(KEY_A, KEY_B))
plain = AES.new(key, AES.MODE_CBC, IV).decrypt(ENC)
license_key = plain[:-plain[-1]].decode()

print(license_key)
print("CTF{" + hashlib.sha256(license_key.encode()).hexdigest() + "}")
NSLV-7X9K-A3R5-M2P8
CTF{<64 hex chars>}
The three techniques in this challenge — RegisterNatives binding, control flow flattening, and split key material — appear together in real-world Android protections. OLLVM and O-MVLL provide the CFF pass; commercial packers like DexGuard and Bangcle add RegisterNatives indirection; and key splitting across multiple .rodata arrays is standard practice in banking and DRM applications. The intended solve path is Ghidra + deflat.py/angr for the static route, or Frida + RegisterNatives hook for the dynamic route.