7 pontos por GN⁺ 2025-07-25 | 1 comentários | Compartilhar no WhatsApp
  • Segurança de memória e segurança entre threads não são conceitos separáveis, e sem segurança entre threads não é possível alcançar uma verdadeira segurança de memória
  • No caso de linguagens não seguras entre threads como Go, apenas problemas de thread já podem quebrar a segurança de memória
  • Algumas linguagens, como Java, garantem segurança no nível da linguagem por meio de um modelo de memória para concorrência que trata até mesmo data races como comportamento definido
  • Go é vulnerável a data races, e há casos reais de violação de segurança de memória
  • A propriedade realmente importante que deve ser tratada como central é a ausência de Undefined Behavior (comportamento indefinido)

Não é possível garantir segurança de memória sem segurança entre threads

Confusão conceitual: segurança de memória vs. segurança entre threads

  • Recentemente, segurança de memória tem recebido muita atenção, mas não há clareza suficiente sobre o que isso realmente significa
  • Tradicionalmente, segurança de memória se refere a linguagens que impedem acessos de memória como use-after-free ou out-of-bounds
  • segurança entre threads se refere a programas sem bugs de concorrência, e os dois conceitos muitas vezes são tratados como separados
  • O autor argumenta que essa distinção não é útil na prática e enfatiza que o que realmente queremos é a ausência de Undefined Behavior (UB)

Violação de segurança de memória causada por data race: exemplo em Go

  • Para mostrar o problema de tratar segurança de memória e segurança entre threads separadamente, é apresentado um exemplo na linguagem Go
  • Go é classificada como uma linguagem segura em memória, mas em um programa como o abaixo, apenas uma data race já pode causar um erro de memória
alterar repetidamente globalVar para valores de tipos diferentes (Int, Ptr) enquanto outra goroutine lê esse valor e chama um método
  • Como duas threads atualizam separadamente os dois ponteiros internos de globalVar (dados e vtable) de forma sobreposta, uma leitura no meio desse processo pode observar um estado misto e causar acesso incorreto à memória
  • Como resultado, o programa pode tentar acessar um endereço inválido (por exemplo, 0x2a, hexadecimal para 42) e encerrar com erro
  • O mesmo fenômeno também pode ocorrer com interfaces, slices etc. em Go, por não haver atualização atômica de múltiplos campos

Como outras linguagens tratam concorrência e segurança de memória

  • Outras linguagens, como Java, também podem ter data races, mas aplicam um modelo de memória de concorrência definido para garantir que o programa não quebre a própria linguagem
    • Exemplo: Java projeta cuidadosamente seu modelo de memória para evitar que, mesmo em ambiente multithread, o programa caia em falhas do runtime como um segmentation fault forçado
  • A maioria das linguagens controla problemas de concorrência de uma destas duas formas
    • Definindo um modelo de memória que garante comportamento consistente para todos os programas concorrentes (ao custo de limitar otimizações do compilador e aumentar a complexidade de implementação)
      • Java, C#, OCaml, JavaScript, WebAssembly etc.
    • Usando um sistema de tipos forte para proibir a maior parte das data races e tratar com segurança apenas poucas exceções (Rust, strict concurrency de Swift)
  • Go não segue nenhuma dessas duas abordagens
    • Só garante segurança de memória quando não há data race
    • Há ferramentas de detecção de data race, mas em programas reais é difícil validar todas as situações apenas com testes
    • Pesquisas e experiência prática já relataram vários casos reais de violação de segurança de memória

O modelo de memória do Go e problemas de documentação

  • A documentação oficial do modelo de memória de Go afirma que a maioria das races tem resultados limitados, mas não explica claramente que algumas data races podem ter resultados ilimitados
  • Também há a alegação de que Go seria semelhante a Java/JavaScript, mas essas duas linguagens fizeram muito mais esforço para garantir segurança de concorrência do que Go
  • Só em algumas seções mais detalhadas da documentação aparece, de forma limitada, a menção de que certas data races podem causar comportamento totalmente indefinido

Conclusão: a ausência de Undefined Behavior (UB) é o verdadeiro objetivo

  • Na prática, a propriedade que o usuário realmente quer é que o programa não quebre a própria linguagem (ausência de UB)
  • As várias vulnerabilidades de segurança causadas por violações de segurança de memória existem porque UB de fato ocorreu
  • A partir do momento em que UB acontece, todo comportamento posterior se torna imprevisível e pode ser explorado por um atacante
  • A diferença essencial entre linguagens “seguras” e “não seguras” está na possibilidade de ocorrência de UB
  • Em vez de separar em detalhes segurança de memória, segurança entre threads, segurança de tipos etc., o ponto central é a ocorrência ou não de UB
  • Na prática, segurança existe em um espectro, e Go é mais segura que C, mas não garante segurança completa
  • Com base nos dados, é muito difícil “provar” a segurança real de Go, e é importante entender corretamente as consequências pouco intuitivas das escolhas feitas por cada linguagem

1 comentários

 
GN⁺ 2025-07-25
Comentários do Hacker News
  • Isso aconteceu no meu time da Dropbox: escrever em estruturas de dados num servidor Go sem sincronização era uma espécie de ritual de passagem para engenheiros recém-chegados, porque acabavam causando segfault repetidamente
    O Swift também tem o mesmo problema, e já escrevi um programa mostrando que o Swift pode causar segfault com muita facilidade ao acessar estruturas de dados compartilhadas
    Dizer que Go é memory-safe como Rust ou Java é um pouco exagerado
  • O Swift está tentando resolver esse problema, mas no mundo real já existe muito código inseguro, então a mudança é muito lenta e dolorosa
  • Tenho curiosidade, porque a própria especificação do Go deixa bem claro que estruturas básicas como map não são thread-safe, então é preciso ter cuidado ao modificá-las
    Queria ouvir mais detalhes sobre o que aconteceu na Dropbox
  • Quero enfatizar que “memory safety no sentido de Rust ou Java” não é uma definição rigorosa do termo
    Memory safety, mais do que um conceito de PLT (teoria de linguagens de programação), é um termo de segurança de software
    No fim, os programadores Go também entendem bem essa diferença, e por isso o Go parte da premissa de “não se comunicar por meio de compartilhamento, mas compartilhar por meio de comunicação”
    Claro, na prática esse conceito não se concretizou totalmente, e hoje todo mundo entende que o Go moderno também tem muito compartilhamento e precisa de sincronização
  • Para colocar em perspectiva, vale se perguntar quantos casos alterados e de fato não memory-safe existem em Go, ou qual é a probabilidade real de um programa Go não ser memory-safe
  • Java também não é memory-safe no mesmo sentido que Rust
  • Esse tema volta e meia reaparece, de forma parecida com os problemas de soundness hole do Rust; não é uma preocupação inútil, mas a chance de topar com isso por acaso é bem baixa
    Na prática, rodando Go por muitos anos, acho que quase nunca vi esse tipo de bug acontecer de verdade
    A Uber fez um levantamento detalhado sobre bugs em código Go, e neste artigo há uma tabela mostrando com que frequência isso realmente acontece
    Em Go, a maioria dos problemas de acesso concorrente a map ou slice acontece no mesmo slice, e ainda depende de ocorrer torn read, então na prática não é tão comum
    Ainda assim, o motivo de as pessoas evitarem bem esse tipo de problema provavelmente é que costumam tomar cuidado suficiente e entendem bem o risco de reatribuir variáveis em contexto de acesso concorrente
    Como a linguagem já tem atomics, channel e mutex, na prática é raro usar errado em cenário concorrente, e também existe o race detector, então quando esse tipo de problema aparece costuma ser encontrado rápido
    Mesmo com perda de desempenho, vejo o problema de torn read como algo que simplesmente dá para corrigir, e nunca foi uma grande questão em código Go em produção
    Vídeo relacionado
  • Já tive a experiência de passar meses tentando capturar um bug de data race em Go
    O race detector também não encontrou nada, e ninguém entendia o que estava acontecendo
    No fim, um contador de loop estourava e repetia o mesmo cálculo um número absurdo de vezes, fazendo com que requisições às vezes levassem 3 minutos em vez de 100 ms
    Descobrimos o problema indiretamente em produção usando perf, e minha experiência de depuração como desenvolvedor de plataforma ajudou bastante o time
    Depois de me expor tanto a vários tipos de race em Go, pessoalmente gostaria que Rust fosse adotado em todo lugar
  • Os mantenedores do Rust também reconhecem soundness holes como bugs
    Por exemplo, esta issue exige uma grande refatoração do compilador, então leva tempo para ser resolvida
  • A Uber diz que programas Go “expõem 8 vezes mais concorrência” do que microsserviços Java; fiquei curioso sobre o que significa usar “concorrência” como se fosse um substantivo contável nesse contexto
  • O Zig também afirma ser memory-safe, mas não tem um conceito equivalente aos tipos Send/Sync do Rust
    Na prática, ainda há pouco código concorrente em Zig, então o problema não apareceu com força, mas acho que, quando recursos assíncronos forem mais usados, vários problemas podem explodir de uma vez
  • Mesmo um programa Zig single-thread compilado com ReleaseSafe, por exemplo ao dereferenciar um ponteiro cuja variável local já saiu de escopo, não está livre do risco de corrupção de memória em nenhum modo de otimização
  • A alegação de memory safety do Zig chega perto de ser uma piada
    Claro, ele reduz bugs em relação a C, mas isso também vale para C++, e ninguém diz que C++ é memory-safe
  • Em código real, nunca vi código Go com vulnerabilidade causada por data race, a menos que tenha sido desenhado de forma maliciosa
    Claro, isso não significa que o risco seja absolutamente zero, mas sugere que, do ponto de vista de segurança, isso provavelmente não é uma prioridade em aplicações Go
    Já em código C/C++, de 60% a 75% das vulnerabilidades reais vêm de problemas de memory safety
    Memory safety também está em um contínuo, e acho que depois de certo ponto o ganho marginal diminui
  • Eu já vi, sim, código Go vulnerável por causa de data race
  • Estou achando que a dor de manutenção é muito maior do que CVE
    Mesmo um bug não explorável continua sendo um bug que precisa ser corrigido
    Como se gasta muito mais tempo com manutenção do que com desenvolvimento inicial, se algo puder reduzir manutenção, já vale a pena mesmo que atrase o lançamento inicial
  • A memory safety é importante porque a maioria dos CVEs em programas C vem de bugs de memory safety
    Já em Go, thread safety não é uma causa principal de CVE
    Em teoria faz sentido, mas na prática isso não se destaca tanto
  • O importante é o que de fato se pode fazer em uma thread
    Ao compartilhar memória, se você corrompe uma estrutura de dados, isso pode produzir comportamento inseguro ou incorreto em outra thread
    Por exemplo, se uma thread muda o tamanho de um vetor enquanto outra acessa esse vetor, algo que seria seguro em execução sequencial se torna perigoso sob concorrência
    O Go também não está livre disso
  • Problemas típicos de memory safety em C têm alta chance de levar a RCE (execução remota de código)
    Já um problema de thread safety que termina em segfault pode resultar apenas em um ataque de DoS (negação de serviço)
    Uma race condition até pode levar a um ataque mais forte, mas é muito mais difícil de disparar
  • Mesmo que um CVE seja mais grave, corrupção de dados ou crash causados por bug de threading continuam sendo bugs que alguém vai ter de triar, analisar e corrigir
  • É triste que a maioria das linguagens com threads ofereça, por padrão, variáveis globais e acesso irrestrito a memória compartilhada
    Isso é uma causa principal de corrupção de dados e races
    Em muitos casos, um modelo baseado em processos é melhor que threads para concorrência, mas tem a desvantagem de ser pesado demais
    Se o padrão fosse passar para cada thread todos os dados necessários via message passing, acho que a maior parte desses problemas desapareceria
    De qualquer forma, temos liberdade na plataforma para usar variáveis globais e memória compartilhada; então basta escolher não usar
  • O Rust é um exemplo representativo de linguagem moderna que consegue embutir thread safety no sistema de tipos
    O objetivo original do Rust não era ser uma linguagem de sistema memory-safe, mas uma linguagem de sistema thread-safe; a memory safety veio naturalmente como consequência
    Em Rust, dá para usar concorrência estruturada com thread::scope e afins, então trabalhar com threads é bem confortável
  • Message passing pode causar mais problemas lógicos, como race condition e deadlock, do que compartilhamento de memória, então não é uma solução mágica
  • Em Go, há uma forte ênfase em comunicação entre goroutines (channel etc.) em vez de compartilhamento direto de memória
    Veja este documento
  • Mesmo passando objetos entre goroutines por channel, Go não tem conceitos como tipos sendable, ownership ou referências read-only, então não é fácil usar isso com segurança
    Exemplo real:
    func processData(lines <-chan []byte) {
     for line := range lines {
      fmt.Printf("processing line: %v\n", line)
     }
    }
    
    func main() {
     lines := make(chan []byte)
     go processData(lines)
    
     var buf bytes.Buffer
     for range 3 {
      buf.WriteString("mock data, assume this got read into the buffer from a file or something")
      lines <- buf.Bytes()
      buf.Reset()
     }
    }
    
    Nesse código, buf.Bytes() passa uma referência direta para a memória interna, e a chamada a Reset() reutiliza a backing memory, fazendo com que processData e main acabem acessando a mesma memória ao mesmo tempo e gerando uma data race
    Em Rust, esse código nem compilaria, porque seriam duas referências mutáveis, então a linguagem forçaria transferência de ownership ou cópia
    Em Go, isso é fácil de confundir; bytes.Buffer.ReadBytes("\n") e .String() retornam cópias e são seguros, mas .Bytes() é perigoso desse jeito
    Os channels do Rust bloqueiam esse problema na raiz com os conceitos de ownership e transferência, mas o Go não tem esse tipo de proteção
    No fim, isso parece mais lento do que mutex e oferece uma experiência mais difícil de usar corretamente para quem está começando em Go
  • Em programas reais em golang, o padrão de “comunicar-se por compartilhamento” acaba causando um grande volume de problemas lógicos, e no fim o compartilhamento de memória vira o normal
    Ou seja, race “segura” ou deadlock “seguro” acabam sendo até mais comuns
  • Discussões sobre bugs de concorrência tendem a ignorar que, na maioria dos apps, a maior parte dos bugs realmente importantes vem de travas, transações e isolamento de transações aplicados de forma errada dentro do banco de dados
    Na teoria de PL, a abordagem de race freedom do Rust pode parecer atraente, mas em apps reais os dados importantes quase sempre estão todos no RDBMS, e basta não usar FOR UPDATE num SELECT, por exemplo, para surgir race do mesmo jeito
    Mesmo que um app Rust não use unsafe em lugar nenhum, dependendo do banco, races continuam existindo
  • O termo “memory safety” surgiu para explicar um conceito originalmente complexo, mas com o tempo seu significado foi se ampliando ou se estreitando
    Em Go, a ausência de exploits reais mostra que a linguagem quase não permite bugs de corrupção de memória
    Se seguirmos a tese do texto, a maioria das linguagens de alto nível — com exceção apenas de Java, segundo o artigo — também deixaria de ser memory-safe
    O Rust pode ser “mais” seguro que Go, mas “memory safety” não é um espectro contínuo; é um conceito de passa/falha
    Se alguém quer afirmar que uma linguagem é memory-unsafe, precisa obrigatoriamente mostrar um POC
  • Se a parte importante do termo memory safety for “type confusion”, então o Go também não é exceção
    O exemplo do texto mostra que, ao tratar um int por engano como ponteiro, é fácil provocar corrupção de memória
    Na demo usam 42 de propósito para causar segfault, mas se tivessem usado um endereço real, haveria corrupção real
  • Data races violam memory safety porque podem colocar o programa em estados que a especificação da linguagem não reconhece, como encerramento forçado por SIGSEGV
    Portanto, uma linguagem em que data races são possíveis não pode ser considerada memory-safe
  • Como no exemplo do texto, torn read de fat pointer via type confusion, ou torn read de slice levando a gravação fora dos limites, também são cenários possíveis
    Fica difícil chamar isso de memory-safe
  • Em matemática e física também é comum que termos evoluam e mudem de significado
    Para evitar esse problema, às vezes se usam epônimos, como “curvatura gaussiana” ou “integrais de Riemann”
    Também existem casos em que “o sentido original fica mais restrito, enquanto o uso mais amplo se expande”, como em “grupo de Galois”
    Nesse aspecto, memory safety não é exceção
  • Queria entender por que, pela definição do autor, Java não seria memory-safe
    Gostaria de um exemplo concreto
  • O próprio Go oficialmente também tem uma definição pouco clara de memory safety
    Em respostas de FAQ como esta menção a memory safety ou esta sobre unions, há uma insinuação de que Go é memory-safe, mas não fica claro o que isso significa de fato
    Em uma apresentação de 2012, Rob Pike disse “Not purely memory safe”, mas nem o significado de “purely” é definido
    Até na documentação do race detector a definição de “safe” é vaga (documento de exemplo)
    Externamente, porém, é comum ver afirmações fortes de que Go é uma “memory-safe programming language”
    Por exemplo, na documentação de segurança da fly.io ou em um documento da memorysafety.org que classifica Go como memory safe
    Mas esse mesmo material também descreve “Out of Bounds Reads and Writes” como problema de memory safety, e o erro de Go apontado no post se encaixa justamente nessa condição
    No mínimo, Go e a comunidade deveriam esclarecer melhor o significado exato de “memory safety”
    Enquanto existirem casos assim, o ideal é não chamar Go de linguagem memory-safe sem explicação adicional
  • A própria definição de memory safety também mudou um pouco com o tempo
    Quando Go foi criado, a visão dominante era que “se tem garbage collector, então é memory-safe”, e em comparação com C/C++ ele é de fato muito mais seguro