1 Punkte von GN⁺ 2024-03-31 | 1 Kommentare | Auf WhatsApp teilen
  • Die xz/liblzma-Backdoor ist nicht nur ein Problem des finalen binären Payloads; die während des Builds ausgeführte Bash-Stufe ist durch mehrere Schichten aus Extraktion und Entschlüsselung verborgen, was die Analyse erschwert.
  • Betroffen sind xz/liblzma 5.6.0 und 5.6.1; die verschleierten Skripte und der binäre Payload stecken in zwei Dateien, die wie Testdateien aussehen.
  • Stage 0 beginnt in m4/build-to-host.m4, repariert einen scheinbar beschädigten xz-Stream und extrahiert mit xz -d das Stage-1-Skript, das anschließend ausgeführt wird.
  • Stage 1 schneidet Daten aus good-large_compressed.lzma heraus, ersetzt sie und führt dann ein Stage-2-Bash-Skript aus; in 5.6.1 wurde Code hinzugefügt, der fünfmal prüft, ob Linux ausgeführt wird.
  • Stage 2 nutzt File Carving, eine Substitutionschiffre und eine AWK-basierte RC4-Variante zur Entschlüsselung, um eine .o-Datei in den Build- und Link-Prozess einzuschleusen; 5.6.1 enthält außerdem eine Erweiterungsstruktur, mit der künftig zusätzliche Skripte ausgeführt werden könnten.

Umfang der Bash-Stufen der xz/liblzma-Backdoor

  • Andres Freund meldete auf der oss-security-Mailingliste die Entdeckung der xz/liblzma-Backdoor; diese Backdoor betrifft den OpenSSH server.
  • Gegenstand der Analyse ist nicht die binäre Backdoor selbst, sondern die zuvor ausgeführten Bash-basierten Anfangsstufen und ihre Verschleierung.
  • Der Bash-Ablauf reicht von Stage 0 bis Stage 2; auch eine potenzielle Stage 3, die offenbar nicht vollständig implementiert ist, wird teilweise sichtbar.
  • Die verschleierten und verschlüsselten Stufen sowie die spätere binäre Backdoor sind in zwei Testdateien versteckt:
    • tests/files/bad-3-corrupt_lzma2.xz
    • tests/files/good-large_compressed.lzma
  • Betroffen sind die Versionen 5.6.0 und 5.6.1; zwischen beiden Versionen gibt es kleinere Unterschiede.

Stage 0: Reparatur eines xz-Streams, der wie eine beschädigte Testdatei aussieht

  • Ausgangspunkt ist Code in m4/build-to-host.m4, der offenbar an irgendeiner Stelle während des Build-Prozesses ausgeführt wird.
  • Die zentrale Pipeline verbindet sed, tr und xz -d, um das Stage-1-Skript zu extrahieren.
  • Nachdem die Bytes aus tests/files/bad-3-corrupt_lzma2.xz gelesen wurden, werden einige Bytes mit tr "\t \-_" " \t_\-" ersetzt:
    • Der Tabulator 0x09 wird zu einem Leerzeichen 0x20.
    • Das Leerzeichen 0x20 wird zu einem Tabulator 0x09.
    • Der Bindestrich 0x2d wird zu einem Unterstrich 0x5f.
    • Der Unterstrich 0x5f wird zu einem Bindestrich 0x2d.
  • Diese Ersetzung dient dazu, bad-3-corrupt_lzma2.xz wieder in einen gültigen xz-Stream zu verwandeln.
  • Am Ende wird der reparierte Stream mit xz entpackt; Fehler werden ignoriert, das Stage-1-Skript wird gewonnen und sofort ausgeführt.
    • Der Stream sieht abgeschnitten aus, aber die relevanten Ausgaben wurden bereits geschrieben, sodass das kein Problem ist.

Stage 1: Stage 2 aus good-large_compressed.lzma herausholen

  • Stage 1 ist die kurze Bash-Datei aus Andres Freunds Mail, die mit ####Hello#### beginnt.
  • Der erste Unterschied zwischen den beiden Versionen sind zufällige Bytes im Kommentar der zweiten Zeile:
    • 5.6.0: 86 F9 5A F7 2E 68 6A BC
    • 5.6.1: E5 55 89 B7 24 04 D8 17
  • In 5.6.1 wurde eine Linux-Prüfung der Form [ ! $(uname) = "Linux" ] && exit 0 hinzugefügt; dieselbe Prüfung wird fünfmal wiederholt.
  • Die Extraktion von Stage 2 beginnt mit einer langen Zeile export i=...; dieser Wert wird später wie eine Art Funktion verwendet, die über eval $i aufgerufen wird.
  • Der Extraktionsablauf ist als Pipeline aufgebaut, in der Standardausgabe und Standardeingabe fortlaufend verbunden werden:
    • Zunächst wird good-large_compressed.lzma mit xz -dc entpackt.
    • eval $i wiederholt head-Aufrufe in einem Muster, bei dem 1024 Bytes verworfen und 2048 Bytes ausgegeben werden.
    • Am Dateiende gibt 5.6.0 724 Bytes aus, 5.6.1 939 Bytes.
  • Diese head-Kette entfernt leere Bereiche mit niedriger Entropie und fügt nur die Bereiche zusammen, die tatsächliche Daten enthalten.
  • Anschließend wird mit tail -c +31233 der vordere Teil verworfen.
    • In diesem vorderen Teil ist die im nächsten Schritt extrahierte binäre Backdoor versteckt.
    • In 5.6.0 werden dadurch effektiv die ersten 31264 Bytes verworfen, in 5.6.1 die ersten 31232 Bytes.
  • Danach wird tr wie eine einfache Substitutionschiffre verwendet; die Mapping-Keys unterscheiden sich zwischen 5.6.0 und 5.6.1:
    • 5.6.0: tr "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\377"
    • 5.6.1: tr "\114-\321\322-\377\35-\47\14-\34\0-\13\50-\113" "\0-\377"
  • Die ersetzten Daten werden mit xz -F raw --lzma1 -dc erneut entpackt; die daraus resultierende Stage 2 wird sofort ausgeführt.

Stage 2: Änderung des Build-Prozesses und Erweiterungsmechanismus

  • Stage 2 ist die Datei infected.txt, die Andres Freund der ursprünglichen Mail angehängt hatte; es handelt sich um ein Bash-Skript, in dem die eigentliche Änderung des Kompilierungsprozesses stattfindet.
  • Aus Sicht der Verschleierung gibt es in Stage 2 drei interessante Fragmente, von denen zwei nur in 5.6.1 vorkommen.
  • Die beiden Fragmente in 5.6.1 sind nahezu identisch und suchen in tests/files/ nach Dateien mit bestimmten Signaturen:
    • Fragment 1 sucht nach ~!:_ W und |_!{ -.
    • Fragment 3 sucht nach jV!.^% und %.R.1Z.
  • Wenn die Signaturen gefunden werden, werden Dateiname und Offset extrahiert, und der Bytebereich ab der ersten Signatur bis vor die zweite Signatur wird herausgeschnitten.
  • Die herausgeschnittenen Daten werden mit dem in 5.6.0 verwendeten tr-Key per Substitution entschlüsselt, dann mit xz -F raw --lzma2 -dc entpackt und per eval ausgeführt.
  • In den untersuchten TAR-Archiven von 5.6.0 und 5.6.1 gab es keine Datei mit diesen Signaturen.
  • Diese Struktur wirkt wie ein Erweiterungs- oder Patch-System, mit dem künftig Skripte ausgeführt werden könnten, indem lediglich neue Testdateien hinzugefügt werden, ohne die vorhandene Testdatei zum Transport des Payloads erneut zu verändern.

Stage 2: Extraktion der binären Backdoor-.o-Datei

  • Ein weiterer Kernbestandteil von Stage 2 ist der Code, der eine .o-Datei extrahiert und in den Kompilier- und Link-Prozess einschleust.
  • Das extrahierte Ergebnis heißt liblzma_la-crc64-fast.o; dabei handelt es sich um die finale binäre Backdoor.
  • Der Extraktionsprozess verbindet wie in den vorherigen Stufen mehrere Standardwerkzeuge zu einer Pipeline:
    • good-large_compressed.lzma wird mit xz -dc entpackt.
    • Die head-Kette aus eval $i, die in Stage 1 verwendet wurde, wird erneut genutzt.
    • Mit sed "s/\(.\)/\1\n/g" wird nach jedem Byte ein Zeilenumbruch eingefügt, sodass eine Form mit einer Zeile pro Byte entsteht.
  • Das folgende AWK-Skript führt auf dem Eingabestream eine RC4-ähnliche Entschlüsselung aus:
    • Das Key-Array wird mit ((i * 7) + 5) % 256 erzeugt.
    • 5.6.0 überspringt die ersten 4096 Bytes des ausgegebenen PRNG-Streams.
    • 5.6.1 überspringt 8192 Bytes.
    • Das ursprüngliche RC4 verwendet XOR, dieser Code nutzt jedoch Addition in der Form (v + k) % 256.
  • Die entschlüsselten Daten werden mit xz -dc --single-stream entpackt.
  • Anschließend wird mit head nur der benötigte Bytebereich herausgeschnitten und als liblzma_la-crc64-fast.o gespeichert.
    • Die komprimierte, aber teilweise modifizierte Payload-Größe beträgt 88792 Bytes in 5.6.0 und 88664 Bytes in 5.6.1.
    • Der endgültig extrahierte Bereich hat eine Größe von etwa 86 KB.

Charakter der mit Standardwerkzeugen gebauten Verschleierung

  • Die gesamte Bash-Stufe besteht ausschließlich aus Standard-Kommandozeilenwerkzeugen:
    • sed
    • tr
    • xz
    • head
    • tail
    • awk
    • grep
    • cut
    • expr
  • Der Payload ist in Binärdateien versteckt, die wie normale Testdateien aussehen, und nur die benötigten Bereiche werden per File Carving herausgezogen.
  • Für die Verschleierung werden eine einfache Substitutionschiffre und eine AWK-basierte RC4-Variante zur Entschlüsselung kombiniert.
  • Die Ausführung verläuft über insgesamt drei Stufen; in 5.6.1 ist eine Struktur enthalten, mit der später durch Hinzufügen separater Testdateien Skripte ausgeführt werden könnten.
  • Die gesamte Struktur ist so gestaltet, dass sie harmlos wirkt und gut verborgen bleibt; wenn dieser Fall zufällig entdeckt wurde, stellt sich die Frage, wie viele ähnliche, bislang unentdeckte Fälle es noch gibt.

1 Kommentare

 
GN⁺ 2024-03-31
Meinungen auf Hacker News
  • Dank der vereinfachten Erklärung und des Vergleichs mit dem Rauschbild bekommt man ein Gefühl dafür, was die Leute mit Raffinesse meinen.
    Auf reddit habe ich auch gesehen, dass die „Sandboxing“-Methode durch einen einzigen Punkt kaputtgemacht wurde; ganz links in der Zeile direkt nach #include ist ein Punkt zu sehen.
    https://git.tukaani.org/?p=xz.git;a=commitdiff;h=328c52da8a2...
    https://old.reddit.com/r/linux/comments/1brhlur/xz_utils_bac...

    • Wirklich bösartig. Im diff sieht es einfach wie +, +., + aus, und dieser eine Punkt fällt nicht auf.
    • Ich hasse den Einsatz solcher Compile-/Build-Zeit-Bedingungen wirklich. Es ist schwer zu testen, ob sie eingeschaltet sind, wenn sie eingeschaltet sein sollen, und ausgeschaltet, wenn sie ausgeschaltet sein sollen – besonders wenn sie in einem Build-System ohne Unit-Test-Framework stecken.
      Schon einfache Fehler, bei denen Tests immer fehlschlagen oder immer erfolgreich sind, sind lästig; man sieht gut, warum das ein attraktives Ziel für böswilliges Verhalten ist.
    • Sehr wahrscheinlich war das keine Absicht, sondern schlicht ein Fehler.
      a) Praktisch niemand baut dieses Paket tatsächlich mit cmake.
      b) Wenn man es mit cmake und -DENABLE_SANDBOX=landlock baut, schlägt der Build einfach fehl: https://i.imgur.com/7xbeWFx.png
      Der Punkt deaktiviert das Sandboxing nicht, sondern sorgt nur dafür, dass es sich mit cmake nicht bauen lässt. Wenn tatsächlich jemand mit cmake gebaut hätte, hätte er den Fehler gesehen und gemerkt, dass etwas nicht stimmt. Deshalb ergibt es wenig Sinn, das als böswilligen Versuch zur Schwächung der Sicherheit zu deuten.
  • „Wenn das zufällig entdeckt wurde, wie viel gibt es dann noch, das nicht entdeckt wurde?“ ist die wichtigste Frage.
    Es ist kaum vorstellbar, dass Andres Freund zufällig genau das einzige populäre Open-Source-Projekt mit eingeschleuster Backdoor gefunden hat. Es wäre nicht überraschend, wenn schon ein Dutzend solcher Dinge in freier Wildbahn existierten.

    • Es war nicht einfach zufällig entdeckt; eher hat er es gespürt. Das ist ein Unterschied.
      Der nächste Angreifer wird weit weniger unvorsichtig damit sein, Spuren wie längere Laufzeiten zu hinterlassen.
    • Möglich, aber in der Praxis gibt es vielleicht gar nicht so viele kritische Fälle. Wenn es viele gäbe, würde man solche Situationen vermutlich häufiger sehen.
  • Der unangenehme Gedanke hier ist, dass Unit-Tests den Angriffsweg eröffnet haben. Ohne Tests wäre es viel schwieriger gewesen, das so zu verstecken.

    • Der Angreifer hat sogar die Spuren der initialen Payload mit einem harmlosen Absatz im README überdeckt, im Sinne von: „Hier gibt es nichts zu sehen!“
      bad-3-corrupt_lzma2.xz enthält drei Streams; der erste und der dritte sind gültige xz-Streams. Beim mittleren Stream sind Stream-Header, Block-Header, Index und Stream-Footer korrekt, nur die LZMA2-Daten sind beschädigt, und es steht dort, dass er mit --single-stream dekomprimiert werden sollte.
      Die Strings ####Hello#### und ####World#### sorgen dafür, dass ein plausibel normal wirkendes Ergebnis herauskommt, wenn man die README-Anweisungen tatsächlich befolgt.
      $ cat tests/files/bad-3-corrupt_lzma2.xz | xz -d --single-stream
      ####Hello####
      Diese Strings sind Shell-Kommentare und behindern daher die Ausführung der Payload nicht.
      Schließlich dienen sie später als Marker, mit denen die Regex die Datei finden kann, ohne den Dateinamen direkt zu referenzieren oder die tatsächlichen Hello-/World-Strings zu verwenden.
      $ gl_am_configmake=\grep -aErls "#{4}[[:alnum:]]{5}#{4}$" $srcdir/ 2>/dev/null``
      $ echo $gl_am_configmake
      ./tests/files/bad-3-corrupt_lzma2.xz
    • Bei sicherheitskritischen Projekten sollte die Build-Infrastruktur wohl so konfiguriert sein, dass es einen Fehler oder zumindest eine Warnung gibt, wenn Binärdateien in den Build einfließen.
      Diese Prüfung müsste transitiv gelten, sodass auch beim Versuch einer Linux-Distribution, auf eine neue liblzma-Version zu aktualisieren, der Build wegen neuer binärer Abhängigkeiten fehlschlägt oder warnt.
      Ich weiß nicht, wie verbreitet diese Praxis bei Builds von Linux-Distributionen ist. Wenn sie verbreitet ist, wäre das Aufräumen enorm viel Arbeit, und es ist unklar, ob es überhaupt machbar ist. Mit bazel scheint es möglich, bei anderen Build-Systemen weiß ich es nicht.
  • Ich frage mich, ob schon jemand auf GitHub nach ähnlichen head-|tail-Tricks gesucht hat. Schwer zu glauben, dass das eigens hierfür erfunden wurde.

    • Ich habe so etwas ziemlich oft in Unix-Installern kommerzieller Software gesehen.
      Eine riesige .sh-Datei zeigt die Lizenz an und holt die Zustimmung ein, danach cattet sie sich selbst und extrahiert über eine head/tail-Pipe mit cpio die eigentlichen Assets.
    • Clever, aber nicht völlig neu; es liegt eher nahe an den vorgesehenen Einsatzfällen solcher Tools.
    • Eine Gelegenheit für ein Paper.
  • Eine bessere Antwort habe ich nicht, aber ist dieser undurchsichtige Bash-Klumpen an sich nicht schon ein Code Smell?
    Wie in anderen Bereichen der Softwareentwicklung auch: Hätte man das nicht weniger opak schreiben können, damit klarer sichtbar ist, was passiert?
    Mir ist klar, dass ein Maintainer bösartigen Code ohne so strenge Prüfung einschleusen kann wie ein externer Contributor, aber es muss doch einen besseren Weg geben als einen „knappen“ Code-Klumpen, der praktisch wie unbeabsichtigte Obfuscation wirkt.

    • Das ist ein sehr alter Smell.
      Das Kernproblem ist, dass es in den 80er- und 90er-Jahren unzählige Unix-artige Systeme gab, jeweils mit eigenen Fehlern und fehlenden Features, und Softwareautoren ihre Build-Abhängigkeiten minimieren wollten.
      Deshalb haben viele Communities Builds auf Shell-Skripte standardisiert, die überall laufen. Shell-Skripte zu schreiben war aber mühsam, und die Leute begannen, Tools wie den M4-Makro-Präprozessor zu verwenden, um Shell-Skripte zu erzeugen.
      Das Ergebnis ist, dass viele Projekte riesige, undurchsichtige Shell-Skript-Brocken haben, für den Fall, dass jemand den Code auf AIX oder einem kaputten uralten Unix ausführen möchte.
      Um dieses undurchdringliche Shell-Gestrüpp loszuwerden, müsste man die Zahl der unterstützten Plattformen stark reduzieren, sich auf sauberere Build-Tools standardisieren und mehr Kerninfrastruktur in Sprachen bauen, die für portable Builds keine Shell benötigen.
      Das ist aber eine riesige Aufgabe, und ein großer Teil der zentralen C-Bibliotheken wird von ein oder zwei unbezahlten Freiwilligen gepflegt. Die Unterstützung für „Obscurnix-1997“ einzustellen, ist meist auch eine ziemlich kontroverse Entscheidung.
      Deshalb ist ein großer Teil der Kerninfrastruktur immer noch von einem Sumpf aus undurchschaubaren, maschinell erzeugten Shell-Skripten umgeben.
    • Diese Shell wurde nicht von Menschen geschrieben, sondern ist generierter Code. Weil autoconf so weit verbreitet ist, gibt es Berge von generiertem Shell-Konfigurationscode, und autoconf erzeugt mit M4-Makro-Präprozessor-Skripten Tausende Zeilen schwer lesbarer portabler Shell-Skripte.
      Eine nicht geringe Zahl von Tools wird auf diese Weise gebaut.
    • Dass Bash auf den ersten Blick undurchsichtig aussieht, halte ich an sich nicht für ein Warnsignal. Dicht geschriebene Bash-Skripte sehen manchmal so aus. Ob man sie so dicht schreiben sollte, ist eine andere Diskussion.
      Verdächtig wirken die wiederholten tr-Aufrufe. Wenn ich so etwas sehe, gehe ich davon aus, dass jemand besonders clever sein will, und „clever“ ist hier negativ gemeint. Wäre ich Maintainer, hätte ich bei solchem Code um eine Erklärung gebeten, was er tut. Denn fast immer gibt es bessere Lösungen, die solches Chaining vermeiden.
      Das eigentliche Problem ist, dass es keinen anderen Maintainer gab, der sich diesen Code beim Eingang hätte ansehen können. Eine wichtige Komponente des Stacks hing von einer einzelnen Person ab, und in diesem Fall war diese Person böswillig.
    • Nicht „unbeabsichtigt“ – der Kernpunkt ist, dass es absichtlich obfuskiert wurde.
    • Vielleicht habe ich die Gefahr der XV-Backdoor missverstanden, aber gibt es eine Möglichkeit, Bash so auszuführen, dass sie nichts execen kann? Ich frage mich, ob Bash so etwas wie einen „Sicherheitsmodus“ haben könnte.
      Allerdings kann ich mir beim configure-Skript von xv nicht vorstellen, wie man Bash in einem solchen hypothetischen „Sicherheitsmodus“ starten könnte, also nehme ich das zurück.
  • https://github.com/tukaani-project/.github/issues/2

    • Ziemlich witzig. Das ist nicht nur eine schreckliche absichtliche Backdoor, sondern auch ein GPL-Verstoß, weil die Backdoor ein abgeleitetes Werk ist, der Quellcode nicht enthalten war und sie nicht in der „bevorzugten Form zur Bearbeitung“ verteilt wurde.
  • Das gesamte C-Ökosystem, einschließlich Build-Tools und alter Unix-Utilities, ist ein Sicherheitschaos, das nur darauf wartet, ausgenutzt zu werden, und irgendwann wird es ausgenutzt.
    Man muss sich nur ansehen, wie leicht ein einzelner Punkt alles kaputtmachen kann. Die Leute müssen endlich begreifen, dass man die Sicherheit der Welt nicht mehr auf C setzen kann.
    Ich hoffe, man nutzt Ada oder Rust mit modernen Toolchains.

    • Geht es nicht kaputt, wenn man Rust einen Punkt hinzufügt?
  • Ich verstehe überhaupt nicht, wie das durch ein Code Review gekommen und gemergt werden konnte. Wenn ich nichts übersehe, wirkt das absurd nachlässig.

    • Der böswillige Akteur war Co-Maintainer des Repositories, seit einiger Zeit aktiver als der ursprüngliche Maintainer und hatte volle Commit-Rechte. Es wurde ohne PR und ohne Review direkt nach master committed.
      Außerdem war es stark obfuskiert in einer als Testdatei ausgewiesenen Binärdatei, also in „good“/„bad“-xz-komprimierten Testdateien. Wenn man nicht weiß, wonach man suchen soll, kann man das nicht erkennen.
    • Die Commit-Message der Testdatei behauptete, sie sei mit einem Zufallsgenerator erstellt worden. Die Person, die den Release-Tarball erzeugt hat, setzte die letzte Zeile an die passende Stelle, aber das wurde nicht ins Repository eingecheckt.
    • Bei einem Paket mit nur einem aktiven Maintainer gibt es kein Code Review.
  • Mit LTS-Distributionen kann man bis zu einem gewissen Grad geschützt sein. Slackware scheint für Pakete lzma, also tar.xz, zu verwenden, aber die letzte stabile Version außer -current hatte dieses Problem nicht.
    Wenn man noch einen Schritt weiter in Richtung freie Software gehen möchte: Auch Hyperbola GNU hatte dieses Problem nicht.
    Zusätzlich linkt auch Slackware -current sshd nicht gegen xz und verwendet auch kein systemd.