È possibile valutare programmaticamente la sicurezza per codice arbitrario?


10

Ultimamente ho pensato molto al codice sicuro. Thread-safe. Memory-safe. Non esplodere nella tua faccia con una sicurezza segreta. Ma per chiarezza nella domanda, usiamo il modello di sicurezza di Rust come nostra definizione corrente.

Spesso, garantire la sicurezza è un po 'un grosso problema, perché, come dimostrato dalla necessità di Rust unsafe, ci sono alcune idee di programmazione molto ragionevoli, come la concorrenza, che non possono essere implementate in Rust senza usare la unsafeparola chiave . Anche se la concorrenza può essere resa perfettamente sicura con blocchi, mutex, canali e isolamento della memoria o cosa hai, questo richiede di lavorare al di fuori del modello di sicurezza di Rust unsafee quindi assicurare manualmente al compilatore che "Sì, so cosa sto facendo Questo non sembra sicuro, ma ho dimostrato matematicamente che è perfettamente sicuro. "

Ma in genere, ciò si riduce alla creazione manuale di modelli di queste cose e alla dimostrazione della loro sicurezza con i dimostratori di teoremi . Sia dal punto di vista dell'informatica (è possibile) sia dal punto di vista della praticità (prenderà la vita dell'universo), è ragionevole immaginare un programma che prende un codice arbitrario in un linguaggio arbitrario e valuta se sia " Antiruggine "?

Avvertenze :

  • Una facile spiegazione su questo è quella di sottolineare che il programma potrebbe essere inalterante e quindi il problema di arresto ci fallisce. Supponiamo che qualsiasi programma inviato al lettore sia bloccato
  • Sebbene l'obiettivo sia "codice arbitrario in una lingua arbitraria", sono ovviamente consapevole che ciò dipende dalla familiarità del programma con la lingua scelta, che prenderemo come dato

2
Codice arbitrario? No. Immagino che non si possa nemmeno provare la sicurezza del codice più utile a causa delle eccezioni di I / O e hardware.
Telastyn,

7
Perché non si tiene conto del problema dell'arresto? Ognuno degli esempi che hai citato, e molti altri, hanno dimostrato di essere equivalenti a risolvere il problema di Halting, il problema di funzione, il teorema di Rice o qualsiasi altro teorema di indecidibilità: sicurezza del puntatore, sicurezza della memoria, thread -sicurezza, eccezioni, purezza, I / O-sicurezza, blocco-sicurezza, garanzie di progresso, ecc. Il problema di interruzione è una delle proprietà statiche più semplici che si potrebbero desiderare di sapere, tutto il resto che si elenca è molto più difficile .
Jörg W Mittag,

3
Se ti interessa solo i falsi positivi e sei disposto ad accettare i falsi negativi, ho un algoritmo che classifica tutto: "È sicuro? No"
Caleth,

È assolutamente non necessario utilizzare unsafeRust scrivere codice concorrente. Sono disponibili diversi meccanismi, che vanno dalle primitive di sincronizzazione ai canali ispirati all'attore.
RubberDuck,

Risposte:


8

Ciò di cui stiamo parlando alla fine qui è tempo di compilazione vs runtime.

Gli errori del tempo di compilazione, se ci pensate, alla fine equivalgono al compilatore in grado di determinare quali problemi avete nel vostro programma prima ancora che venga eseguito. Ovviamente non è un compilatore di "linguaggio arbitrario", ma tornerò su questo a breve. Il compilatore, in tutta la sua infinita saggezza, non elenca tuttavia tutti i problemi che possono essere determinati dal compilatore. Ciò dipende in parte dalla capacità di scrittura del compilatore, ma la ragione principale di ciò è che molte cose sono determinate in fase di esecuzione .

Gli errori di runtime, come ben sapete, sono sicuro come me, sono qualsiasi tipo di errore che si verifica durante l'esecuzione del programma stesso. Ciò include la divisione per zero, eccezioni puntatore null, problemi hardware e molti altri fattori.

La natura degli errori di runtime significa che non è possibile anticipare tali errori in fase di compilazione. Se potessi, verrebbero quasi sicuramente controllati al momento della compilazione. Se è possibile garantire che un numero sia zero al momento della compilazione, è possibile eseguire alcune conclusioni logiche, ad esempio la divisione di un numero per quel numero comporterà un errore aritmetico causato dalla divisione per zero.

In quanto tale, in un modo molto reale, il nemico di garantire a livello programmatico il corretto funzionamento di un programma sta eseguendo controlli di runtime invece di controlli di compilazione. Un esempio potrebbe essere l'esecuzione di un cast dinamico su un altro tipo. Se questo è permesso, tu, il programmatore, stai essenzialmente scavalcando la capacità del compilatore di sapere se è una cosa sicura da fare. Alcuni linguaggi di programmazione hanno deciso che questo è accettabile, mentre altri ti avvertiranno almeno in fase di compilazione.

Un altro buon esempio potrebbe essere quello di consentire ai null di far parte del linguaggio, poiché potrebbero verificarsi eccezioni al puntatore null se si consentono i null. Alcune lingue hanno eliminato del tutto il problema impedendo che variabili non dichiarate esplicitamente in grado di contenere valori null siano dichiarate senza che sia stato immediatamente assegnato un valore (ad esempio Kotlin). Sebbene non sia possibile eliminare un errore di runtime dell'eccezione puntatore null, è possibile impedire che ciò accada rimuovendo la natura dinamica della lingua. In Kotlin, puoi forzare la possibilità di mantenere valori nulli, ovviamente, ma è ovvio che si tratta di un "metaforico" che gli acquirenti devono fare, poiché devi dichiararlo esplicitamente come tale.

Potresti avere concettualmente un compilatore in grado di controllare gli errori in ogni lingua? Sì, ma sarebbe probabilmente un compilatore goffo e altamente instabile in cui dovresti necessariamente fornire in anticipo la lingua da compilare. Inoltre non poteva sapere molte cose sul tuo programma, più di quanto i compilatori per lingue specifiche conoscano alcune cose al riguardo, come il problema di interruzione di cui hai parlato. A quanto pare, molte informazioni che potrebbero essere interessanti da imparare su un programma sono impossibili da raccogliere. Questo è stato dimostrato, quindi non è probabile che cambi presto.

Tornando al tuo punto principale. I metodi non sono automaticamente thread-safe. C'è una ragione pratica per questo, che è che i metodi thread-safe sono anche più lenti anche quando i thread non vengono utilizzati. Rust decide che possono eliminare i problemi di runtime rendendo i metodi thread sicuri per impostazione predefinita, e questa è la loro scelta. Tuttavia ha un costo.

Potrebbe essere possibile dimostrare matematicamente la correttezza di un programma, ma sarebbe con l'avvertenza che avresti letteralmente zero funzionalità di runtime nella lingua. Saresti in grado di leggere questa lingua e sapere cosa fa senza sorprese. Il linguaggio probabilmente sembrerebbe molto matematico in natura, e probabilmente non è una coincidenza lì. Il secondo avvertimento è che si verificano ancora errori di runtime , che potrebbero non avere nulla a che fare con il programma stesso. Pertanto, il programma può essere dimostrato corretto, assumendo una serie di ipotesi riguardanti il computer su cui è in esecuzione su sono accurate e non cambiano, che ovviamente sempre non accade in ogni caso e spesso.


3

I sistemi di tipi sono prove verificabili automaticamente di alcuni aspetti della correttezza. Ad esempio, il sistema di tipi di Rust può dimostrare che un riferimento non sopravvive all'oggetto referenziato o che un oggetto referenziato non viene modificato da un altro thread.

Ma i sistemi di tipi sono piuttosto limitati:

  • Si imbattono rapidamente in problemi di decidibilità. In particolare, il sistema di tipi stesso dovrebbe essere decidibile, tuttavia molti sistemi di tipi pratici sono accidentalmente Turing Complete (incluso C ++ a causa di modelli e Rust a causa di tratti). Inoltre, alcune proprietà del programma che stanno verificando potrebbero essere indecidibili nel caso generale, soprattutto se alcuni programmi si arrestano (o divergono).

  • Inoltre, i sistemi di tipi dovrebbero funzionare rapidamente, idealmente in tempo lineare. Non tutte le prove possibili dovrebbero essere presenti nel sistema dei tipi. Ad esempio, viene generalmente evitata l'analisi dell'intero programma e le prove sono mirate a singoli moduli o funzioni.

A causa di queste limitazioni, i sistemi di tipi tendono a verificare solo proprietà abbastanza deboli che sono facili da dimostrare, ad esempio che una funzione viene chiamata con valori di tipo corretto. Tuttavia, anche questo limita sostanzialmente l'espressività, quindi è comune avere soluzioni alternative (come interface{}in Go, dynamicin C #, Objectin Java, void*in C) o persino usare linguaggi che evitino completamente la tipizzazione statica.

Più sono le proprietà più forti che verifichiamo, meno espressiva sarà la lingua in genere. Se hai scritto Rust, conoscerai questi momenti di "combattimento con il compilatore" in cui il compilatore rifiuta il codice apparentemente corretto, perché non è stato in grado di dimostrare la correttezza. In alcuni casi, non è possibile esprimere un certo programma in Rust anche quando crediamo di poterne dimostrare la correttezza. Il unsafemeccanismo in Rust o C # ti consente di sfuggire ai confini del sistema di tipi. In alcuni casi, rinviare i controlli al runtime può essere un'altra opzione, ma ciò significa che non possiamo rifiutare alcuni programmi non validi. Questa è una questione di definizione. Un programma Rust che protegge dal panico per quanto riguarda il sistema di tipi, ma non necessariamente dal punto di vista di un programmatore o di un utente.

Le lingue sono progettate insieme al loro sistema di tipi. È raro che un nuovo sistema di tipi sia imposto su un linguaggio esistente (ma vedi ad esempio MyPy, Flow o TypeScript). Il linguaggio proverà a semplificare la scrittura di codice conforme al sistema dei tipi, ad esempio offrendo annotazioni sui tipi o introducendo strutture di flusso di controllo facili da dimostrare. Lingue diverse possono finire con soluzioni diverse. Ad esempio Java ha il concetto di finalvariabili assegnate esattamente una volta, in modo simile alle non mutvariabili di Rust :

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

Java ha regole di sistema di tipo per determinare se tutti i percorsi assegnano la variabile o terminano la funzione prima di poter accedere alla variabile. Al contrario, Rust semplifica questa dimostrazione non avendo variabili dichiarate ma non progettate, ma consente di restituire valori dalle istruzioni del flusso di controllo:

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

Questo sembra un punto davvero secondario quando si capisce il compito, ma l'ambito chiaro è estremamente importante per le prove legate alla vita.

Se dovessimo applicare un sistema di tipo Rust a Java, avremmo problemi molto più grandi di così: gli oggetti Java non sono annotati con i tempi di vita, quindi dovremmo trattarli come &'static SomeClasso Arc<dyn SomeClass>. Ciò indebolirebbe qualsiasi prova risultante. Java non ha inoltre un concetto di immutabilità a livello di tipo, quindi non possiamo distinguere tra &e &muttipi. Dovremmo trattare qualsiasi oggetto come una cella o un Mutex, anche se questo potrebbe assumere garanzie più forti di quelle effettivamente offerte da Java (la modifica di un campo Java non è thread-safe se non sincronizzata e volatile). Infine, Rust non ha idea dell'ereditarietà dell'implementazione in stile Java.

TL; DR: i sistemi di tipo sono dimostratori di teoremi. Ma sono limitati da problemi di decidibilità e problemi di prestazioni. Non puoi semplicemente prendere un sistema di tipo e applicarlo a una lingua diversa, poiché la sintassi della lingua di destinazione potrebbe non fornire le informazioni necessarie e perché la semantica potrebbe essere incompatibile.


3

Quanto è sicuro?

Sì, è quasi possibile scrivere un tale verificatore: il tuo programma deve solo restituire la costante UNSAFE. Avrai ragione il 99% delle volte

Perché anche se esegui un programma Rust sicuro, qualcuno può ancora staccare la spina durante la sua esecuzione: quindi il tuo programma potrebbe fermarsi anche se teoricamente non dovrebbe.

E anche se il tuo server è in esecuzione in una gabbia lontana in un bunker, un processo adiacente potrebbe eseguire un exploit martello e far girare un po 'il tuo programma Rust presumibilmente sicuro.

Quello che sto cercando di dire è che il tuo software funzionerà in un ambiente non deterministico e molti fattori esterni potrebbero influenzare l'esecuzione.

Scherzo a parte, verifica automatizzata

Esistono già analizzatori di codice statici in grado di individuare costrutti di programmazione rischiosi (variabili non inizializzate, buffer overflow, ecc ...). Questi funzionano creando un grafico del programma e analizzando la propagazione dei vincoli (tipi, intervalli di valori, sequenziamento).

Questo tipo di analisi viene eseguita anche da alcuni compilatori per motivi di ottimizzazione.

È certamente possibile fare un ulteriore passo avanti, analizzare anche la concorrenza e fare deduzioni sulla propagazione dei vincoli attraverso diversi thread, sincronizzazione e condizioni di gara. Tuttavia, molto rapidamente ti imbatteresti nel problema dell'esplosione combinatoria tra i percorsi di esecuzione e molte incognite (I / O, programmazione del sistema operativo, input dell'utente, comportamento dei programmi esterni, interruzioni, ecc.) Che ridurranno i vincoli noti a loro nudi minimo e rendere molto difficile trarre utili conclusioni automatizzate sul codice arbitrario.


1

Turing lo affrontò nel lontano 1936 con il suo articolo sul problema dell'arresto. Uno dei risultati è che, solo che è impossibile scrivere un algoritmo che il 100% delle volte sia in grado di analizzare il codice e determinare correttamente se si fermerà o meno, è impossibile scrivere un algoritmo che possa correttamente il 100% delle volte determinare se il codice ha una proprietà particolare o meno, inclusa la "sicurezza", comunque si desideri definirla.

Tuttavia, il risultato di Turing non preclude la possibilità di un programma che può il 100% delle volte (1) determinare assolutamente il codice è sicuro, (2) determinare assolutamente che il codice non è sicuro o (3) alzare le mani antropomorficamente e dire "Cavolo, non lo so.". Il compilatore di Rust, in generale, è in questa categoria.


Quindi, finché hai un'opzione "non sicura", sì?
TheEnvironmentalist

1
L'asporto è che è sempre possibile scrivere un programma in grado di confondere un programma di analisi del programma. La perfezione è impossibile. La praticità potrebbe essere possibile.
NovaDenizen,

1

Se un programma è totale (il nome tecnico per un programma che è garantito per arrestarsi), è teoricamente possibile dimostrare qualsiasi proprietà arbitraria sul programma dato abbastanza risorse. Puoi semplicemente esplorare ogni potenziale stato in cui il programma potrebbe entrare e controllare se qualcuno di loro viola la tua proprietà. Il linguaggio di verifica del modello TLA + utilizza una variante di questo approccio, utilizzando la teoria degli insiemi per verificare le proprietà rispetto a insiemi di potenziali stati del programma, anziché calcolare tutti gli stati.

Tecnicamente, qualsiasi programma eseguito su qualsiasi hardware fisico pratico è totale o un ciclo dimostrabile a causa del fatto che hai a disposizione solo una quantità limitata di spazio di archiviazione, quindi c'è solo un numero finito di stati in cui il computer può trovarsi. il computer è in realtà una macchina a stati finiti, non Turing completo, ma lo spazio degli stati è così grande che è più facile fingere che stiano diventando completi).

Il problema con questo approccio è che ha una complessità esponenziale rispetto alla quantità di memoria e alle dimensioni del programma, rendendolo poco pratico per qualcosa di diverso dal nucleo degli algoritmi e impossibile da applicare a basi di codice significative nel loro insieme.

Quindi la stragrande maggioranza della ricerca è focalizzata sulle prove. La corrispondenza Curry-Howard afferma che una prova di correttezza e un sistema di tipi sono la stessa cosa, quindi la maggior parte della ricerca pratica va sotto il nome di sistemi di tipo. Particolarmente rilevanti per questa discussione sono Coq e Idriss, oltre a Rust che hai già menzionato. Coq affronta il problema di ingegneria sottostante dall'altra direzione. Prendendo una prova della correttezza del codice arbitrario nel linguaggio Coq, può generare codice che esegue il programma provato. Nel frattempo Idriss utilizza un sistema di tipo dipendente per dimostrare il codice arbitrario in un linguaggio come Haskell puro. Ciò che fanno entrambe queste lingue è spingere i difficili problemi di generare una prova realizzabile sullo scrittore, consentendo al controllo del tipo di concentrarsi sul controllo della prova. Controllare la prova è un problema molto più semplice, ma questo rende le lingue molto più difficili con cui lavorare.

Entrambe queste lingue sono state progettate appositamente per rendere le prove più facili, usando la purezza per controllare quale stato è rilevante per quali parti del programma. Per molte lingue tradizionali, la semplice dimostrazione che un pezzo di stato è irrilevante per una prova di una parte del programma può essere una questione complessa a causa della natura degli effetti collaterali e dei valori mutabili.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.