Blog
Fallstricke in Benutzeranwendungen
2022-06-26
Nur weil Benutzeranwendungen in StuBS starten, heißt dies leider noch nicht, dass alles korrekt implementiert wurde.
Deswegen gibt es hier eine (unvollständige!) Liste an kleinen Beispielanwendungen, welche mehr oder weniger offensichtliche Randfälle prüfen.
Ein
return
inmain()
darf keinen Fehler werfen:int main() { << "bye!" << endl; cout return 0; }
Die
init.cc
muss sich darum kümmern, zum Beispiel mit einer kurzen Ausgabe, dass die Anwendung sich beendet hat und (derzeit mangelsexit
) Endlossschleife.Benutzeranwendungen sollen nicht den Kernel auslesen können:
int main() { << "First Kernel bytes: " << hex cout << *reinterpret_cast<volatile unsigned long *>(0x1000000) << endl; << "You shouldn't be here!" << endl; cout return 1; }
Ein Page Fault sollte nicht vom Benutzer gefaked werden dürfen:
int main() { // Cause a real page fault (to set cr2) volatile char tmp[9001] = {}; (void) tmp; // Fake Page Fault asm volatile("int $14\n\t"); // a GPF would stop this thread << "You shouldn't be here!" << endl; cout return 0; }
Dies sollte einen General Protection Fault (13) auslösen – denn die Page Fault Exception (14) darf nur vom System selbst (MMU) ausgelöst werden. Entsprechend muss
IDT::handle
für den Page Fault mitDPL_KERNEL
(eigentlich Standard) konfiguriert werdenFlüchtige (scratch) Register dürfen sich während eines Page Faults nicht ändern:
int main() { long rax, rcx, rdx, rsi, rdi; register long r8 asm("r8"); register long r9 asm("r9"); register long r10 asm("r10"); register long r11 asm("r11"); asm volatile("mov $0xa000f000a, %%rax\n\t" "mov $0xc000f000c, %%rcx\n\t" "mov $0xd000f000d, %%rdx\n\t" "mov $0x100000001, %%rsi\n\t" "mov $0xd0000000d, %%rdi\n\t" "mov $0x8000a0008, %%r8\n\t" "mov $0x9000a0009, %%r9\n\t" "mov $0x0100a0010, %%r10\n\t" "mov $0x1100a0011, %%r11\n\t" // Cause Pagefault "mov -9001(%%rsp), %%rbx\n\t" : "=a"(rax), "=c"(rcx), "=d"(rdx), "=S"(rsi), "=D"(rdi) :: "rbx"); if (rax != 0xa000f000a) cout << "rax was not preserved!" << endl; if (rcx != 0xc000f000c) cout << "rcx was not preserved!" << endl; if (rdx != 0xd000f000d) cout << "rdx was not preserved!" << endl; if (rsi != 0x100000001) cout << "rsi was not preserved!" << endl; if (rdi != 0xd0000000d) cout << "rdi was not preserved!" << endl; if (r8 != 0x8000a0008) cout << "r8 was not preserved!" << endl; if (r9 != 0x9000a0009) cout << "r9 was not preserved!" << endl; if (r10 != 0x0100a0010) cout << "r10 was not preserved!" << endl; if (r11 != 0x1100a0011) cout << "r11 was not preserved!" << endl; return 0; }
Initialisierung
Unter Umständen ist es notwendig, vor dem Sprung in die eigentliche
Anwendung (main
) noch weitere Initialisierungen
durchzuführen – so muss zum Beispiel bei einem globalen C++ Objekten der
(nicht constexpr
) Konstruktor ausgeführt werden.
Initialisierung zum Programmstart über
.preinit_array
wird nicht ausgeführt:int i = 23; void init_func_x() { *= 2; i } void init_func_y() { -= 2; i } __attribute__((section(".preinit_array"))) void (*preinit_array[])(void) = { &init_func_y, &init_func_x }; int main() { if (i == 23) << "No preinit_array executed!" << endl; cout else if (i != 42) << "preinit_array executed in wrong order!" << endl; cout return 0; }
Die (veraltete)
.init
wird nicht ausgeführt:int i = 23; extern "C" void init_func_x() { *= 2; i } extern "C" void init_func_y() { -= 2; i } asm(".section .init\n\t" "call init_func_y\n\t" "call init_func_x\n\t" ".section .text\n\t"); void main() { if (i != 42) << "No .init!" << endl; cout }
Im Linkerskript wurde vermutlich nicht die Reihenfolge der C-Runtime-Objekte korrekt eingehalten, Details dazu im Blogbeitrag Das Elend mit dem Linker im Abschnitt Binden
Initialisierung zum Programmstart über
init_array
wird nicht ausgeführt:int i = 23; __attribute__((constructor(142))) void init_func_x() { *= 2; i } __attribute__((constructor(123))) void init_func_y() { -= 2; i } int main() { if (i == 23) << "No init_array executed!" << endl; cout else if (i != 42) << "init_array executed in wrong order!" << endl; cout return 0; }
Die Initialisierungsreihenfolge muss
.preinit_array
,.init
,.init_array
sein.
Und ebenfalls gibt es eine Deinitialisierung (.fini
und
.fini_array
), welche analog funktionieren muss.
Stackzugriff
Der Stack soll dynamisch bei Bedarf wachsen – aber nur bis zu einer maximalen Grenze (z.B. 1 MB).
Es ist nicht zwingend immer so, dass der Stack schrittweise Page für Page in Richtung kleinerer Adressen wächst – es kann auch vorkommen, dass der erste Zugriff gleich einige Seiten überspringt:
int main() { char buf[31337]; [0] = 0x23; buf << "&buf = " << reinterpret_cast<void*>(&buf) << endl; cout return 0; }
Schreiben jenseits der maximalen Stackgrenze (Beispiel: 64MB unterhalb dem Stack) sollte fehlschlagen:
int main() { char buf[0x4000000]; [0] = 0x23; buf << "&buf = " << reinterpret_cast<void*>(&buf) << endl; cout << "You shouldn't be here!" << endl; cout return 1; }
Abhängigkeiten durch den Compiler
Der Compiler kann (relativ unerwartet) Funktionsaufrufe einbauen, Details dazu im Blogbeitrag Das Elend mit dem Linker im Abschnitt Die libgcc und weitere Abhängigkeiten.
Der Compiler wirft unter Umständen ein
undefined reference to 'memcpy'
:struct Foo { char bar[9001]; } foo; int main() { = foo; // memcpy Foo baz << "baz: " << (void*)&baz << endl; cout return 0; }
Der Compiler wirft unter Umständen ein
undefined reference to 'memset'
:int main() { char zeroed[9001] = {}; // memset << "zeroed: " << (void*)zeroed << endl; cout return 0; }
Die entsprechenden beiden Funktionen müssen also von der
libsys
bereit gestellt werden!
Zeiger als Systemaufrufparameter
Der Kernel sollte immer kritisch gegenüber Zeigern sein, die er von
einer Benutzeranwendung bekommt. Ein einfaches Beispiel ist der
write
Syscall – gefährlich wird es aber natürlich mit dem
read
Syscall, mit welchem es ohne richtiger Prüfung
passieren kann, dass wichtige interne Daten (oder gar Code)
überschrieben werden!
Wie ein solcher invalider Zugriff bei der Systemaufrufschnittstelle
behandelt werden soll ist Implementierungssache, eine Möglichkeit wäre
bei ungültigen Zeigern den Systemaufruf direkt mit 0
(=
keine Ausgabe) zurückkehren zu lassen.
Benutzeranwendungen können via Syscall den Kernel auslesen
int main() { if (sys_write(0, reinterpret_cast<char*>(0x1000000), 42) != 0) (1, "You shouldn't be here!\n", 23); sys_writereturn 0; }
Benutzeranwendungen können via Syscall auf den APIC zugreifen
int main() { if (sys_write(0, reinterpret_cast<char*>(0xfec00000), 42) != 0) (1, "You shouldn't be here!\n", 23); sys_writereturn 0; }
Benutzeranwendungen können schlicht ungültigen Speicher adressieren
int main() { if (sys_write(0, reinterpret_cast<char*>(0x5000000), 42) != 0) (1, "You shouldn't be here!\n", 23); sys_writereturn 0; }
Wie sollte der Zeiger geprüft werden
Zum Beispiel mit Hilfe einer resolve
Funktion, welche
die (virtuelle) Adresse des Zeigers auflöst und dabei auch prüft, ob das
Usermode-Bit gesetzt ist.
Sofern an diese Adresse auch geschrieben werden soll
(read
Systemaufruf), so muss entsprechend zusätzlich auch
das Writable-Bit gesetzt sein.
Und dieser Check muss für jede Seite gemacht werden – ein Zeiger auf
0x4002f50
mit Länge 5000 muss entsprechend die
Berechtigungen der Seiten 0x4002000
, 0x4003000
sowie 0x4004000
im virtuellen Adressraum überprüfen, bevor
er mit dem eigentlichen Systemaufruf fortfahren kann.
Fehler beim ELF Laden
Die extra 2.5 ECTS kommen nicht umsonst 😀, dafür müssen auch noch die Fallstricke von ELF-Dateien überwunden werden.
BSS ist nicht genullt:
char zeroed[9001]; void main() { for (uint32_t i = 0; i < 9001; i++) if (zeroed[i] != '\0') << "zeroed[" << i << "] = " << hex << (int)zeroed[i] << endl; cout while(1); }
Falls die Speichergröße eines ELF-Segments größer als die Dateigröße ist, muss die Differenz mit
0
initialisiert werden (z.B. viamemset
)
Fehlerhafte Berechtigungen
Die Segmente in ELFs ermöglichen uns die Vorteile der Berechtigungen
im x64
Paging voll auszuspielen und dadurch frühzeitige
fehlerhaft Userspace Apps zu erkennen – solange korrekt
implementiert!
Schreiben auf nur lesbares Datensegment (
.rodata
) möglich:const int i = 23; // prevent compiler optimization volatile int * iptr = reinterpret_cast<volatile int*>(const_cast<int*>(&i)); int main() { *iptr = 42; if (*iptr != 23) << "Test failed: " << iptr << " (.rodata) = " << *iptr << " (not 23!)"<< endl; cout << "Test failed: Write to " << iptr << " (.rodata) didn't stop app!"<< endl; cout return 1; }
Schreiben in Codesegment (
.text
) möglichint main() { // try to change data in .text volatile void ** ptr = reinterpret_cast<volatile void**>(main); *ptr = reinterpret_cast<void*>(0xbadbeef); if (*ptr == reinterpret_cast<void*>(0xbadbeef)) << "Test failed: main @ " << ptr << " (.text) = " << *ptr << endl; cout << "Test failed: Write to " << ptr << " (.text) didn't stop app!"<< endl; cout return 1; }
Man kann Code auf dem Stack ausführen:
void fail() { (1, "You shouldn't be here!\n", 23); sys_writewhile(1); } int main() { char exec[3]; // Machine code bytes for `call *%rdi` [0] = '\xff'; exec[1] = '\xd7'; exec// Machine code bytes for `ret` [2] = '\xc3'; execauto fp = reinterpret_cast<void(*)(void(*)())>(exec); (fail); fp<< "Test failed: Executing code from stack didn't stop app!"<< endl; cout return 1; }
Man kann Code im Datensegment (
.data
) ausführen:// Machine code bytes for `call *%rdi; nop; ret;` char exec_data[] = {'\xff', '\xd7', '\xc3', '\x90', '\0' }; void fail() { (1, "You shouldn't be here!\n", 23); sys_writewhile(1); } int main() { auto fp = reinterpret_cast<void(*)(void(*)())>(exec_data); (fail); fp<< "Test failed: Executing code from .data didn't stop app!"<< endl; cout return 1; }
Man kann Code im nur lesbaren Datensegment (
.rodata
) ausführen:// Bytes für Maschinencode `call *%rdi; ret;` const char exec_rodata[] = {'\xff', '\xd7', '\xc3','\0' }; void fail() { (1, "You shouldn't be here!\n", 23); sys_writewhile(1); } int main() { auto fp = reinterpret_cast<void(*)(void(*)())>(exec_rodata); (fail); fp<< "Test failed: Executing code from .rodata didn't stop app!"<< endl; cout return 1; }
Obige Fehler resultieren meist aus entweder einem
Nichtberücksichtigen der Berechtigungsflags im ELF Program
Header beim Laden der Apps und dem Mappen der entsprechenden Seiten
– oder aber einem fehlerhaften Linkerskript für die Anwendungen
(user/sections.ld
).
Mittel dem Kommandozeilenwerkzeug readelf
kann in der
App-Binärdatei geprüft werden, ob die Segmente die korrekten
Berechtigungen haben (die Parameter -Wl
sind hierbei
nützlich). Üblicherweise hat man drei LOAD
-Segmente mit
Berechtigungen R X
(für Code in .text
),
R
(für Konstanten in .rodata
) und
RW
(für Daten in .data
). Sollte jedoch ein
Segment gleichzeitig als schreib- und ausführbar (RWE
)
markiert sein, so fehlt vermutlich mindestens ein
. = ALIGN(0x1000);
im Linkerskript: dadurch kann der Linker
die Berechtigungen feingranular setzen, ohne die 4K Ausrichtung klebt er
.text
und .data
unter Umständen zusammen…