Photo by Dawid Zawiła / Unsplash

Writeup: The Flag Is A Lie - CyberEdu CTF (Reverse)

Feri Harjulianto

Overview

A Unity IL2CPP Linux game (built with Unity 6000.3.8f1) presents a Portal-inspired testing facility where the player solves puzzles involving crate pushing. The readme warns: "You wake up in a suspicious testing facility. In exchange for solving a few puzzles, you'll be rewarded with a nice flag... but, can you really trust it?" — hinting that the in-game flag is fake and the real flag is hidden elsewhere.

Analysis

1. Identifying the Game Structure

The game ships as a standard Unity IL2CPP build:

TheFlagIsALie.x86_64          # Linux executable
GameAssembly.so                # Compiled IL2CPP native code
TheFlagIsALie_Data/
  global-metadata.dat          # IL2CPP metadata (v39)
  level0                       # Main game scene
  level1                       # Secondary scene
  Logs/session-20260225-111621.unrl   # Encrypted recording file (~22 MB)

Using Cpp2IL, the game's C# classes were recovered. Key scripts found:

  • PlayerScript — Player movement/interaction
  • ExitGameOnTrigger — Calls Application.Quit on trigger
  • _o9b4256010e — Obfuscated name for FlagObfuscationController (42 methods)
  • _oc1e208cb7a — AES key holder class

2. Understanding the FlagObfuscationController

The FlagObfuscationController tracks every game entity's position and records it to a .unrl binary file. The recording format is:

Header: "UNRL" + uint32 version (2) + byte encrypted_flag (1)
Records: [4-byte IV_len][16-byte IV][4-byte CT_len][48-byte ciphertext] ...

Each record is AES-CBC encrypted. Decrypted records contain:

  • type (1 byte): 1=Create, 2=Delete, 3=Update
  • entityId (4 bytes): Entity identifier
  • timestamp (8 bytes): Double precision time
  • position (12 bytes): Vector3 (x, y, z) — for Create/Update
  • rotation (16 bytes): Quaternion — for Create/Update

Total: 312,098 records tracking 9,568 entities over the play session.

3. Extracting the AES Key

The AES key is stored as 16 int32 fields in the _oc1e208cb7a MonoBehaviour, serialized in level1. By reading the raw serialized data at the known offset:

Offset 0x78: 26 00 00 00  c1 00 00 00  c1 00 00 00  56 00 00 00
Offset 0x88: a2 00 00 00  2d 00 00 00  31 00 00 00  74 00 00 00
Offset 0x98: e5 00 00 00  eb 00 00 00  f7 00 00 00  c1 00 00 00
Offset 0xa8: c8 00 00 00  b9 00 00 00  4b 00 00 00  6e 00 00 00

Each int32 is truncated to a byte (values < 256), yielding the 16-byte AES key:

26 c1 c1 56 a2 2d 31 74 e5 eb f7 c1 c8 b9 4b 6e

4. Decrypting Entity Positions

Using the extracted key to decrypt all records:

from Crypto.Cipher import AES
import struct

key = bytes([0x26, 0xc1, 0xc1, 0x56, 0xa2, 0x2d, 0x31, 0x74,
             0xe5, 0xeb, 0xf7, 0xc1, 0xc8, 0xb9, 0x4b, 0x6e])

with open("session-20260225-111621.unrl", "rb") as f:
    header = f.read(9)  # UNRL header
    while True:
        iv_len_data = f.read(4)
        if len(iv_len_data) < 4:
            break
        iv_len = struct.unpack('<I', iv_len_data)[0]
        iv = f.read(iv_len)
        ct_len = struct.unpack('<I', f.read(4))[0]
        ct = f.read(ct_len)

        cipher = AES.new(key, AES.MODE_CBC, iv)
        pt = cipher.decrypt(ct)
        pad_len = pt[-1]
        pt = pt[:-pad_len]  # Strip PKCS7 padding

        rec_type = pt[0]
        entity_id = struct.unpack('<i', pt[1:5])[0]
        if rec_type in (1, 3) and len(pt) >= 25:
            x, y, z = struct.unpack('<fff', pt[13:25])
            # Track final position per entity

5. Discovering the Hidden Wall

Among 9,568 entities, entities 1-180 are special "wall" entities. All 180 are created at random positions initially but are updated throughout the session to their final positions on a wall at X ≈ 610.78 - 611.12.

Key observations:

  • All 180 wall entities survive until the end (never deleted)
  • Final positions: X ≈ 610-611, Y ≈ -1.7 to 6.1, Z ≈ 186 to 401
  • All rotations are identity quaternions (0, 0, 0, 1)

6. Rendering the Flag

Plotting the 180 wall entity positions on the Y-Z plane (side view of the wall) initially produced unreadable characters. The key insight was that the text is mirrored — it's meant to be read from the other side of the wall (flipping the Z axis):

from PIL import Image, ImageDraw

# Z-flipped rendering (view from behind the wall)
for x, y, z in positions:
    px = int((z_max - z) * scale)   # Flip Z axis
    py = int((y_max - y) * scale)
    draw.rectangle(...)

This reveals the flag in pixel-font block letters:

UNR{CERTIFIED_CRATE_PUSHER}

The wall blocks form 3-pixel-wide, 5-pixel-tall characters arranged on a ~215-unit wide wall, readable only when viewed from behind (Z-axis mirrored).

Summary

StepTechnique
1Cpp2IL to recover C# class/method names from IL2CPP binary
2Parse Unity serialized data in level1 to extract AES key
3Decrypt 312K AES-CBC encrypted entity position records from .unrl
4Track final positions of 180 wall entities through Create/Update records
5Render wall entity positions as 2D image, Z-axis mirrored

The challenge title "The Flag Is A Lie" (a Portal reference) hints that the flag shown in-game is fake. The real flag is hidden in the encrypted entity tracking data — 180 wall blocks that spell out the answer when viewed from the correct direction.

Flag

UNR{CERTIFIED_CRATE_PUSHER}
CryptoCTFWriteupReverse