all writing

HKCERT CTF 2026 - [game] Not My Type

November 8, 202620 min read

Copying the passage was harder than typing it; after a dozen false starts, one cumulative drag and a strict character slice won the race.

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

I expected this to be about typing speed. It was about getting the passage out of a remote desktop before the visit expired.

Challenge

Category: game
Achievement: Not my type - win a race in Typeway 2000

Typeway 2000 is a typing race at the Game Center. It runs inside a remote desktop viewer, shows about three lines of a random passage, and expects around 1,150 to 1,180 characters. The race clock is two minutes. The remote visit is often shorter.

Inspecting the kiosk

The entry path was matchmaking JoinRoom{1:"game-center"}, a redeem call with an Origin header, a browser_session cookie, and a WebSocket carrying RFB 3.8, the VNC protocol, at 1280x720. The viewer page is 2,392 bytes of noVNC and opens one socket:

GET wss://game.cgo.chal.sh/s/<res>/<gen>/socket
Cookie: browser_session=...
Origin: https://game.cgo.chal.sh

S: RFB 003.008
C: RFB 003.008
S: 01 01              one security type: 1 (None)
C: 01
S: 00 00 00 00        SecurityResult OK
C: 01                 ClientInit, shared
S: 1280 720 + pixel format + "browser-<gen>:99"

The desktop is x11vnc on browser-<gen>:99. Two client messages make it usable. SetPixelFormat asks for the layout the canvas is built around, and SetEncodings asks for Raw only, so every update is a rectangle of bytes:

00 00 00 00 20 18 00 01 00 ff 00 ff 00 ff 10 08 00 00 00 00
  SetPixelFormat: 32bpp, depth 24, true colour, red 16 / green 8 / blue 0

02 00 00 01 00 00 00 00
  SetEncodings: one entry, Raw (0)

I got the first socket back as a 403. The client passed Origin twice, through additional_headers and through origin=. Removing one copy completed the handshake. The next connection completed and sat at update_seq=0: greeting, security, name, no pixels, close. The handshake had gone through, so I suspected the pixel format. The fix was the 20-byte SetPixelFormat that had produced the earlier vnc1.png capture. The host answers 503 while the browser boots, so the client retried the connect twelve times, four seconds apart, and got frames.

kiosk before a run

The copy

I had the model wrong for the first phase. The text buffer shows three lines, and I assumed it only exposed those lines, roughly 300 characters, and that the caret had to scroll it. That was not how the selection worked. A drag that starts above the passage at the STANDARD header and ends at the bottom of the buffer holds the whole passage in the X selection. Chromium's selection grows cumulatively, and x11vnc sends the whole thing on copy. The viewport shows three lines; the selection does not.

Earlier, dragging the visible text and pressing Ctrl+C returned 220 exact characters. Select-all did not: the app swallowed it and no ServerCutText arrived. Shift-click did not extend the selection, and a drag that ran past the bottom edge stopped at the visible area. Mouse wheel, scrollbar arrows, track clicks and thumb drags did not move the passage.

The first multi-line copies came back cumulative: 266, 415, 567, 719, 795 characters. A throwaway copy action reached 1,711 characters against a 1,171-character target. The lengths were not noise. Each drag added to the existing selection instead of replacing it.

The working geometry was a drag from (806, 435) at the header to (945, 549) at the bottom, held briefly so the view auto-scrolled. A neutral click at (700, 436) cleared the selection first:

await v.click(700, 436)                  # neutral header, outside the selection
await v.drag(806, 435, 945, 549, 8)      # header to bottom edge, scrolls while held
await v.tap(0x63, (KEY_CTRL,))           # Ctrl+C

Drags ending at y=541 left the viewport still. Drags ending at y=549 scrolled it, and the same drag also selected. Copying a different middle line at y=501 changed the clipboard and produced a throwaway cut; I drained that by cut_seq, the clipboard event counter, rejected any delayed one-liner under 150 characters, and then did the full header-to-bottom drag.

ServerCutText is the server's clipboard message. It is a type byte, three pad bytes, a 32-bit length, then the text:

03 00 00 00 00 00 04 c2  ...1218 bytes...

x11vnc only emits it when the clipboard changes. Re-copying the same selection produces no message at all, so cut_seq stays where it was. A ClientCutText nonce did not force a re-push either, because x11vnc suppresses the echo of a clipboard it just set. That is why the diagnostic was the counter and not a timeout: every wait compared cut_seq before and after, so silence was distinguishable from a slow event. The expensive case was Chromium. A mousedown inside an existing selection starts a drag-and-drop of the selected text, not a new selection. The screenshot after a three-line drag showed only the first line blue, and cut_seq had not moved. That one behaviour made 15 of 16 copy rounds return nothing while looking like a clipboard or timing problem.

OCR was exact at native resolution, but it only saw the visible lines, and OCR of a selected region sometimes turned into garbage. The two reads looked like this:

OCR        the afternoon. There is a certain pleasure in fixing something small. A loose
clipboard  the afternoon. There is a certain pleasure in fixing something small. A loose

OCR        handle a eranbed chalf a etuhharn bay: pach ane acke far a mamant nf
ocr_ratio  0.547, guard: copy mismatch

The first pair agrees character for character. The second is a selected region, where the blue highlight and antialiasing broke recognition. The guard compared OCR with the clipboard copy and refused to type on a mismatch. The ocr_ratio in the round log ranged from 0.23 to 0.55 on those failures; the false scroll detections were reading noise.

On the winning visit the raw clipboard was 1,218 characters. The header, STANDARD / 209 WORDS ... New text, occupied the first 37. The next 1,181 were the passage. I found the passage by its opening, not by counting a header:

raw = v.cut                        # 1218 chars, decoded latin-1
pos = raw.find(opening[:28])       # 37
passage = raw[pos:pos + target]    # 1181

The raw head and tail:

STANDARD / 209 WORDS
â\x86» New text
We made a list of things to bring: a notebook, two pens, a camera, and a little patience. ...

... By sunset, the map was folded away. Sometimes a wrong turn is exactly the right direction.

The arrow glyph arrived as six latin-1 bytes instead of one; the block above shows the control byte escaped. That is why the header measured 37 and not the 32 the page suggested. The slice itself was exact:

raw[37:37+1181]

I did not clean whitespace. An earlier slice passed through re.sub(r'\s+', ' ', ...) before the length check; that collapsed whitespace and left the passage five or six characters short of the displayed target. The winning slice kept every space and ended on direction.

The typing

The first complete burst had the right text and the wrong keys. My key mapping sent uppercase letters and shifted punctuation as Shift+lowercase:

if ch.isalpha() and ch.isupper():
    base = ord(ch.lower())
    return shift_down + key_down(base) + key_up(base) + shift_up

That run matched 1,141 of 1,162, with 21 incorrect entries, 91%, while BYTE sat at 41% with 01:12 left. The scoreboard gave the count; the screenshot showed the pattern in red: A, W and :.

shift run near the end

A KeyEvent is four header bytes and a 32-bit keysym. The header is type 4, the down flag, and two pad bytes:

04 01 00 00 00 00 00 41    KeyEvent down, keysym 0x41 'A'
04 00 00 00 00 00 00 41    KeyEvent up,   keysym 0x41 'A'
04 01 00 00 00 00 00 3a    KeyEvent down, keysym 0x3a ':'

Sending each printable character as its own ASCII keysym fixed it. A went as 0x41, : as 0x3a, and no synthetic Shift was added. The server derives the modifier from the keysym. I sent a ten-character sample, checked the counter, then sent the rest in 200-character frames with 40 ms between frames. The kiosk processed the queue and never stalled.

mid-race counter climbing

The counter is strict. I typed 298 characters from a clipboard-exact copy and the counter read 287 / 1180. Then I sent 50 filler z characters and it stayed at 287 / 1180. Only the correct next character advances it, so the copy had to be exact at the boundaries; the 11 rejected characters were enough to keep the caret inside the visible window. Another visit restarted the app with a new passage mid-race, clock back to 01:45. One typed line did not register at all and the status stayed at "Input ready".

The visit lifetime was the other limit. One visit was created at 13:28:01Z and closed at 13:29:32Z, 91 seconds later:

1008 (policy violation) access expired or control unavailable

The 30-second heartbeat did not extend it. Boot time varied from 0.5 seconds to about 38 seconds, so a run could lose half its life before the first character was on screen. The winning visit booted in 1.7 seconds.

The solver kept the whole run in one process: login, redeem, the RFB socket, the copy and the typing. Splitting those across tools burned the 91 seconds in round-trips.

The arithmetic said the race was not about speed. The rival, BYTE, was rated 110.5 WPM. That is 124.9 seconds for 1,150 characters against a 120-second cap, so BYTE could not finish the passage. Any completed run won. The problem was extraction fidelity.

The result was 1,181/1,181 matched, 100%, 1,182 WPM, 00:11, first place. The win popup showed the flag.

win popup with flag

hkcert26{born_to_type_forced_to_script_wczy}

The race did not unlock the achievement. After the win, typerace.py ach still reported typeracer_speed_uk99 unlocked_at: null. I ran submit_flag.py on the on-screen flag. The SubmitFlag call returned UNLOCKED (1) for typeracer_speed_uk99 with retry_after=0s. The row came from the flag submission, not from crossing the line.

The script

The solver below is a cleaned-up reconstruction, not the driver that ran. The host, ticket and cookie are placeholders, and the retry and OCR-check paths are abbreviated. It keeps the parts that mattered: the RFB handshake, the cumulative drag, the strict slice and direct ASCII keysyms.

The first stage logs in, redeems the visit and opens the RFB-over-WebSocket. open_session() appears before RFB because Python resolves the class name at call time; the class body continues in the next block. The handshake stops after ServerInit and hands off to configure(), where the drawing format is chosen.

#!/usr/bin/env python3
"""Reconstruction of the Typeway 2000 solver, not the original driver.

Placeholders:
    HOST: game host, without scheme.
    TICKET: fragment of the entry_url returned by JoinRoom{1:"game-center"}.
    COOKIE: browser_session value set by /access/redeem.

The live driver retried the WebSocket while the game host answered 503 during
browser boot. That loop is omitted here so the protocol flow stays readable.
A failed step raises and aborts rather than typing a partial passage.
"""

import asyncio
import json
import struct
import subprocess
import urllib.request

import websockets

HOST = "game.example.invalid"
ORIGIN = f"https://{HOST}"
TICKET = "<ticket-from-JoinRoom>"
COOKIE = "browser_session=<cookie>"

# The winning clipboard was 1218 latin-1 characters. The first 37 were the
# "STANDARD / 209 WORDS" header and the arrow glyph; the passage started at
# offset 37 and the kiosk's own counter showed 1181 characters.
#
# Do not whitespace-normalise the slice. An earlier version applied
# re.sub(r"\s+", " ", ...) before the length check; that collapsed runs and
# left the passage five or six characters short of the displayed target.
HEADER = 37
CHARS = 1181


async def login_and_join():
    """Return the ticket produced by login and JoinRoom{1:"game-center"}.

    POST /login and the matchmaking socket are account plumbing, not the
    exploit, so this reconstruction returns the ticket directly.
    """
    return TICKET


def redeem(ticket):
    """Exchange a matchmaking ticket for a browser session.

    /access/redeem requires the page Origin. Its response sets the
    browser_session cookie, which the WebSocket handshake must present again.
    """
    req = urllib.request.Request(
        f"{ORIGIN}/access/redeem",
        data=json.dumps({"ticket": ticket}).encode(),
        headers={"Origin": ORIGIN, "Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=20) as response:
        body = json.loads(response.read())
        cookie = response.headers["Set-Cookie"].split(";", 1)[0]

    # path is the per-visit /s/<resource>/<generation>/ prefix.
    return body["path"], cookie


async def open_session():
    """Redeem a visit and connect the RFB WebSocket.

    RFB is defined below this function. Python resolves the name when the
    function is called, not when it is defined, so this forward reference is
    safe once the whole module has loaded.
    """
    path, cookie = redeem(await login_and_join())
    url = f"wss://{HOST}{path.rstrip('/')}/socket"

    # The socket needs both the browser_session cookie and the page Origin.
    # The first live attempt sent Origin twice and got a 403. This call sends
    # exactly one. A 503 here means the VM is still booting; the live driver
    # retried twelve times, four seconds apart.
    ws = await websockets.connect(
        url,
        additional_headers={"Cookie": cookie},
        origin=ORIGIN,
        max_size=None,
    )
    rfb = RFB(ws)
    await rfb.handshake()
    return ws, rfb


class RFB:
    """Minimal RFB 3.8 client over the noVNC WebSocket."""

    def __init__(self, ws):
        self.ws = ws
        self.buf = bytearray()
        self.w = 1280
        self.h = 720
        self.cut = ""
        self.cut_seq = 0

    async def read(self, n):
        """Read exactly n bytes from the RFB stream.

        WebSocket frames do not align with RFB messages, so everything is
        buffered here and handed out per message.
        """
        while len(self.buf) < n:
            self.buf += await self.ws.recv()

        out = bytes(self.buf[:n])
        del self.buf[:n]
        return out

    async def handshake(self):
        # RFB starts with a 12 byte version string. The server offers 3.8 and
        # the client answers with the same version. A mismatch changes the
        # layout of every following message.
        assert (await self.read(12)).startswith(b"RFB ")
        await self.ws.send(b"RFB 003.008\n")

        # Security negotiation is one byte count followed by that many type
        # bytes. Type 1 is "None", the only type this kiosk offers. Sending
        # 0x01 selects it, and SecurityResult must be four zero bytes.
        count = (await self.read(1))[0]
        assert 1 in await self.read(count)
        await self.ws.send(b"\x01")

        security = await self.read(4)
        assert security == b"\x00\x00\x00\x00"

        # ClientInit: 0x01 means shared. The stock noVNC page sends the same
        # value, and the kiosk only has one viewer anyway.
        await self.ws.send(b"\x01")

        # ServerInit: width, height, a 16 byte server PixelFormat, then a
        # length-prefixed desktop name. The name identifies the VM desktop.
        self.w, self.h = struct.unpack(">HH", await self.read(4))
        name_len = struct.unpack(">I", await self.read(4))[0]
        await self.read(16 + name_len)
        self.canvas = bytearray(self.w * self.h * 4)

        # The transport is ready at this point, but no drawing format has
        # been chosen. configure() sends the two client messages that matter
        # for pixels and is defined in the next stage.
        await self.configure()

The next block sends SetPixelFormat and SetEncodings, then reads server messages. The 20-byte pixel format is the one that produced pixels in the live run; the earlier mismatch left update_seq at zero. Raw encoding keeps every update as literal BGRA rectangles, and parse() also catches the ServerCutText message that carries the clipboard.

    async def configure(self):
        # SetPixelFormat: 4 bytes of message type and padding, then 16 bytes
        # of format. 32 bpp, depth 24, little-endian, true colour, max 255 per
        # channel, shifts 16/8/0. This is BGRA in memory, which is what the
        # canvas and the PPM conversion below assume.
        #
        # The exact blob matters. A format that disagreed with the server
        # produced a completed handshake with update_seq=0 and no pixels in
        # the real run.
        pixel_format = struct.pack(
            ">BBBBHHHBBB", 32, 24, 0, 1, 255, 255, 255, 16, 8, 0
        )
        await self.ws.send(bytes(4) + pixel_format + b"\x00\x00\x00")

        # SetEncodings: type 2, pad, one entry, encoding 0 (Raw). Raw means
        # every update is a rectangle of literal BGRA bytes. There is no
        # compression state and no decoder surface to get wrong.
        encoding = struct.pack(">BBH", 2, 0, 1) + struct.pack(">i", 0)
        await self.ws.send(encoding)

    def parse(self):
        """Parse complete server messages from the buffer.

        Only framebuffer updates and cut text matter to this solver.
        """
        while self.buf:
            kind = self.buf[0]

            if kind == 0:
                # FramebufferUpdate: type, pad, number of rectangles. Each
                # rectangle is x, y, w, h, encoding, then w*h*4 Raw bytes.
                if len(self.buf) < 4:
                    return

                count = struct.unpack_from(">H", self.buf, 2)[0]
                pos = 4
                rects = []

                for _ in range(count):
                    if len(self.buf) < pos + 12:
                        return

                    x, y, w, h, enc = struct.unpack_from(">HHHHi", self.buf, pos)
                    pos += 12

                    if enc != 0 or len(self.buf) < pos + w * h * 4:
                        return

                    rects.append((x, y, w, h, self.buf[pos : pos + w * h * 4]))
                    pos += w * h * 4

                del self.buf[:pos]

                # The kiosk only sends changed regions, so old pixels must
                # survive in the persistent canvas.
                for x, y, w, h, data in rects:
                    for row in range(h):
                        dst = ((y + row) * self.w + x) * 4
                        self.canvas[dst : dst + w * 4] = data[
                            row * w * 4 : (row + 1) * w * 4
                        ]

            elif kind == 3:
                # ServerCutText is the clipboard. x11vnc only emits it when
                # the X clipboard actually changes, so re-copying the same
                # selection produces no message. cut_seq counts these
                # messages, not characters, and is the only reliable copy
                # signal.
                if len(self.buf) < 8:
                    return

                size = struct.unpack_from(">I", self.buf, 4)[0]
                if len(self.buf) < 8 + size:
                    return

                self.cut = bytes(self.buf[8 : 8 + size]).decode("latin-1")
                self.cut_seq += 1
                del self.buf[: 8 + size]

            else:
                # Bell or an unexpected type. One byte is not enough to know
                # the length, so stop parsing rather than desynchronise.
                return

    async def pump(self, seconds):
        """Poll the socket for a bounded time.

        RFB has no request/response correlation here, so callers pump until
        the state they need changes or the deadline passes.
        """
        end = asyncio.get_running_loop().time() + seconds
        while asyncio.get_running_loop().time() < end:
            try:
                chunk = await asyncio.wait_for(self.ws.recv(), 0.1)
            except asyncio.TimeoutError:
                continue

            self.buf += chunk
            self.parse()

This block is the copy path. It clears the old selection, drags from the STANDARD header to the bottom edge, sends Ctrl+C and waits on cut_seq rather than a fixed sleep. The slice is left unnormalised because the kiosk counts raw characters. The class body is split across blocks only for presentation.

    async def pointer(self, x, y, mask=0):
        # PointerEvent: type 5, button mask, x, y. Mask bit 0 is the left
        # button. A drag is mouse down, several moves with the bit held, then
        # mouse up at the destination.
        await self.ws.send(struct.pack(">BBHH", 5, mask, x, y))

    async def click(self, x, y):
        for mask in (0, 1, 0):
            await self.pointer(x, y, mask)
            await asyncio.sleep(0.02)

    async def drag(self, x1, y1, x2, y2, steps=8):
        # Start above the passage at the STANDARD header, not inside the
        # passage text. Chromium treats a mousedown inside an existing
        # selection as drag-and-drop of that selection, so starting inside it
        # moves text instead of growing the selection.
        #
        # The end point is the buffer's bottom edge. This is the only gesture
        # that auto-scrolls the kiosk; drags ending a few pixels higher leave
        # the viewport still.
        await self.pointer(x1, y1, 0)
        await self.pointer(x1, y1, 1)

        for i in range(1, steps + 1):
            x = x1 + (x2 - x1) * i // steps
            y = y1 + (y2 - y1) * i // steps
            await self.pointer(x, y, 1)
            await asyncio.sleep(0.016)

        await self.pointer(x2, y2, 0)

    async def key(self, keysym, down):
        # KeyEvent: type 4, down flag, two pad bytes, 32 bit keysym. The
        # keysym is an X11 keysym, not a Unicode code point.
        await self.ws.send(struct.pack(">BBHI", 4, int(down), 0, keysym))

    async def ctrl_c(self):
        # Ctrl+C: press Control_L, press c, release c, release Control_L.
        # 0xFFE3 is Control_L; 0x63 is lowercase c.
        for keysym, down in ((0xFFE3, 1), (0x63, 1), (0x63, 0), (0xFFE3, 0)):
            await self.key(keysym, down)

    async def cut_after(self, seq, timeout=2.0):
        """Wait for a cut event newer than seq.

        A plain sleep cannot distinguish a slow copy from a silent one,
        because x11vnc sends nothing for an unchanged clipboard.
        """
        end = asyncio.get_running_loop().time() + timeout
        while self.cut_seq <= seq and asyncio.get_running_loop().time() < end:
            await self.pump(0.1)

        return self.cut if self.cut_seq > seq else ""

    async def copy_passage(self):
        """Clear the selection, drag, copy, and slice the passage.

        The neutral click on the header clears any old selection and cannot
        start a new one. The drag then grows a cumulative selection from the
        STANDARD header to the bottom edge. The slice is not normalised: the
        kiosk counts raw characters, and collapsed whitespace shifts every
        following character.
        """
        await self.click(700, 436)
        await self.drag(806, 435, 945, 549)

        seq = self.cut_seq
        await self.ctrl_c()
        raw = await self.cut_after(seq)

        # If the copy was silent, raw is empty and the slice is short. Abort
        # rather than typing a partial passage: the counter is strict, and a
        # wrong character stalls the caret.
        passage = raw[HEADER : HEADER + CHARS]
        assert len(passage) == CHARS and passage.endswith("."), len(passage)
        return passage

The last block types the passage and captures the popup. It sends F2, focuses the input, then emits one ASCII keysym per character in 200-character frames. The final crop is read with tesseract; the flag is text on the win popup.

    async def type_text(self, text):
        # One KeyEvent down/up pair per printable character. The keysym is
        # the ASCII value itself: A is 0x41, : is 0x3a.
        #
        # Do not synthesize Shift+lowercase for uppercase or shifted
        # punctuation. The earlier run did that and produced 21 wrong
        # characters because the server applies the modifier again. x11vnc
        # derives the modifier from the keysym.
        for start in range(0, len(text), 200):
            blob = bytearray()

            for ch in text[start : start + 200]:
                keysym = 0xFF0D if ch == "\n" else ord(ch)
                blob += struct.pack(">BBHI", 4, 1, 0, keysym)
                blob += struct.pack(">BBHI", 4, 0, 0, keysym)

            # 200 characters is about 3.2 KB of events. The real client used
            # 200 character frames with a 40 ms gap so the kiosk could drain
            # its input queue without stalling.
            await self.ws.send(bytes(blob))
            await asyncio.sleep(0.04)

    def read_text(self, path, x, y, w, h):
        # Crop the framebuffer and write a binary PPM, then let tesseract
        # read it. PPM avoids a PNG encoder here. The crop is the win popup
        # region, where the flag is rendered as text.
        with open(path, "wb") as out:
            out.write(b"P6\n%d %d\n255\n" % (w, h))

            for row in range(y, y + h):
                for col in range(x, x + w):
                    i = (row * self.w + col) * 4

                    # Canvas is BGRA; PPM wants RGB.
                    out.write(
                        bytes(
                            (
                                self.canvas[i + 2],
                                self.canvas[i + 1],
                                self.canvas[i],
                            )
                        )
                    )

        result = subprocess.run(
            ["tesseract", path, "stdout", "--psm", "7"],
            capture_output=True,
            text=True,
            timeout=20,
        )
        return result.stdout.strip()


async def solve():
    ws, rfb = await open_session()

    try:
        passage = await rfb.copy_passage()

        # F2 arms the race. The first keystroke also starts the clock, but F2
        # avoids losing that first character to focus.
        await rfb.key(0xFFBF, True)
        await rfb.key(0xFFBF, False)

        # Click the text field before typing. Without focus the keys go to
        # the page and the counter does not move.
        await rfb.click(520, 568)
        await rfb.type_text(passage)

        # Give the kiosk time to process the queue and paint the result.
        await rfb.pump(6.0)
        print(rfb.read_text("/tmp/typeway-finish.ppm", 300, 180, 700, 260))
    # Always close the socket, including after a failed slice assertion.
    finally:
        await ws.close()


if __name__ == "__main__":
    asyncio.run(solve())