Featured image

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:

strings

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

code

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') / det
  • y = (-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

https://midi-visualizer-web.chal.idek.team

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/
  • showDirListing is enabled, so it serves the directory listing

Going to that URL gives us the directory listing of uploads/:

directory-listing

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

directory-listing-flag

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

flag.png

idek{oh_no_you_leaked_my_songs_1234657}