- 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
- Já 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
Comentários do Hacker News
segfaultrepetidamenteO Swift também tem o mesmo problema, e já escrevi um programa mostrando que o Swift pode causar
segfaultcom muita facilidade ao acessar estruturas de dados compartilhadasDizer que Go é memory-safe como Rust ou Java é um pouco exagerado
mapnão são thread-safe, então é preciso ter cuidado ao modificá-lasQueria ouvir mais detalhes sobre o que aconteceu na Dropbox
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
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
mapousliceacontece no mesmo slice, e ainda depende de ocorrertorn read, então na prática não é tão comumAinda 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 readcomo algo que simplesmente dá para corrigir, e nunca foi uma grande questão em código Go em produçãoVídeo relacionado
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 timeDepois de me expor tanto a vários tipos de race em Go, pessoalmente gostaria que Rust fosse adotado em todo lugar
Por exemplo, esta issue exige uma grande refatoração do compilador, então leva tempo para ser resolvida
Send/Syncdo RustNa 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
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çãoClaro, ele reduz bugs em relação a C, mas isso também vale para C++, e ninguém diz que C++ é memory-safe
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
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
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
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
Já um problema de thread safety que termina em
segfaultpode 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
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 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::scopee afins, então trabalhar com threads é bem confortávelchanneletc.) em vez de compartilhamento direto de memóriaVeja este documento
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çaExemplo real: Nesse código,
buf.Bytes()passa uma referência direta para a memória interna, e a chamada aReset()reutiliza a backing memory, fazendo com queprocessDataemainacabem acessando a mesma memória ao mesmo tempo e gerando uma data raceEm 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 jeitoOs 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
mutexe oferece uma experiência mais difícil de usar corretamente para quem está começando em GoOu seja, race “segura” ou deadlock “seguro” acabam sendo até mais comuns
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 UPDATEnumSELECT, por exemplo, para surgir race do mesmo jeitoMesmo que um app Rust não use
unsafeem lugar nenhum, dependendo do banco, races continuam existindoEm 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
O exemplo do texto mostra que, ao tratar um
intpor engano como ponteiro, é fácil provocar corrupção de memóriaNa demo usam 42 de propósito para causar
segfault, mas se tivessem usado um endereço real, haveria corrupção realSIGSEGVPortanto, uma linguagem em que data races são possíveis não pode ser considerada memory-safe
torn readde fat pointer via type confusion, outorn readde slice levando a gravação fora dos limites, também são cenários possíveisFica difícil chamar isso de memory-safe
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
Gostaria de um exemplo concreto
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
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