5 pontos por GN⁺ 2025-01-24 | 4 comentários | Compartilhar no WhatsApp
  • Enquanto a EdgeDB migrava I/O de rede de Python para Rust, novos testes de HTTP fetch baseados em reqwest pareciam travar apenas no CI ARM64, mas na verdade estavam crashando
  • A análise do core dump mostrou que o ponto problemático não era o novo código HTTP, e sim dentro de getenv() da libc; ele falhava ao tentar ler o ponteiro inválido 0x220 enquanto percorria o array de variáveis de ambiente
  • Outra thread definia SSL_CERT_FILE e SSL_CERT_DIR no caminho do openssl-probe, fazendo setenv() realocar environ; ao mesmo tempo, o caminho de tratamento de erro do Python chamava getenv(), criando uma condição de corrida
  • O código Rust não tinha unsafe explícito, mas, enquanto std::env::set_var() alterava o ambiente global, outras runtimes ou chamadas diretas à libc não eram sincronizadas pelo lock interno do Rust
  • A solução foi migrar no Linux para rustls em vez do backend rust-native-tls/openssl do reqwest, e tanto o Rust 2024 edition quanto a glibc também estão mudando para reduzir problemas dessa família

Crash revelado apenas no CI ARM64

  • A EdgeDB estava criando uma nova funcionalidade de HTTP fetch enquanto portava uma parte significativa do código de I/O de rede de Python para Rust
  • Usou reqwest como biblioteca de cliente HTTP, e a funcionalidade passava localmente e nos runners de CI x86_64, mas falhava de forma intermitente em runners de CI ARM64
  • No início, parecia que o runner de testes travava indefinidamente, e nos logs do CI um teste permanecia em execução sem erro até dar timeout horas depois
  • A hipótese inicial foi a diferença de modelo de memória entre Intel e ARM64
    • Intel tem um modelo de memória relativamente estrito, com uma ordem total de escritas de memória com a qual todos os processadores concordam
    • ARM tem um modelo de memória com ordenação mais fraca, em que escritas podem aparecer em ordens diferentes para threads diferentes

Rastreando o core dump no ambiente Docker do CI

  • As máquinas noturnas de CI rodavam na Amazon AWS, então era possível acessar como usuário root real fora do contêiner e ver dmesg e logs do sistema
  • Eles procuraram o PID que parecia travado dentro e fora do contêiner, mas o processo não existia; isso revelou que era um crash, não um deadlock
  • Como contêineres Docker são namespaces de processo, o core dump foi encaminhado para o host Docker, e em journalctl foi confirmado o core dump do processo python3
  • Ao abrir o core com gdb pela primeira vez, o backtrace era inútil porque faltavam os arquivos .so de dentro do contêiner
  • Depois de copiar /lib, /usr etc. do contêiner e configurar solib-absolute-prefix no gdb, foi confirmado que o ponto do crash era getenv() em libc.so.6

O ponteiro quebrado de variável de ambiente lido por getenv()

  • O backtrace completo seguia o fluxo getenv()__dcigettext()strerror_r()strerror()PyErr_SetFromErrnoWithFilenameObjects()
  • Não foi o novo código HTTP que crashou diretamente; o Python estava criando uma exceção baseada em errno e, passando por um caminho relacionado a gettext, chamou getenv()
  • A implementação de getenv() da GLIBC 2.17 percorre o environ do POSIX como uma lista char ** e inspeciona ponteiros para strings de variáveis de ambiente até o ponteiro NULL final
  • Pela desmontagem e pelo estado dos registradores, getenv() crashou ao tentar ler um byte do endereço x19 = 0x220
    • 0x220 era claramente um endereço de memória inválido
    • Ao inspecionar o próprio environ, a lista atual de variáveis de ambiente parecia consistente

Causa: condição de corrida entre setenv() e getenv()

  • setenv() não é uma função que possa ser chamada com segurança em ambientes multithread, e crashes estranhos em getenv() da libc causados por isso já foram redescobertos várias vezes
  • Ao comparar a desmontagem com o código C, x20 correspondia ao ponteiro ep que percorre o array environ
  • No momento do crash, x20 era 0x248b5000, enquanto o environ atual era 0x28655750, cerca de 60 MB adiante
  • Ao comparar a memória ao redor do array de ambiente antigo com o environ atual, a última diferença aparecia nos itens SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt e SSL_CERT_DIR=/etc/ssl/certs
  • Outra thread havia movido o environ durante uma chamada a setenv(), e getenv() pode ter continuado lendo o array de ambiente antigo em uma situação de use-after-free

Ligação com openssl-probe e o backend TLS

  • Em uma issue antiga relacionada a rust-native-tls, foi encontrada a pista de que openssl-probe define as variáveis de ambiente SSL_CERT_FILE e SSL_CERT_DIR para localizar certificados do sistema
  • No Linux, esse caminho é chamado ao usar o backend openssl de rust-native-tls
  • O código problemático do openssl-probe define as duas variáveis de ambiente com env::set_var(), sem unsafe explícito
    • SSL_CERT_FILE
    • SSL_CERT_DIR
  • Por causa dessa combinação, código Rust sem unsafe interagiu mal com o uso da libc no mesmo processo e produziu um crash

Por que foi reproduzido no Linux ARM64

  • Esse crash ocorre quando outra thread chama getenv() exatamente no momento em que setenv() move o array de ambiente com realloc
  • Para reproduzir, várias condições precisavam coincidir
    • A quantidade de variáveis de ambiente precisava ser suficiente para acionar realloc
    • Uma falha de I/O não relacionada precisava ser capturada pelo asyncio
    • O caminho de tratamento de erro do Python precisava chamar getenv() exatamente no mesmo momento para obter a variável de ambiente LANGUAGE
  • O valor inválido 0x220 era próximo do tamanho antigo do ambiente em palavras de 64 bits
    • 0x220 / 8 = 68
    • Esse valor sobrescreveu o espaço do NULL de término do bloco de ambiente antigo, e parecia ser um valor usado pelo malloc do sistema para indicar o tamanho de um bloco livre
  • Como eram necessárias muitas precondições, foi uma sorte que o problema fosse razoavelmente reproduzível em uma única plataforma

Pistas confirmadas na desmontagem ARM64

  • Houve confusão porque, na desmontagem de getenv(), não era fácil ver onde x20 mudava
  • O ponto-chave era o modo de endereçamento pre-indexado do AArch64
  • ldr x19, [x20, #8]! funciona assim
    • x19 = *(x20 + 8)
    • x20 = x20 + 8
  • Por causa desse modo de endereçamento, x20 estava percorrendo o array de ponteiros de variáveis de ambiente mesmo sem aparecer explicitamente no lado esquerdo

Correção aplicada e mudanças em projetos relacionados

  • Por fim, decidiu-se migrar no Linux para rustls em vez do backend rust-native-tls/openssl do reqwest
  • O motivo original para escolher o backend TLS nativo era evitar carregar dois engines TLS enquanto o código Python era migrado para Rust
  • Depois desse problema, concluiu-se que, no curto prazo, carregar dois engines TLS era aceitável
  • Uma alternativa seria executar a primeira chamada a try_init_ssl_cert_env_vars() mantendo o GIL do Python
    • O Rust tem um lock interno que impede corridas entre códigos Rust que leem e escrevem o ambiente
    • Mas isso não impede casos em que código de outras linguagens usa diretamente a libc
    • Manter o GIL bloquearia ao menos a corrida com threads do Python
  • O projeto Rust já reconheceu esse problema e planeja tornar funções setter de ambiente unsafe no 2024 edition
  • O projeto glibc também adicionou recentemente uma mudança que aumenta a segurança em threads de getenv() evitando realloc e vazando arrays de ambiente antigos

4 comentários

 
carnoxen 2025-01-24

setenv não é thread-safe, e C não quer corrigir isso

A função setenv está causando problemas de novo.

 
y15un 2025-01-24

Eu escreveria o título como: "A insegurança de thread da stdlib de C é algo que nem o Rust, tão seguro assim, consegue remediar". :)

 
halfenif 2025-01-24

Entendi com certeza.

 
GN⁺ 2025-01-24
Opiniões no Hacker News
  • O ponto mais importante aqui é que, na próxima edição do Rust, as funções de definição de variáveis de ambiente passarão a ser unsafe.
    Com sorte, o impacto deve chegar até os crates que causam esse tipo de crash; enquanto isso, foi aberta uma issue no upstream: https://github.com/alexcrichton/openssl-probe/issues/30

    • Mas o problema fundamental — isto é, o fato de que getenv e setenv ou unsetenv não podem ser chamados com segurança a partir de threads diferentes — não será realmente corrigido.
      A única solução confiável parece ser alterar essas funções para que elas necessariamente adquiram um mutex.
    • Acho razoável, e fico contente, que o autor da biblioteca tenha concluído que “a melhor solução hoje para esse problema é usar uma biblioteca como rustls e parar de usar este crate”.
      Infelizmente, o ecossistema não é assim: https://github.com/seanmonstar/reqwest/blob/master/Cargo.tom...
    • As pessoas são treinadas a ignorar blocos e prefixos como ____UNSAFE_payattention__nevermindthatthisappears50timesinthisfile___.
      Algo parecido acontece em frameworks web: no Vue há a diretiva v-html, e no React há dangerouslySetInnerHTML; nesse aspecto, acho que o Vue é claramente melhor.
  • set_var e remove_var da biblioteca padrão do Rust passarão a exigir corretamente um bloco unsafe {} na próxima edição, a edição 2024.
    A documentação também agora menciona a questão de segurança, mas ter tornado essas funções safe desde o início foi um erro — um erro que linguagens de nível mais alto também cometeram.
    https://doc.rust-lang.org/stable/std/env/fn.set_var.html
    A glibc tem um patch que torna getenv seguro em mais casos nos quais o ambiente é modificado, mas C ainda permite acesso direto a environ, então não dá para torná-lo completamente seguro em situações em que há modificações: https://github.com/bminor/glibc/commit/7a61e7f557a97ab597d6f...

    • É surpreendente que a glibc agora mantenha versões antigas e adote uma política de redimensionamento exponencial.
      Isso cria um vazamento de memória de tamanho constante amortizado para cada variável de ambiente ativa, mas, segundo dizem, as próprias variáveis já têm esse tipo de vazamento, inclusive variando conforme o tamanho e incluindo valores que não são mais usados.
      Parece claro que deve haver casos de uso patológicos em que, mesmo em programas corretos do ponto de vista da API, a memória cresça indefinidamente por causa disso.
      Para corrigir programas que violaram as regras ao usar a API em várias threads, parece que estão introduzindo em programas que seguem a API um bug de crescimento infinito de memória; é interessante, mas incômodo. Passa uma sensação de pragmatismo acima do dogma.
    • Se a implementação da biblioteca padrão consegue lidar com a sincronização, fica a dúvida de por que exigir unsafe.
  • Mesmo que os mantenedores da biblioteca padrão de C sejam contra tornar setenv seguro para múltiplas threads, no mínimo deveria ser definida uma nova API thread-safe, seja dentro do POSIX, seja primeiro como um padrão de facto que o POSIX aceite depois.
    Se o tempo gasto explicando por que nada pode ser feito tivesse sido usado para corrigir esse problema, já poderia existir uma substituta para o antigo setenv, que muitos projetos de software poderiam depreciar e remover.
    Vendo que a glibc está fazendo mudanças para praticamente eliminar esse problema, a afirmação do mantenedor da Musl de que isso não pode ser corrigido dentro da Musl também fica pouco convincente.

    • O maior problema não é a inexistência de uma API thread-safe, mas a existência de extern char **environ;.
      Enquanto environ for publicamente acessível, não há sequer garantia de que setenv e getenv sejam usados, pois eles não são obrigatórios.
      Se fosse possível eliminar environ, tornar setenv e getenv thread-safe seria relativamente simples. Se não for possível eliminá-lo, é impossível; ainda assim, dá para argumentar que tornar setenv e getenv thread-safe seria uma melhoria, mesmo que não seja uma solução completa.
    • Parece que todas as funções exec() que não recebem o ambiente como argumento ou que pesquisam o PATH para encontrar executáveis também passariam a precisar de bloqueio.
    • Não acho convincente dizer que você sabe mais do que os especialistas que concluíram que isso não pode ser corrigido de forma retrocompatível.
  • Encontrar bugs relacionados a variáveis de ambiente no Linux parece uma espécie de rito de passagem, e em outros Unix isso, curiosamente, tende a ser menos problemático
    Linus e o kernel corrigem bugs do POSIX de forma pragmática, fazendo com que eles não estourem na prática; é meio engraçado que a glibc continue atrasada mesmo décadas depois de as pessoas tentarem ao menos mitigar o problema
    É verdade que existem todo tipo de dores de cabeça como TZ, mas, se tivessem oferecido getenv_r(), sincronizado isso com setenv() e apenas emitido avisos na etapa de compilação/linkedição ao usar getenv(), boa parte dos problemas teria desaparecido
    Dava até para ir além e usar uma abordagem de cópia na escrita (COW), deixando o ponteiro do ambiente como somente leitura
    Em vez disso, empurraram o problema para cada aplicação, o que é um grande erro, porque quem escreve aplicações quase nunca consegue saber o que suas dependências fazem. Foi o que aconteceu comigo muito tempo atrás, e na época o fornecedor de uma biblioteca de código fechado me disse para parar de usar aquele clone de Unix de brinquedo chamado Linux

    • Não sei de onde vem esse julgamento de que “há bugs relacionados a variáveis de ambiente no Linux e, em outros Unix, isso é menos problemático”
      O problema não é a implementação, é a própria API. setenv(), unsetenv(), putenv() e, especialmente, environ são inerentemente inseguros em programas multithread
      getenv_r() também não resolve tudo. Isso porque uma thread pode chamar setenv() enquanto outra está copiando o valor antigo de uma variável de ambiente para o buffer fornecido
      Claro, getenv_r() corrige o caso em que, depois de receber algo via getenv(), outra thread chama setenv() e invalida aquela memória, mas não há como impedir outras chamadas que quebram a API
      A libc pode mitigar parte do problema pegando um mutex dentro de getenv()/setenv()/putenv()/unsetenv(), mas ainda não há como a libc garantir que o valor retornado por getenv() permanecerá válido pelo tempo necessário para o código chamador usá-lo
      Também não há uma boa forma de tornar seguro o acesso direto a environ. Seria possível tornar environ thread-local, mas então cada thread poderia ficar permanentemente com uma visão diferente do ambiente, e o resultado de uma chamada a getenv_r() poderia divergir de uma verificação direta de environ
      Preservar a compatibilidade retroativa aqui é realmente difícil, e só adicionar um mutex para proteger essas funções já poderia alterar a semântica de programas existentes e quebrá-los
  • Houve um texto antigo dizendo que setenv é horrível: https://www.evanjones.ca/setenv-is-not-thread-safe.html
    Também houve uma discussão, e já no primeiro comentário se dizia que isso causava problemas em Rust: https://news.ycombinator.com/item?id=38342642

    • Isso já é um fato conhecido
      Aqui, a maior parte do restante do problema parece estar no ambiente de desenvolvimento. Eles testavam usando Docker em uma máquina remota em um datacenter da Amazon, e essa máquina não conseguia reportar crashes de processos
      Além disso, dentro do contêiner também não havia informações de símbolos de depuração suficientes para obter um backtrace. Se tivessem recebido um backtrace limpo na primeira falha, teria ficado claro imediatamente
      Para começo de conversa, também é questionável por que usar setenv
  • Essa conversa me lembra o movimento 12-factor app, no qual alguns antigos colegas acreditavam muito. Um desses “factors” dizia que a configuração da aplicação deveria ser feita por variáveis de ambiente
    Sempre achei isso meio tolo, porque esse modo de configuração é uma estrutura que coloca valores do tipo string em um espaço de nomes plano, como se fosse uma cesta
    Também vejo os riscos de getenv()/setenv()/environ como um argumento forte para não usar variáveis de ambiente para configuração
    Claro que nem sempre há uma alternativa excelente e bem suportada. Eu prefiro arquivos de configuração, e também dá para usar configurações-modelo preenchendo apenas os valores de desenvolvimento, staging e produção. Normalmente uso YAML, apesar das desvantagens e armadilhas; pode até haver formatos melhores para arquivos de configuração, mas acho YAML muito melhor do que variáveis de ambiente

    • Há muita antipatia forte contra o Windows e a Microsoft, mas, com o tempo, há vários casos em que o design de API deles acaba se justificando
      No NT, variáveis de ambiente podem ser tipadas e modeladas, e também existe o Registro, um banco de dados de configuração com espaços de nomes. Embora seja verboso e estranho
      Além disso, o MSVC fornece versões thread-safe de quase todas as funções da biblioteca padrão
      Ouço com frequência novos desenvolvedores C/C++ lamentarem a falta de compatibilidade POSIX do MSVC, mas parece que não pensam a fundo no que isso realmente significa. Está mais para querer compatibilidade cruzada com programas em C escritos nos anos 1990
    • Tenho preocupações parecidas com variáveis de ambiente. Não gosto do fato de poderem ser lidas de qualquer lugar
      Elas atrapalham a capacidade de inferir o comportamento só pela assinatura de uma função e tornam impuras muitas funções que poderiam ter sido puras
      Se existisse um recurso de linguagem para marcar uma aplicação de modo que, em qualquer processo, variáveis de ambiente não pudessem ser usadas e só pudessem ser lidas uma única vez — não uma vez por variável, mas uma única vez em lote — eu o usaria em todo lugar
    • getenv() em si é perfeitamente aceitável; o problema é setenv()
      Em teoria, o ambiente é configurado antes que essa aplicação misteriosa seja iniciada, então não deveria haver necessidade de usar isso
      Mas um espaço de nomes plano, valores string e um espaço global livre, compartilhado por todos sem saber quais bibliotecas e módulos vão entrar, não são uma boa ideia mesmo sem o problema de segurança de setenv()
    • O “12-factor app” deveria ganhar um apêndice dizendo que, enquanto o processo estiver vivo, o ambiente deve ser tratado como somente leitura
      A maioria dos problemas que as pessoas mencionam aqui parece vir do abuso do ambiente como um armazenamento chave-valor para estado global mutável. Não sei por que alguém iria querer fazer isso
      A JVM trata o ambiente como efetivamente imutável, e é possível que empresas usuárias de Scala e Java, como a SoundCloud, tenham influenciado o movimento 12-factor app. Nunca passei por uma situação em que o ambiente mudasse ou causasse problemas de threading
      Mesmo que o ambiente mude, a cópia imutável criada na inicialização da JVM permanece a mesma, e o código que interage com o ambiente pelas APIs Java comuns não vê essa modificação
      O problema dos arquivos de configuração é que o parsing é por processo. É por isso que Linux/Unix é tão bagunçado. Cada ferramenta tem suas próprias convenções e mecanismos de configuração, e não há um padrão
      No ecossistema Docker, dentro do contêiner você pode fazer o que quiser, mas a interface com o exterior é montar volumes e seguir o esquema complexo de configuração de cada app, ou simplesmente usar variáveis de ambiente
      Hoje, a maior parte dos softwares modernos rodando em Docker é amigável o suficiente ao Docker para que seu comportamento possa ser totalmente controlado pelo ambiente, e em muitos casos isso basta
      Se você usa Docker Compose ou Kubernetes, acaba tendo em um arquivo YAML a lista de variáveis de ambiente que define como o processo é iniciado, então isso também entrega, em certa medida, a estrutura desejada. Não gosto de YAML, mas funciona bem o bastante; problemas de sintaxe podem arruinar seu dia, mas as alternativas também não são isentas de problemas
    • Na verdade, isso é uma questão separada. Se você lê variáveis de ambiente como configuração e depois não mexe mais nelas, é totalmente seguro
      Eu também uso o estilo 12-factor app, mas, depois que elas entram na aplicação, valido e armazeno as variáveis de ambiente e os dados. Depois disso, não há problema algum
  • É um ótimo texto que investiga um bug difícil de perceber
    Tinha de tudo: bug intermitente, peculiaridade de arquitetura, algo escondido dentro de uma dependência, Rust, GIL do Python e até gettext
    Relatos detalhados de resolução de problemas como esse são o material mais próximo de vivenciar a situação diretamente. Quando a dependência é que está usando aquilo, fica difícil simplesmente dizer “é só não usar X”, porque como eu saberia?

  • Dizem que “as máquinas de CI noturnas rodam na Amazon AWS e têm a vantagem de poder usar um usuário root real, não um contêiner”, mas ao mesmo tempo que “não há arquivos necessários fora do contêiner, e o contêiner é muito minimalista, então não dá para instalar gdb facilmente”
    As pessoas agora perderam a capacidade de compilar e depurar localmente sem nuvem e contêineres?

    • Sim. É chocante como SaaS em nuvem distorceu a compreensão das pessoas
      Para fazer coisas muito triviais, passou a ser necessária toda uma complexidade de nuvem e camadas de implantação. É como reverter 100% a revolução do PC e voltar à era desajeitada e cara da computação de mainframe
      O motivo é que há dinheiro na nuvem, e a nuvem é DRM. Se você coloca o software lá, pode cobrar assinatura, não há como contornar, e dá para manter uma dependência perfeita para sempre. Muitas vezes o usuário nem consegue tirar os próprios dados
      Também fica conveniente fazer análises em tempo real para otimização do produto
      A arquitetura de computação vem a jusante do modelo de negócio. O mainframe morreu da primeira vez porque não havia internet e o PC era mais barato, mas também porque os fornecedores perderam muito poder de aprisionamento
      Agora existe um jeito de ressuscitar um modelo muito mais lucrativo. As liberdades incômodas dos usuários desapareceram e, francamente, quando os usuários obtêm essas liberdades, muitas vezes acabam não pagando, o que também dificulta a existência de negócios de software de alta qualidade
    • Este é um problema como uma corrupção aleatória de memória que só aparece em ARM
      É bem provável que não conseguissem reproduzir o crash localmente. As máquinas dos desenvolvedores eram majoritariamente x86, e nelas o crash não teria acontecido
      O tratamento do crash deveria ter sido melhor, mas eles também parecem reconhecer isso, e esse não é o ponto central discutido aqui
    • Claro que não perderam essa capacidade, mas nas nossas máquinas o crash não acontecia
    • Como depurar localmente se é bem provável que você não tenha um dispositivo rodando a arquitetura problemática? De todo modo, é muito mais rápido depurar no ambiente real em que a falha ocorre
  • Estado global mutável é maligno. Se você é amigo de alguém, não deixa essa pessoa usar estado global mutável
    Odeio variáveis de ambiente. Esse é o “jeito Linux”, mas eu evito como a peste. Recomendo muito
    A libc é horrível, e o mundo já deveria ter seguido em frente

    • Variáveis de ambiente são ok se forem tratadas como somente leitura dentro do processo
    • Se “estado global mutável é maligno”, então também temos que jogar fora CPU e RAM
    • Fico curioso para saber qual alternativa você propõe
      O problema não é o Linux, nem estado ou recursos globais mutáveis, nem a libc
      O problema é que, no trabalho, não dão tempo para fazer as coisas direito. Por exemplo, para pegar o problema no GDB antes que ele estoure, o chefe precisa dar tempo para depurar persistentemente e rastrear de volta o código e tudo que esse código toca
      Há dinheiro demais em código meio cru. Triste, mas verdade
    • A libc empurrou o mundo para a era da informação
    • Fico curioso para saber qual é a alternativa que você prefere
  • Havia tantos problemas desse tipo que, no fim, fiz patch em getenv / setenv / putenv com LD_PRELOAD

    • Foi com uma implementação fixa que vaza o ambiente, como a que acabou de entrar na glibc?