Sistemele de operare sunt de obicei asociate cu C.

Această asociere este de înțeles. C este mic, previzibil, apropiat de mașină și dominant istoric în lucrul la kernel. Îi dă programatorului acces direct la memorie, registre, layout și convenții de apel.

Acestea sunt exact lucrurile de care are nevoie un kernel.

Sunt și exact lucrurile care fac kernelurile greu de securizat.

EriX este scris în principal în Rust deoarece proiectul este construit în jurul unei idei centrale:

Autoritatea trebuie să fie explicită.

Aceasta se aplică la capabilități, structuri de handoff de boot, mesaje IPC, obiecte de memorie și acces la dispozitive. Se aplică și limbajului de implementare. Rust nu face dezvoltarea de kernel automat sigură, dar oferă codului o cale de a face vizibile ownership, mutația, lifetimes și nesiguranța.

Această vizibilitate contează.


Kernelul este un loc incomod pentru buguri de memorie

Bugurile de memorie sunt rele peste tot.

Într-un kernel, sunt mai rele.

Un use-after-free într-o aplicație obișnuită poate corupe acea aplicație. Un use-after-free în kernel poate corupe starea schedulerului, tabelele de capabilități, spațiile de adrese sau mapările de dispozitive.

O eroare de limite într-un parser din user space poate prăbuși un proces. O eroare de limite într-un parser de boot poate afecta ce cod execută mașina în continuare.

Un pointer greșit într-o aplicație este un bug local. Un pointer greșit într-un kernel poate fi un bug de autoritate.

De aceea siguranța memoriei nu este o preocupare cosmetică pentru EriX. Kernelul face parte din baza de calcul de încredere. Bootloaderul face parte din baza de calcul de încredere. Mai multe biblioteci de parser și ABI sunt TCB sau adiacente TCB.

Dacă aceste componente gestionează memoria greșit, modelul de izolare poate eșua înainte ca orice verificare de capabilitate să poată ajuta.


Ce oferă Rust unui kernel

Rust oferă unui kernel mai multe valori implicite utile.

Oferă slices cu verificare de limite în loc de perechi pointer-lungime neverificate în codul obișnuit.

Oferă reguli de ownership care fac aliasingul accidental mai dificil.

Oferă lifetimes care pot exprima vederi împrumutate în imagini de boot, binare ELF, bloburi de handoff și buffere IPC fără a pretinde că acele vederi dețin stocarea de dedesubt.

Oferă Result și enumuri ca instrumente obișnuite pentru a face explicite căile de eroare.

Oferă un sistem de tipuri suficient de puternic pentru a distinge multe stări pe care codul C le-ar reprezenta adesea ca comentarii, flaguri sau convenții.

Nimic din acestea nu elimină nevoia de proiectare a kernelului.

Rust nu va decide ce înseamnă o capabilitate. Nu va decide ce serviciu trebuie să primească un endpoint de dispozitiv. Nu va demonstra că un protocol IPC este corect. Nu va preveni fiecare race într-un scheduler.

Dar schimbă valoarea implicită.

În C, valoarea implicită este că aproape orice funcție poate face aritmetică de pointeri neverificată, poate aliasa memorie mutabilă, poate reinterpreta bytes ca structuri și poate face overflow tăcut dacă programatorul nu este disciplinat peste tot.

În Rust, codul obișnuit nu poate face aceste lucruri fără să treacă prin unsafe sau printr-o operație verificată explicit.

Aceasta este o diferență practică de securitate.


no_std este mediul normal

Rust pentru kernel nu este Rust obișnuit de aplicație.

Crate-ul kernelului EriX este #![no_std]. Nu rulează deasupra unui sistem de operare. Este substratul sistemului de operare.

Multe biblioteci EriX sunt și ele no_std, inclusiv:

  • lib-bootimg
  • lib-elf
  • lib-handoff
  • lib-ipc
  • lib-capabi
  • lib-bootstrap
  • lib-console
  • lib-device
  • lib-driver
  • lib-time

Mai multe dintre aceste crates folosesc și #![deny(unsafe_code)].

Această combinație este importantă. Înseamnă că aceleași biblioteci pot fi folosite în boot, kernel și contexte de servicii freestanding fără a depinde de servicii ale OS-ului host. Înseamnă și că parser-ele și codul ABI pot fi adesea scrise fără pointeri raw.

Aceasta este una dintre cele mai bune potriviri ale Rust pentru EriX.

Proiectul are multe crates mici a căror sarcină nu este să atingă direct hardware-ul. Sarcina lor este să parseze, să valideze, să encodeze, să decodeze și să respingă. Acestea sunt exact sarcinile în care Rust sigur este valoros.


Parserele sigure sunt granițe de securitate

EriX tratează parser-ele ca granițe de securitate.

lib-bootimg parsează și verifică boot.img. Calea sa de citire face parte din lanțul de încredere al bootului. Validează structura, alinierea, limitele, hashurile și semnăturile înainte ca bootloaderul să aibă încredere în imagine.

lib-elf parsează binare ELF64 înainte ca bootloaderul să folosească segmentele de încărcare.

lib-handoff validează structuri de handoff versionate între etapele de boot și între kernel și rootd.

lib-capabi definește tipuri de capabilități, drepturi, constante de slot, descriptori de transfer și helpers de validare pentru startup.

Aceste crates sunt intenționat plictisitoare.

Folosesc slices de bytes. Folosesc aritmetică verificată. Returnează erori structurate. Resping versiuni nesuportate, dimensiuni greșite, offseturi greșite și tabele malformate.

Nu fac I/O. Nu dețin autoritate runtime. Nu ghicesc.

Acest design ar fi posibil în C, dar ar cere disciplină susținută:

  • fiecare pointer trebuie verificat
  • fiecare offset trebuie verificat în raport cu limitele
  • fiecare operație întreagă trebuie auditată pentru overflow
  • fiecare vedere returnată trebuie legată manual de lifetime-ul bufferului de intrare

Rust face aceste reguli mai ieftin de menținut.

De exemplu, o imagine de boot parsată poate împrumuta din slice-ul original de bytes. Un segment ELF validat poate expune bytes împrumutați în loc să fabrice un pointer nou neverificat. Un parser de handoff poate returna un iterator care verifică intervalul fiecărei intrări înainte de parsare.

Limbajul nu demonstrează că parserul este semantic perfect.

Însă elimină o clasă mare de greșeli accidentale de memorie din calea normală a parserului.


unsafe nu dispare

Dezvoltarea de kernel nu poate fi Rust sigur în întregime.

Interfața hardware nu este type-safe. Registrele CPU, tabelele de pagini, descriptorii de întreruperi, port I/O, MMIO, bufferele DMA și pointerii de intrare de boot nu sosesc ca valori Rust prietenoase.

La un moment dat, kernelul trebuie să spună:

Știu ce înseamnă această adresă raw.

Această afirmație este unsafe.

EriX are unsafe în locurile unde mașina îl forțează:

  • codul de intrare de boot convertește un pointer și o lungime raw de handoff într-un slice de bytes
  • glue-ul syscall x86_64 folosește inline assembly și o ABI fixă de registre
  • context switching folosește assembly specific arhitecturii
  • GDT, IDT, TSS și inițializarea syscall ating starea descriptorilor CPU
  • manipularea tabelelor de pagini folosește citiri și scrieri volatile
  • căile seriale și de dispozitive legacy folosesc port I/O
  • driverele cu MMIO și DMA folosesc accessors volatili
  • câteva runtimes single-threaded folosesc UnsafeCell în spatele unor buffere statice documentate

Această listă nu este un eșec al Rust.

Este granița onestă dintre limbaj și mașină.

Scopul nu este să nu existe unsafe nicăieri. Scopul este să păstrăm unsafe mic, local, documentat și înconjurat de interfețe sigure.


Granițele unsafe trebuie să fie înguste

Întrebarea importantă nu este:

Conține codebase-ul unsafe?

Întrebarea importantă este:

Unde este unsafe, și ce invariant îl face valid?

În EriX, puntea syscall raw din ipc-syscall_x86_64 este un exemplu bun.

Crate-ul expune funcții wrapper sigure precum ipc_call, ipc_recv, ipc_reply, query_local_cap și drop_local_cap. Intern are o singură graniță de assembly raw care mapează argumentele la ABI-ul syscall de registre x86_64 și returnează valori raw de registre.

Wrapperul nu dereferențiază singur pointeri de utilizator. Pasează valorile de pointer și lungime către kernel, unde au loc verificările de capabilitate, de endpoint și de lungime a mesajului.

Aceasta este forma corectă:

  • assembly-ul raw este izolat
  • ABI-ul este documentat
  • API-ul public este tipat
  • validarea semantică rămâne în kernel

Granița de context switch a kernelului urmează același model. Există assembly specific arhitecturii pentru salvarea și restaurarea registrelor, dar restul schedulerului poate lucra cu o structură KernelContext și un trait ContextSwitcher. Testele host pot folosi un switcher soft care modelează predarea stării fără a face CPU-ul să sară efectiv.

Această separare contează deoarece păstrează cea mai mare parte a logicii schedulerului testabilă fără executarea instrucțiunilor privilegiate.


Accesul la hardware rămâne acces la hardware

Rust nu face MMIO sigur de unul singur.

Dacă un driver scrie valoarea greșită în registrul greșit, Rust nu va salva dispozitivul. Dacă un buffer DMA este descris incorect, hardware-ul poate încă scrie în memorie. Dacă o intrare de tabel de pagini este greșită, CPU-ul va aplica maparea greșită foarte eficient.

EriX tratează asta în două moduri.

Mai întâi, autoritatea hardware este făcută explicită. Un driver nu ar trebui să primească autoritate largă asupra mașinii. Ar trebui să primească doar device frame-ul, intervalul I/O, linia de întrerupere sau familia de endpointuri de care are nevoie.

Apoi, codul unsafe care atinge hardware-ul este păstrat aproape de granița hardware.

De exemplu, drv-virtio-block are helpers volatili pentru MMIO și DMA. Asta este de așteptat pentru un driver de block. Punctul important este că acest cod rulează într-un driver din user space cu o suprafață îngustă de autoritate de dispozitiv, nu într-un kernel monolitic mare cu autoritate completă asupra mașinii.

Rust ajută în interiorul acelui driver, dar EriX se bazează în continuare pe arhitectură:

  • capabilități explicite de dispozitiv
  • memorie de dispozitiv tipată precum CAP_TYPE_DEVICE_FRAME
  • endpointuri kernel-control înguste
  • izolarea driverelor în user space
  • validare de startup fail-closed

Limbajul și designul OS se consolidează reciproc.

Niciunul nu îl înlocuiește pe celălalt.


Rust se potrivește gândirii bazate pe capabilități

Modelul de ownership al Rust nu este același lucru cu un sistem de capabilități.

Un borrow Rust nu este o capabilitate EriX. Un tip Rust nu este autoritate impusă de kernel. Compilatorul nu poate ști dacă unui proces ar trebui să i se permită să mapeze un frame sau să trimită către un endpoint.

Totuși, Rust și capabilitățile se potrivesc bine.

Ambele încurajează explicitul.

În EriX, un component poate acționa doar când deține capabilitatea potrivită cu drepturile potrivite. În Rust, codul poate muta date doar când are tipul corect de acces la acele date.

Această asemănare este utilă cultural și mecanic.

Încurajează API-uri în care autoritatea este trecută ca valoare, nu descoperită prin stare globală. Încurajează funcțiile să spună de ce au nevoie. Încurajează codul de startup să valideze bundle-ul real de capabilități primit în loc să presupună că un număr de slot înseamnă permisiune.

lib-capabi este un exemplu al acestui stil. Nu impune autoritate la runtime, dar definește limbajul comun al autorității: ID-uri de tipuri de capabilități, flaguri de drepturi, constante de slot, descriptori de transfer, helpers de rol și validare de startup.

Pentru că este no_std și deny(unsafe_code), acest vocabular al autorității poate fi folosit larg fără a transforma fiecare consumator într-un consumator de memorie raw.


Comparația cu C

C oferă unui kernel control maxim cu cost minim de abstracție.

Aceasta este forța lui.

Este și slăbiciunea lui.

În C, o tabelă de capabilități poate fi indexată în afara limitelor. Un buffer de mesaj poate fi copiat cu lungimea greșită. O structură packed de pe disc poate fi citită printr-un pointer nealiniat. Un pointer poate trăi mai mult decât stocarea pe care o descrie. Un overflow întreg poate deveni o alocare prea mică. O funcție poate muta accidental starea printr-un alias la care apelantul nu se aștepta.

Toate acestea pot fi evitate în C.

Sunt evitate prin review, convenție, analiză statică, sanitizers, fuzzing și disciplină.

Aceste instrumente contează. EriX ar avea în continuare nevoie de review, teste și fuzzing dacă ar fi scris complet în Rust.

Diferența este că Rust mută multe dintre aceste verificări în modelul implicit al limbajului.

Când EriX parsează o imagine de boot, parcurge o tabelă de transfer, construiește un mesaj IPC sau validează un envelope de handoff, reprezentarea obișnuită este un slice, enum, iterator, operație întreagă verificată sau Result structurat.

În C, reprezentarea obișnuită ar fi adesea un pointer, o lungime, un cast și o promisiune.

Promisiunile nu sunt lipsite de valoare.

Dar kernelurile acumulează promisiuni rapid.

Rust reduce câte dintre aceste promisiuni trebuie să fie de încredere.


Rust nu elimină bugurile de design

Rust nu este o dovadă de securitate.

Nu împiedică acordarea capabilității greșite.

Nu împiedică un endpoint să aibă drepturi prea largi.

Nu decide dacă rootd, procd sau deviced trebuie să dețină o decizie de politică.

Nu face automat un parser să respingă fiecare fișier semantic invalid.

Nu elimină canalele laterale.

Nu face DMA coerent.

Nu face ordonarea întreruperilor simplă.

Nu face schedulerul echitabil.

De aceea EriX încă pune accent pe arhitectură:

  • TCB mică
  • capabilități explicite
  • endpointuri înguste
  • startup determinist
  • comportament fail-closed
  • dependențe clean-room
  • servicii în user space
  • autoritate de driver specifică rolului

Rust îmbunătățește substratul de implementare. Nu înlocuiește modelul de securitate.


Panic nu este o strategie de eroare

Codul de kernel are nevoie de un model clar de eșec.

În EriX, eșecurile recuperabile din input extern ar trebui returnate ca erori. Date handoff malformate, versiuni nesuportate, descriptori de transfer invalizi, tipuri de endpoint greșite și autoritate lipsă nu ar trebui reparate în tăcere.

Ar trebui să eșueze explicit.

Panic este rezervat pentru încălcări de invariante interne, nu pentru validarea obișnuită a inputului.

Acest lucru contează deoarece Rust face panic ușor, dar un kernel nu poate trata panic ca pe un mecanism normal de control al fluxului. Un parser din TCB nu ar trebui să facă panic pe input malformat. O cale de boot nu ar trebui să unwindeze accidental prin starea firmware. Un serviciu runtime nu ar trebui să trateze input controlat de atacator ca motiv pentru a intra într-o cale de recuperare nedefinită.

Modelul Rust util este:

  • validează înainte de a avea încredere
  • returnează Result
  • păstrează variantele de eroare explicite
  • rezervă panic pentru stări interne imposibile
  • eșuează închis când starea de securitate este ambiguă

Acest model apare în crate-urile de parser și handoff ale EriX.


Beneficii pentru teste

Rust ajută și testarea.

Kernelul EriX este un crate no_std, dar mare parte din logica lui poate fi testată pe host cu suport #[cfg(test)].

Acest lucru este util pentru cod care nu ar trebui să necesite un boot de VM doar pentru a valida un invariant pur:

  • verificări de limite ale parserelor
  • validare handoff
  • parsare de descriptori de capabilitate
  • logică de măști de drepturi
  • tabele de politică de sloturi
  • tranziții de stare ale schedulerului
  • modele soft de context switch

Părțile specifice arhitecturii au încă nevoie de teste de integrare. Inline assembly, manipularea tabelelor de pagini, livrarea întreruperilor și tranzițiile în user mode trebuie testate în mediul în care rulează cu adevărat.

Dar Rust face mai ușor să păstrăm logica pură pură.

Acest lucru este valoros într-un microkernel, deoarece multe componente pot fi reduse la mici mașini de stare deterministe în jurul mesajelor și capabilităților explicite.


De ce contează asta pentru EriX

EriX nu folosește Rust pentru că Rust este la modă.

Îl folosește deoarece obiectivele proiectului se aliniază cu punctele forte ale limbajului:

  • reducerea nesiguranței memoriei în cod de încredere
  • vizibilitatea ownershipului și mutației
  • izolarea accesului raw la hardware în spatele unor granițe înguste
  • păstrarea codului de parser sigur, determinist și auditabil
  • exprimarea datelor de protocol și autoritate cu structuri tipate
  • suport pentru cod no_std partajat între boot, kernel și servicii user space
  • a face multe buguri mai greu de scris de la început

Codul unsafe rămas este încă serios.

Trebuie revizuit la fel de atent cum ar fi revizuit C. Diferența este că EriX poate concentra această examinare în locurile unde mașina chiar forțează control raw: cod de intrare, configurare CPU, tabele de pagini, syscalls, context switches, MMIO, DMA și port I/O.

Aceasta este valoarea practică.

Rust nu face kernelul sigur.

Face părțile unsafe să iasă în evidență.


Privind înainte

Următorul pas este să urmărim execuția de la primul cod încărcat de firmware până în kernel.

Acea cale este locul unde multe idei din articolele anterioare se întâlnesc: TCB, granițe Rust, parsere validate, imagini de boot semnate, structuri handoff și primul transfer de autoritate a mașinii către obiecte explicite ale kernelului.

Următorul articol va parcurge procesul de boot EriX de la firmware la kernel: punctul de pornire UEFI, modelul de handoff al bootloaderului și de ce kernelul este intrat ca executabil higher-half.