É possível avaliar programaticamente a segurança de código arbitrário?

9

Ultimamente, tenho pensado muito em código seguro. Discussão segura. Memória segura. Não-seguro-explodir-em-seu-rosto-com-um-segfault. Mas, para maior clareza na questão, vamos usar o modelo de segurança de Rust como nossa definição atual.

Freqüentemente, garantir a segurança é um problema do tamanho da Internet, porque, conforme comprovado pela necessidade de Rust unsafe, existem algumas idéias de programação muito razoáveis, como concorrência, que não podem ser implementadas no Rust sem o uso da unsafepalavra - chave . Mesmo que a simultaneidade possa ser perfeitamente segura com bloqueios, mutexes, canais e isolamento de memória ou o que você tem, isso exige trabalhar fora do modelo de segurança da Rust unsafee, em seguida, garantir manualmente ao compilador que: "Sim, eu sei o que estou fazendo Isso parece inseguro, mas eu já provei matematicamente que é perfeitamente seguro ".

Mas, normalmente, isso se resume a criar modelos manualmente e provar que eles são seguros com provadores de teoremas . Tanto da perspectiva da ciência da computação (é possível) como da perspectiva da praticidade (isso levará a vida do universo), é razoável imaginar um programa que leve código arbitrário em uma linguagem arbitrária e avalie se é ou não " Ferrugem segura "?

Advertências :

  • Uma solução fácil é salientar que o programa pode ser desinteressante e, portanto, o problema da interrupção nos falha. Digamos que qualquer programa fornecido ao leitor seja interrompido
  • Embora o "código arbitrário em um idioma arbitrário" seja o objetivo, é claro que estou ciente de que isso depende da familiaridade do programa com o idioma escolhido, o que consideraremos como um dado
O ambientalista
fonte
2
Código arbitrário? Não. Imagino que você não possa provar a segurança do código mais útil por causa de exceções de E / S e hardware.
Telastyn
7
Por que você está desconsiderando o problema da parada? Todos os exemplos que você mencionou, e muitos mais, provaram ser equivalentes à solução do Problema de Parada, Problema de Função, Teorema de Rice ou qualquer um dos outros Teoremas de Indecidibilidade: segurança de ponteiro, segurança de memória, segmento -segurança, segurança de exceção, pureza, segurança de E / S, segurança de trava, garantias de progresso etc. O Problema da Parada é uma das propriedades estáticas mais simples possíveis que você poderia querer saber, tudo o que você lista é muito mais difícil .
Jörg W Mittag
3
Se você só se preocupam com falsos positivos, e estão dispostos a aceitar falsos negativos, eu tenho um algoritmo que classifica tudo: "É seguro Não?"
Caleth
Você absolutamente não precisa usar o unsafeRust para escrever código simultâneo. São vários mecanismos diferentes disponíveis, desde primitivas de sincronização até canais inspirados em atores.
precisa

Respostas:

8

No final das contas, estamos falando de tempo de compilação versus tempo de execução.

Erros de tempo de compilação, se você pensar sobre isso, acabam resultando no compilador capaz de determinar quais problemas você tem no seu programa antes mesmo de ser executado. Obviamente, não é um compilador de "linguagem arbitrária", mas voltarei a isso em breve. O compilador, em toda a sua infinita sabedoria, não lista todos os problemas que podem ser determinados pelo compilador. Isso depende parcialmente de quão bem o compilador foi gravado, mas a principal razão para isso é que muitas coisas são determinadas em tempo de execução .

Erros de tempo de execução, como você está bem familiarizado, tenho certeza que sou, são qualquer tipo de erro que ocorre durante a execução do próprio programa. Isso inclui dividir por zero, exceções de ponteiro nulo, problemas de hardware e muitos outros fatores.

A natureza dos erros de tempo de execução significa que você não pode antecipar os erros em tempo de compilação. Se você pudesse, eles quase certamente seriam verificados em tempo de compilação. Se você puder garantir que um número seja zero no tempo de compilação, poderá executar certas conclusões lógicas, como dividir qualquer número por esse número resultará em um erro aritmético causado pela divisão por zero.

Como tal, de uma maneira muito real, o inimigo de garantir programaticamente o funcionamento adequado de um programa está executando verificações de tempo de execução em vez de compilar verificações de tempo. Um exemplo disso pode estar executando uma conversão dinâmica para outro tipo. Se isso for permitido, você, o programador, está substituindo essencialmente a capacidade do compilador de saber se isso é uma coisa segura a se fazer. Algumas linguagens de programação decidiram que isso é aceitável, enquanto outras pelo menos o alertam em tempo de compilação.

Outro bom exemplo pode ser permitir que os nulos façam parte do idioma, pois podem ocorrer exceções de ponteiro nulo se você permitir nulos. Alguns idiomas eliminaram esse problema completamente, impedindo que variáveis ​​não declaradas explicitamente consigam manter valores nulos a serem declarados sem a atribuição imediata de um valor (como o Kotlin, por exemplo). Embora não seja possível eliminar um erro de tempo de execução da exceção do ponteiro nulo, é possível impedir que isso aconteça removendo a natureza dinâmica do idioma. No Kotlin, você pode forçar a possibilidade de manter valores nulos, é claro, mas nem é preciso dizer que esse é um metafórico "compradores devem tomar cuidado", pois você deve explicitamente explicá-lo como tal.

Você poderia conceitualmente ter um compilador que pudesse verificar erros em todos os idiomas? Sim, mas provavelmente seria um compilador desajeitado e altamente instável, no qual seria necessário fornecer necessariamente o idioma que está sendo compilado anteriormente. Ele também não sabia muitas coisas sobre o seu programa, assim como os compiladores para idiomas específicos sabem algumas coisas sobre ele, como o problema de interrupção, como você mencionou. Como se vê, é impossível obter muitas informações que podem ser interessantes para aprender sobre um programa. Isso foi comprovado, portanto, não é provável que mude tão cedo.

Voltando ao seu ponto principal. Os métodos não são automaticamente seguros para threads. Existe uma razão prática para isso: métodos de segurança de encadeamento também são mais lentos, mesmo quando não estão sendo usados. O Rust decide que eles podem eliminar problemas de tempo de execução, tornando os métodos seguros por padrão, e essa é a escolha deles. Mas tem um custo.

Pode ser possível provar matematicamente a correção de um programa, mas seria com a ressalva que você teria literalmente zero recursos de tempo de execução no idioma. Você seria capaz de ler este idioma e saber o que ele faz sem surpresas. A linguagem provavelmente pareceria muito matemática por natureza, e isso provavelmente não é coincidência. A segunda ressalva é que erros de tempo de execução ainda acontecem, o que pode não ter nada a ver com o próprio programa. Portanto, o programa pode ser provada correta, assumindo uma série de pressupostos sobre o computador que está sendo executado em são precisas e não fazer a mudança, o que, claro, sempre que acontecer de qualquer maneira e muitas vezes.

Neil
fonte
3

O sistema de tipos é uma prova automaticamente verificável de alguns aspectos da correção. Por exemplo, o sistema de tipos de Rust pode provar que uma referência não sobrevive ao objeto referenciado ou que um objeto referenciado não é modificado por outro encadeamento.

Mas os sistemas de tipos são bastante restritos:

  • Eles rapidamente se deparam com problemas de decisão. Em particular, o próprio sistema de tipos deve ser decidível, mas muitos sistemas de tipos práticos são acidentalmente Turing Complete (incluindo C ++ por causa de modelos e Rust por causa de características). Além disso, certas propriedades do programa que estão verificando podem ser indecidíveis no caso geral, principalmente se algum programa pára (ou diverge).

  • Além disso, os sistemas de tipos devem ser executados rapidamente, idealmente em tempo linear. Nem todas as provas possíveis devem ser apresentadas no sistema de tipos. Por exemplo, a análise de todo o programa é geralmente evitada, e as provas têm escopo definido para módulos ou funções individuais.

Devido a essas limitações, os sistemas de tipos tendem a verificar apenas propriedades razoavelmente fracas que são fáceis de provar, por exemplo, que uma função é chamada com valores do tipo correto. No entanto, mesmo isso limita substancialmente a expressividade, por isso é comum ter soluções alternativas (como interface{}no Go, dynamicno C #, Objectno Java, void*no C) ou até mesmo usar linguagens que evitam completamente a digitação estática.

Quanto mais fortes as propriedades que verificamos, menos expressivo o idioma fica. Se você escreveu Rust, conhecerá esses momentos de "briga com o compilador" em que o compilador rejeita o código aparentemente correto, porque não foi capaz de provar a correção. Em alguns casos, não é possível expressar um determinado programa no Rust, mesmo quando acreditamos que podemos provar sua correção. O unsafemecanismo em Rust ou C # permite que você escape dos limites do sistema de tipos. Em alguns casos, adiar as verificações para o tempo de execução pode ser outra opção - mas isso significa que não podemos rejeitar alguns programas inválidos. Esta é uma questão de definição. Um programa Rust que entre em pânico é seguro no que diz respeito ao sistema de tipos, mas não necessariamente da perspectiva de um programador ou usuário.

Os idiomas são projetados juntamente com seu sistema de tipos. É raro que um novo sistema de tipos seja imposto a uma linguagem existente (mas consulte, por exemplo, MyPy, Flow ou TypeScript). A linguagem tentará facilitar a gravação de código compatível com o sistema de tipos, por exemplo, oferecendo anotações de tipo ou introduzindo estruturas de fluxo de controle fáceis de provar. Idiomas diferentes podem acabar com soluções diferentes. Por exemplo, Java tem o conceito de finalvariáveis ​​atribuídas exatamente uma vez, semelhante às não mutvariáveis ​​de Rust :

final int x;
if (...) { ... }
else     { ... }
doSomethingWith(x);

Java possui regras de sistema de tipos para determinar se todos os caminhos atribuem a variável ou finalizam a função antes que a variável possa ser acessada. Por outro lado, o Rust simplifica essa prova por não ter variáveis ​​declaradas, mas não designadas, mas permite retornar valores das instruções de fluxo de controle:

let x = if ... { ... } else { ... };
do_something_with(x)

Isso parece um ponto muito pequeno ao definir uma tarefa, mas o escopo claro é extremamente importante para as provas relacionadas à vida.

Se aplicássemos um sistema do tipo Rust ao Java, teríamos problemas muito maiores do que isso: os objetos Java não são anotados com o tempo de vida útil, portanto, teríamos que tratá-los como &'static SomeClassou Arc<dyn SomeClass>. Isso enfraqueceria todas as provas resultantes. Java também não possui um conceito de imutabilidade no nível de tipo, portanto não podemos distinguir entre &e &muttipos. Teríamos que tratar qualquer objeto como uma célula ou um mutex, embora isso possa assumir garantias mais fortes do que o Java realmente oferece (alterar um campo Java não é seguro para threads, a menos que sincronizado e volátil). Por fim, Rust não tem nenhum conceito de herança de implementação no estilo Java.

TL; DR: sistemas do tipo são provadores de teoremas. Mas eles são limitados por problemas de decisão e preocupações de desempenho. Você não pode simplesmente pegar um sistema de tipos e aplicá-lo a um idioma diferente, pois a sintaxe do idioma do destino pode não fornecer as informações necessárias e porque a semântica pode ser incompatível.

amon
fonte
3

Quão seguro é seguro?

Sim, é quase possível escrever um verificador: seu programa precisa retornar a constante UNSAFE. Você estará certo 99% das vezes

Porque, mesmo que você execute um programa Rust seguro, alguém ainda pode desconectar durante a execução: portanto, seu programa pode parar mesmo que teoricamente não deva.

E mesmo que seu servidor esteja sendo executado em uma gaiola de faraday em um bunker, um processo vizinho pode executar uma exploração de martelo de linha e dar uma guinada em um de seu programa Rust supostamente seguro.

O que estou tentando dizer é que seu software será executado em um ambiente não determinístico e muitos fatores externos podem influenciar a execução.

Brincadeira à parte, verificação automatizada

Já existem analisadores de código estático que podem detectar construções de programação arriscadas (variáveis ​​não inicializadas, estouros de buffer, etc ...). Eles funcionam criando um gráfico do seu programa e analisando a propagação de restrições (tipos, intervalos de valores, seqüenciamento).

Esse tipo de análise também é executada por alguns compiladores em prol da otimização.

Certamente é possível dar um passo adiante, além de analisar a simultaneidade e fazer deduções sobre a propagação de restrições em vários segmentos, condições de sincronização e corrida. No entanto, muito rapidamente você se depararia com o problema de explosão combinatória entre os caminhos de execução e muitas incógnitas (E / S, agendamento de SO, entrada do usuário, comportamento de programas externos, interrupções etc.) que reduzirão as restrições conhecidas à sua descoberta. mínimo e dificulta a conclusão automatizada útil de códigos arbitrários.

Christophe
fonte
1

Turing abordou isso em 1936 com seu artigo sobre o problema da parada. Um dos resultados é que, apenas que é impossível escrever um algoritmo que 100% do tempo possa analisar o código e determinar corretamente se ele irá parar ou não, é impossível escrever um algoritmo que possa 100% do tempo corretamente determine se o código tem alguma propriedade específica ou não, incluindo "segurança", no entanto, você deseja defini-lo.

No entanto, o resultado de Turing não exclui a possibilidade de um programa que possa 100% das vezes (1) determinar absolutamente o código como seguro, (2) determinar absolutamente que o código é inseguro ou (3) antropomorficamente levantar as mãos e dizer "Caramba, eu não sei". O compilador de Rust, de um modo geral, está nessa categoria.

NovaDenizen
fonte
Então, desde que você tenha uma opção "não tenho certeza", sim?
TheEnvironmentalist
11
O argumento é que sempre é possível escrever um programa capaz de confundir um programa de análise de programa. A perfeição é impossível. A praticidade pode ser possível.
NovaDenizen
1

Se um programa for total (o nome técnico de um programa que é garantido para ser interrompido), teoricamente é possível provar qualquer propriedade arbitrária sobre o programa, com recursos suficientes. Você pode simplesmente explorar todos os estados em potencial em que o programa pode entrar e verificar se algum deles viola sua propriedade. A linguagem de verificação do modelo TLA + usa uma variante dessa abordagem, usando a teoria dos conjuntos para verificar suas propriedades em relação aos conjuntos de estados potenciais do programa, em vez de calcular todos os estados.

Tecnicamente, qualquer programa executado em qualquer hardware físico prático é total ou um loop comprovável, porque você tem apenas uma quantidade limitada de armazenamento disponível; portanto, existe apenas um número finito de estados nos quais o computador pode estar. Na verdade, o computador é uma máquina de estados finitos, e não Turing completo, mas o espaço de estados é tão grande que é mais fácil fingir que eles estão se tornando completos).

O problema dessa abordagem é que ela tem complexidade exponencial em relação à quantidade de armazenamento e tamanho do programa, tornando-o impraticável para qualquer coisa que não seja o núcleo dos algoritmos e impossível de aplicar a bases de código significativas na íntegra.

Portanto, a grande maioria das pesquisas é focada em provas. A correspondência de Curry-Howard afirma que uma prova de correção e um sistema de tipos são a mesma coisa; portanto, a maioria das pesquisas práticas é denominada sistema de tipos. Particularmente relevantes para essa discussão são Coq e Idriss, além de Rust, que você já mencionou. Coq aborda o problema de engenharia subjacente a partir de outra direção. Ao comprovar a exatidão do código arbitrário na linguagem Coq, ele pode gerar um código que executa o programa comprovado. Enquanto isso, Idriss usa um sistema de tipos dependentes para provar código arbitrário em uma linguagem pura como Haskell. O que essas duas linguagens fazem é colocar os problemas difíceis de gerar uma prova viável no gravador, permitindo que o verificador de tipos se concentre em verificar a prova. Verificar a prova é um problema muito mais simples, mas isso deixa os idiomas muito mais difíceis de trabalhar.

Ambas as linguagens foram projetadas especificamente para facilitar as provas, usando a pureza para controlar qual estado é relevante para quais partes do programa. Para muitos idiomas tradicionais, apenas provar que um estado é irrelevante para uma prova de parte do programa pode ser uma questão complexa devido à natureza dos efeitos colaterais e dos valores mutáveis.

user1937198
fonte