all writing

HKCERT CTF 2026 - [game] Out of Reach

November 8, 202615 min read

A sealed room had no door, but its chest's 15-unit reach extended exactly to the nearest tile we could stand on.

Background

HKCERT CTF 2026 was a CTF hosted by HKCERT. The 24 hour event was held from 6 Nov, 2026 18:00 HKT to 7 Nov, 2026 18:00 HKT.

Team: Capture The Failures

Thoughts

The game category was 401 Unauthorized, a Unity dungeon crawler. I spent most of it looking at dungeon rooms. This one took eight hours and forty-four minutes to stop looking at the wrong room, then about a minute to open the chest.

Challenge

Category: game
Achievement: Out of Reach - "Opened the chest no one can walk to."

The target is a sealed-chest on dungeon floor 2. It sits in its own chamber, template room.sealed.chest_f2. The template is 22x18 with a continuous wall three tiles thick. The manifests assign it 0 doors. Flood fill finds 0 reachable floor tiles from spawn. There is no entrance.

Floor 2 names the template once. It is the only floor with the key, and the only place the template appears:

"slotCols": 13,
"slotRows": 13,
"bossTemplateId": "room.boss.grand_arena_f2",
"sealedRoomTemplateId": "room.sealed.chest_f2"

The template is not in floor 2's roomPool and there is no Sealed entry in its roomRules. The generator stamps it after the connected web, in a free slot at the edge. rooms.json carries the room itself. The collision rows are the whole argument, # wall and . floor:

######################
######################
######################
###................###
###................###
###................###
###................###
###................###
###.....#.....#....###
###................###
###................###
###................###
###................###
###................###
###................###
######################
######################
######################

The middle row has two pillars at local (8, 9) and (14, 9). The entity list has one anchor, and it is the chest:

"entities": [{"x": 11, "y": 9, "kind": "InteractableAnchor",
              "specId": "sealed-chest", "trigger": null}]

The chest's content entry says what the room is for:

{ "id": "sealed-chest", "interactive": "interact.treasure.chest",
  "config": { "type": "Rewards", "rewards": [
    { "id": "reward.currency", "config": { "type": "Currency",
      "kind": "Gold", "amount": 400, "chance": 1.0 } } ] } }

The stamped copy in both captured floor-2 generations matched the template tile for tile. No doorway is carved, and the wall ring is three tiles thick on every side. A door exists only as an entry in the chamber manifest's Doors[]. DoorDto carries a position, an orientation and a boss-gate flag, nothing else. The sealed chamber is absent from Doors[] in both generations, and there is no event that adds one.

The wrong room

The achievement list was the first lead. out_of_reach was there with its name visible, its description masked, no icon and a rate of 0.0. I mapped the other rows by elimination: corner_chest to the boss chest, home_chest to the lobby, maze_conqueror to the arcade. out_of_reach was the leftover. dungeons.json guarantees a pp1Room on floor 3, so I put the achievement on that PP1 secret room and spent eight hours and forty-four minutes there. That was wrong for the whole stretch. PP1 is a plate and candle circuit with its own achievement, secret_room_sequence, credited separately. out_of_reach was a chest.

What disproved it was the reveal. The row was credited at 20:31:30Z and its description resolved to Opened the chest no one can walk to. There is no chest in PP1. The floor-2 chamber, meanwhile, contains exactly one sealed-chest, and it is the only chamber in seven floor records with zero doors.

I had one false confirmation on the way. I sent an InteractCommand for interactable-0000006 right after a floor change and got command.dungeon.interact_invalid. It looked like the server refusing an out-of-range interact. The id was stale: interactables are numbered per floor, and floor 2 starts at interactable-0000014. The rejection was about the id, not the distance. The first live floor-2 chest I saw was interactable-0000015, then later interactable-0000026 in another generation. Reusing an id from the previous floor can only fail. I also tried the lobby chest from spawn:

RoomCommandRejected{CommandType:"InteractCommand",
                    FailReason:"command.lobby.interact_invalid"}

Six lobby runs died on the first leftward MoveCommand with a 1013 re-matchmake, which is the server dropping the run and re-queuing it. The lobby was not a route either.

Checking the range

Floor 2 had the only chamber with no doors. Across five distinct dungeon generations, seven floor records in total, no other chamber had zero doors. The sealed chamber had 0 doors, 0 passages and 0 graph edges in both floor-2 generations. Two full manifests carried 41 chambers with 136 doors, and 34 chambers with 98 doors. In the first layout the chamber next to it had two doors, both on other faces. The sealed one had none.

Flood fill from spawn reached 0 of its 190 floor tiles. In the first run, those 190 tiles were the only unreachable floor on the map. The count comes from the packed collision mask, not a mesh: bit = y * width + x, walkable = (mask[bit >> 3] >> (bit & 7)) & 1, flooded four ways from spawn_anchor. The template is stamped tile for tile, so the 190 floor cells are there and the wall ring is closed.

I measured the gap. In one layout the last walkable tile west of the vault was (238, 9) with centre (238.5, 9.5). The chest centre was (253.5, 9.5). Same y, exactly 15.000 apart. Six wall tiles sit between them at x=239..244. A second layout moved both objects and gave the same 15.000: last tile centre (260.5, 207.5), chest (275.5, 207.5):

run A:  253.5 - 238.5 = 15.000
run B:  275.5 - 260.5 = 15.000

Exactly one walkable tile centre per run falls inside 15.0, the boundary tile itself. The wall run between the two is six contiguous wall cells, three from the neighbour's east face and three from the vault's west face, so the 15.0 is not a round convenience. It is the distance the level leaves you.

Each interactable reports an interact_range. This chest reports 15.0. Every other floor-2 interactable reports 1.5 (29 of them) or 1.0 (one). A live floor-2 state line:

[cheat] layout 80edd87b... 286x234 floor=2 theme=olivelab spawn=(143.5, 9.5)
[cheat] chambers ready room=80edd87b... floor=2 chambers=42
[cheat] SealedChest ACK InteractableStateChanged id=interactable-0000026 active=False range=15 pos=(275.5, 207.5)
[cheat] SealedChest target id=interactable-0000026 spec=sealed-chest pos=(275.5, 207.5) range=15m active=False

The stock prompt takes PromptReach = Mathf.Min(InteractRange, 1.5f), so the hint can never appear at 15.0:

public static float PromptReach(InteractableState s)
{
    return Mathf.Min(s.InteractRange, 1.5f);
}

The client caps the prompt, not the command. InteractCommand carries only the host id:

public sealed class InteractCommand : GameCommand
{
    public string InteractHostId { get; }
}

On the wire the envelope is a sequence number and field 13. A real frame from an earlier probe, carrying a different object, shows the shape:

08 0c 6a 16 0a 14 696e74657261637461626c652d30303030303036
|     |  |  |     "interactable-0000006"
|     |  |  field 1, length 20
|     |  field 13, interact, length 22
|     input_seq 12

I pressed E at the wall and nothing happened. No focused target means no request, because InteractableVisualRegistry.RequestInteract only sends when the resolver has produced one. Movement and collision stay server side. Earlier remote interacts on other objects were rejected. The plan was to stay at the boundary and bypass only the prompt, not the range check.

The mod

I used a BepInEx plugin, a Unity mod loader, that I already had for map overlays. The sealed chest gets its own kind, checked before IsChest, with colour RGBA(1.00, 0.05, 0.55) (#FF0D8C) and label SEALED. It is not dimmed while inactive, so it does not look like a looted chest. The minimap paints the chamber in RGB (255, 0, 120) at 0.42 with a one-tile #FF46AF outline and a Vault:1 footer; the tint is baked into the cached texture and survives zoom and preset changes.

Minimap view of the sealed chamber, distinct from the normal rooms, with the chest marker

I added a J hotkey. It finds SpecId == "sealed-chest" in the live cache, takes the nearest match, and calls the game's own interact action with that run's object ID:

private static bool TryFindSealedChest(GameStateService state, Vector2 player,
                                       out InteractableState target)
{
    target = default(InteractableState);
    bool found = false;
    float bestSqr = float.MaxValue;

    foreach (KeyValuePair<string, InteractableState> pair in state.Interactables)
    {
        InteractableState candidate = pair.Value;
        if (!InteractableClassifier.IsSealed(candidate.SpecId)) continue;
        float sqr = (candidate.Position - player).sqrMagnitude;
        if (found && sqr >= bestSqr) continue;
        bestSqr = sqr;
        target = candidate;
        found = true;
    }
    return found;
}

The send is one call:

ServiceLocator.Get<IPlayerActions>().Interact(id).Forget();

That goes through IPlayerActions.Interact(hostId) and sends one InteractCommand. No teleport. No map edit. No prompt patch. The server still validates distance.

The module resets on room or floor change. It allows at most two sends per target per room, four seconds apart, with a five-second ack window. It latches off on RoomCommandRejected{InteractCommand}, re-arms on a genuine state flip, and stays inert after the run ends. The first build of the sealed module was clean: 0 warnings, 0 errors, CTFCheat.dll at 168,960 bytes.

The script

The module below is a cleaned-up reconstruction of the send path, not the shipped file. The overlay, minimap tint and detailed logging are removed. The API names and guards are the ones the post describes.

The first stage declares the fields, finds the sealed chest by SpecId, and computes the box distance. Finding by spec matters because the host id changes per floor and per run. The range check uses InteractRange, which is 15.0 here; the stock prompt caps the hint at 1.5, so the UI can never focus it.

// Reconstruction, not the shipped module. Illustrative BepInEx plugin.
using Cysharp.Threading.Tasks;
using BepInEx;
using UnityEngine;
using CTFGame.Interaction;
using CTFGame.Networking;
using CTFGame.Networking.Actions;
using CTFGame.Networking.Wire;

public sealed class SealedChestSolver : BaseUnityPlugin
{
    // The chest advertises 15.0 on floor 2. The fallback only applies if the
    // live state reports a non-positive range, which should not happen here.
    private const float FallbackRange = 15.0f;

    // Two sends separate "no answer" from "rejected". More attempts look like
    // spam and make the ack ambiguous.
    private const int MaxAttempts = 2;

    // Four seconds is one server tick plus a network round trip. The shipped
    // module used the same interval between attempts.
    private const float RetrySeconds = 4.0f;

    // Five seconds is how long a state change or rejection counts as the
    // answer to our send. After that the attempt is treated as stale.
    private const float AckSeconds = 5.0f;

    private string _roomId = "";
    private string _targetId = "";
    private int _floor = -1;
    private int _sends;
    private float _lastSend;
    private bool _pending;
    private bool _rejected;

    private static bool TryFindChest(
        GameStateService state,
        out InteractableState target
    )
    {
        // The live cache is keyed by per-run interactable id. Scan values and
        // filter by spec id so the same code works in any floor-2 generation.
        target = default;
        float best = float.MaxValue;

        foreach (var pair in state.Interactables)
        {
            InteractableState candidate = pair.Value;

            if (!InteractableClassifier.IsSealed(candidate.SpecId))
            {
                continue;
            }

            // Nearest match only. There is one sealed chest per floor, but
            // selecting by distance stays correct if a layout ever has more.
            float distance = (candidate.Position - state.LocalPlayerPosition)
                .sqrMagnitude;

            if (distance >= best)
            {
                continue;
            }

            best = distance;
            target = candidate;
        }

        return best < float.MaxValue;
    }

    private bool TryGetTargetInRange(
        GameStateService state,
        out InteractableState chest,
        out float range
    )
    {
        chest = default;
        range = 0f;

        // Find by spec id, not by a fixed interactable id. Interactable ids
        // are per floor and per run, so an id from a previous visit is stale
        // by definition. A stale id produces command.dungeon.interact_invalid,
        // which looks like a range rejection but is really the wrong object.
        if (!TryFindChest(state, out chest) || chest.IsActive)
        {
            return false;
        }

        var player = state.LocalPlayerPosition;
        range = chest.InteractRange > 0f ? chest.InteractRange : FallbackRange;

        // The 15.0 matters: the object's own range reaches the boundary tile,
        // while the stock prompt is capped at Mathf.Min(range, 1.5f) and can
        // never focus it. The server still sees the object's range, not the
        // prompt cap.
        //
        // Box distance, not centre distance. The stock client uses the same
        // formula: subtract each half extent, clamp at zero, then take the
        // length. With zero half extents this equals the centre distance,
        // which is the 15.000 case on this chest.
        float dx = Mathf.Max(
            Mathf.Abs(player.x - chest.Position.x) - chest.HalfExtents.x,
            0f
        );
        float dy = Mathf.Max(
            Mathf.Abs(player.y - chest.Position.y) - chest.HalfExtents.y,
            0f
        );

        return dx * dx + dy * dy <= range * range;
    }

The second stage is the J key handler. It resets state on a room or floor change, then applies the rejection latch, the pending ack window, the four-second debounce and the two-attempt cap before it calls the range helper and the send.

    private void Update()
    {
        // J is a deliberate one-shot. The stock E prompt never appears for
        // this chest: the object advertises 15.0 but the client caps the hint
        // at 1.5. A separate key avoids fighting the UI and keeps the send
        // explicit.
        if (!Input.GetKeyDown(KeyCode.J))
        {
            return;
        }

        var state = GameStateService.Instance;

        if (state == null || !state.IsRoomCurrent)
        {
            return;
        }

        // A dungeon run reuses the room id across floors, so the floor index
        // is what distinguishes a new layout. Reset both when either moves.
        // Without this, _sends and _rejected from the old floor leak into the
        // new one and the fresh chest looks already attempted.
        if (state.RoomId != _roomId || state.FloorIndex != _floor)
        {
            ResetRun(state.RoomId, state.FloorIndex);
        }

        // A rejection means the server already answered no for this target.
        // Keep the latch until the room changes so a held key cannot turn a
        // refusal into a retry loop.
        if (_rejected || _sends >= MaxAttempts)
        {
            return;
        }

        // While an answer is outstanding, do nothing. A second press could
        // overtake the first and make the ack ambiguous.
        if (_pending && Time.unscaledTime - _lastSend < AckSeconds)
        {
            return;
        }

        // Space repeated presses at least RetrySeconds apart. This is the
        // debounce that stops a key repeat or double tap from sending twice
        // in the same server tick.
        if (Time.unscaledTime - _lastSend < RetrySeconds)
        {
            return;
        }

        if (!TryGetTargetInRange(state, out InteractableState chest, out float range))
        {
            return;
        }

        Send(chest, range);
    }

The last stage performs the send and handles the answer. IPlayerActions.Interact carries only the host id, so the server still validates distance. The rejection handler latches the attempt, and the state handler clears the pending flag only for the target that was sent.

    private void Send(InteractableState chest, float range)
    {
        _targetId = chest.InteractableId;
        _sends++;
        _lastSend = Time.unscaledTime;
        _pending = true;

        Logger.LogInfo(
            $"SEND {_sends}/{MaxAttempts} {_targetId} range={range:F1}"
        );

        // This is the game's own action path. InteractCommand carries only
        // the host id; it has no position and no distance field. The range
        // check above is client-side politeness, and the server still
        // validates the real distance against its own player position.
        ServiceLocator.Get<IPlayerActions>().Interact(_targetId).Forget();
    }

    private void ResetRun(string roomId, int floor)
    {
        // Clear every send field on a room or floor change. Keeping _sends
        // across a descent would silently consume the new floor's attempts;
        // keeping _rejected would make a valid chest untouchable.
        _roomId = roomId;
        _floor = floor;
        _targetId = "";
        _sends = 0;
        _lastSend = 0f;
        _pending = false;
        _rejected = false;

        Logger.LogInfo($"room {roomId} floor {floor}; send state reset");
    }

    private void OnEnable()
    {
        RunEventBus.OnInteractableStateChanged += OnState;
        RunEventBus.OnRoomCommandRejected += OnRejected;
    }

    private void OnDisable()
    {
        RunEventBus.OnInteractableStateChanged -= OnState;
        RunEventBus.OnRoomCommandRejected -= OnRejected;
    }

    private void OnState(InteractableState state)
    {
        // Only the target we sent is interesting. The server also pushes an
        // initial inactive state on floor entry, which is not an answer.
        if (state.InteractableId != _targetId)
        {
            return;
        }

        _pending = false;

        if (state.IsActive)
        {
            Logger.LogInfo("accepted: chest active");
        }
    }

    private void OnRejected(RoomCommandRejectedEvent evt)
    {
        // A rejection is authoritative for this attempt. Latch it so the next
        // J press reports the reason instead of sending another command.
        if (evt.CommandType != "InteractCommand" || !_pending)
        {
            return;
        }

        _pending = false;
        _rejected = true;

        Logger.LogWarning($"rejected: {evt.FailReason}");
    }
}

Opening it

An earlier floor-2 log ends at discovery:

[cheat] SealedChest ACK InteractableStateChanged id=interactable-0000026 active=False range=15 pos=(275.5, 207.5)
[cheat] SealedChest target id=interactable-0000026 spec=sealed-chest pos=(275.5, 207.5) range=15m active=False
[cheat] RUN ENDED reason=DISCONNECT floor=2 pos=(145.0, 10.1)

That client dropped before the press. The player was at (145.0, 10.1) and the boundary tile is at (260, 207), so it was never close. The ACK is an initial state push, not a response to a command; no SEND line exists in the retained log. That log is how I confirmed the chest is real, per-run, and 15.0, and how I learned not to reuse its id.

The sealed build went live at 20:19:33Z. The instruction was one tile and one key: stand on the single tile whose centre is 15.000 from the chest, (238, 9) in the first layout and (260, 207) in the second, press J, and watch for InteractableStateChanged or RoomCommandRejected. The chest opened. At 20:31:30Z the platform credited out_of_reach. The description read: "Opened the chest no one can walk to."

That was it. The room has no door. The chest did not need one.