Formalna weryfikacja na przykładzie zadania o wilku, kozie i kapuście

Moim zdaniem temat formalnej weryfikacji w rosyjskojęzycznej przestrzeni internetowej jest niewystarczająco omawiany, a szczególnie brakuje prostych i obrazowych przykładów.

Podam przykład z zagranicznego źródła i uzupełnię własnym rozwiązaniem znanego zadania o przewozie wilka, kozy i kapusty na drugą stronę rzeki.

Jednak najpierw krótko opiszę, czym jest formalna weryfikacja i dlaczego jest potrzebna.

Formalna weryfikacja zazwyczaj oznacza sprawdzenie jednego programu lub algorytmu za pomocą innego.

Jest to potrzebne, aby upewnić się, że zachowanie programu odpowiada oczekiwaniom, a także zapewnić jego bezpieczeństwo.

Formalna weryfikacja jest najpotężniejszym narzędziem do wykrywania i usuwania podatności: pozwala znaleźć wszystkie istniejące błędy i luki w programie, lub dowieść, że ich nie ma.
Warto zauważyć, że w niektórych przypadkach jest to niemożliwe, jak na przykład w zadaniu o 8 hetmanach na planszy szerokiej na 1000 pól: wszystko sprowadza się do złożoności algorytmicznej lub problemu zatrzymywania.

Jednak w każdym przypadku otrzymuje się jedną z trzech odpowiedzi: program jest poprawny, niepoprawny lub - nie udało się obliczyć odpowiedzi.

W przypadku braku możliwości znalezienia odpowiedzi, często można przerobić niejasne fragmenty programu, zmniejszając ich złożoność algorytmiczną, aby uzyskać konkretną odpowiedź tak lub nie.

Formalna weryfikacja jest stosowana na przykład w jądrze systemu Windows i systemach operacyjnych dronów Darpa, aby zapewnić maksymalny poziom ochrony.

Będziemy korzystać z Z3Prover, bardzo potężnego narzędzia do zautomatyzowanego dowodzenia twierdzeń i rozwiązywania równań.

Z3 rzeczywiście rozwiązuje równania, a nie szuka ich wartości metodą brutalnego przeszukiwania.
Oznacza to, że jest w stanie znaleźć odpowiedź, nawet w przypadkach, gdy liczba kombinacji wejściowych wynosi 10^100.

A to zaledwie około tuzina argumentów wejściowych typu Integer, co często występuje w praktyce.

Zadanie o 8 hetmanach (czerpane z anglojęzycznego podręcznika).

Formalna weryfikacja na przykładzie zadania o wilku, kozie i kapuście

# We know each queen must be in a different row.
# So, we represent each queen by a single integer: the column position
Q = [ Int('Q_%i' % (i + 1)) for i in range(8) ]

# Each queen is in a column {1, ... 8 }
val_c = [ And(1 <= Q[i], Q[i] <= 8) for i in range(8) ]

# At most one queen per column
col_c = [ Distinct(Q) ]

# Diagonal constraint
diag_c = [ If(i == j,
              True,
              And(Q[i] - Q[j] != i - j, Q[i] - Q[j] != j - i))
           for i in range(8) for j in range(i) ]

solve(val_c + col_c + diag_c)

Uruchamiając Z3, otrzymujemy rozwiązanie:

[Q_5 = 1,
 Q_8 = 7,
 Q_3 = 8,
 Q_2 = 2,
 Q_6 = 3,
 Q_4 = 6,
 Q_7 = 5,
 Q_1 = 4]

Zadanie o hetmanach porównywalne jest z programem, który przyjmuje współrzędne 8 hetmanów i zwraca odpowiedź, czy hetmany się biją.

Gdybyśmy podeszli do tego programu przy pomocy formalnej weryfikacji, w porównaniu do zadania, potrzebowalibyśmy po prostu wykonać jeszcze jeden krok w postaci przekształcenia kodu programu w równanie: w istocie byłoby ono identyczne z naszym (oczywiście, o ile program został napisany bez błędów).

Podobnie będzie w przypadku poszukiwania luk: określamy tylko wymagane warunki wyjściowe, na przykład hasło admina, przekształcamy oryginalny lub zdekompilowany kod w równania zgodne z weryfikacją, a następnie uzyskujemy odpowiedź na to, jakie dane należy podać na wejściu, aby osiągnąć cel.

Moim zdaniem, zadanie o wilku, kozie i kapuście jest jeszcze ciekawsze, ponieważ do jego rozwiązania potrzebne są już liczne (7) kroki.

Jeśli zadanie o hetmanach porównać do sytuacji, w której można uzyskać dostęp do serwera przy pomocy jednego żądania GET lub POST, to wilk, koza i kapusta ilustruje przykład znacznie bardziej skomplikowanej i powszechnej kategorii, w której cele można osiągnąć tylko za pomocą kilku żądań.

To porównywalne, na przykład, ze scenariuszem, w którym należy znaleźć SQL injection, zapisać przez nią plik, a następnie podnieść swoje uprawnienia i dopiero potem uzyskać hasło.

Warunki zadania i jego rozwiązanieRolnik musi przewieźć przez rzekę wilka, kozę i kapustę. Rolnik ma łódź, która może pomieścić, oprócz samego rolnika, tylko jeden obiekt. Wilk zje kozę, a koza zje kapustę, jeśli rolnik zostawi je bez opieki.

Rozwiązanie polega na tym, że na czwartym kroku rolnik musi przewieźć kozę z powrotem.
Teraz przystąpmy do rozwiązania w sposób programistyczny.

Oznaczmy rolnika, wilka, kozę i kapustę jako 4 zmienne, które przyjmują wartość tylko 0 lub 1. Zero oznacza, że są na lewym brzegu, a jeden - że są na prawym.

import json
from z3 import *
s = Solver()
Num = 8

Human = [ Int('Human_%i' % (i + 1)) for i in range(Num) ]
Wolf = [ Int('Wolf_%i' % (i + 1)) for i in range(Num) ]
Goat = [ Int('Goat_%i' % (i + 1)) for i in range(Num) ]
Cabbage = [ Int('Cabbage_%i' % (i + 1)) for i in range(Num) ]

# Każda istota może być tylko po lewej (0) lub prawej stronie (1) w każdym stanie
HumanSide = [ Or(Human[i] == 0, Human[i] == 1) for i in range(Num) ]
WolfSide = [ Or(Wolf[i] == 0, Wolf[i] == 1) for i in range(Num) ]
GoatSide = [ Or(Goat[i] == 0, Goat[i] == 1) for i in range(Num) ]
CabbageSide = [ Or(Cabbage[i] == 0, Cabbage[i] == 1) for i in range(Num) ]
Side = HumanSide + WolfSide + GoatSide + CabbageSide

Num — to liczba kroków potrzebnych do rozwiązania. Każdy krok reprezentuje stan rzeki, łodzi i wszystkich istot.

Na razie wybierzemy go losowo i z zapasem, weźmiemy 10.

Każda istota jest reprezentowana w 10 egzemplarzach — to jej wartość na każdym z 10 kroków.

Teraz ustalimy warunki dla startu i mety.

Start = [ Human[0] == 0, Wolf[0] == 0, Goat[0] == 0, Cabbage[0] == 0 ]
Finish = [ Human[9] == 1, Wolf[9] == 1, Goat[9] == 1, Cabbage[9] == 1 ]

Następnie ustalimy warunki, w których wilk zjada kozę lub koza kapustę, jako ograniczenia w równaniu.
(W obecności rolnika agresja jest niemożliwa)

# Wolf cant stand with goat, and goat with cabbage without human. Not 2, not 0 which means that they are one the same side
Safe = [ And( Or(Wolf[i] != Goat[i], Wolf[i] == Human[i]), Or(Goat[i] != Cabbage[i], Goat[i] == Human[i])) for i in range(Num) ]

Wreszcie ustalimy wszystkie możliwe działania rolnika podczas przewozu w obie strony.
Może zabrać ze sobą wilka, kozę lub kapustę, lub nikogo nie zabierać, lub w ogóle nie płynąć.

Oczywiście, bez rolnika nikt nie może się przeprawić.

Będzie to wyrażone tym, że każde następne stan rzeki, łodzi i istot może różnić się od poprzedniego tylko w ściśle ograniczony sposób.

Nie więcej niż o 2 bity, i z wieloma innymi ograniczeniami, ponieważ rolnik może przewieźć na raz tylko jedną istotę i nie można zostawić wszystkich razem.

Travel = [ Or(
And(Human[i] == Human[i+1] + 1, Wolf[i] == Wolf[i+1] + 1, Goat[i] == Goat[i+1], Cabbage[i] == Cabbage[i+1]),
And(Human[i] == Human[i+1] + 1, Goat[i] == Goat[i+1] + 1, Wolf[i] == Wolf[i+1], Cabbage[i] == Cabbage[i+1]),
And(Human[i] == Human[i+1] + 1, Cabbage[i] == Cabbage[i+1] + 1, Wolf[i] == Wolf[i+1], Goat[i] == Goat[i+1]),
And(Human[i] == Human[i+1] - 1, Wolf[i] == Wolf[i+1] - 1, Goat[i] == Goat[i+1], Cabbage[i] == Cabbage[i+1]),
And(Human[i] == Human[i+1] - 1, Goat[i] == Goat[i+1] - 1, Wolf[i] == Wolf[i+1], Cabbage[i] == Cabbage[i+1]),
And(Human[i] == Human[i+1] - 1, Cabbage[i] == Cabbage[i+1] - 1, Wolf[i] == Wolf[i+1], Goat[i] == Goat[i+1]),
And(Wolf[i] == Wolf[i+1], Goat[i] == Goat[i+1], Cabbage[i] == Cabbage[i+1])) for i in range(Num-1) ]

Uruchomimy rozwiązanie.

solve(Side + Start + Finish + Safe + Travel)

I otrzymujemy odpowiedź!

Z3 znalazł niesprzeczny, spełniający wszystkie warunki zbiór stanów.
Taki czterowymiarowy odcisk czasoprzestrzeni.

Zobaczmy, co się stało.

Widzimy, że w końcu wszyscy się przeprawili, ale na początku nasz rolnik postanowił odpocząć i przez pierwsze 2 kroki nigdzie nie płynął.

Human_2 = 0
Human_3 = 0

To wskazuje, że wybraliśmy nadmiarową liczbę stanów, a 8 będzie wystarczające.

W naszym przypadku rolnik postąpił w ten sposób: start, odpoczynek, odpoczynek, przeprawa kozy, przeprawa z powrotem, przeprawa kapusty, powrót z kozą, przeprawa wilka, powrót z powrotem sam, ponowna dostawa kozy.

Ale ostatecznie zadanie zostało rozwiązane.

#Старт.
 Human_1 = 0
 Wolf_1 = 0
 Goat_1 = 0
 Cabbage_1 = 0
 
 #Фермер отдыхает.
 Human_2 = 0
 Wolf_2 = 0
 Goat_2 = 0
 Cabbage_2 = 0
 
 #Фермер отдыхает.
 Human_3 = 0
 Wolf_3 = 0
 Goat_3 = 0
 Cabbage_3 = 0
 
 #Фермер отвозит козу на нужный берег.
 Human_4 = 1
 Wolf_4 = 0
 Goat_4 = 1
 Cabbage_4 = 0
 
 #Фермер возвращается.
 Human_5 = 0
 Wolf_5 = 0
 Goat_5 = 1
 Cabbage_5 = 0
 
 #Фермер отвозит капусту на нужный берег.
 Human_6 = 1
 Wolf_6 = 0
 Cabbage_6 = 1
 Goat_6 = 1
 
 #Ключевая часть операции: фермер возвращает козу обратно.
 Human_7 = 0
 Wolf_7 = 0
 Goat_7 = 0
 Cabbage_7 = 1
 
 #Фермер отвозит волка на другой берег, где он теперь находится вместе с капустой.
 Human_8 = 1
 Wolf_8 = 1
 Goat_8 = 0
 Cabbage_8 = 1
 
 #Фермер возвращается за козой.
 Human_9 = 0
 Wolf_9 = 1
 Goat_9 = 0
 Cabbage_9 = 1
 
 #Фермер повторно доставляет козу на нужный берег и завершают переправу.
 Human_10 = 1
 Wolf_10 = 1
 Goat_10 = 1
 Cabbage_10 = 1

Teraz spróbujmy zmienić warunki i udowodnić, że nie ma rozwiązań.

W tym celu nadamy naszemu wilkowi roślinożerność i będzie chciał zjeść kapustę.
Można to porównać do sytuacji, w której naszym celem jest ochrona aplikacji, a my musimy upewnić się, że nie ma żadnych luk.

 Safe = [ And( Or(Wolf[i] != Goat[i], Wolf[i] == Human[i]), Or(Goat[i] != Cabbage[i], Goat[i] == Human[i]), Or(Wolf[i] != Cabbage[i], Goat[i] == Human[i])) for i in range(Num) ]

Z3 zwrócił nam następującą odpowiedź:

 brak rozwiązania

Oznacza to, że rzeczywiście nie ma rozwiązań.

W ten sposób programowo udowodniliśmy niemożność przetransportowania z omnibusem wilkiem, bez strat dla farmera.

Jeśli odbiorcy uznają ten temat za interesujący, w kolejnych artykułach opowiem, jak przekształcić zwykły program lub funkcję w zgodne z formalnymi metodami równanie i je rozwiązać, odkrywając tym samym zarówno wszystkie legalne scenariusze, jak i luki. Najpierw na tym samym zadaniu, ale przedstawionym już w postaci programu, a następnie stopniowo komplikując i przechodząc do bieżących przykładów z obszaru rozwoju oprogramowania.

Kolejny artykuł jest już gotowy:
Tworzenie systemu formalnej weryfikacji od podstaw: Pisanie symbolicznego VM w PHP i Pythonie

W nim przechodzę od formalnej weryfikacji zadań do programów i opisuję,
w jaki sposób można je automatycznie konwertować na systemy formalnych reguł.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster