Skip to main content

Lone Explorer - Ctrl+Space Quals 2025

·2973 words·14 mins

Original Challenge Writeup by Frank01001

Wandering through the endless deep space, you feel the cold realization strike — your elbow brushed the console, and the instruction manual for your vessel was flung into the void, spinning away like a discarded prayer.

“Why would anyone design such a curse of a button?!” your voice cracks against the silence, swallowed by the infinite dark. The words linger, useless, as regret coils tighter around you. You should have studied the manual long before launch.

This ship, your ship, is infamous across the stars for a labyrinth of controls no sane mind can master. Every switch whispers confusion. Every dial mocks you. Returning home will demand more than luck.


You are currently orbiting Amdion Secundus and are required to navigate to the moon of Dan.

Challenge Overview
#

The challenge is a Unity game to reverse. The player is in a spaceship and has to figure out how to operate it to return home. When the binary is run, the player is presented with a spaceship deck. They can look around with the mouse and interact with various controls by clicking on them.

There are

  • Simple buttons (which constitute the main keyboard of the ship)
  • Switch buttons (which can be toggled on and off)
  • Dials (which can be rotated to set a value)
  • A single lever (which can be pulled up and down)
  • A butterfly lever (which can be pulled up and down)
  • A pullable piston (which sits at the bottom of the deck on the right side)

While you watch the instruction manual drifting away into the void, you can start interacting with the controls and notice that some of them trigger warnings: e.g. “Warning: Air pressure levels below critical levels!” or “Warning: Windshield opening!”. Just clicking around randomly will not get you very far, as the ship is very sensitive and many wrong actions will lead to a game over.

  • Air pressure low
  • Windshield opening

Reversing the Game
#

spaceship-deck

The game is built with Unity 6000.0.54f1 and is compiled with IL2CPP (Intermediate Language To C++) . This means that the intermediate representation of the C# code is translated to C++ for compilation.

There are existing tools to reverse engineer IL2CPP binaries, such as IL2CppInspector, which has been forked and improved in many instances. One of the most up-to-date versions (also compatible with Unity 6) is this one:

Once built, the tool allows to produce an equivalent Visual Studio solution, an injection project, json metadata files, and even deobfuscation scripts for decompilers like Ghidra, Binary Ninja and IDA Pro.

Where is the flag?
#

The flag for the challenge is not embedded in the binary. Instead, it is provided by the remote server upon submission of a correct solution. The solution is verified with a token generated by the game when the moon of Dan is reached.

Reaching the destination entails two main steps:

  • Selecting a valid path in the Navigation Systems and executing it
  • Turning on the Reality Engines

The former requires a careful consideration of available fuel, as well as the damage that highly-radioactive stars can cause to the ship’s shields. The latter requires a series of preconditions to be met.

Solution Steps
#

During the CTF, players came up with a wide range of creative strategies to tackle the challenge. Some hooked into the binary with Frida and called functions directly, while others relied on Cheat Engine to manipulate memory values in real time. A few participants went even further, reconstructing the Unity project from decompiled code—sometimes with the help of LLMs. Others chose a different route, using MelonLoader to inject custom code into the game and automate their solution.

Another, less invasive approach, is to debug the executable with breakpoints on custom functions to understand what each button and lever is doing. This is a bit more tedious, but it allows to understand the game logic and to find a solution without modifying the binary.

Identifying relevant functions is straightforward, since the demangled symbols start with the namespace or class name. For example you can ignore all functions that start with UnityEngine::, Unity::, UnityEditor::, Microsoft::, or System::.

If you patiently reverse each control, you should end up with a map of the ship’s deck. For your convenience, here is an interactive version of the ship’s deck with all controls labeled:

Switches like those of the Life Support and Oxygen Ejection cause game over in a few seconds, so they are the ones that should be avoided for a successful run.

Solution Steps
#

Let’s now go through the steps required to reach the moon of Dan and generate the token.

1. Equalizing the Reality Fuel Tanks
#

[Header("Settings")]
    float maxTankDelta = 0.3f; // Maximum allowed difference between left and right tank levels before balancing

    [...]

    public void ToggleEngine()
    {
        IsOpen = !IsOpen;

        float tankDelta = Mathf.Abs(fuelTankPair.levelsLeft - fuelTankPair.levelsRight);

        [...]

        bool prerequisitesMet = shields.AreOn &&
            [...]
            tankDelta < maxTankDelta &&

        IsOn = IsOpen && prerequisitesMet;
        [...]

One of the prerequisites to turn on the Reality Engines is that the difference between the left and right tanks for the Reality Fuel must be less than 0.3 liters. The tanks can be equalized by using the pullable piston on the left side of the deck, but among the 7 available tanks, the one containing Reality Fuel is the 7th one (index 6). The selected tank can be changed by using the dial on the north-east side of the deck (six presses).

2. Setting up the Helmic Regulator
#

The Helmic Regulator has a state represented by a 3x3 matrix of integers initialized to zeros. The target state is:

$$ \begin{bmatrix} 0 & -3 & 0 \\ 3 & 7 & 3 \\ 0 & -3 & 0 \end{bmatrix} $$

Let

$$ \mathbf{A} = \begin{bmatrix} a_{00} & a_{01} & a_{02} \\ a_{10} & a_{11} & a_{12} \\ a_{20} & a_{21} & a_{22} \end{bmatrix}, \quad \mathbf{A}' \text{ denotes the result after each transform, and } \\ \operatorname{clamp}(\cdot) \text{ clamps to the valid range.} $$

1. ArcotrinsicTransform (Clockwise Rotation)
#

$$ \mathbf{A}' = \begin{bmatrix} a_{20} & a_{10} & a_{00} \\ a_{21} & a_{11} & a_{01} \\ a_{22} & a_{12} & a_{02} \end{bmatrix}. $$

2. InverseGeorgianTransform (Counter-clockwise Rotation)
#

$$ \mathbf{A}' = \begin{bmatrix} a_{02} & a_{12} & a_{22} \\ a_{01} & a_{11} & a_{21} \\ a_{00} & a_{10} & a_{20} \end{bmatrix}. $$

3. QuantumFluxModulation (Checkerboard \(\pm 1\))
#

$$ a'_{ij}=\operatorname{clamp}\!\big(a_{ij}+\delta_{ij}\big), \qquad \delta_{ij}= \begin{cases} +1, & (i+j)\equiv 0 \pmod{2},\\[4pt] -1, & (i+j)\equiv 1 \pmod{2}. \end{cases} $$

4. TemporalPhaseAlignment (Transpose, then Nudge Middle Row)
#

$$ A^{T}_{ij}=a_{ji}, \qquad a'_{1j}=\operatorname{clamp}\!\big(A^{T}_{1j}+1\big)\ \ (j=0,1,2), \qquad a'_{ij}=A^{T}_{ij}\ \ (i\neq 1). $$

5. SubspaceHarmonicTuning (Toroidal Shift by \(rotorMode\))
#

Let

$$ k=\texttt{rotorMode}\bmod 3. $$

If \(\texttt{rotorMode}\) is even \(row(k)\) shifts right by \(1\):

$$ [a'_{k0},a'_{k1},a'_{k2}]=[a_{k2},a_{k0},a_{k1}],\qquad a'_{ij}=a_{ij}\ (i\neq k). $$

If \(\texttt{rotorMode}\) is odd (column \(k\) shifts down by \(1\)):

$$ \begin{bmatrix} a'_{0k}\\ a'_{1k}\\ a'_{2k} \end{bmatrix} = \begin{bmatrix} a_{2k}\\ a_{0k}\\ a_{1k} \end{bmatrix}, \qquad a'_{ij}=a_{ij}\ (j\neq k). $$

6. HarmonicModulationTransform (Column +2 / -2)
#

Let

$$ c_{+}=\texttt{rotorMode}\bmod 3,\qquad c_{-}=(\texttt{rotorMode}+1)\bmod 3, $$

then

$$ a'_{i\,c_{+}}=\operatorname{clamp}\!\big(a_{i\,c_{+}}+2\big),\qquad a'_{i\,c_{-}}=\operatorname{clamp}\!\big(a_{i\,c_{-}}-2\big),\qquad a'_{ij}=a_{ij}\ \ (j\notin\{c_{+},c_{-}\}). $$

Finding a sequence of operations to reach the target state from the initial state can be done with a simple brute-forcing or a search algorithm. There are infinite solutions, one of which is:

  1. Set \(rotorMode\) to 7
  2. Harmonic Modulation Transform
  3. Inverse Georgian Transform
  4. Temporal Phase Alignment
  5. Harmonic Modulation Transform
  6. Temporal Phase Alignment
  7. Arcotrinsic Transform
  8. Arcotrinsic Transform
  9. Quantum Flux Modulation
  10. Quantum Flux Modulation

3. Turning on the Fluid Links#

The Fluid Links can be turned on by clicking the fourth switch from the top on the left side of the deck. The linked paths must be set to 3 by using the dial on the north side of the deck (three presses).

4. Misc Settings
#

What follows are the remaining settings that must be set to the correct values:

  • Fluid Tanks Cooling Mode set to Gravitothermal Conversion (dial on the west side of the deck, 7 presses)
  • Roboidal Thrusters ON (bottom round button next to the numpad)
  • Spline Reticulators ON (bottom-left long button on the keyboard)
  • Jagger Stabilizers ON (third switch from the bottom on the left side of the deck)
  • Quantum Foam Injectors ON (button above the Helmic Regulator Inverse Georgian Transform)
  • Structural Stabilizers ON (button to the left of the planet selector, near the L-shaped enter button)

5. Navigation
#

Navigation is enabled by initiating the Laser Solidifier (top-most red switch on the right side of the deck, to the left of the buttefly lever) and then pressing the single button on top of the enter L-shaped key. The navigation screen will light up and you can start selecting bodies.

You can select stars, planets, and moons by using the three buttons on the row on the left of the Right Directional Indicator. The input field will be pre-filled with star-, planet-, or moon- respectively. You can then type the ID of the body you want to select by using the numpad on the right side of the deck. The IDs are 8-digit numbers, so you can use the numpad to type them in. If you make a mistake, you can use the backspace button (the button with a left arrow) to delete the last digit. Once you have typed in the ID, you can press the enter button (the large L-shaped button) to confirm your selection.

But what are the bodies you need to select? The game loads the universe map from a custom binary format which puts together the mantissas of floating point values with the same exponent for better compression. The universe map is parsed by the UniverseDecoder class. Once they are loaded in the game, you can easily parse them, bypassing the need to actually decode the custom format.

I made a simple Python script to explore the universe that I procedurally generated, so you can see the journey that we have to endure from Amdion Secundus to Dan:

The navigation is actually affected by some key restrictions, namely the fuel and the shields. The fuel is consumed based on the distance between bodies and their masses, while the shields are damaged by the radiation of stars. The TryValidatePath function in the NavigationSystems class checks if the selected path is valid based on these constraints.

using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;

enum SelectedBodyType
{
    Star,
    Planet,
    Moon
}

public class NavigationSystems : MonoBehaviour
{
    [...]

    // Constants
    private const double FUEL_ENERGY_PER_LITER = 385_367.0;
    private const double GRAVITATIONAL_CONSTANT = 6.67430;
    private const double ENERGY_PER_UNIT = 10_000.0;
    private const double SHIP_MASS = 0.00001;

    [...]

    public bool TryValidatePath(
        List<Body> path,
        out string error,
        out double litersRequired)
    {
        error = string.Empty;
        litersRequired = 0.0;

        if (path == null || path.Count < 2)
        {
            error = "Path must contain at least two bodies.";
            return false;
        }

        TankPair realityFuelTank = fluidTanks.tanks[6];
        double litersAvailable = realityFuelTank.levelsLeft + realityFuelTank.levelsRight;

        // --- Iterate hops ---
        double totalEnergyNeeded = 0.0;
        for (int i = 0; i < path.Count - 1; i++)
        {
            Body src = path[i];
            Body dst = path[i + 1];

            if (!CanTravel(src, dst))
            {
                error = $"Invalid hop: cannot travel from '{src.Name}' to '{dst.Name}'.";
                return false;
            }

            // Energy requirement for this hop (mirrors Python)
            double hopEnergy = ComputeEnergyRequirement(src, dst);
            totalEnergyNeeded += hopEnergy;

            double litersSoFar = totalEnergyNeeded / FUEL_ENERGY_PER_LITER;
            if (litersSoFar > litersAvailable)
            {
                litersRequired = litersSoFar;
                error = $"Not enough fuel to reach '{dst.Name}'. " +
                        $"Required so far: {litersSoFar:F2} L, available: {litersAvailable:F2} L.";
                return false;
            }

            // Shield damage when *at* src (matches Python: damage based on current location)
            double dmg = ComputeDamageAtLocation(src);
            shields.Health -= Math.Min(shields.Health, dmg);
            if (shields.Health <= 0.0)
            {
                litersRequired = totalEnergyNeeded / FUEL_ENERGY_PER_LITER;
                error = $"Shields would fail near '{src.Name}'. Path unsafe.";
                return false;
            }
        }

        litersRequired = totalEnergyNeeded / FUEL_ENERGY_PER_LITER;

        if (litersRequired > litersAvailable)
        {
            error = $"Not enough fuel. Required: {litersRequired:F2} L, available: {litersAvailable:F2} L.";
            return false;
        }

        return true;
    }

    // ---------- Helpers (ported logic) ----------

    private bool CanTravel(Body src, Body dst)
    {
        // Star -> Star (allowed)
        if (src is Star && dst is Star) return true;

        // Star -> Planet (must be its child)
        if (src is Star s && dst is Planet p) return ReferenceEquals(p.Parent, s);

        // Planet -> Star (must be its parent)
        if (src is Planet p2 && dst is Star s2) return ReferenceEquals(p2.Parent, s2);

        // Planet -> Moon (must be its child)
        if (src is Planet p3 && dst is Moon m) return ReferenceEquals(m.Parent, p3);

        // Moon -> Planet (must be its parent)
        if (src is Moon m2 && dst is Planet p4) return ReferenceEquals(m2.Parent, p4);

        // Everything else is invalid
        return false;
    }

    private double ComputeEnergyRequirement(Body src, Body dst)
    {
        double distance = Vector3d.Distance(src.Coords, dst.Coords);
        if (distance == 0)
        {
            // mirror Python's "orbiting around" fallback ďż˝ use 2 * radius of destination
            distance = 2.0 * dst.Radius;
        }

        // gravitational_ease = sqrt( (src.mass^2 + dst.mass^2) / 2 )
        double gravitationalEase = Math.Sqrt((src.Mass * src.Mass + dst.Mass * dst.Mass) / 2.0);

        // energy = ship_mass * (distance^2) * ENERGY_PER_UNIT / gravitational_ease
        double energy = SHIP_MASS * (distance * distance) * ENERGY_PER_UNIT / Math.Max(gravitationalEase, 1e-12);
        energy = Math.Max(FUEL_ENERGY_PER_LITER * 5, energy);  // ensure non-negative
        return energy;
    }

    private double ComputeDamageAtLocation(Body location)
    {
        if (location is Star star)
        {
            string rt = star.RadiationType?.Trim() ?? string.Empty;
            bool highRad = string.Equals(rt, "Gamma", StringComparison.OrdinalIgnoreCase) ||
                           string.Equals(rt, "X-ray", StringComparison.OrdinalIgnoreCase);

            if (highRad)
            {
                // radiation_amount = exp(T/3000) * attenuation * 300
                double radiationAmount = Math.Exp(star.TemperatureK / 3000.0) * star.AttenuationCoeff * 300.0;
                return Math.Max(0.0, radiationAmount);
            }
        }
        return 0.0;
    }

}

The path to the moon of Dan can be found with simple graph search algorithms. Given the value of fuel available, the solution was supposed to be unique. Unfotunately, due to some mistakes I made in translating my original Python mock to C# build of the challenge, this was not the case.

A possible valid path to the moon of Dan is:

  1. moon-30560278 (Amdion Secundus) [Automatically selected at start]
  2. planet-11993756 (Peia V)
  3. star-93815079 (Topia)
  4. start-57979924 (Lastria D)
  5. star-63172551 (Riona Ultra)
  6. planet-33397698 (Kogalac Minima)
  7. moon-15940827 (Dan)

6. Engaging the Reality Engines
#

The final steps are to disengage the handbrake (single lever on the left side of the deck) and to pull up the butterly lever on the right side of the deck. If everything is set up correctly, the Reality Engines will turn on and, after a visual effect that distorts reality, you will be presented with a congratulatory message and a token.

token-message

The (flawed) Token Generation
#

This was the code that generated the token at the end of the game:

using UnityEngine;
using System;
using System.Security.Cryptography;
using UnityEngine.UI;
using TMPro;

public class FlagVerificationTokenGenerator : MonoBehaviour
{
    private bool hasFinished = false;

    // References
    private RealityEngine realityEngine;
    private HelmicRegulator helmicRegulator;
    private FluidLinks fluidLinks;
    private FluidTanks fluidTanks;
    private NavigationSystems navigationSystems;

    [...]

    public void InitiateFinalCheck()
    {
        bool goodPath = true;
        int sequenceLength = navigationSystems.SelectedSequence.Count;

        if (navigationSystems.SelectedSequence[0].Id != "moon-30560278") goodPath = false;
        if (navigationSystems.SelectedSequence[sequenceLength - 1].Id != "moon-15940827") goodPath = false;

        string error = "";
        double usedFuel = 0.0;

        goodPath = goodPath && navigationSystems.TryValidatePath(navigationSystems.SelectedSequence, out error, out usedFuel);

        if (goodPath)
        {
            string token = GenerateToken(usedFuel);

            tokenDisplay.text = "Congratulations! Go get your flag:\n" + token + "\n\n(The token has been copied to your clipboard)";
            tokenDisplay.color = new Color(0.0f, 1.0f, 0.0f, 0.0f);
        }
        else
        {
            tokenDisplay.text = "Final path is invalid. Cannot generate token.";
        }

        tokenDisplay.fontSize = 24.24f;

        hasFinished = true;
    }

    public string GenerateToken(double usedFuel)
    {
        System.Text.StringBuilder toHash = new System.Text.StringBuilder();
        System.Text.StringBuilder completeToken = new System.Text.StringBuilder();

        foreach (var action in helmicRegulator.actionLog)
        {
            completeToken.Append(action + "-");
        }

        // Remove last hyphen and add a $
        if (helmicRegulator.actionLog.Count > 0)
            completeToken.Length--; // Remove last hyphen

        completeToken.Append("$");

        // Reality Engine state
        toHash.Append(realityEngine.IsOn ? "1" : "0");
        toHash.Append(realityEngine.IsOpen ? "1" : "0");
        toHash.Append(realityEngine.RoboidalThrustersOn ? "1" : "0");
        toHash.Append(realityEngine.SplineReticulatorsOn ? "1" : "0");
        toHash.Append(realityEngine.JaggerStabilizersOn ? "1" : "0");
        toHash.Append(realityEngine.QuantumFoamInjectorsOn ? "1" : "0");
        toHash.Append(realityEngine.StructuralStabilizersOn ? "1" : "0");
        toHash.Append(realityEngine.HandbrakeOn ? "1" : "0");
        toHash.Append(realityEngine.Gear.ToString());

        // Helmic Regulator state
        for (int i = 0; i < 3; i++)
            for (int j = 0; j < 3; j++)
                toHash.Append(helmicRegulator.matrixState[i][j].ToString("D2")); // Two digits per entry

        // Fluid Links state
        toHash.Append(fluidLinks.AreOn ? "1" : "0");
        toHash.Append(fluidLinks.LinkedPaths.ToString("X8")); // Hexadecimal representation

        // Fluid Tanks Cooling Mode
        toHash.Append(Enum.GetName(typeof(CoolingMode), fluidTanks.coolingMode));

        // Navigation Systems state
        var sequence = navigationSystems.SelectedSequence;
        foreach (var body in sequence)
        {
            toHash.Append(body.Id + ";");
        }

        toHash.Append(usedFuel.ToString("F6")); // Six decimal places for used fuel

        // SHA512
        var sha256 = SHA256.Create();
        var digest = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(toHash.ToString()));
        toHash.Clear();

        string hex = BitConverter.ToString(digest).Replace("-", "").ToLowerInvariant();

        completeToken.Append(hex);

        // Copy to clipboard
        GUIUtility.systemCopyBuffer = completeToken.ToString();

        return completeToken.ToString();
    }
}

During the CTF, I realized that I had made a mistake in trusting that my floating point calculations would be consistent across different systems. In fact, this mistake made token-based validation of the solution completely unreliable. Other than the calculations for the path validation, I had included a floating point value in the computation of the hash.

toHash.Append(usedFuel.ToString("F6")); // Six decimal places for used fuel

This meant that even if two players reached the moon of Dan with the same settings, if their floating point calculations differed even slightly, they would end up with different tokens. This was not the intended behavior, and could have scaled extremely poorly. Luckily, the manual verification of solutions during the CTF did not break the challenge.

I’m sharing the flawed code here for completeness, so that others can learn from my mistake. Be careful around floating points.

Putting the Pieces Together
#

The following video shows the complete sequence of actions in order to solve the challenge.

After connecting to the remote server and solving the proof-of-work, the token is submitted and the flag is received:

Your token is valid, congratulation captain! Here's your flag:
space{th3_1nstructi0n_m4nual_1s_n0w_orb17ing_4r0und_k4rn___1d1j20}