Spring Cloud Config Server CVE-2026-41002 - falha TOCTOU com symlink em Kubernetes - Meu Universo Nerd

O erro número 1 de quem coloca o Spring Cloud Config Server em produção hoje é tratar o filesystem do pod como se fosse só seu. Volume compartilhado, basedir clonado pelo JGit e um vizinho de namespace que troca o caminho por um symlink na hora certa. O resultado é leitura e escrita de arquivos fora do diretório esperado.

Esse cenário virou o CVE-2026-41002 (CVSS 7.4), divulgado em 2026-05-06 e corrigido no Spring Cloud Config 4.3.3 e 5.0.3. É uma falha de TOCTOU, e ela cai direto na arquitetura cloud-native que a maioria de nós usa sem pensar duas vezes. Bora entender o que acontece e como blindar isso hoje mesmo.

Como Tech Leader, já vi esse padrão de "o filesystem é meu" derrubar mais de um time. Você sobe o Config Server, ele clona o repositório Git num diretório local, serve as configs para a malha de microsserviços e ninguém mais olha pra ele. Até o dia em que ele faz algo que você não pediu.

O que é o Spring Cloud Config Server e por que ele clona um repositório

O Config Server é a peça central de configuração distribuída em arquiteturas de microsserviços Java. Em vez de cada serviço carregar seu próprio application.yaml, todos buscam a configuração de um ponto único, que normalmente lê de um repositório Git. Isso dá versionamento, auditoria e rollback de configuração de graça.

Por baixo dos panos, quem faz esse trabalho é o JGitEnvironmentRepository. Quando uma requisição chega pedindo a config de um serviço, ele garante que existe uma cópia local do repositório num diretório de trabalho, o tal do basedir. Se o diretório já existe com lixo de uma execução anterior, ele limpa e clona de novo. Parece inofensivo, certinho? O problema mora exatamente nesse "limpa e clona".

Segundo o advisory oficial do Spring, o fluxo do JGit faz três coisas em sequência: verifica o basedir, apaga recursivamente o conteúdo dele e manda o JGit clonar no mesmo caminho re-resolvido. Três operações distintas sobre o mesmo caminho, em momentos diferentes. Guarde essa frase, porque é aí que o ataque acontece.

// Versão simplificada do fluxo vulnerável (check-delete-clone)
private void prepararBasedir(File basedir) throws IOException {
    // 1) CHECK: o caminho existe e parece ok agora
    if (basedir.exists()) {
        // 2) USE (parte 1): apaga tudo que estiver dentro
        FileUtils.delete(basedir, FileUtils.RECURSIVE);
    }
    // 3) USE (parte 2): clona no caminho re-resolvido
    Git.cloneRepository()
       .setURI("https://git.interno/configs.git")
       .setDirectory(basedir)
       .call();
}

Entre o passo 1 (verificar) e o passo 3 (usar), existe uma janela de tempo. Se nesse intervalo alguém conseguir trocar o que o caminho aponta, o JGit vai operar em cima de outra coisa, sem perceber. Esse intervalo entre checar e usar tem nome: TOCTOU.

TOCTOU na prática: o que é Time-of-Check to Time-of-Use

TOCTOU é a sigla de Time-of-check to time-of-use. É uma classe de race condition de segurança em que o programa valida um recurso num instante e usa esse recurso num instante posterior, assumindo que nada mudou no meio do caminho. Em filesystem, "nada mudou" é uma suposição perigosa.

A documentação da OWASP sobre TOCTOU descreve o padrão clássico: você checa que um arquivo é seguro, e antes de abri-lo um atacante troca aquele arquivo por um link simbólico apontando para /etc/shadow. Quando seu código finalmente lê o "arquivo seguro", está lendo o alvo do atacante.

No caso do Config Server, o ataque é assim: o basedir (por exemplo, a pasta folha onde o repo é clonado) é trocado por um symlink que aponta para um diretório fora do working directory do Git. Como a verificação já passou, o delete recursivo e o clone seguem o symlink e escapam do basedir. Isso permite ler e sobrescrever arquivos arbitrários no filesystem do pod, conforme as permissões do processo.

# O que o atacante local faz na janela de tempo
# (vizinho de namespace, init container malicioso, processo comprometido)
rm -rf /shared/config-basedir/repo
ln -s /etc/secrets /shared/config-basedir/repo
# Agora o delete + clone do JGit operam sobre /etc/secrets, nao sobre o repo

Repare numa coisa importante: o atacante precisa de acesso local ao filesystem onde o basedir vive. "Local" não quer dizer "improvável". No mundo Kubernetes, local é qualquer coisa que compartilhe aquele volume.

E tem mais um detalhe que costuma confundir quem nunca explorou uma race condition: "ganhar a corrida" não exige sorte cirúrgica. O atacante não precisa acertar o milésimo de segundo numa única tentativa. O Config Server refaz o ciclo de limpa e clona toda vez que recebe uma requisição de refresh ou que detecta mudança no repositório. Quem ataca só precisa rodar o swap em loop e esperar uma das centenas de janelas que abrem ao longo do dia. É a diferença entre "teoricamente possível" e "questão de tempo", e é por isso que TOCTOU é tratado como falha séria mesmo quando o intervalo parece minúsculo.

Por que o Kubernetes transforma isso num problema real

Aqui está o ponto que a maioria esquece. Numa máquina dedicada, o filesystem do Config Server é só dele, e a superfície de ataque local é pequena. No Kubernetes, a gente quebra essa premissa o tempo todo sem perceber. Volume compartilhado entre containers do mesmo pod, PersistentVolume com ReadWriteMany, init containers, sidecars de log, hostPath montado. Cada um desses é um vizinho com acesso ao mesmo caminho.

Quando o basedir do Config Server cai num volume compartilhado, qualquer processo que escreva nesse volume pode jogar o symlink swap na janela TOCTOU. E o Config Server normalmente roda com permissões generosas, porque precisa escrever no basedir. Junta tudo: filesystem compartilhado, processo privilegiado e uma race condition na biblioteca. É o cenário perfeito de erro universal de arquitetura.

O jeito errado, que vejo direto, é montar um volume "de trabalho" compartilhado para o Config Server clonar o repo, porque "assim o cache sobrevive ao restart". O jeito certo é o oposto:

# Jeito ERRADO: basedir num volume compartilhado e gravavel por varios
volumes:
  - name: config-work
    persistentVolumeClaim:
      claimName: shared-rwx-pvc   # ReadWriteMany: varios pods escrevem aqui

# Jeito CERTO: basedir efemero, isolado e exclusivo do pod
volumes:
  - name: config-work
    emptyDir: {}          # vida util do pod, ninguem de fora escreve

Como corrigir o CVE-2026-41002 do jeito certo

A correção tem duas camadas, e você precisa das duas. A primeira é óbvia e obrigatória: atualizar a dependência. A segunda é arquitetural e é o que separa quem só aplica patch de quem entende a falha.

Camada 1, o upgrade. O fix está no Spring Cloud Config 4.3.3 (linha 2023.x / Spring Boot 3.x) e no 5.0.3 (linha 2025.x / Spring Boot 4.x). Se você gerencia versão pelo BOM do Spring Cloud, suba a release train. Não basta bumpar só o config server; mantenha o BOM coerente.

<!-- pom.xml: alinhe a release train do Spring Cloud que ja traz o fix -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>2025.0.3</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

Camada 2, o isolamento do filesystem. Mesmo com o patch, a boa prática é nunca deixar o basedir do Config Server num caminho que terceiros possam escrever. Force um diretório efêmero e exclusivo do processo, e valide que ele não é um symlink antes de usar. A própria JVM te dá ferramenta para isso com o NIO, que sabe não seguir links:

import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;

public final class BasedirGuard {

    // Defesa em profundidade: recusa basedir que seja symlink ou esteja
    // fora da raiz exclusiva do pod. Nao confie so no patch.
    public static Path validar(Path raizExclusiva, Path basedir) {
        if (Files.isSymbolicLink(basedir)) {
            throw new SecurityException("basedir nao pode ser symlink: " + basedir);
        }
        Path real = basedir.toAbsolutePath().normalize();
        if (!real.startsWith(raizExclusiva.toAbsolutePath().normalize())) {
            throw new SecurityException("basedir escapou da raiz: " + real);
        }
        return real;
    }
}

E no application.yaml do Config Server, aponte o basedir para um caminho efêmero e exclusivo, nunca para o volume compartilhado:

spring:
  cloud:
    config:
      server:
        git:
          uri: "https://git.interno/configs.git"
          basedir: "/tmp/config-repo"   # emptyDir, exclusivo do pod
          force-pull: true

Junte com um securityContext que roda o pod como usuário não-root e com readOnlyRootFilesystem, deixando gravável só o diretório efêmero do basedir. Aí mesmo que apareça uma nova variação de TOCTOU amanhã, a superfície de ataque continua minúscula.

# securityContext que fecha a categoria de ataque local
securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]

Essa combinação não é exclusiva do Config Server. É o mesmo padrão que eu aplico em qualquer workload que escreve em disco dentro do cluster. A lição de fundo do CVE-2026-41002 é que biblioteca segura mais arquitetura descuidada ainda dá vulnerabilidade. O JGit confiava no caminho que recebia, e o caminho vinha de um lugar que outros podiam mexer. Sempre que a sua aplicação confia num recurso de filesystem que terceiros controlam, você tem um TOCTOU esperando para nascer.

O que muda no seu dia a dia de plataforma

Na prática, três coisas mudam a partir de hoje. Primeira: o CVE-2026-41002 tem CVSS 7.4 e foi reportado por um engenheiro do PayPal, então não é teórico, tem gente exercitando o vetor. Se você roda Config Server numa versão anterior à 4.3.3 ou 5.0.3, ele entra na sua fila de patch desta sprint, não da próxima.

Segunda: vale uma auditoria rápida de onde o basedir de cada Config Server está caindo. Procure por spring.cloud.config.server.git.basedir apontando para volume montado, e por PVCs com ReadWriteMany ligados ao deployment do config. Se achar, troque por emptyDir e siga a vida.

Quando você está exposto:

  • Config Server com backend Git rodando em pod com volume compartilhado ou hostPath
  • Versão do Spring Cloud Config anterior a 4.3.3 (linha 3.x) ou 5.0.3 (linha 4.x)
  • Processo do config rodando como root com filesystem raiz gravável

Quando o risco é baixo (mas patcheie mesmo assim):

  • Basedir em emptyDir exclusivo do pod, sem vizinho gravando no caminho
  • Pod com runAsNonRoot e readOnlyRootFilesystem ativos

Quem cuida de plataforma sabe que defesa em profundidade não é luxo. O patch fecha a janela conhecida. O isolamento de filesystem fecha a categoria inteira de falha. Faça os dois, do jeito que os sêniores fazem.

Perguntas frequentes sobre o CVE-2026-41002

O que é exatamente o CVE-2026-41002?
É uma vulnerabilidade de TOCTOU (race condition de segurança) no Spring Cloud Config Server com backend Git. O JGitEnvironmentRepository checa o basedir, apaga seu conteúdo e clona no mesmo caminho, abrindo uma janela em que um atacante local troca o caminho por um symlink e faz o acesso escapar do diretório esperado. CVSS 7.4, corrigido em 4.3.3 e 5.0.3.

Preciso me preocupar se meu Config Server não usa Git?
A falha está no fluxo do backend Git (JGitEnvironmentRepository). Se você usa backend nativo de filesystem, Vault ou outro, esse vetor específico não se aplica. Ainda assim, atualizar a release train do Spring Cloud é a recomendação padrão, porque ela carrega outros fixes.

O symlink swap funciona de qualquer lugar da rede?
Não. O atacante precisa de acesso local de escrita ao filesystem onde o basedir vive. O risco fica alto justamente em Kubernetes, onde volumes compartilhados e ReadWriteMany dão esse acesso local a outros containers e pods. Em host dedicado e isolado, a exposição é bem menor.

Só atualizar a versão resolve?
O patch fecha a janela TOCTOU conhecida e é obrigatório. Mas tratar o basedir como efêmero e exclusivo do pod, recusar symlinks e rodar como não-root elimina a categoria inteira de ataque local. As duas camadas juntas são o que recomendo como Tech Leader.

Como eu testo se estou vulnerável sem explorar nada?
Cheque a versão do spring-cloud-config-server resolvida pelo seu BOM e o valor de spring.cloud.config.server.git.basedir. Versão anterior a 4.3.3 ou 5.0.3 mais basedir em volume compartilhado é igual a risco confirmado. Não precisa de PoC para priorizar o patch.

Takeaways e próximos passos

  • TOCTOU é uma race condition de segurança: checar e usar um recurso em instantes diferentes assume que nada mudou, e em filesystem isso é falso
  • O Kubernetes amplifica o risco: volume compartilhado e ReadWriteMany dão a um vizinho o acesso local que o ataque precisa
  • Corrija em duas camadas: suba para Spring Cloud Config 4.3.3 ou 5.0.3 e isole o basedir em emptyDir exclusivo, recusando symlinks

Você já rodou um Config Server com volume compartilhado sem perceber esse risco? Conta nos comentários como está a arquitetura de configuração na sua stack. Quero saber quantos times caíram nessa de "o filesystem é meu".

Na próxima semana vou abrir a série de DevSecOps Java mostrando como amarrar securityContext, readOnlyRootFilesystem e Network Policies num template de pod seguro de verdade para Spring Boot. Se você cuida de plataforma, esse é o conteúdo que vai virar seu checklist de produção. Para não perder, acompanhe os conteúdos sobre Spring Boot e DevOps no Meu Universo Nerd, veja também o material sobre Spring Security e proteção de endpoints em produção e a discussão sobre observabilidade e segurança em microsserviços Java.