Slipping into zip-slip - Cómo Recrear y Explotar esta Vulnerabilidad

25 de marzo de 2025


main_image

¿Qué es una vulnerabilidad Zip Slip?

Zip Slip es una vulnerabilidad crítica de sobreescritura de archivos arbitrarios que puede conducir a la ejecución remota de código. Ocurre cuando un archivo comprimido (como un ZIP) contiene nombres de archivo manipulados con secuencias de "traversal" de directorios. Si una aplicación no valida adecuadamente estos nombres al extraer el archivo, puede sobrescribir archivos en ubicaciones no deseadas del sistema, permitiendo potencialmente la ejecución de código malicioso.

¿Cómo funciona Zip Slip?

Cuando un archivo ZIP es creado, puede contener archivos con nombres que incluyen una ruta relativa en el sistema. Esto permite a un atacante manipular el archivo para que se extraiga en una ubicación fuera del directorio previsto. Si una aplicación extrae estos archivos sin realizar las comprobaciones adecuadas, puede sobrescribir archivos sensibles en el sistema.

Por ejemplo, si un atacante logra crear un archivo ZIP que contiene una ruta como ../../etc/passwd (en sistemas Unix), al extraerlo, el archivo passwd podría sobrescribirse, lo que puede tener consecuencias graves, como la alteración de datos del sistema o la ejecución de código malicioso. Este es un ejemplo muy genérico, ya que en la mayoría de los sistemas, el usuario que ejecuta el servidor web no tiene grandes privilegios, pero aún así, se podrían escribir otros archivos sensibles o almacenar una WebShell.

¿Por qué se produce?

Esto generalmente ocurre cuando no se validan los nombres de los archivos antes de extraerlos. Además, sería conveniente que sea el propio sistema el encargado de renombrar los archivos y enjaularlos en un directorio específico. Esto no solo sería más seguro, si no que permitiría una mayor escalibilidad con el tiempo.

Prueba de Concepto

El siguiente script de Python es vulnerable a Zip Slip.

from flask import Flask, request, jsonify, render_template
import zipfile
import os

app = Flask(__name__)

UPLOAD_FOLDER = 'uploads'
EXTRACT_FOLDER = 'extracted_files'

os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(EXTRACT_FOLDER, exist_ok=True)

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/upload', methods=['POST'])
def upload_file():
    if 'file' not in request.files:
        return jsonify({'error': 'No file part'}), 400
    
    file = request.files['file']
    
    if file.filename == '':
        return jsonify({'error': 'No selected file'}), 400

    zip_path = os.path.join(UPLOAD_FOLDER, file.filename)
    file.save(zip_path)

    try:
        with zipfile.ZipFile(zip_path, 'r') as zip_ref:
            for member in zip_ref.infolist():
                extracted_path = os.path.join(EXTRACT_FOLDER, member.filename)
                print(extracted_path)
                print(f"Extracting {member.filename} to: {extracted_path}")
                zip_ref.extract(member, os.path.dirname(extracted_path))
                
        return jsonify({'message': 'File extracted successfully'}), 200
    except Exception as e:
        return jsonify({'error': str(e)}), 500

if __name__ == '__main__':
    app.run(debug=True)

Dentro templates, se encuentra el index.html que contiene el formulario de carga de archivos.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Upload ZIP File</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #f4f4f9;
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
        }
        .container {
            background-color: #fff;
            border-radius: 8px;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
            padding: 20px;
            width: 100%;
            max-width: 500px;
        }
        h1 {
            color: #333;
            text-align: center;
            margin-bottom: 20px;
        }
        label {
            font-size: 16px;
            margin-bottom: 10px;
            display: block;
            color: #555;
        }
        input[type="file"] {
            margin-bottom: 20px;
            padding: 10px;
            width: 100%;
            background-color: #f4f4f4;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        button {
            background-color: #4CAF50;
            color: white;
            border: none;
            padding: 10px 20px;
            font-size: 16px;
            border-radius: 4px;
            cursor: pointer;
            width: 100%;
        }
        button:hover {
            background-color: #45a049;
        }
        .error, .message {
            color: #ff4d4d;
            font-size: 14px;
            text-align: center;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Upload ZIP File</h1>
        <form action="/upload" method="POST" enctype="multipart/form-data">
            <label for="file">Select a ZIP file to upload:</label>
            <input type="file" id="file" name="file" accept=".zip" required>
            <button type="submit">Upload</button>
        </form>
    </div>
</body>
</html>

Una vez ejecutado el servidor, se verá lo siguiente:

Para generar el archivo ZIP, que contiene un Directory Path Traversal, en el nombre, se utilizará el siguiente script en Python:

import zipfile

zip_filename = 'evil.zip'

with zipfile.ZipFile(zip_filename, 'w') as zipf:
    zipf.writestr('../test.txt', 'Test content for the malicious file.')
    
print(f"ZIP file created: {zip_filename}")

Al subirse, en el stout de python, se muestra la ruta de extracción.

En el directorio de trabajo, se almacenará test.txt.

Mitigación de la Vulnerabilidad

Se podría implementar una condición, para que en caso de que detecte dos puntos, no lo extraiga.

if '..' in extracted_path:
    return jsonify({'error': 'Malicious file detected'}), 400

Sin embargo, esto sigue siendo vulnerable, ya que aún así, se pueden utilizar rutas absolutas. Entonces, otra solución más óptima sería concatenar con un path absoluto el directorio de destino con el nombre del archivo a extraer.

extracted_path = os.path.abspath(os.path.join(EXTRACT_FOLDER, member.filename))
base_path = os.path.abspath(EXTRACT_FOLDER)

if not extracted_path.startswith(base_path + os.sep):
    return jsonify({'error': 'Malicious file detected'}), 400

El archivo app.py se puede descargar desde el siguiente repositorio de Github: https://github.com/rubbxalc/zip-slip-python-poc