Arno (HackTheBox) — Android Unity IL2CPP

Classic Unity IL2CPP flow: unpack the APK, dump libil2cpp.so + global-metadata.dat, find key / IV / ciphertext, then decrypt in Python.

0. Context

Challenge: Arno (HackTheBox). The only file you get is an Android app: Arno.apk.

Opening the APK makes the challenge type immediately clear: both libil2cpp.so and global-metadata.dat are present, so this is Unity IL2CPP — not plain Mono C#.

The basic idea:

  1. Confirm it’s Unity IL2CPP (not plain Mono C#).
  2. Use Il2CppDumper to get fake C# + mappings.
  3. Track where key, IV and encrypted flag are stored.
  4. Recreate the AES decrypt in Python and print the flag.

1. Decompiling the APK

First step: unpack the APK with apktool.

apktool d Arno.apk -o app_dec

Then I checked the usual Unity IL2CPP locations:

app_dec/lib/arm64-v8a/libil2cpp.so
app_dec/assets/bin/Data/Managed/Metadata/global-metadata.dat

If you see both libil2cpp.so and global-metadata.dat, you’re in IL2CPP territory — there’s no direct C# decompile path, so I had to go through the native side plus metadata.

2. Running Il2CppDumper

With those two files found, the next step is Il2CppDumper. I had it in my $PATH, so I just ran:

il2cppdumper \
  app_dec/lib/arm64-v8a/libil2cpp.so \
  app_dec/assets/bin/Data/Managed/Metadata/global-metadata.dat \
  Arno_dump

It produced:

  • dump.cs
  • script.json
  • il2cpp.h
  • DummyDll/Assembly-CSharp.dll and friends

For this challenge, the important ones are:

  • dump.cs – all types / methods / fields in one huge C# file.
  • DummyDll/Assembly-CSharp.dll – for quick browsing with ilspycmd.
  • script.json + il2cpp.h – for IDA so that functions get proper names.

3. Quick C# view with ilspycmd

Before loading ARM64 in IDA, I used ilspycmd to get a quick view of the managed code.

I dumped the dummy DLL with ilspycmd:

cd Arno_dump/DummyDll
ilspycmd -p Assembly-CSharp.dll -o src

Then I searched for anything flag-related:

grep -R "FlagControl" -n src
sed -n '1,200p' src/FlagControl.cs

FlagControl is exactly what you expect: Unity stuff for the UI, plus the interesting methods: GetKey(), GetIV(), GetFlag(), DecryptFlag().

The methods are tagged with attributes like [Address(RVA = "0x16D1838", VA = "0x16D1838")], which makes it easy to line them up with the native side in IDA later.

4. IDA Pro + Il2CppDumper script

Next, I opened libil2cpp.so in IDA (ARM64). To get sane function names, I ran the Il2CppDumper script that comes with the tool:

  1. Open libil2cpp.so in IDA (64-bit ARM).
  2. File → Script file… → select ida_with_struct.py (or similar).
  3. When prompted:
    • script.jsonArno_dump/script.json
    • il2cpp.hArno_dump/il2cpp.h

After that finishes, IDA suddenly looks much friendlier: functions like FlagControl__GetKey, FlagControl__GetIV, FlagControl__GetFlag, FlagControl__DecryptFlag show up in the names list.

5. Finding key / IV / flag in metadata

The app doesn’t hardcode the key/IV/flag as obvious byte arrays in the native code. Instead, they’re stored in global-metadata.dat under the usual <PrivateImplementationDetails> umbrella.

5.1. <PrivateImplementationDetails> in dump.cs

At the end of dump.cs I searched for PrivateImplementationDetails:

grep -n "PrivateImplementationDetails" dump.cs

There are a bunch of lines like:

private struct <PrivateImplementationDetails>.__StaticArrayInitTypeSize=16 { ... }
private struct <PrivateImplementationDetails>.__StaticArrayInitTypeSize=32 { ... }
...

internal static readonly <PrivateImplementationDetails>.__StaticArrayInitTypeSize=38 2694... /*Metadata offset 0x3EBA20*/; // 0x0
internal static readonly <PrivateImplementationDetails>.__StaticArrayInitTypeSize=16 36CB... /*Metadata offset 0x3EBA48*/; // 0x26
internal static readonly <PrivateImplementationDetails>.__StaticArrayInitTypeSize=17 4356... /*Metadata offset 0x3EBA60*/; // 0x36
internal static readonly <PrivateImplementationDetails>.__StaticArrayInitTypeSize=48 6F30... /*Metadata offset 0x3EBA78*/; // 0x47
internal static readonly <PrivateImplementationDetails>.__StaticArrayInitTypeSize=32 703A... /*Metadata offset 0x3EBAB0*/; // 0x77

Each line gives you:

  • the size of the array (16, 32, 48, ...),
  • a hash-like name,
  • and a metadata offset inside global-metadata.dat.

From IDA, you can see which of these fields each of GetKey, GetIV and GetFlag uses.

For Arno, it ended up like this for me:

  • Key: 32 bytes → offset 0x3EBAB0
  • IV: 16 bytes → offset 0x3EBA48
  • Encrypted flag: 48 bytes → offset 0x3EBA78

5.2. Extracting the bytes with xxd

With the offsets known, I went into the metadata directory and used xxd to slice the bytes straight out of global-metadata.dat:

cd app_dec/assets/bin/Data/Managed/Metadata

# KEY (32 bytes)
xxd -s 0x3EBAB0 -l 0x20 global-metadata.dat

# IV (16 bytes)
xxd -s 0x3EBA48 -l 0x10 global-metadata.dat

# FLAG ciphertext (48 bytes)
xxd -s 0x3EBA78 -l 0x30 global-metadata.dat

After cleaning the output into plain hex strings (no spaces, no offsets), I had:

key_hex = "cfdc33ccbee6dc775ba146b95d0fea6cbcc3ee3e5e76531d2cd79c140758f08d"
iv_hex  = "bbf5a8d7066fd51b43d959c044365cdf"
ct_hex  = "13ebf3953a9b8c13c6e5471f7eeaa0174b6c1fac41802002da16eb32fa88f63c570185a8bc218d9ef3ac03e218d30c55"

6. Understanding DecryptFlag in IDA

The last missing piece is how the app actually decrypts the flag. That’s all in FlagControl__DecryptFlag.

Following the calls in IDA, you can spot the usual suspects:

  • Aes.Create() (or the IL2CPP equivalent)
  • Key and IV being set from GetKey() / GetIV()
  • MemoryStream + CryptoStream in decrypt mode
  • Finally, Encoding.UTF8.GetString(...) on the decrypted bytes

AES-CBC with PKCS7 padding, output is a UTF-8 string (the flag).

7. Python script to decrypt the flag

Here’s the script I used. It only depends on pycryptodome. The hex values are the ones extracted with xxd earlier.
from Crypto.Cipher import AES
from binascii import unhexlify

key_hex = "cfdc33ccbee6dc775ba146b95d0fea6cbcc3ee3e5e76531d2cd79c140758f08d"
iv_hex  = "bbf5a8d7066fd51b43d959c044365cdf"
ct_hex  = "13ebf3953a9b8c13c6e5471f7eeaa0174b6c1fac41802002da16eb32fa88f63c570185a8bc218d9ef3ac03e218d30c55"

key = unhexlify(key_hex)
iv = unhexlify(iv_hex)
ct = unhexlify(ct_hex)

cipher = AES.new(key, AES.MODE_CBC, iv)
pt = cipher.decrypt(ct)

# strip PKCS7 padding
pad_len = pt[-1]
flag = pt[:-pad_len].decode("utf-8")
print(flag)

Running this prints the flag in the expected HTB{...} format.

Wrap-up

Arno follows the standard Unity IL2CPP Android challenge pattern:

  • Find libil2cpp.so + global-metadata.dat inside the APK.
  • Use Il2CppDumper to get native mappings and a reconstructed C# view.
  • Use IDA with script.json to recover function names.
  • Map <PrivateImplementationDetails> fields to offsets in the metadata file.
  • Rebuild the AES-CBC decryption in a script and recover the flag.