2 pontos por GN⁺ 10 일 전 | 1 comentários | Compartilhar no WhatsApp
  • Estrutura que rastreia ponteiros C/C++ junto com metadados de AllocationRecord e realiza verificação de limites de memória na desreferenciação
  • Método que move, junto com o valor original do ponteiro, os metadados correspondentes em atribuições de ponteiros, aritmética, passagem de argumentos de função, retorno e chamadas a malloc·free, ou os converte em chamadas específicas do Fil-C
  • Os metadados de ponteiros dentro da memória heap são armazenados separadamente em invisible_bytes; ao carregar/armazenar ponteiros, o valor e os metadados são lidos e gravados juntos, com verificação de alinhamento adicional
  • filc_free libera apenas visible_bytes e invisible_bytes, mantendo o próprio AllocationRecord; a limpeza posterior fica a cargo do coletor de lixo, e variáveis locais cujo endereço possa escapar passam por promoção para heap
  • Embora ainda existam complexidades de implementação real, como threads, ponteiros de função e otimizações de memória/desempenho, há potencial de uso para verificação de segurança de memória em grandes bases C/C++ ou como exemplo concreto de sistema para pointer provenance

Modelo simplificado do Fil-C

  • Fil-C usa uma estrutura que rastreia metadados de AllocationRecord* junto com ponteiros para tratar código C/C++ com segurança de memória
    • A implementação real reescreve LLVM IR, mas o modelo simplificado transforma automaticamente o código-fonte C/C++
    • Para cada variável local do tipo ponteiro em uma função, é adicionada uma variável local correspondente do tipo AllocationRecord*
    • Por exemplo, para T1* p1, é adicionada AllocationRecord* p1ar = NULL
  • Atribuições simples e cálculos sobre variáveis locais de ponteiro movem também o AllocationRecord* junto com o valor original do ponteiro
    • p1 = p2 é transformado em p1 = p2, p1ar = p2ar
    • p1 = p2 + 10 também vem acompanhado de p1ar = p2ar
    • Cast de inteiro para ponteiro define os metadados como NULL
    • Cast de ponteiro para inteiro permanece inalterado
  • Na passagem de argumentos e retorno de funções, também se passa um AllocationRecord* adicional junto com o ponteiro, e certas chamadas da biblioteca padrão são substituídas por funções específicas do Fil-C
    • Chamadas a malloc e free são convertidas para filc_malloc e filc_free
    • Por exemplo, p1 = malloc(x); free(p1); vira {p1, p1ar} = filc_malloc(x); filc_free(p1, p1ar);
  • filc_malloc não faz apenas uma alocação para a memória solicitada, mas três alocações
    • Alocação do objeto AllocationRecord
    • Alocação de visible_bytes para os dados reais
    • Alocação com calloc de invisible_bytes para armazenar metadados invisíveis
    • AllocationRecord inclui os campos visible_bytes, invisible_bytes e length

Desreferenciação e verificação de limites

  • Ao desreferenciar um ponteiro, usa-se o AllocationRecord* associado para realizar verificação de limites
    • Verifica-se se os metadados do ponteiro não são NULL
    • Calcula-se a diferença entre a posição atual do ponteiro e o endereço inicial de visible_bytes
    • Verifica-se se o offset é menor que o comprimento total
    • Verifica-se se o comprimento restante é suficiente para o tamanho do alvo da desreferenciação
  • O mesmo procedimento de verificação se aplica tanto a leitura quanto a escrita
    • A verificação é feita antes de x = *p1
    • A mesma forma de verificação é feita antes de *p2 = x
  • Com essa estrutura, acessos fora da faixa alocada do alvo apontado pelo ponteiro são bloqueados

Ponteiros na heap e invisible_bytes

  • Como o compilador não pode gerenciar diretamente ponteiros armazenados na memória heap com variáveis separadas, como faz com variáveis locais, usa-se invisible_bytes
    • Se houver um ponteiro na posição visible_bytes + i, o AllocationRecord* correspondente é armazenado na posição invisible_bytes + i
    • Em outras palavras, invisible_bytes funciona como um array cujo tipo de elemento é AllocationRecord*
  • Ao ler ou escrever um valor de ponteiro na memória, além da verificação de limites normal, adiciona-se verificação de alinhamento
    • Verifica-se se o offset i é múltiplo de sizeof(AllocationRecord*)
    • Só com essa condição é possível acessar invisible_bytes com segurança como um array de AllocationRecord**
  • Ao carregar um ponteiro, os metadados também são carregados junto com o ponteiro de dados
    • p2 = *p1 recebe, após p2 = *p1, a adição de p2ar = *(AllocationRecord**)(p1ar->invisible_bytes + i)
  • Ao armazenar um ponteiro, grava-se não só o valor do ponteiro, mas também os metadados correspondentes
    • *p1 = p2 executa, após armazenar o dado real, *(AllocationRecord**)(p1ar->invisible_bytes + i) = p2ar

filc_free e o coletor de lixo

  • filc_free libera duas regiões de memória após verificar a consistência com o AllocationRecord, quando o ponteiro não é NULL
    • Verifica par != NULL
    • Verifica p == par->visible_bytes
    • Libera visible_bytes e invisible_bytes
    • Depois, define visible_bytes e invisible_bytes como NULL e length como 0
  • Embora filc_malloc faça três alocações, filc_free não libera o próprio objeto AllocationRecord
    • Essa diferença é tratada pelo coletor de lixo
  • No modelo simplificado, um GC stop-the-world já é suficiente; o Fil-C real usa um coletor paralelo, concorrente e incremental
    • O GC rastreia seguindo os objetos AllocationRecord
    • AllocationRecord inacessíveis são marcados para liberação
  • O GC também executa mais duas tarefas
    • Ao liberar um AllocationRecord inacessível, chama filc_free
    • Todos os ponteiros que apontam para um AllocationRecord com length igual a 0 são alterados para um único AllocationRecord canônico de comprimento 0
  • Com esse comportamento, mesmo sem chamar free, não há vazamento permanente de memória
    • O GC libera automaticamente
    • Ainda assim, chamar free permite liberar memória mais cedo do que esperar pelo GC
  • Depois de free, o AllocationRecord correspondente acaba se tornando inacessível e pode ser limpo mais tarde

Escape de endereço de variável local e promoção para heap

  • Com a existência de GC, amplia-se a faixa em que endereços de variáveis locais podem ser tratados com segurança
    • Se o endereço de uma variável local foi obtido e o compilador não consegue provar que esse endereço não escapa para além da vida útil da variável, ela é promovida para alocação na heap
  • Essas variáveis locais passam a ser alocadas com malloc em vez de na pilha
    • Não é necessário inserir um free correspondente
    • O GC fica responsável pela coleta

Versão Fil-C de memmove

  • A memmove da biblioteca padrão C lida com memória arbitrária, o que cria o problema de o compilador não saber se há ponteiros nela
  • Para isso, aplica-se uma heurística
    • Ponteiros dentro de memória arbitrária precisam estar totalmente contidos dentro daquele intervalo de memória
    • Os ponteiros precisam estar corretamente alinhados
  • Por essa regra, há diferença de comportamento mesmo ao mover os mesmos 8 bytes
    • Se memmove mover 8 bytes alinhados de uma vez, a faixa correspondente de invisible_bytes também é movida
    • Se memmove mover esses 8 bytes em 8 operações de 1 byte, invisible_bytes não é movido

Complexidades adicionais na implementação real

  • Threads

    • Concorrência é um fator que aumenta a complexidade do GC
    • filc_free não pode liberar memória imediatamente
      • Isso porque pode haver condição de corrida entre a thread que está liberando e outra thread acessando a mesma memória
    • Operações atômicas sobre ponteiros também exigem tratamento adicional
      • A reescrita básica transforma loads/stores de ponteiro em dois loads/stores, quebrando a atomicidade
  • Ponteiros de função

    • Metadados adicionais em AllocationRecord indicam se visible_bytes não são dados comuns, mas um ponteiro para código executável
    • Uma chamada por meio do ponteiro de função p1 verifica p1 == p1ar->visible_bytes e também se p1ar representa um ponteiro de função
    • Para evitar ataques de confusão de tipo com ponteiros de função, também é necessária validação de assinatura de tipo na ABI de chamada
    • Um método é fazer com que todas as funções tenham a mesma assinatura de tipo
      • Como se todos os argumentos fossem colocados em uma struct e passados via memória
      • Na fronteira da ABI, cada função receberia apenas um único AllocationRecord correspondente a essa struct
  • Otimização de uso de memória

    • É possível considerar fazer filc_malloc não alocar invisible_bytes imediatamente, deixando isso para quando necessário
    • Também se pode considerar colocar AllocationRecord e visible_bytes juntos em uma única alocação
    • Se o malloc subjacente anexar metadados no início de cada alocação, também se pode considerar incorporar esses metadados ao AllocationRecord
  • Otimização de desempenho

    • A segurança de memória do Fil-C vem com custo de desempenho
    • Há espaço para aplicar várias técnicas para recuperar parte do desempenho perdido

Quando usar Fil-C

  • Pode ser usado quando uma grande base de código C/C++ parece funcionar, mas não tem verificação de segurança de memória, e há disposição para aceitar GC e grande perda de desempenho em troca dessa segurança
    • Menciona-se a possibilidade de servir como medida temporária antes de reescrever em Java, Go ou Rust
  • Também é possível executar Fil-C com o objetivo de detectar bugs de memória, como com ASan
    • É possível rodar código C/C++ sob Fil-C para verificar bugs de memória
  • Em linguagens em que a linguagem de compilação e de runtime é a mesma e a segurança em tempo de compilação é forte, pode haver uso para avaliação segura em tempo de compilação
    • Zig é citado como exemplo
    • Mesmo que a avaliação em runtime não seja segura, a avaliação em tempo de compilação pode usar a configuração do Fil-C
  • Também tem valor como um exemplo concreto de sistema que lida com pointer provenance
    • Levanta-se a questão de se, quando p1 e p2 têm o mesmo tipo, a otimização if (p1 == p2) { f(p1); }if (p1 == p2) { f(p2); } seria possível
    • No Fil-C, como o AllocationRecord* passado para f pode ser diferente, a resposta é explicitamente não
    • Nesse ponto, o Fil-C serve como exemplo concreto de sistema com pointer provenance

1 comentários

 
GN⁺ 10 일 전
Comentários no Hacker News
  • Acho que seria um experimento bem interessante adicionar invisicaps a algo como chibicc ou slimcc
    Também haveria espaço para testar contagem de referências ou variações do invisible capability system, e talvez desse até para economizar memória em troca de um pequeno custo de indireção
  • Eu criei o filc-bazel-template e empacotei como um Bazel target
    Espero que isso ajude quem quiser usar os dois juntos para fazer hermetic builds
  • Não entendi muito bem o significado desta frase
    Upon freeing an unreachable AllocationRecord, call filc_free on it.
    Pelo que entendi, a intenção era dizer que, antes de liberar um AR inalcançável, é preciso liberar primeiro a memória apontada pelos campos visible_bytes e invisible_bytes
  • Sinto que Fil-C é um dos projetos mais subestimados que já vi até hoje
    Mais do que dizer “rewrite it in Rust” por segurança, acho mais interessante o fato de ele conseguir compilar programas C existentes de forma totalmente memory-safe
    • Na minha visão, é preciso considerar algumas coisas junto
      Primeiro, Fil-C é mais lento e maior. Se isso fosse aceitável, daria até para dizer que, nos últimos 10 anos, teria feito mais sentido migrar antes para Java ou C# do que para Rust
      Segundo, ainda continua sendo C. Para manter código existente, tudo bem, mas se for escrever muito código novo, eu acho Rust muito mais agradável
      Terceiro, Fil-C oferece segurança em runtime, enquanto Rust consegue expressar parte disso em compile time. Indo além, linguagens como WUFFS tentam provar a segurança já na compilação sem checks em runtime, então o código pode até estar logicamente errado, mas a proposta é impedir crashes ou execução arbitrária de código
    • Eu não diria que está subestimado aqui. Já houve bastante discussão sobre isso
      Houve threads como Fil-Qt: A Qt Base build with Fil-C experience, Linux Sandboxes and Fil-C, Ported freetype, fontconfig, harfbuzz, and graphite to Fil-C, A Note on Fil-C, Notes by djb on using Fil-C, Fil-C: A memory-safe C implementation e Fil's Unbelievable Garbage Collector
    • A principal limitação do Fil-C, para mim, é que ele fornece runtime memory safety
      Ainda dá para escrever código que não é memory-safe, e agora isso significa mais que o resultado vira um crash garantido em vez de uma vulnerabilidade
      Se você estiver criando algo como uma web API que recebe entrada não confiável, esse tipo de problema no fim pode virar denial-of-service, então é melhor, mas ainda está longe de ser bom o bastante
      Não estou tentando diminuir o trabalho do Fil-C; só acho que abordagens de runtime têm limitações claras
    • Obrigado pelo interesse
      Mas, para ser justo, Fil-C é bem mais lento que Rust e também usa mais memória
      Por outro lado, Fil-C suporta safe dynamic linking e, em alguns aspectos, dá até para dizer que ele é mais estritamente seguro que Rust
      No fim, são trade-offs, então acho que cada um deve escolher de acordo com o seu contexto
    • Tenho a impressão de que é raro ver programadores de C/C++ se animarem quando você diz que eles podem acoplar um garbage collector ao próprio programa
      Por isso, mesmo que a ideia seja tecnicamente interessante, parece difícil que ela conquiste as pessoas no plano emocional
  • Pelo que vejo, Fil-C não é memory-safe em situações de data race
    Os valores de capability e pointer podem ser corrompidos durante uma atribuição, então, se o interleaving entre threads for ruim, pode acabar acessando um objeto com um ponteiro incorreto e causar mau funcionamento arbitrário
    Acho que essa limitação em si é aceitável, mas é decepcionante ver um clima em que até apoiadores atacam com força quem aponta esse problema
    • Até onde eu sei, essa parte é tratada com atomic ops
      Infelizmente, isso também é uma das grandes causas do overhead
  • Para mim, isso no fim é mais uma variação de técnicas da família de fat pointers
    Esse tipo de abordagem já foi implementado e descartado muitas vezes, por oferecer garantias de segurança insuficientes, por ser difícil atravessar non-fat ABI boundaries ou por ter overhead alto
    • Mas hoje em dia há novamente um movimento de suporte direto em hardware para fat pointers, então talvez não seja algo a descartar cedo demais
      Além disso, eu diria que filc não se explica apenas como um simples fat pointer
    • Também acho que é preciso considerar que várias plataformas já oferecem hardware memory tagging