Kompajleri i trojanci
Godine 1983. najprestižniju nagradu u oblasti računarstva, Tjuringovu nagradu, dobili su Denis Riči1 i Ken Tompson2 zbog njihovog razvoja UNIX operativnog sistema. U sklopu dodele nagrade, Ken Tompson je održao je predavanje u kom je opisao jednu vrstu trojanca - štetnog programa koji ima sposobnost razmnožavanja. Sadržaj Tompsonovog predavanja je sledeće godine objavljen u žurnalu Communications of the ACM pod nazivom Reflections on Trusting Trust, i rad se danas se smatra klasikom u oblasti računarstva.
Rad je kratak (3 strane) i napisan jednostavnim jezikom, zbog čega je pristupačan širokom auditorijumu. Ipak u njemu se pojavljuju izuzetno duboke ideje računarstva. Tompsonovo genijalno povezivanje tih ideja omogućilo mu je da konstruiše trojanski napad protiv koga "nema leka". Predstojeći tekst pokušaće da čitaocima približi Tompsonov napad.
Tompson je rad podelio u tri celine (tri koraka u konstrukciji napada). Primere je dao kroz C jezik, a ovde će biti dati analogni primeri kroz Go jezik, jer su nešto jednostavniji u odnosu na one napisane u C-u3. Svi primeri u Go jeziku se mogu pokrenuti.
Prvi deo
Prvi korak u napadu predstavlja pisanje programa koji reprodukuje sopstveni kôd. Takav program je poznat i kao kvajn (eng. quine4). Pri tome, kvajn ne sme da učita sopstveni kôd iz spoljnog resursa (npr iz datoteke, ili sa servera).
Pisanje takvog programa je odlična programerska vežba, te preporučujem da ovde pauzirate čitanje i pokušate da konstruišete kvajn u omiljenom jeziku. U nastavku sledi konstrukcija kvajna u Go jeziku.
Počnimo od prostog programa koji ispisuje početak sopstvenog koda:
package main import "fmt" func main() { fmt.Println("package main\n\nimport \"fmt\"\n\nfunc main() {\n") }
Problem nastaje ako želimo da u nisku "package main\n\nimport \"fmt\"\n\nfunc main() {\n"
dodamo i naredni red koda. Naime, u narednom redu se nalazi sama niska, te bi dodavanje reda u nisku dovelo do toga da se ta niska uveća, što znači i da bi se i red uvećao, pa bi i niska morala dodatno da se uveća itd… Prosto, niska ne može sadržati red u kom se sama nalazi. Zbog toga, umesto literala niske, u Println
smestićemo jednu promenljivu koja sadrži kôd:
package main
import "fmt"
func main() {
fmt.Println(code)
}
var code = `package main
import "fmt"
func main() {
fmt.Println(code)
}
var code = `
Na prvi pogled, nismo mnogo postigli, jer i dalje ne možemo u promenljivu code
smestiti ceo kôd programa. Trik je u tome da unutar argumenta Println
funkcije nadovežemo dve niske code
. Naravno, sve izmene koje napravimo unutar main
funkcije moramo napraviti i unutar samog literala:
package main import "fmt" func main() { fmt.Println(code + code) } var code = `package main import "fmt" func main() { fmt.Println(code + code) } var code = `
Pokretanjem programa uviđamo da smo skoro došli do kvajna, ali nedostaju kosi navodnici koji bi obuhvatili "drugu polovinu" koda:
Kako unutar literala niske definisanog sa kosim navodnicima ne možemo direktno uneti kosi navodnik (barem ne bez kosnih crta što bi dodatno zakomplikovalo konstrukciju…), ispisaćemo kosi navodnik koristeći njegovu ASCII vrednost (0x60
):
package main import "fmt" func main() { fmt.Println(code + "\x60" + code + "\x60") } var code = `package main import "fmt" func main() { fmt.Println(code + "\x60" + code + "\x60") } var code = `
Ovim smo zaista konstruisali kvajn, što se može lako proveriti u terminalu.
Konkretni kvajn programi zavise od osobina jezika u kom se pišu, ali u većini jezika moguće je iskoristiti ideju koju smo ovde izložili. I sam primer napisan u C-u koji je Tompson izložio u radu zasniva se na tome da se ista niska dva puta ispiše.
Istaknimo jednu bitnu činjenicu: kvajn se lako može proširivati sa raznim funkcionalnostima pri čemu će i dalje ostati kvajn. Dovoljno je sva nova proširenja u kodu dopisati i u samu nisku. Na primer, naš kvajn može poslati poruku na neki server:
package main import ( "bytes" "fmt" "net/http" ) func main() { payload := []byte("hello") req, _ := http.NewRequest("PUT", "http://example.com/", bytes.NewBuffer(payload)) http.DefaultClient.Do(req) fmt.Println(c + "\x60" + c + "\x60") } var c = `package main import ( "bytes" "fmt" "net/http" ) func main() { payload := []byte("hello") req, _ := http.NewRequest("PUT", "http://example.com/", bytes.NewBuffer(payload)) http.DefaultClient.Do(req) fmt.Println(c + "\x60" + c + "\x60") } var c = `
Za Tompsonov napad nije nužno da kvajn program ispisuje sopstveni kôd u terminal. Umesto toga potrebno je da kvajn ispiše kôd u datoteku ili deo memorije. Iz prethodnih primera je jasno da i to možemo postići.
Drugi deo
U drugom delu rada, Ken Tompson nam skreće pažnju na zanimljiv odnos programskog jezika i kompajlera tog jezika, prikazujući jedan problem u konstrukciji kompajlera.
Zamislimo da želimo da nastavimo razvoj Go jezika u posebnom smeru, uvodeći ključne reči na srpskom. Na primer, prvo možemo zameniti ključnu reč func
sa FUNKCIJA
5. Naš novi programski jezik (ili bolje reći dijalekat) nazvaćemo Go++.
Konstruisaćemo kompajler za Go++ služeći se izvršnom verzijom standardnog Go kompajlera. Naredni kompajler sva pojavljivanja ključne reči FUNKCIJA
zamenjuje sa ključnom reči func
a zatim prosleđuje datoteku postojećem6 kompajleru:
Jednom kada iskompajliramo go++.go datoteku dobijamo kompajler za jezik u kom je moguće koristiti FUNKCIJA
ključnu reč. Prema tome, i kôd kompajlera možemo napisati u Go++ jeziku:
$ go build -i go++_1 go++.go $ sed -i 's/func main()/FUNKCIJA main()/g' go++.go # prelazak na novu sintaksu $ ./go++_1 go++.go # novi kompajler kompajlira go++ kod $ ./go++ go++.go # kompajler napisan u go++ dijalektu kompajlira go++ kod
Bitna stvar je da datoteku sa novom sintaksom ne možemo iskompajlirati bez prve kompilacije datoteke go++.go. Moramo prvo iskompajlirati verziju programa koja je kompatibilna sa Go kompajlerom, a tek nakon toga možemo iskoristiti Go++ kompajler da bi kompajlirali datoteke sa FUNKCIJA
ključnom reči. Međutim, jednom kada imamo kompajler koji razume Go++ više nam nije neophodna prvobitna verzija datoteke go++.go (napisana u Go jeziku), i Go++ kompajler dalje dobijamo kompilacijom datoteke napisane u Go++ jeziku!
Tompson je u radu ovaj korak prikazao kroz još suptilniji primer C kompajlera koji je u jednom trenutku morao da razume nizove karaktera poput \n
ali nije mogao da ih poseduje u sopstvenom kodu. Na primer, neka je dat kompajler C jezika koga ćemo nazvati C1. Deo ovog kompajlera koji obrađuje niske mogao bi da sadrži kôd poput sledećeg:
Navedeni kôd parsira i obrađuje niz \n
iz niske. Međutim, u samom kodu se pojavljuje niz karaktera \n
, što znači da ako ne posedujemo kompajler koji razume \n
ne možemo iskompajlirati navedeni kôd. Stoga, prvobitno je morao biti iskompajliran kôd nekog kompajlera C0 u kom bi se umesto niza \n
iskoristila ASCII vrednost karaktera:
Jednom kada iskompajliramo kôd c0.c, sa dobijenim kompajlerom možemo da kompajliramo c1.c. Dobijeni C1 kompajler može da kompajlira sopstveni kôd tj. c1.c, i kôd c0.c nam više nije neophodan. Primetimo da smo ovim dobili kompajler C1 koji u sopstvenom kodu nigde nema navedeno da karakter \n
poseduje ASCII vrednost 10
. Ali i bez te eksplicitne veze on uspešno može da tumači niz \n
! Dakle kompajler C0 je u nekom smislu "naučio" kompajler C1 šta \n
znači, i ovo "znanje" se dalje ne prenosi preko koda već kroz same kompajlere (izvršne datoteke).
Tompson u ovom koraku želi da nam ukaže na fenomen koji smo videli kroz dva primera: informacije se prenose sa kompajlera na kompajler, iako nisu eksplicitno navedene u kodu.
Treći deo
U trećem i finalnom delu, Tompson navodi kako se kompajler može izmeniti tako da prepozna kôd nekog karakterističnog programa a zatim i da ubaci namernu grešku u taj kod. Tompson navodi primer login programa koji je bio prisutan na svakom UNIX sistemu i imao značajnu ulogu u bezbednosti. Maliciozni kompajler bi mogao detektovati kompilaciju login programa, a zatim u izvršnu verziju ubaciti mogućnost logovanja sa nekom master šifrom koju napadač zna.
Detekcija kompilacije login programa nije problem. Programi poput login programa imaju karakteristične delove koda, i ti kodovi se retko menjaju. Problem (za napadača) je što bi se ovakav napad lako razotkrio pri pregledu koda kompajlera. Zbog toga, koristimo ideju iz drugog dela: napisaćemo kompajler koji u kôd drugog kompajlera ubacuje napad login programa. Dakle, napad se realizuje u dva koraka:
- Maliciozni kompajler detektuje kompilaciju kompajlera, i u izvršne verzije ubacuje napad na login program,
- Zaraženi kompajleri kompajliraju login program i u njega ubacuju sigurnosni propust.
Sve što je potrebno za napad, jeste da napadač distribuira izvršnu verziju zaraženog kompajlera (na primer kroz neki repozitorijum paketa). Korisnici ovog kompajlera ne mogu (bez detaljne i naporne analiza asemblera) znati za napad unutar kompajlera. Ako sami iskompajliraju neki drugi kompajler, dobijeni kompajleri će sadržati napad na login program, iako će izvorni kôd bit "čist", u šta se korisnici mogu uveriti.
Da bi se osigurali da se napad širi, iskoristićemo ideju iz prvog dela: ne samo da ćemo prilikom kompilacije kompajlera ubaciti napad login programa, već ćemo reprodukovati i sam napad na kompajler. Zbog toga nam je potreban kvajn: kôd koji reproducira sam sebe. Na ovaj način zaraženi kompajleri će nastaviti da prenose napad na ostale kompajlere koje budu kompajlirali.
Realizacija napada
Ideja opisanog napada jeste veoma elegantna, ali istovremeno deluje da bi realizacija bila nepraktična ili čak nemoguća. Ipak, sam Tompson je uspešno implementirao opisan napad 1975. godine (ili možda čak i ranije), i zarazio CC (prvi C kompajler). Iako Tompson nije nameravao, zaraženi kompajler je stigao do korisnika koji nisu znali za njegovu malicioznost. Detalji o tome koliko je zaraženi kompajler naneo štete ostaće zauvek zaboravljeni. Tek neka svedočenja po raznim mejling listama pružaju informacije o tome šta se tačno desilo sa originalnim Tompsonovim napadom. Videti [2] za detalje o hronologiji napada i dodatnim referencama.
Na osnovu [2], imamo uvid kako je originalan Tompsonov napad tačno izgledao. U nastavku je data nih.c datoteka čiji sadržaj je potrebno nadovezati na cc.c datoteku (kôd CC kompajlera) uz neke male dorade. Po Tompsonovim rečima nih
je skraćenica za not invented here. U datoteci nih.c definisana je jedna celobrojna promenljiva nihflag
, jedna niska nihstr
, i dve funkcije codenih
i repronih
. Funkcija codenih
služi za detekciju kodova login.c i cc.c, pri čemu se veći deo ove funkcije odnosi na cc.c datoteku. U slučaju datoteke login.c, funkcija codenih
dodaje u kôd mogućnost da se korisnik uloguje sa šifrom "codenih". U slučaju datotke cc.c, funkcija codenih
u kôd dodaje poziv na same sebe kao i poziv funkcije repronih
. Funkcija repronih
je zapravo kvajn koji na kraj cc.c postavlja sadržaj nih.c. Promenljiva nihflag
služi za čuvanje stanja tokom kompilacije, dok niska nihstr
predstavlja osnov za kvajn (kao što smo videli u prvom delu).
Primetimo da je neophodno izvršiti preprocesiranje nih.c datoteke, tako što će se nihstr
popuniti oktalnim brojevima koji predstavljaju trenutni sadržaj datoteke. To se može uraditi pomoću standardnih Unix komanda (za detalje, ponovo upućujemo na [2]). Na kraju procesa nihstr
će izgledati poput:
char nihstr[] { 0156, 0151, 0150, 0146, ... 0175, 012, 0175, 012, 0 };
Odbrana
Genijalnost napada je u tome ga je izuzetno teško otkriti, a pritom korisnici nemaju mnogo razlog za sumnju. Naime, za open source programe se često vezuje osećaj sigurnosti, jer svi korisnici mogu pregledati kôd i ustanoviti šta tačno neki program radi. Međutim, u ovom napadu, kompajler modifikuje kôd programa kog kompajlira7. Naravno, mi možemo pregledati kôd kompajlera A kog koristimo, ali moramo iskoristiti neki drugi kompajler B da bi kompajlirali kôd A. Čak i da pregledamo kôd kompajlera B, moramo iskoristiti neki treći kompajler C, itd... Pre ili kasnije moramo se osloniti na neku izvršnu datoteku koju nismo sami iskompajlirali8.
Dakle, pregled izvornog koda nam ne garantuje sigurnost. Sa druge strane, pregled mašinskog koda (ili čak dekompajliranog koda) neke izvršne datoteke, bi bio praktično neizvodljiv posao zbog količine potrebnog truda.
U doktorskoj tezi [3] David A. Wheeler je opisao jedan metod koji značajno umanjuje mogućnost Tompsonovog napada. Ideja je da se kôd nekog kompajlera kompajlira sa 2 različita kompajlera. Na primer, recimo da posedujemo kôd CC kompajlera, kao i izvršne datoteke CC i TinyC kompajlera koje su možda kompromitovane. Ako CC kôd iskompajliramo sa CC i TinyC programom dobićemo naravno različite izvršne datoteke (ova činjenica nema veze sa napadom, u pitanju je samo upotreba različitih kompajlera), i poređenje tih datoteka (bit po bit) nam ne može mnogo reći. Međutim, sada posedujemo dva CC kompajlera, nastala od istog koda. Stoga, ako sa ovim novim kompajlerima iskompajliramo ponovo kôd CC trebalo bi da ne postoji razlika između dobijenih izvršnih datoteka. Ako je razlika prisutna, znaćemo da je jedan od kompajlera kompromitovan. Ako ne uočimo razliku, ili su oba kompajlera nisu kompromitovana ili su oba kompromitovana.
Distribuirati zaražen kompajler je veoma teško, a još teže je distribuirati dva zaražena kompajlera (pri čemu svaki od napada sadrži i onaj drugi). Prema tome, ako je prethodno opisani test negativan, možemo imati malo više sigurnosti da kompajleri nisu zaraženi. Naravno, ovo nije definitivan dokaz, ali isti postupak možemo ponoviti sa još više nezavisnih kompajlera (na primer, GCC, MSVC, itd...), čime se još više smanjuje mogućnost da su svi kompajleri zaraženi.
Literatura
- Reflections on trusting trust, Ken Thompson, Communications of the ACM, Volume 27, Issue 8
- Running the “Reflections on Trusting Trust” Compiler, Russ Cox
- Fully Countering Trusting Trust through Diverse Double-Compiling, David A. Wheeler
- Dennis Ritchie (1941–2011) ↩︎
- Ken Thompson (1943-) ↩︎
- Tompson je značajno uticao na razvoj jezika C, a takođe je i jedan od autora jezika Go. ↩︎
- Naziv programa je dat po logičaru Willard Van Orman Quine-u. U literaturi se takođe sreće i naziv SELF. ↩︎
- Naravno, ovo je samo primer osmišljen za potrebe ovog teksta, ali argumenti koji slede mogu su primeniti na bilo koju karakteristiku jezika. Videćemo uskoro primer koji je Tompson dao. ↩︎
- Primetimo da nije 'varanje' to što pozivamo
go
kao izvršnu datoteku. Lako smo mogli mogli da prekompajliramogo
kompajler sa našom doradom. ↩︎ - Ovo smo demonstrirali i sa Go++ kompajlerom. ↩︎
- Mala digresija sa teme: zanimljivo bi bilo zamisliti kako izgledao bootstraping proces kada bi nekim katastrofalnim slučajem (jaka solarna oluja) izgubili sve digitalne podatke na Zemlji. ↩︎