HTB: Arno Challenge

Arno - HackTheBox Challenge Writeup

Challenge Information

FieldValue
NameArno
CategoryMobile
DifficultyEasy
Authord3vn0mi

Description

Arno Dorian is known for his memorable quotes, but he also has a knack for letting his sharp tongue get him into trouble, often saying the wrong thing at the worst possible moments. Can you make him say the magic words?

Despite the site listing it under a generic/forensics-sounding header, Arno is actually a Unity mobile game shipped as an APK. The “magic words” turn out to be an AES-encrypted flag baked into the app’s IL2CPP metadata.

Solution

The provided download initially looked broken — a 0-byte Arno.apk. Re-fetching the challenge archive (rather than treating it as an OSINT dead end) produced the real, correctly sized APK, protected with the standard HTB zip password. Unpacking it revealed a Unity IL2CPP build: the interesting logic lives in libil2cpp.so plus global-metadata.dat, not in readable Java/Kotlin bytecode, since Unity strips the C# assemblies down to native code and a metadata blob at build time.

With no Il2CppDumper available in the environment, the metadata header was parsed directly in Python to locate the string, type, and field-default-value tables. That surfaced exactly one interesting user class, FlagControl, exposing GetKey, GetIV, GetFlag, and DecryptFlag methods — a strong signal the flag is decrypted at runtime from embedded key material rather than stored in plaintext.

Recovering the actual byte arrays required navigating two IL2CPP quirks:

  1. The Il2CppFieldDefaultValue table is ordered (fieldIndex, typeIndex, dataIndex), not the more commonly assumed (fieldIndex, dataIndex, typeIndex) — using the “obvious” ordering pulls nothing.
  2. Roslyn compiles constant byte arrays into a <PrivateImplementationDetails> class where each backing field is named after the uppercase SHA-256 hash of its own contents. So rather than guessing which blob is which, each candidate array’s length and hash were checked to positively identify it.

That resolved five embedded arrays of sizes 16, 17, 32, 38, and 48 bytes. Two were decoys — the source file path string (\Assets\Scripts\FlagControl.cs, 38 bytes) and the literal class name FlagControl (17 bytes) — while the real material was the 32-byte AES key, 16-byte IV, and 48-byte ciphertext. Decrypting the ciphertext with AES-256-CBC and the recovered key/IV yielded the flag with clean PKCS#7 padding, confirming the correct blobs had been identified.

Key Steps

1. Recover the real APK (the initial download was a 0-byte artifact):

Terminal window
# Re-download and unzip the HTB challenge archive with the standard password
unzip -o -P hackthebox a12c738d-....zip -d .
ls -la && file *.apk

2. Unpack the APK and confirm it’s a Unity IL2CPP build:

Terminal window
mkdir -p ex && unzip -o -q Arno.apk -d ex
ls -la ex/assets/bin/Data/
xxd ex/assets/bin/Data/Managed/Metadata/global-metadata.dat | head
# libil2cpp.so (~54MB) + global-metadata.dat (v31) confirm Unity IL2CPP

3. Parse the IL2CPP global-metadata header manually (no Il2CppDumper available):

import struct
d = open('ex/assets/bin/Data/Managed/Metadata/global-metadata.dat', 'rb').read()
# Probe each (offset, size) pair in the header and sanity-check
# against the known string-literal region to identify each table.
so, ss = 0x5d404, 0x11c31 # example: located string-literal section
strings_blob = d[so:so + ss]

4. Locate FlagControl and its field default values (note the non-obvious tuple order):

# Il2CppFieldDefaultValue rows are (fieldIndex, typeIndex, dataIndex) —
# NOT (fieldIndex, dataIndex, typeIndex) as one might assume.
for row in field_default_values:
field_index, type_index, data_index = row
# ...

5. Identify each embedded byte array by its SHA-256-named backing field:

import hashlib
# Roslyn names <PrivateImplementationDetails> fields after the
# uppercase SHA-256 hash of their own byte contents.
for blob in candidate_arrays:
digest = hashlib.sha256(blob).hexdigest().upper()
print(len(blob), digest) # match against field names to confirm identity

6. Decrypt the flag with the recovered key/IV/ciphertext:

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
key = arrays[32] # 32-byte AES key
iv = arrays[16] # 16-byte IV
ct = arrays[48] # 48-byte ciphertext
cipher = AES.new(key, AES.MODE_CBC, iv)
flag = unpad(cipher.decrypt(ct), AES.block_size)
print(flag.decode()) # -> HTB{REDACTED}

7. Submit the flag:

from htb_api import HTBClient
c = HTBClient()
c.submit_challenge_flag(challenge_id, "HTB{REDACTED}")

Tools Used

  • unzip — extracting the HTB archive and the APK contents
  • Python 3 (struct, hashlib) — manual IL2CPP global-metadata.dat parsing (no Il2CppDumper available)
  • pycryptodome (Crypto.Cipher.AES) — AES-256-CBC decryption with PKCS#7 unpadding
  • strings, xxd — initial triage of the metadata blob and APK assets

Key Learnings

  • A 0-byte download isn’t always a dead end to be OSINT’d — it can just be a bad fetch. Re-pulling the actual challenge archive resolved the “empty file” cleanly instead of chasing red herrings.
  • Unity mobile challenges hide logic in IL2CPP metadata, not Java/Kotlin. When an APK’s assets/bin/Data contains libil2cpp.so and global-metadata.dat, standard Android decompilers won’t show the interesting code — you need an IL2CPP-aware approach (ideally Il2CppDumper, or manual header parsing when it’s unavailable).
  • IL2CPP’s Il2CppFieldDefaultValue table ordering is a known trap — it’s (fieldIndex, typeIndex, dataIndex), and assuming the more intuitive (fieldIndex, dataIndex, typeIndex) silently yields nothing.
  • Roslyn-compiled constant arrays are self-identifying. <PrivateImplementationDetails> field names are the uppercase SHA-256 hash of the byte array they back — a reliable way to confirm which extracted blob is the key, IV, or ciphertext versus a decoy string constant, without guessing by size alone.
  • Decoy data is a real hazard in reversing challenges. Two of the five candidate byte arrays here were plausible-looking but irrelevant (a source path and a class name string); hash-verifying each candidate against its field name avoided wasting a decrypt attempt on the wrong blob.