rev/constructor (371 solves) Link to heading
Heard of constructor?
We are given a chall binary, running file chall says it’s an ELF binary.
$ file chall
chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
It looks like it’s stripped as well.
Opening it in IDA, we can see some strings:

/proc/self/cmdline seems useful, looking at the string usages, we can find where it’s used:

At this point, we can see that the flag is read from command line and function sub_401910 is responsible for checking whether the flag is correct or not.
As I’m a bit lazy and the binary is stripped. I wanted to see if I can cheese the challenge with instruction counting. We know the flag starts with idek{, so we can test the first few characters and see how many instructions are executed.
$ perf stat -e instructions ./chall
👀
Performance counter stats for './chall':
1,954 instructions:u
$ perf stat -e instructions ./chall i
Wrong!
Performance counter stats for './chall i':
1,981 instructions:u
perf stat -e instructions ./chall id
Wrong!
Performance counter stats for './chall id':
1,988 instructions:u
and so on…
We can see that the number of instructions is not increasing by much, but it still is increasing.
Creating a simple python script to automate this:
import subprocess
import json
import string
def check(flag):
result = subprocess.run(
["perf", "stat", "-e", "instructions", "-j", "./chall", flag],
capture_output=True,
text=True,
)
output = json.loads(result.stderr)
return output
current_flag = ""
alphabet = string.printable
while True:
best_guess = None
best_value = 0
for char in alphabet:
guess = current_flag + char
output = check(guess)
if output is None:
continue
value = float(output.get("counter-value", "0"))
if value > best_value:
best_value = value
best_guess = guess
current_flag = best_guess
print(f"Current flag: {current_flag}")
Running this script, we can see that it finds the flag in a few seconds:
$ python solve.py
Current flag: i
Current flag: id
Current flag: ide
Current flag: idek
Current flag: idek{
Current flag: idek{h
Current flag: idek{he
...
Current flag: idek{he4rd_0f_constructors?_now_you_d1d!!}
idek{he4rd_0f_constructors?_now_you_d1d!!}
misc/gacha-gate (144 solves) Link to heading
⸜(。˃ ᵕ ˂ )⸝♡
nc gacha-gate.chal.idek.team 1337
Connecting to the server, we get the following response:
$ nc gacha-gate.chal.idek.team 1337
== proof-of-work: disabled ==
lets play a game!
(~(~(~((~((((~(90360693 & 2848942516)) | 2720339680) & (((~iiliiIIliI) | (IliiIllilI & iiIIllllII)) | ((IiiIillllI | 3250535904) & (IilIliIiIi ^ IiiIillIlI)))) & (~lllIliIiIl))) | ((~((~((IlIIIIIlli ^ 2482988931) ^ (~IIiiIiliii))) | (((~3379024963) & (~1495863895)) | ((3904843086 | 4233479438) | (47910526 ^ lIillliiii))))) ^ iiIIiIiIiI)))))
Looks like an expression, let’s open the attachment and see the server code:
#!/usr/bin/env python3
import contextlib
import os
import random
import re
import signal
import sys
from z3 import ArithRef, BitVec, BitVecRef, BitVecVal, Solver, simplify, unsat
WIDTH = 32
OPS = ['~', '&', '^', '|']
MAX_DEPTH = 10
FLAG = os.getenv('FLAG', 'idek{fake_flag}')
VARS = set('iIl')
def rnd_const() -> tuple[str, BitVecRef]:
v = random.getrandbits(WIDTH)
return str(v), BitVecVal(v, WIDTH)
def rnd_var() -> tuple[str, BitVecRef]:
name = ''.join(random.choices(tuple(VARS), k=10))
return name, BitVec(name, WIDTH)
def combine(
op: str,
left: tuple[str, BitVecRef],
right: tuple[str, BitVecRef] | None = None,
) -> tuple[str, ArithRef]:
if op == '~':
s_left, z_left = left
return f'(~{s_left})', ~z_left
s_l, z_l = left
s_r, z_r = right
return f'({s_l} {op} {s_r})', {
'&': z_l & z_r,
'^': z_l ^ z_r,
'|': z_l | z_r,
}[op]
def random_expr(depth: int = 0) -> tuple[str, ArithRef]:
if depth >= MAX_DEPTH or random.random() < 0.1:
return random.choice((rnd_var, rnd_const))()
op = random.choice(OPS)
if op == '~':
return combine(op, random_expr(depth + 1))
return combine(op, random_expr(depth + 1), random_expr(depth + 1))
TOKEN_RE = re.compile(r'[0-9]+|[iIl]+|[~&^|]')
def parse_rpn(s: str) -> ArithRef:
tokens = TOKEN_RE.findall(s)
if not tokens:
raise ValueError('empty input')
var_cache: dict[str, BitVecRef] = {}
stack: list[BitVecRef] = []
for t in tokens:
if t.isdigit():
stack.append(BitVecVal(int(t), WIDTH))
elif re.fullmatch(r'[iIl]+', t):
if t not in var_cache:
var_cache[t] = BitVec(t, WIDTH)
stack.append(var_cache[t])
elif t in OPS:
if t == '~':
if len(stack) < 1:
raise ValueError('stack underflow')
a = stack.pop()
stack.append(~a)
else:
if len(stack) < 2:
raise ValueError('stack underflow')
b = stack.pop()
a = stack.pop()
stack.append({'&': a & b, '^': a ^ b, '|': a | b}[t])
else:
raise ValueError(f'bad token {t}')
if len(stack) != 1:
raise ValueError('malformed expression')
return stack[0]
def equivalent(e1: ArithRef, e2: ArithRef) -> tuple[bool, Solver]:
s = Solver()
s.set(timeout=5000)
s.add(simplify(e1) != simplify(e2))
return s.check() == unsat, s
def _timeout_handler(_: int, __) -> None:
raise TimeoutError
def main() -> None:
signal.signal(signal.SIGALRM, _timeout_handler)
print('lets play a game!')
for _ in range(50):
random.seed()
expr_str, expr_z3 = random_expr()
print(expr_str, flush=True)
signal.alarm(5)
try:
line = sys.stdin.readline()
signal.alarm(0)
except TimeoutError:
print('too slow!')
return
try:
rpn_z3 = parse_rpn(line.strip())
except Exception as e:
print('invalid input:', e)
return
print('let me see..')
is_eq, s = equivalent(expr_z3, rpn_z3)
if not is_eq:
print('wrong!')
with contextlib.suppress(BaseException):
print('counter example:', s.model())
return
print(FLAG)
if __name__ == '__main__':
main()
The server generates a random expression in infix notation, then asks us to provide the same expression in reverse polish notation.
The solution is simple: read the expression, convert it to rpn and send it back. This is already a solved problem, so I asked ChatGPT for a function to convert infix to rpn, then just added pwntools code.
import re
from typing import Iterable, List
from pwn import *
TOKEN_RE = re.compile(r"[0-9]+|[iIl]+|[~&^|()]")
PREC = {
"~": 3,
"&": 2,
"^": 1,
"|": 0,
}
RIGHT_ASSOCIATIVE = {"~"}
def infix_to_rpn(expr: str) -> str:
output: List[str] = []
op_stack: List[str] = []
tokens = TOKEN_RE.findall(expr)
if not tokens:
raise ValueError("no tokens found")
for tok in tokens:
if tok.isdigit() or re.fullmatch(r"[iIl]+", tok):
output.append(tok)
elif tok in PREC:
while op_stack:
top = op_stack[-1]
if top == "(":
break
if PREC[top] > PREC[tok] or (
PREC[top] == PREC[tok] and tok not in RIGHT_ASSOCIATIVE
):
output.append(op_stack.pop())
else:
break
op_stack.append(tok)
elif tok == "(":
op_stack.append(tok)
elif tok == ")":
while op_stack and op_stack[-1] != "(":
output.append(op_stack.pop())
if not op_stack or op_stack.pop() != "(":
raise ValueError("mismatched parentheses")
else:
raise ValueError(f"bad token {tok!r}")
while op_stack:
op = op_stack.pop()
if op == "(":
raise ValueError("mismatched parentheses")
output.append(op)
return " ".join(output)
io = remote("gacha-gate.chal.idek.team", 1337)
io.recvuntil(b"lets play a game!\n")
for i in range(50):
expr = io.recvline().decode().strip()
sol = infix_to_rpn(expr)
io.sendline(sol.encode())
io.recvuntil(b"let me see..\n")
print(f"Done round {i+1}")
io.interactive()
Running the script, we get the flag:
idek{n4nds_r_funct10nally_c0mpl3t3!}
crypto/Catch (134 solves) Link to heading
Through every cryptic stride, the cat weaves secrets in the fabric of numbers—seek the patterns, and the path shall reveal the prize.
nc catch.chal.idek.team 1337
We are provided with a chall.py file:
from Crypto.Random.random import randint, choice
import os
# In a realm where curiosity roams free, our fearless cat sets out on an epic journey.
# Even the cleverest feline must respect the boundaries of its world—this magical limit holds all wonders within.
limit = 0xE5DB6A6D765B1BA6E727AA7A87A792C49BB9DDEB2BAD999F5EA04F047255D5A72E193A7D58AA8EF619B0262DE6D25651085842FD9C385FA4F1032C305F44B8A4F92B16C8115D0595CEBFCCC1C655CA20DB597FF1F01E0DB70B9073FBAA1AE5E489484C7A45C215EA02DB3C77F1865E1E8597CB0B0AF3241CD8214BD5B5C1491F
# Through cryptic patterns, our cat deciphers its next move.
def walking(x, y, part):
# Each step is guided by a fragment of the cat's own secret mind.
epart = [int.from_bytes(part[i : i + 2], "big") for i in range(0, len(part), 2)]
xx = epart[0] * x + epart[1] * y
yy = epart[2] * x + epart[3] * y
return xx, yy
# Enter the Cat: curious wanderer and keeper of hidden paths.
class Cat:
def __init__(self):
# The cat's starting position is born of pure randomness.
self.x = randint(0, 2**256)
self.y = randint(0, 2**256)
# Deep within, its mind holds a thousand mysterious fragments.
while True:
self.mind = os.urandom(1000)
self.step = [self.mind[i : i + 8] for i in range(0, 1000, 8)]
if len(set(self.step)) == len(self.step):
break
# The epic chase begins: the cat ponders and strides toward the horizon.
def moving(self):
for _ in range(30):
# A moment of reflection: choose a thought from the cat's endless mind.
part = choice(self.step)
self.step.remove(part)
# With each heartbeat, the cat takes a cryptic step.
xx, yy = walking(self.x, self.y, part)
self.x, self.y = xx, yy
# When the wild spirit reaches the edge, it respects the boundary and pauses.
if self.x > limit or self.y > limit:
self.x %= limit
self.y %= limit
break
# When the cosmos beckons, the cat reveals its secret coordinates.
def position(self):
return (self.x, self.y)
# Adventurer, your quest: find and connect with 20 elusive cats.
for round in range(20):
try:
print(f"👉 Hunt {round+1}/20 begins!")
cat = Cat()
# At the start, you and the cat share the same starlit square.
human_pos = cat.position()
print(f"🐱✨ Co-location: {human_pos}")
print(f"🔮 Cat's hidden mind: {cat.mind.hex()}")
# But the cat, ever playful, dashes into the unknown...
cat.moving()
print("😸 The chase is on!")
print(f"🗺️ Cat now at: {cat.position()}")
# Your turn: recall the cat's secret path fragments to catch up.
mind = bytes.fromhex(input("🤔 Path to recall (hex): "))
# Step by step, follow the trail the cat has laid.
for i in range(0, len(mind), 8):
part = mind[i : i + 8]
if part not in cat.mind:
print("❌ Lost in the labyrinth of thoughts.")
exit()
human_pos = walking(human_pos[0], human_pos[1], part)
# At last, if destiny aligns...
if human_pos == cat.position():
print("🎉 Reunion! You have found your feline friend! 🐾")
else:
print("😿 The path eludes you... Your heart aches.")
exit()
except Exception:
print("🙀 A puzzle too tangled for tonight. Rest well.")
exit()
# Triumph at last: the final cat yields the secret prize.
print(f"🏆 Victory! The treasure lies within: {open('flag.txt').read()}")
The cat moves using linear transformations - each 8-byte fragment contains 4 coefficients that form a 2x2 matrix: (x', y') = (a*x + b*y, c*x + d*y).
Since we know where the cat starts and ends up, we just need to reverse each transformation. For a 2x2 matrix with determinant det = a*d - b*c, the inverse is:
x = (d*x' - b*y') / dety = (-c*x' + a*y') / det
We can work backwards from the final position: try each fragment to see which one gives us integer coordinates (exact division), then repeat until we get back to the start.
Solve script:
from pwn import *
def split(mind: bytes):
return [mind[i : i + 8] for i in range(0, 1000, 8)]
def coeffs(part: bytes):
return [int.from_bytes(part[i : i + 2], "big") for i in range(0, 8, 2)]
def inv_step(xp, yp, a, b, c, d):
det = a * d - b * c
if det == 0:
return None
num_x = d * xp - b * yp
num_y = -c * xp + a * yp
if num_x % det or num_y % det:
return None
return num_x // det, num_y // det
def solve(xf, yf, parts):
seq = []
cx, cy = xf, yf
for _ in range(30):
for p in parts:
a, b, c, d = coeffs(p)
prev = inv_step(cx, cy, a, b, c, d)
if prev is None:
continue
px, py = prev
seq.append(p)
cx, cy = px, py
break
return list(reversed(seq))
io = remote("catch.chal.idek.team", 1337)
for round in range(20):
io.recvuntil(b"begins!")
io.recvuntil(b"Co-location: (")
x0, y0 = io.recvuntil(b")", drop=True).strip().decode().split(", ")
io.recvuntil(b"hidden mind: ")
mind = bytes.fromhex(io.recvline().strip().decode())
io.recvuntil(b"Cat now at: (")
xf, yf = io.recvuntil(b")", drop=True).strip().decode().split(", ")
io.recvuntil(b"(hex): ")
parts = split(mind)
x0, y0, xf, yf = map(int, (x0, y0, xf, yf))
seq = solve(xf, yf, parts)
io.sendline("".join(p.hex() for p in seq))
print(f"Done round {round+1}")
io.interactive()
Running the script, we get the flag:
🎉 Reunion! You have found your feline friend! 🐾
🏆 Victory! The treasure lies within: idek{Catch_and_cat_sound_really_similar_haha}
idek{Catch_and_cat_sound_really_similar_haha}
web/midi visualizer (38 solves) Link to heading
upload, visualize, and share your midi files here! please just don’t steal my unreleased songs
We are given the source code for the challenge. It’s a simple web server written with Deno:
import { serveDir } from "jsr:@std/http/file-server";
Deno.serve({ port: 1337 }, async (req) => {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/upload") {
try {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return new Response("no file provided", { status: 400 });
}
const bytes = new Uint8Array(await file.arrayBuffer());
const randomBytes = crypto.getRandomValues(new Uint8Array(16));
const hex = Array.from(randomBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const filename = `${hex}.mid`;
await Deno.writeFile(`uploads/${filename}`, bytes);
return new Response(
JSON.stringify({
filename: filename,
}),
{
headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
return new Response(`upload failed`, { status: 500 });
}
}
if (url.pathname === "/") {
const file = await Deno.readFile("./index.html");
return new Response(file, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
if (url.pathname.startsWith("/uploads/")) {
return serveDir(req, {
fsRoot: "uploads",
urlRoot: "uploads",
});
}
return serveDir(req, {
fsRoot: "static",
urlRoot: "static",
showDirListing: true,
showDotfiles: true,
});
});
The flag is stored in uploads/ folder but with a random name. We need a way to enumerate uploads and find the name of the flag file.
I thought of uploading a malicious file (e.g. a symlink) but that wouldn’t work in this case. My next guess was the serveDir function, we can find the code on GitHub:
async function createServeDirResponse(
req: Request,
opts: ServeDirOptions,
) {
const target = opts.fsRoot ?? ".";
const urlRoot = opts.urlRoot;
const showIndex = opts.showIndex ?? true;
const cleanUrls = (opts as { cleanUrls?: boolean }).cleanUrls ?? false;
const showDotfiles = opts.showDotfiles || false;
const { etagAlgorithm = "SHA-256", showDirListing = false, quiet = false } =
opts;
const url = new URL(req.url);
const decodedUrl = decodeURIComponent(url.pathname);
let normalizedPath = posixNormalize(decodedUrl);
if (urlRoot && !normalizedPath.startsWith("/" + urlRoot)) {
return createStandardResponse(STATUS_CODE.NotFound);
}
// Redirect paths like `/foo////bar` and `/foo/bar/////` to normalized paths.
if (normalizedPath !== decodedUrl) {
url.pathname = normalizedPath;
return Response.redirect(url, 301);
}
if (urlRoot) {
normalizedPath = normalizedPath.replace(urlRoot, "");
}
// Remove trailing slashes to avoid ENOENT errors
// when accessing a path to a file with a trailing slash.
if (normalizedPath.endsWith("/")) {
normalizedPath = normalizedPath.slice(0, -1);
}
// Exclude dotfiles if showDotfiles is false
if (!showDotfiles && /\/\./.test(normalizedPath)) {
return createStandardResponse(STATUS_CODE.NotFound);
}
// Resolve path
// If cleanUrls is enabled, automatically append ".html" if not present
// and it does not shadow another existing file or directory
let fsPath = join(target, normalizedPath);
if (cleanUrls && !fsPath.endsWith(".html") && !(await exists(fsPath))) {
fsPath += ".html";
}
const fileInfo = await Deno.stat(fsPath);
// For files, remove the trailing slash from the path.
if (fileInfo.isFile && url.pathname.endsWith("/")) {
url.pathname = url.pathname.slice(0, -1);
return Response.redirect(url, 301);
}
// For directories, the path must have a trailing slash.
if (fileInfo.isDirectory && !url.pathname.endsWith("/")) {
// On directory listing pages,
// if the current URL's pathname doesn't end with a slash, any
// relative URLs in the index file will resolve against the parent
// directory, rather than the current directory. To prevent that, we
// return a 301 redirect to the URL with a slash.
url.pathname += "/";
return Response.redirect(url, 301);
}
// if target is file, serve file.
if (!fileInfo.isDirectory) {
return serveFile(req, fsPath, {
etagAlgorithm,
fileInfo,
});
}
// if target is directory, serve index or dir listing.
if (showIndex) { // serve index.html
const indexPath = join(fsPath, "index.html");
let indexFileInfo: Deno.FileInfo | undefined;
try {
indexFileInfo = await Deno.lstat(indexPath);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) {
throw error;
}
// skip Not Found error
}
if (indexFileInfo?.isFile) {
return serveFile(req, indexPath, {
etagAlgorithm,
fileInfo: indexFileInfo,
});
}
}
if (showDirListing) { // serve directory list
return serveDirIndex(fsPath, { showDotfiles, target, quiet });
}
return createStandardResponse(STATUS_CODE.NotFound);
}
showDirListing option is exactly what we need. But it’s only enabled for static directory, not uploads.
Going to /static/../uploads/ wouldn’t work because the URL gets normalized and checked if it’s the same. This would just redirect you to /uploads/.
The bug happens here:
if (urlRoot) {
normalizedPath = normalizedPath.replace(urlRoot, "");
}
Let’s see what would happen if went to /static../uploads/:
- URL path =
/static../uploads/ - Normalizes to
/static../uploads/ - Passes startsWith("/static") check
- “static” replaced with "" =>
/../uploads/ showDirListingis enabled, so it serves the directory listing
Going to that URL gives us the directory listing of uploads/:

Scrolling to the bottom, we can see the flag file:

Opening the file in a MIDI editor gives us the flag:

idek{oh_no_you_leaked_my_songs_1234657}