PwnCollege — Wily Webserver (integration-web-overflow)
Challenge Overview
Challenge: Wily Webserver
Binary:
/challenge/integration-web-overflow
Concepts: Web Security + Binary Exploitation
Flag: Verify with cat /flag (root
only)
A setuid web server that serves files from
/challenge/files/ on port 80. It has a buffer overflow in
send_file() combined with a path traversal
vulnerability.
Binary Analysis
Protections
| Protection | Status |
|---|---|
| PIE | Disabled (base 0x400000) |
| Stack Canary | Disabled |
| NX | Disabled (RWX stack) |
| ASLR | Disabled (constructor sets
ADDR_NO_RANDOMIZE) |
| RELRO | Full (GOT read-only) |
Imports
personality, write, read,
open, close, execve,
fstat, prctl, sprintf,
strstr, mmap, socket,
accept, bind, listen,
setsockopt
Constructor Analysis
The constructor runs before main():
1 | void __attribute__((constructor)) disable_aslr(...) { |
This disables ASLR and re-execs. PR_SET_NO_NEW_PRIVS
prevents the setuid bit from taking effect after re-exec, but the
process retains its original privileges (if started as root, stays root
— the binary can still read /flag).
Vulnerability
Path Traversal
The HTTP request path is concatenated into a file path:
1 | sprintf(resolved_path, "/challenge/files/%s", path); |
Path traversal works: ../../../tmp/pwn →
/challenge/files/../../../tmp/pwn →
/tmp/pwn
Buffer Overflow in send_file()
1 | struct response_t { |
The response.content[8192] buffer is on the stack. When
the file is larger than 8192 - header_size (~8054 bytes),
the read overflows past the buffer into adjacent stack data.
Stack Layout
1 | Address Content Size |
The strstr Check
1 | REQUIRE(!strstr(response.content, "pwn.college{"), 403); |
The 403 error path writes the error and returns without
closing file_fd. This is a file descriptor
leak — the flag fd stays open.
Exploit Strategy
1. fd Leak (Bypass seccomp)
The process may have seccomp that blocks open() syscalls
from non-.text addresses (i.e., shellcode running from the
stack). To bypass:
- Request 1:
../../../flag→ server opens/flagas fd 5, reads flag, finds it in strstr, returns 403 WITHOUT closing fd 5 - Request 2:
../../../tmp/pwn→ overflow triggers, shellcode reads from the still-open fd 5
2. Buffer Overflow Payload
File structure (8180 bytes total):
1 | HS = 138 # HTTP header size |
3. Stack Pivot
After leave; ret with the overwritten values:
1 | leave: RSP = saved_RBP = CONTENT + 8200 |
The self-referential saved RBP creates a circular pointer that keeps the stack at a known location.
Shellcode
fd Leak Shellcode
1 | /* lseek(5, 0, SEEK_SET) — rewind leaked flag fd to offset 0 */ |
Direct open() Shellcode
If seccomp doesn't block open from the stack:
1 | /* Push "/flag" onto stack */ |
Complete Exploit Script
1 | #!/usr/bin/env python3 |
Pitfalls Encountered
1. Offset Calculation Bug
The most critical bug: saved_rbp was being placed at
file offset 8208-hs instead of 8200-hs. This
meant the return address was never actually overwritten — the function
returned normally to handle_connection. The marker strings
in the shellcode were found in the response because they were embedded
in the static payload file, NOT from shellcode execution.
Diagnostic technique: Place a marker
(PAYEND) at the very end of the payload file. Look for the
shellcode's marker AFTER PAYEND. If it appears before, it's
from the static payload.
2. RIP-Relative Addressing
Hand-assembled shellcode with lea rdi, [rip + offset] is
error-prone — every instruction insertion changes the offset. Use
push-based string construction instead:
1 | push rax ; null terminator |
3. No lseek in PLT
The binary doesn't import lseek (syscall 8). It must be
called directly via the syscall instruction, which may be
blocked by seccomp. The fd leak approach depends on lseek
to rewind the leaked fd back to offset 0. If seccomp blocks it,
pread64 (syscall 17) is an alternative that reads from a
specific offset without seeking.
4. Server Crashes from exit()
The shellcode calls exit(0) (syscall 60) which kills the
entire process. This requires restarting the server between attempts. A
more elegant approach would use close(client_fd) + pause or
return to the challenge loop, but exit is simpler for single-shot
exploitation.
Key Gadgets
| Address | Gadget |
|---|---|
0x401e73 |
pop rdi; ret |
0x401e71 |
pop rsi; pop r15; ret |
0x40101a |
ret |
0x401240 |
execve@plt |
0x4020b5 |
"/proc/self/exe" string |
Lessons Learned
- Verify shellcode execution with both a payload-end marker AND a shellcode marker — looking for the shellcode marker IN the response doesn't distinguish between static payload data and runtime writes.
- Use
rfind()for the shellcode marker to find the LAST occurrence, which would be from runtime execution (appended after the normal response). - Double-check stack offsets — the
saved_rbpoffset (8200 from content start) is different from thereturn_addressoffset (8208). Off-by-8 errors are fatal. - Disabling ASLR via
personality()happens in a constructor, andPR_SET_NO_NEW_PRIVSprevents setuid re-elevation after the re-exec. The binary's privileges at runtime depend on how it was started by the infrastructure.