Der Absturz in tai64nlocal aus den sourcen von daemontools-0.76.tar.gz (sha256sum:a55535012b2be7a52dcd9eccabb9a198b13be50d0384143bd3b32b8710df4c1f) tritt mit dem nachfolgenden PoC auf. Ich biete einen Patch, einen Workaround und auch eine umfassende Erklärung, warum der Crash von djb vorgesehen ist, worin das eigentliche Problem besteht und warum djb damit auch auf die Nutzung von localtime() in der libc aufmerksam machen will.
mkdir crashtest cd crashtest touch "@4000000065b8dc9f11048f4c-abcdef" touch "@4000000065b8dc9f11048f4cabcdef" touch "@4000000065b8dc9f11048f4cabcdef hallo" ls -1 | tai64nlocalDas funktioniert noch alles.
Alle diese nachfolgenden Zeilen failen jedoch und führend zum crash
touch @4000000065b8dc9f11048f4c0 ls -1 | tai64nlocalEbenso
@4000000065b8dc9f11048f4c1 @4000000065b8dc9f11048f4c2 ... @4000000065b8dc9f11048f4c8 @4000000065b8dc9f11048f4c9 @4000000065b8dc9f11048f4ca @4000000065b8dc9f11048f4cb ... @4000000065b8dc9f11048f4ce @4000000065b8dc9f11048f4cf # nachfolgende failed nicht, weil kein hex char @4000000065b8dc9f11048f4cg ...
Aber auch diese Zeilen failen
rm @* touch @4 ls -1 | tai64nlocaldas gilt auch für
@45678 @a @0 ...
Schauen wir uns die Parsing-Schleife aus dem gezeigten Code bei der Verarbeitung der Testfälle an:
for (;;) {
get(&ch);
u = ch - '0';
if (u >= 10) {
u = ch - 'a';
if (u >= 6) break; // Hier Abbruch bei nonhex 'g'
u += 10;
}
secs <<= 4;
secs += nanosecs >> 28;
nanosecs &= 0xfffffff;
nanosecs <<= 4;
nanosecs += u;
}
Ein gültiger TAI64N-Zeitstempel hat exakt 24 Hex-Zeichen (16 für Sekunden, 8 für Nanosekunden).
Wenn wir ein 25. Hex-Zeichen (wie 0 bis f) anhängen, bricht die Schleife nicht nach 24 Zeichen ab.
Sie läuft einen Schritt weiter. Durch die Bit-Shifts (secs <<= 4) werden alle bisherigen Bits um 4 Positionen nach links verschoben.
Das führt zu einem Integer Overflow. Die vordersten Bits von secs (die den legitimen Zeitraum definieren) fliegen aus dem Speicherbereich der 64-Bit-Variable. Zurück bleibt ein korrupter, meist negativer Wert.
Wenn wir nur @0 übergeben, bricht die Schleife beim nächsten Zeichen (z. B. dem Zeilenumbruch \n) ab. ecs und nanosecs bleiben fast vollständig auf 0. Nachdem die Schleife fertig ist, folgt diese Zeile:
secs -= 4611686018427387914ULL;
Hier wird die TAI64-Epochen-Konstante abgezogen. Bei zu kurzen Strings (wie @0): secs ist 0. Die Rechnung lautet: 0 - 4611686018427387914.
Durch den Shift ist der Wert von secs so klein geworden, dass die Subtraktion dieser großen Konstante den Wert ebenfalls in den ungültigen Bereich zieht.
Wenn dieser fehlerhafte Wert nun an localtime(&secs) übergeben wird, kann das Betriebssystem daraus keine Zeitzone mehr berechnen. localtime() gibt NULL zurück, das Programm dereferenziert den Zeiger blind und stürzt per Segmentation Fault ab.
djb hat hier auf eine Validierung der String-Länge (exakt 24 Zeichen) verzichtet.
Sobald der Input von außen manipuliert werden kann (z. B. weil ein Angreifer eine Zeile in ein Logfile einschleust), wird aus dem pragmatischen Verzicht ein reproduzierbarer Denial-of-Service (Crash).
echo NTVhNTYKPiAgICAgICBpZih0KSB7CjYyYTY0Cj4gICAgICAgfQo= | \ base64 -d | patch tai64nlocal.c
Ganz simpel: Durch das Hinzufügen von
if (t)kapseln wir den gesamten Block, der auf die Member des Structs (t->tm_year, t->tm_mon etc.) zugreifen würde.
t = localtime(&secs);
if (t) {
out(num,fmt_ulong(num,1900 + t->tm_year));
out("-",1); out(num,fmt_uint0(num,1 + t->tm_mon,2));
out("-",1); out(num,fmt_uint0(num,t->tm_mday,2));
out(" ",1); out(num,fmt_uint0(num,t->tm_hour,2));
out(":",1); out(num,fmt_uint0(num,t->tm_min,2));
out(":",1); out(num,fmt_uint0(num,t->tm_sec,2));
out(".",1); out(num,fmt_uint0(num,nanosecs,9));
}
Wenn localtime() fehlschlägt (NULL), wird die Ausgabe der formatierten Zeit komplett übersprungen. Das Verhalten bei den Testcases (@0, @40000...0): Das Programm stürzt nicht mehr ab.
Es liest das ungültige @..., überspringt die Konvertierung und gibt im Anschluss über die verbleibende Schleife einfach den Rest der Zeile aus.
Aber: Wenn t gegen NULL läuft, überspringt der Code die Zeit-Ausgabe. Das bedeutet, dass der originale, ungültige @...-String im Output eventuell "verschluckt" oder unvollständig ausgegeben wird, weil der Parser die Hex-Zeichen bereits aus dem Buffer konsumiert hat.
Für ein reines Filter-Tool wie tai64nlocal ist das aber ein völlig legitimer und sicherer Graceful Degradation-Ansatz: Lieber fehlt bei einer manipulierten Zeile der Zeitstempel, als dass die komplette Log-Pipeline des Servers kollabiert.
Aus djbs Sicht der Systemsicherheit ist ein kontrollierter Absturz durch das Betriebssystem (SIGSEGV) bei einer ungültigen Zeiteingabe akzeptabel, weil kein Speicher korrumpiert wird und kein Sicherheitsleck (wie ein Code-Execution-Exploit) entstehen kann.
Du willst nicht patchen und auch keinen Crash. Ok. Du kannst dazu einfach eine kompakte und elegante awkline voranstellen, bevor du Daten in das ungepatched originale tai64nlocal schiebst. Diese setzen dann das Datum im Fehlerfall einfach auf den größtmöglichen tai64n Zeitstempel. Einen Fall den du später in den Logs auch gut detektieren kannst und es ist ein Fall, der in den nächsten Millionen Jahren nicht eintreten wird. Zudem erhalten wir den problematischen tai64n zeitstempel, für spätere separate Auswertung und stellen stattdessen einfach nur diesen fiktiven Zukunftszeitstempel voran, damit tai64nlocal diesen stattdessen prozessiert.
awk '/^@/&&length($1)!=25{$0="@4000ffffffffffffffffffff "$0}1' | tai64nlocal
Jein. Eher ein bewusster Tradeoff!
djb wusste genau, wie problematisch die Systemfunktion localtime() ist.
Er schreibt dazu auch in der originalen Dokumentation:
Beware, however, that the current implementation of tai64nlocal relies on the UNIX localtime library routine to find the local time. [...] Beware also that most localtime implementations are not Y2038-compliant.
Er warnt also den Nutzer bereits explizit vor den Unzulänglichkeiten der Betriebssystem-Funktion "localtime".
Hätte er den Fehler sauber abfangen wollen (z.B. durch ein eigenes, fehlerfreies localtime), hätte er die komplette Zeitzonen-Datenbank (tzfile) selbst parsen müssen. Das hätte den Code von ca. 80 Zeilen auf hunderte Zeilen aufgebläht und das KISS-Prinzip radikal verletzt. Er verfolgt mit libtai einen anderen Ansatz.
Jein. Es ist als Denial of Service nutzbar, je nachdem in welchem Kontext tai64nlocal eingesetzt wird. Prinzipiell soll es aber nicht im Produktivsystem laufen, denn dort wird ja nur tai64 protokolliert. Nur für die Auswertung lokal ist auch tai64nlocal interessant.
Es entsteht zudem weder ein Speicherleck noch besteht die Gefahr einer Code Execution.
Ein Speicherleck in C entsteht, wenn ein Programm via malloc() Speicher auf dem Heap reserviert, ihn nicht freigibt (free()) und unendlich lange weiterläuft. Bei diesem Absturz passiert jedoch Folgendes:
djb hat im Programm tai64nlocal keinerlei dynamische Speicherallokation verbaut. Es gibt kein einziges malloc im Code. Alle Variablen (secs, nanosecs, num) liegen statisch im Speicher.
Selbst wenn die Funktion localtime() intern minimalen Speicher allokieren sollte: Das Programm stürzt per Segmentation Fault (SIGSEGV) ab. In dem Moment, in dem ein Prozess stirbt, greift das Betriebssystem ein und gibt den gesamten Speicher (Heap, Stack, Daten-Segmente) des Prozesses sofort atomar an das System zurück. Ein permanentes Leak auf dem Server wird damit unmöglich.
Damit ein Angreifer eigenen Code einschleusen und ausführen kann, muss er in der Regel in der Lage sein, den Instruction Pointer (RIP/EIP) der CPU mit einer Adresse zu überschreiben, die er selbst kontrolliert (typischerweise über einen Stack- oder Heap-Buffer-Overflow). Bei unerem PoC passiert jedoch etwas fundamental anderes:
Ein Angreifer kontrolliert bei diesem Absturz keine Zeigeradresse, sondern er erzwingt lediglich den Wert 0x0. Es gibt keine Möglichkeit für ihn, den Kontrollfluss des Programms auf eigenen Schadcode umzulenken.
Es gibt dennoch ein Sicherheitsproblem dabei.
Ein Angreifer attackiert hierbei nicht tai64nlocal selbst, sondern nutzt tai64nlocal als Vektor, um bei einer Lücke in der libc und der dortigen localtime() Funktion manipulierte Daten tief in das Betriebssystem einzuschleusen. Denn tai64nlocal reicht ja nur 1:1 an localtime() durch.
Das Szenario beschreibt exakt das, wovor Daniel J. Bernstein immer gewarnt hat und warum er sich mit den Entwicklern der glibc (insbesondere dem damaligen Maintainer Ulrich Drepper) im Dauerclinch befand:
djbs Argument war damals: Die C-Standardbibliothek ist ein monolithisches, unsicheres Monster. Wenn ein simples Tool wie tai64nlocal eine Zeitzone berechnen will, darf das System nicht im Hintergrund anfangen, komplexe Binärdateien zu parsen. Weil glibc genau das tut, zieht sie jede eigentlich sichere Anwendung (wie djbs Tools) mit in den Abgrund, sobald in der Systembibliothek ein Fehler existiert.
Wenn dein System auf einem aktuellen Patchstand ist, fängt die moderne glibc extreme Werte oder manipulierte Dateien heute ab und liefert (wie im hier erläuterten Szenario) einfach NULL zurück.
Sollte jedoch eine neue, unbekannte Schwachstelle (Zero-Day) im Zeitzonen-Parser des Linux-Kernels oder der glibc auftauchen, wird die ungeschützte Interaktion in tai64nlocal brandgefährlich: Der Absturz erfolgt dann nicht mehr harmlos über eine Null-Pointer-Dereferenzierung, sondern der Angreifer übernimmt via localtime() die Kontrolle über den Prozess.
Das bleibt dir überlassen. Der Patch fixed nicht localtime(), er nimmt dir nur den Crash an dieser Stelle weg. Hier der Hintergrund:
Wenn ein System einen Zustand erreicht, den es nicht sicher verarbeiten kann, muss es sofort stoppen.
Antipattern: Viele Entwickler versuchen, Fehler um jeden Preis wegzubügeln (z. B. durch das Raten von Werten oder blindes Ignorieren), wodurch korrupte Logs oder falsche Systemzustände unbemerkt, aber fehlerhaft weiterarbeiten.
djb sieht das jedoch anders. Wenn eine böswillige oder kaputte Eingabe die Zeitarithmetik zerstört, darf das Tool auf keinen Fall eine falsche, erratene Uhrzeit in das Log schreiben. Ein Absturz (SIGSEGV) ist sauber, unbestechlich und signalisiert dem Administrator sofort, dass etwas mit der Eingabe fundamental nicht stimmt. Der Kernel beendet den Prozess sicher, bevor Schaden entsteht.
Auch sieht er die Aufgabe der Vorprüfung hier nicht in tai64nlocal, sondern eben in der sicheren Verarbeitung in localtime() selbst. Und dort liegt ja der Hund begraben.
djb hat aus genau diesen Unzulänglichkeiten der C-Standardbibliothek eine fundamentale Lehre für die Softwareentwicklung formuliert. Seine Antwort auf das tai64nlocal-Dilemma war nicht, den C-Code mit if-Abfragen vollzustopfen (was das Tool komplexer gemacht hätte), sondern die Erschaffung einer komplett neuen Architektur. Er lehrte damit folgendes:
Verlasse dich niemals auf time.h oder localtime(), wenn du Zuverlässigkeit und Stabilität willst.
Deswegen schrieb er im Anschluss die Bibliothek libtai. In neueren Tools, die auf libtai basieren, wird die Systemfunktion localtime überhaupt nicht mehr aufgerufen. Die Berechnung erfolgt über rein mathematische, komplett absturzsichere Algorithmen. Das Verhalten ist also das perfekte Praxisbeispiel für djbs Design-Philosophie.
Anstatt das Programm durch endlose Validierungen des fehlerhaften Unix-Subsystems künstlich aufzublähen, akzeptiert er den harten Absturz bei korrupten Daten als die sicherste und "ehrlichste" Reaktion des Betriebssystems.
Das war klassische Zeitzonen-Integer-Overflow in der glibc. Wenn localtime() aufgerufen wurde, liest die Bibliothek die Zeitzonendateien (tzfiles, wie /etc/localtime) ein. Diese Dateien enthalten Tabellen für den Wechsel von Sommer- und Winterzeit. Ältere Versionen der glibc (vor 2.11) wiesen beim Parsen dieser Tabellenstrukturen einen Integer-Überlauf auf.
Ein Angreifer, der eine manipulierte Zeitzonendatei auf das System schleusen oder Umgebungsvariablen wie TZ manipulieren konnte, brachte den internen Parser der glibc zum Überlaufen. Dies führte zu einer Heap-Korruption und erlaubte im schlimmsten Fall das Ausführen von beliebigem Schadcode (Remote Code Execution) mit den Rechten der aufrufenden Anwendung.
Diese Schwachstelle zeigt ein modernes, strukturelles Problem im Design von localtime(), das erst vor wenigen Jahren in Verbindung mit modernen Programmiersprachen wie Rust (im chrono-Crate) für Aufsehen sorgte. Das Problem dabei ist, dass die POSIX-Funktion localtime() nicht thread-safe ist. Sie gibt einen Zeiger auf einen statischen, internen Speicherbereich der libc zurück. Wenn ein Thread localtime() aufruft, während ein anderer Thread gleichzeitig über setenv() Umgebungsvariablen wie die Zeitzone (TZ) ändert, kommt es zu einer Data Race und potenzieller Speicher-Korruption (Use-After-Free oder Null-Pointer-Dereferenzierung).
Da moderne Anwendungen fast immer Multithreading nutzen, mutiert der Aufruf von localtime() zu einer permanenten Zeitbombe im Speicher. Rust-Entwickler mussten weite Teile ihrer Zeit-Bibliotheken umschreiben, um den Aufruf dieser alten C-Funktion zu blockieren. Ein später Triumph für djbs Philosophie, die libc komplett zu meiden.
Kein direktes Problem mit localtime(). Aber diese Schwachstelle zeigt, wie fatal die enge Verflechtung der glibc mit Umgebungsvariablen ist. Genau die Art von Verflechtung, die localtime() zwingend benötigt, um über TZ die Zeitzone zu ermitteln. Problem war ein Buffer Overflow im dynamischen Linker (ld.so) der glibc beim Verarbeiten der Umgebungsvariable GLIBC_TUNABLES. djb predigt seit Jahrzehnten, dass sich sicherheitskritische Software niemals auf Umgebungsvariablen verlassen darf, weil diese vom Benutzer/Angreifer kontrolliert werden. localtime() liest beim Start zwingend die Variable TZ aus. Wenn der Parser für solche Umgebungsvariablen in der libc fehlerhaft ist, kollabiert das gesamte Sicherheitskonzept des Programms. Looney Tunables erlaubte es Angreifern, auf fast jedem Linux-System sofort Root-Rechte (Local Privilege Escalation) zu erlangen.
Innerhalb der glibc teilen sich die Zeitfunktionen globale Zustände. Wenn die Funktion tzset() (die von localtime() automatisch aufgerufen wird) im Hintergrund die Zeitzonen-Konfiguration aktualisiert, während gleichzeitig Kindprozesse über posix_spawn erzeugt werden, kam es zu einer unvollständigen Speicherbereinigung.
In diesem Fall ging die Gefahr von einem Use-After-Free-Fehler aus. Das Programm greift auf Speicher zu, der bereits freigegeben wurde. Angreifer können solche Lücken gezielt ausnutzen, um Daten im Speicher zu manipulieren, während das Programm glaubt, eine harmlose Zeitzone zu berechnen.
Nicht nur die GNU-Bibliothek (glibc) ist betroffen, auch andere Betriebssystem-Derivate kämpften mit diesem Design. In der libc von FreeBSD (und älteren macOS-Kernels) gab es einen Pufferüberlauf in der Routine, die die Zeitzonen-Verzeichnisse durchsuchte.
Wenn ein Angreifer eine extrem lange, speziell präparierte TZ-Umgebungsvariable (z.B. TZ=AAAAAAA.../../../etc/localtime) übergab, führte der Aufruf von localtime() in Anwendungen wie Mail-Servern oder Log-Filtern zu einem Stack-basierten Pufferüberlauf. Damit war die vollständige Übernahme des Prozesses möglich.
Hier nun ein kleines Fazit im Sinne des Empirical Software Engineering. Das wiederkehrende Muster hinter all diesen CVEs zeigt die fundamentale Wahrheit der empirischen Sicherheitsforschung, nämlich dass sich Sicherheit nicht an ein komplexes, monolithisches System delegieren lässt.