
Technischer Deep-Dive und Anti-PoC für CVE-2022-3602, einen Punycode-Pufferüberlauf in OpenSSL 3.0.x, mit Reproduktionsskripten, Stack-Analyse und Compiler-Mitigation-Bewertung.
Dieses Dokument und Repository ist eine Aufarbeitung von CVE‑2022‑3602, einem Punycode‑Pufferüberlauf‑Problem in OpenSSL. Es ist ein "Anti‑POC" (das Problem scheint nicht ausnutzbar zu sein) und richtet sich an Leute, die ihre eigenen OpenSSL‑Builds pflegen, und an Compiler‑Entwickler.
Es gibt eine separate CVE in derselben Version, CVE‑2022‑3786, die ebenfalls zu Pufferüberläufen führt, aber ein Angreifer kann in diesem Fall den Inhalt nicht kontrollieren. Es gibt hier keine Reproduktion für dieses Problem, aber dieses Problem kann aufgrund eines Absturzes zu einem Denial‑of‑Service führen.
Abstürze und Pufferüberläufe sind nie gut, und wenn Sie OpenSSL 3.0.x verwenden, ist es ratsam, so schnell wie möglich zu aktualisieren.
Melden Sie etwaige Fehler oder Auslassungen bitte über GitHub‑Issues oder Pull‑Requests.
Es gibt einen Off‑by‑one‑Fehler in der Art und Weise, wie ossl_punycode_decode
die Punycode‑Dekodierung behandelt, der zu einem 4‑Byte‑Überlauf führt. Dieses
Problem ist nur dann sinnvoll, wenn OpenSSL eine Zertifikatskette verarbeitet,
und erfordert zwei Bedingungen. Erstens muss ein CA‑ oder Zwischenzertifikat in
einer Kette ein Namenseinschränkungsfeld enthalten, das Punycode verwendet.
nameConstraints = permitted;email:xn‑maccrthaigh‑n7a.com
Zweitens muss das Blattzertifikat ein SubjectAlternateName‑(SAN‑)otherName‑Feld enthalten, das eine SmtpUTF8Mailbox‑Zeichenkette angibt.
otherName = 1.3.6.1.5.5.7.8.9;UTF8:admin@xn‑maccrthaigh‑n7a.com
Wenn ausgelöst, wird der Punycode im nameConstraints‑Feld, aber nicht der Punycode im otherName‑Feld, von der anfälligen OpenSSL‑Punycode‑Analyse behandelt.
David Benjamin und Matt Caswell haben festgestellt, dass die Prüfung der Namenseinschränkungen nach der gewöhnlichen Zertifikatsketten‑Validierung und Signaturverifikation erfolgt. Für die meisten Anwendungen bedeutet dies, dass das Problem nicht mit einem selbstsignierten Zertifikat oder einer ungültigen Kette ausgelöst werden kann.
Beachten Sie, dass die s_client‑ und s_server‑Anwendungen von openssl für
Debugging‑Zwecke gedacht sind und die Verarbeitung nicht stoppen, wenn eine
Kette ungültig ist.
Eine vertrauenswürdige CA oder ein vertrauenswürdiges Zwischenzertifikat muss die schädliche Nutzlast enthalten und muss außerdem das Blattzertifikat signiert haben, das das Problem auslöst.
Es kann einige Umgebungen geben, in denen nicht vertrauenswürdige Parteien die CAs oder Zwischenstellen sind, z. B. ein Hosting‑Dienst, der kundenbereitgestellte private CAs unterstützt, aber das ist nicht üblich.
Die Antwort für viele Anwendungen wird "nein" sein, aufgrund der Art und Weise, wie der Compiler den Stack angeordnet hat und aufgrund des Vorhandenseins anderer Schutzmaßnahmen wie Stack‑Canaries / Stack‑Cookies, Padding, PIE, FORTIFY_SOURCE.
Das Problem führt tatsächlich zu einem Überlauf von 32 Bit auf dem Stack. Dies reicht nicht aus, um direkt Shell‑Code auszuführen, aber es könnte ausreichen, um den Kontrollfluss einer Anwendung zu ändern. Zum Beispiel könnte ein Sprung zu Shell‑Code, der in eine X509‑Zertifikatskette eingebettet ist, möglich sein, wenn diese Daten auch auf den Stack an eine ausführbare Stelle kopiert werden.
Auf jeder von mir getesteten Linux‑Plattform erfolgt der Überlauf in Padding
und ist harmlos. Theoretisch könnte ein Compiler Variablen so anordnen, dass der
Überlauf in eine der anderen Variablen in der Funktion ossl_a2ulabel erfolgt.
Abhängig von Inlining ist die vollständige Liste der vorhandenen Variablen:
outptr, inptr, size, result, tmpptr, delta, seed, utfsize
und keine davon scheint mir einen offensichtlichen Weg zur Privilegieneskalation oder zu interessanter Kontrolle zu bieten.
Ich habe ein Tarball mit Werkzeugen angehängt, die verwendet werden können, um
Reproduktionen und Überläufe mit so viel Kontrolle über alle vier Bytes wie
möglich zu erstellen. Der Referenz‑Reproduktionsstring (xn--ww90271...aaaa)
überläuft die vier Bytes mit den Werten 0xFF 0x0F 0x0F 0x0F. Wenn dies keine
Anwendung zum Absturz bringt, ist es möglich (wahrscheinlich?), dass diese
Anwendung nicht anfällig ist.
Das Shell‑Skript run-poc kann verwendet werden, um eine schädliche
Zertifikatskette zu erzeugen. Ein bösartiges CA‑Zertifikat wird aus ca.cnf
generiert, und ein auslösendes Blattzertifikat wird aus leaf.cnf generiert.
Das CA‑Zertifikat verwendet die folgende Referenz‑Nutzlast:
xn--ww902716aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Ein Python‑Skript kann verwendet werden, um andere Punycode‑Strings für verschiedene Nutzlasten zu erzeugen.
Wenn run-poc ausgeführt wird, startet es einen OpenSSL‑Client und -Server und
versucht, das Problem zehnmal auszunutzen.
Ein anfälliges OpenSSL wird wahrscheinlich abstürzen. Das bedeutet nicht, dass die Version von OpenSSL anfällig für eine RCE ist, da Stack‑Canaries und Stack‑Cookie‑Schutzmaßnahmen normalerweise ebenfalls einen (sichereren) Anwendungsabsturz verursachen. Beachten Sie auch, dass dies die Schwere der anderen CVE in derselben Version in keiner Weise ändert.
Es ist überraschend nuanciert, nahezu vollständige Kontrolle über alle vier Überlaufbytes zu erlangen, und erfordert die Ausnutzung des Punycode‑Decoders von OpenSSL mit nicht standardgemäßem / ungültigem Punycode. Das beigefügte Tarball enthält ein Skript, das einen String konstruieren kann, der die Nuance bewältigt. Im Folgenden wird erklärt, wie es funktioniert.
Das Sicherheitsproblem liegt in ossl_punycode_decode()
int ossl_punycode_decode(const char *pEncoded, const size_t enc_len,
unsigned int *pDecoded, unsigned int *pout_length)
ossl_punycode_decode wird von ossl_a2ulabel aufgerufen. Der Puffer
pEncoded ist ein mehr oder weniger beliebig großer Puffer, der aus einer
X509‑Zertifikatskette stammt. Es ist der Teil, der nach einem "xn--" in einem
nameConstraint‑Feld kommt. Siehe die [Reproduktion] für die Reproduktion einer
solchen Zertifikatskette.
pDecoded ist ein Array von unsigned ints der Größe LABEL_BUF_SIZE.
LABEL_BUF_SIZE ist 512, und auf den meisten Plattformen ist ein unsigned int
4 Byte breit. Auf den meisten Plattformen ist pDecoded also 2048 Byte lang.
Innerhalb von ossl_punycode_decode() ist der Kern des Problems diese
falsche Längenprüfung:
if (written_out > max_out)
max_out entspricht *pout_length, das immer 512 ist. Und written_out
verfolgt, wie viele unsigned ints in pDecoded geschrieben wurden. Da
written_out erst später nach dem Schreiben erhöht wird, erlaubt diese
fehlerhafte Prüfung, dass 513 unsigned ints in pDecoded geschrieben werden.
Das Endergebnis sieht ungefähr so aus ...
pDecoded = [ ... , 'X', 'Y' , 'Z' ] 'P'
// Indizes 509 510 511
Hier sind die Indizes gemäß der C‑Konvention nullbasiert, also ist Slot
Nummer 511 das 512. Element im Array. 'P' ist eine vier Byte große Nutzlast,
die außerhalb der Grenzen, jenseits des für den Puffer buf in
ossl_a2ulabel() reservierten Speicherplatzes, abgelegt wurde – auf den
pDecoded zeigt.
Vier Byte ist ein kleiner Überlauf und reicht nicht aus, um einen NOP‑Sled zu tragen oder direkt Shell‑Code auszuführen, reicht aber aus, um den Kontrollfluss einer Anwendung zu ändern. Zum Beispiel könnte ein Sprung zu Shell‑Code, der in eine X509‑Zertifikatskette eingebettet ist, möglich sein, abhängig davon, wie diese Daten (oder kopierte Fragmente dieser Daten) gespeichert werden und ob dieser Speicher ausführbar ist. Es gibt jedoch noch mehr Schwierigkeiten für einen Angreifer.
Erstens können das Padding und die Ausrichtung des Stacks durch den Compiler oder Abwehrmaßnahmen wie Stack‑Canaries jede Ausnutzung völlig unmöglich machen.
Zweitens gibt es nur einen Pfad zu ossl_punycode_decode() und dieser Pfad
verwendet einen Puffer auf dem Stack. Dies macht es unwahrscheinlich, dass das
Problem für gleichzeitige 4‑Byte‑Überläufe an verschiedenen Speicherorten
verwendet werden kann.
Punycode‑Strings haben im Grunde zwei Formen. Eine ist xn--c1yn36f (點看) und
eine andere ist xn--maccrthaigh-n7a (maccárthaigh). Der Teil, der nach dem
letzten -‑Trennzeichen kommt, ist eine 36‑äre Bootstring‑Kodierung aller
Unicode‑Codepunkte, die nicht grundlegendes gewöhnliches ASCII sind, zusammen
mit der String‑Position, um sie einzufügen. Wichtig für den Moment ist, dass
der Dekodierungsprozess in ossl_punycode_decode() zwei Werte erzeugt. Einer
ist 'n', der unsigned int‑Codepunktwert, der eingefügt werden soll, und der
andere ist 'i', die Position im Puffer, an der eingefügt werden soll.
Das Schreiben kann auf zwei verschiedene Arten erfolgen. Wenn i irgendwo in
der Mitte des Strings liegt, gibt es ein memmove(), das zuerst "Platz schafft",
indem es alles nach rechts um einen Slot kopiert:
memmove(pDecoded + i + 1, pDecoded + i,
(written_out - i) * sizeof *pDecoded);
und dann schreibt es n in den gerade geschaffenen Platz:
pDecoded[i] = n;
Wenn i am Ende des Strings liegt, hat das memmove() keine Wirkung, da der
letzte Parameter 0 ist. Die andere Zeile wird zu einem einfachen Anhängen.
Nun werden wir uns die drei verschiedenen Arten ansehen, wie eine Nutzlast 'P' in die Überlaufposition gelangen kann, und warum die Einschränkungen entstehen.
Der einfachste Weg, den Überlauf auszulösen, ist, einen Punycode‑String zu
erstellen, der 511 ASCII‑Zeichen und zwei Nicht‑ASCII‑Zeichen enthält. Die
Punycode‑Kodierung einer 513 Zeichen langen Zeichenkette wie "ÁÁAAAAAAAA...AAA"
würde dies tun. In diesem Fall wird passieren, dass, wenn written_out 510
ist, der Puffer wie folgt aussieht ...
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A', ]
// Indizes 0 1 ... 509 510 511
dies sind nur die grundlegenden ASCII‑Zeichen, die kopiert wurden. Dann
analysieren wir den Punycode‑Bootstring und fügen ein 'Á' an Position 0 ein.
Es könnte aber jede Position zwischen 0 und einschließlich 511 sein.
pDecoded = [ 'Á' , 'A' , ... , 'A' , 'A', 'A' ]
// Indizes 0 1 ... 509 510 511
wir wiederholen dies dann:
pDecoded = [ 'Á' , 'Á' , ... , 'A' , 'A', 'A' ] 'A'
// Indizes 0 1 ... 509 510 511 512
dies führt dazu, dass das gewöhnliche ASCII 'A' überläuft, wenn es
"rübergeschoben" wird. Die vier Byte lange Nutzlast wird in diesem Fall zu
0x00 0x00 0x00 0x41. Wie wir sehen werden, ist dies aufgrund der Funktionsweise
von Punycode die einzige Möglichkeit, einen Wert mit einem letzten Byte im
ASCII‑Bereich auszudrücken.
Wir müssen zwei Nicht‑ASCII‑Zeichen verwenden, weil es eine korrekte Grenzenprüfung für die Anzahl der grundlegenden Zeichen gibt, also muss diese kleiner als 512 sein.
Eine zusätzliche Einschränkung, dass der letzte Bytewert nicht 46 sein kann,
ergibt sich daraus, dass ossl_punycode_decode() auf den Teil eines Strings
aufgerufen wird, der einem Literal . vorausgeht. Punycode ist für
Domain‑Labels gedacht, die keine Punkte enthalten können.
Der nächst einfachste Weg, den Überlauf auszulösen, ist, einen 513 Zeichen langen String mit einem Nicht‑ASCII‑Zeichen ganz am Ende zu erstellen. So etwas wie "AAAAAAAAAA...AAÁ". In diesem Fall haben wir für unsere letzten zwei Schritte:
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A' ] // Indizes 0 1 ... 510 511
und
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A' ] 'Á'
// Indizes 0 1 ... 510 511
das Nicht‑ASCII‑Zeichen geht direkt in die Überlaufposition. Der OpenSSL‑Punycode‑Parser erzwingt nicht, dass der Überlaufwert hier tatsächlich ein gültiges Unicode‑Zeichen ist. Es ist mehr oder weniger ein binärer Dekodierungsprozess. Aber die Nuancen der Punycode‑Dekodierung bedeuten, dass Methode 2 nicht so flexibel ist, wie es zunächst scheint.
Im Punycode werden die Werte n und i beide als eine einzige
variable‑length‑Ganzzahl kodiert, die dann mit Basis 36 als ASCII kodiert
wird. Es mag unmöglich erscheinen, zwei unabhängige Zahlen als eine einzige
Ganzzahl zu kodieren, aber der clevere Trick von Punycode ist, die Länge des
Strings (bisher) als verstecktes Feld zu verwenden.
Angenommen, wir haben einen Punycode‑String mit 4 grundlegenden Zeichen und
einem nicht‑grundlegenden, wie AAÁAA. Das wird zunächst nur als die
grundlegenden Zeichen dargestellt ... AAAA. Der Unicode‑Wert von 'Á' ist 225
und seine Position im String ist 2. Der Trick ist, den Wert mit der Länge plus
eins zu multiplizieren und dann die Position zu addieren. Es wird also
((225 * (4+1)) + 2) = 1127, und so wird es kodiert (in variable‑length‑Basis
36).
Zum Dekodieren geht man den anderen Weg. 1127 / 5 = 225 und 1127 % 5 = 2. So
erhält man zwei Zahlen aus einer. Beachten Sie jedoch, dass mit zunehmender
Länge des Strings die Größe des Werts eingeschränkter wird, da sonst das
Vielfache nicht in ein unsigned int passt. Im Allgemeinen verliert man bei
einer Stringlänge von M log M Bits an Breite.
Wenn man die 512. Ganzzahl behandelt, verliert man 9 Bits an Breite. Bei Methode 2 wäre der höchste Wert einer scheinbar 32‑Bit‑Nutzlast tatsächlich nur 2^23. Nicht einmal drei volle Bytes. Methode 2 ist nicht optimal.
Um 4 Bytes Kontrolle zurückzugewinnen, ist der effizienteste Weg, das Nutzlastzeichen immer wieder zu wiederholen. Bisher habe ich zwei weitere relevante Details der Punycode‑Behandlung ausgelassen.
Das erste Detail ist, dass Nicht‑ASCII‑Zeichen nicht in der String‑Reihenfolge kodiert werden, sondern in aufsteigender Wertreihenfolge. Der String "ÉÁ" wird als "Á an Position 1, É an Position 0" kodiert, weil Á einen niedrigeren Wert (225) hat als É (233).
Das zweite Detail ist, dass Nicht‑ASCII‑Zeichen nicht als ihre literal Werte kodiert werden, sondern als ein Delta relativ zum zuletzt dekodierten Wert. Da der erste Wert keinen vorherigen Wert als Referenz hat, gibt es einen hartkodierten Startpunkt von 128.
Diese kleinen Nuancen machen Punycode sehr platzsparend, bedeuten aber auch, dass ein Nicht‑ASCII‑Zeichen einfach nicht auf einen Wert kleiner als 128 dekodiert werden kann. Das kleinste Delta ist 0, und es gibt keine Möglichkeit, ein negatives Delta auszudrücken. Wenn Sie also eine Zahl kleiner als 128 möchten, müssen Sie Methode 1 verwenden.
Es bedeutet auch, dass die beste Strategie für möglichst viel Kontrolle über die Nutzlast darin besteht, die Nutzlast zum einzigen Wert im gesamten String zu machen, da wir so die volle Breite von ihrer Position an der 0. Stelle in der Kodierung nutzen können. Der zu kodierende String sieht am Ende so aus:
[ 'P', 'P', ... 'P', 'P', 'P' ]
0 1 510 511 512
was von OpenSSL wie folgt dekodiert wird ...
pDecoded = [ 'P', 'P', ... 'P', 'P' ] 'P'
0 1 510 511 512
mit P an der Überlaufposition, fähig, jeden Wert zwischen 128 und (2^32 - 1)
darzustellen.
All dies erfordert einen nicht standardgemäßen Punycode‑Encoder, und ich habe ein Skript beigelegt, das eine Nutzlast entweder mit Methode 1 oder Methode 3 nach Bedarf erstellen kann.
Gibt es neben der Aktualisierung von OpenSSL weitere Abschwächungen?
Zertifikatsketten werden in den meisten Umgebungen im Klartext übertragen, und
eine bösartige Kette könnte blockiert werden, indem TCP‑Verbindungen
abgelehnt werden, die eine DER‑kodierte 1.3.6.1.5.5.7.8.9 NID in einem
SubjectAlternateName OtherName‑Feld enthalten.
Leider könnte dieses Feld beliebig zwischen zwei oder mehr Paketen aufgeteilt werden, und es wird wirklich eine Art zustandsbehafteter Mustervergleicher benötigt, um zu blockieren. Zertifikate können auch komprimiert werden, aber OpenSSL 3.0.x unterstützt derzeit keine Zertifikatskomprimierung.
Darüber hinaus sind Client‑Zertifikatsketten mit TLS 1.3 auf der Leitung verschlüsselt, und frühere Versionen von TLS unterstützen verschlüsselte Zertifikatsketten bei der Neuverhandlung einer bestehenden Verbindung. Dies wird manchmal für die vom Server initiierte Zertifikatsauthentifizierung verwendet. Ein Netzwerkfilter wird in diesen Fällen nicht wirksam sein.
Wie kann ich feststellen, ob ich openssl 3 in einem statisch gelinkten Binary verwende?
readelf -a [binary] | grep -i ossl_punycode_decode
sucht nach der anfälligen Funktion in einem statisch gelinkten Binary. Nur OpenSSL >= 3.0 enthält diese Funktion.