Codierung, Hashing und Verschlüsselung sind drei verschiedene Dinge
Eine Zeichenkette, die verwürfelt aussieht, ist keine geschützte Zeichenkette. Drei getrennte Operationen erzeugen unlesbare Ausgaben, und sie zu verwechseln ist der Ursprung echter Sicherheitslücken.
| Umkehrbar? | Braucht einen Schlüssel? | Wofür es da ist | |
|---|---|---|---|
| Codierung | Ja, von jedem | Nein | Bytes durch einen Kanal bringen, der sie roh nicht tragen kann |
| Hashing | Nein | Nein | Fingerabdrücke, Integritätsprüfungen, Nachschlageschlüssel |
| Verschlüsselung | Ja, mit dem Schlüssel | Ja | Geheimhaltung |
Codierung ist ein Wechsel des Alphabets. Base64, Prozentcodierung, Hex und HTML-Entitäten schreiben dieselbe Information anders auf, damit ein nachgelagerter Parser nicht daran erstickt. Es ist kein Geheimnis im Spiel, es gibt also nichts zu bewahren. Eine Caesar-Verschiebung gehört ebenfalls hierher: Das Werkzeug Caesar Cipher probiert alle 25 Verschiebungen auf einmal durch, was gut zusammenfasst, wie viel eine feste Substitution schützt. Hashing bildet beliebige Eingaben auf einen Digest fester Länge ab und lässt sich nicht rückwärts ausführen. Verschlüsselung ist die einzige der drei, die Vertraulichkeit bietet, und sie hat immer einen Schlüssel. Wenn du nicht auf den Schlüssel zeigen kannst, wird nichts verschlüsselt.
Base64 und was es kostet
Base64 teilt drei Bytes (24 Bit) in vier Sechsergruppen und schreibt jede Gruppe als ein Zeichen aus einem Alphabet mit 64 Zeichen. Vier Zeichen je drei Bytes heißt, dass die Ausgabe ein Drittel größer ist. Am stärksten fällt das beim Einbetten von Dateien ins Gewicht: Das Werkzeug File to Base64 erzeugt eine Data-URL, ein 300 KB großes Bild landet also mit rund 400 KB in deinem HTML und wird mit jeder Seite erneut geladen.
Wenn die Eingabe kein Vielfaches von drei Bytes ist, wird die letzte Gruppe mit = aufgefüllt:
"abc" -> YWJj (3 bytes, no padding)
"ab" -> YWI= (2 bytes, one =)
"a" -> YQ== (1 byte, two =)
Das Standardalphabet endet auf + und /, was in einer URL ein Problem ist, denn + wird in Formulardaten als Leerzeichen decodiert und / ist ein Pfadtrenner. Deshalb gibt es ein zweites Alphabet: Base64url tauscht + gegen - und / gegen _ und lässt die Auffüllung meist weg. Deshalb scheitert ein aus einer URL kopiertes Token manchmal in einem Decoder, der auf das Standardalphabet eingestellt ist. Der Base64 Encoder beherrscht beides und behandelt Unicode korrekt, was naive Implementierungen oft nicht tun: Text muss vor dem Base64-Schritt UTF-8-codiert werden.
Nichts davon ist eine Sicherheitsmaßnahme. Ein Wert, den ein Produkt in einer URL als „verschlüsselten“ Identifier bezeichnet, ist sehr oft Base64 einer schlichten Ganzzahl, die jeder in Sekunden lesen, ändern und neu codieren kann. Dasselbe gilt für die Nutzlast eines JSON Web Token: Der JWT Decoder liest sie ohne Schlüssel, denn es gibt nichts aufzuschließen.
Prozentcodierung, und welche Funktion du nimmst
Prozentcodierung ersetzt ein Byte durch % und zwei Hexziffern. Sie arbeitet auf Bytes statt auf Zeichen, Text außerhalb von ASCII wird also zuerst UTF-8-codiert: é wird zu %C3%A9.
Nicht reservierte Zeichen (A-Z, a-z, 0-9, -, ., _, ~) müssen nie codiert werden. Reservierte Zeichen (: / ? # [ ] @ ! $ & ' ( ) * + , ; =) sind die Trennzeichen, die einer URL ihre Struktur geben, und ob eines codiert werden muss, hängt davon ab, ob es als Trennzeichen oder als Daten dient. Das ist der Unterschied zwischen den beiden JavaScript-Funktionen:
encodeURI("https://ex.com/s?q=cats & dogs")
// https://ex.com/s?q=cats%20&%20dogs the & still splits the query
encodeURIComponent("cats & dogs")
// cats%20%26%20dogs safe as a single value
encodeURI ist für eine ganze URL gedacht, die du zulässig machen willst, es lässt Trennzeichen also unangetastet. encodeURIComponent ist für einen einzelnen Wert gedacht, der in ein Pfadsegment oder einen Query-Parameter eingesetzt wird, es codiert sie also. Nimm die zweite für alles, was du einsetzt, und beachte, dass sie !, ', (, ) und * weiterhin stehen lässt.
Dann ist da noch das Pluszeichen. Formular-Bodys, die als application/x-www-form-urlencoded gesendet werden, codieren ein Leerzeichen als +, und die Konvention ist in Query-Strings übergeschwappt, die meisten Server decodieren + in einer Query also als Leerzeichen. Ein wörtliches Plus muss als %2B gesendet werden, und deshalb kommt alex+billing@example.com so oft als alex billing@example.com an, mit zerstörtem Tag. In einem Pfadsegment dagegen ist + einfach ein Plus, und ein Leerzeichen ist %20. Der URL Encoder macht das sichtbar, wenn ein Wert einen Durchlauf übersteht und beim nächsten zerbricht.
Escaping hängt davon ab, wo der Wert landet
Es gibt keine einzelne Form einer Zeichenkette, die „fürs Web escaped“ wäre. Der Kontext entscheidet.
| Kontext | Richtige Behandlung |
|---|---|
| HTML-Text | Entitäten für &, < und > |
| Attribut in Anführungszeichen | Entitäten für & und für das umschließende Anführungszeichen, und setze das Attribut immer in Anführungszeichen |
Innerhalb von <script> | JavaScript-String-Escaping, keine Entitäten |
URL in href oder src | Wert prozentcodieren, fürs Attribut escapen, dann das Schema prüfen |
| CSS-Wert | CSS-Escaping, und baue url() nie aus Benutzereingaben |
Der Skriptfall erwischt viele. Skriptinhalte werden nicht als Entitäten decodiert, eine dort geschriebene Entität bleibt also wörtlich stehen, und der HTML-Parser schließt den Block bei der Zeichenfolge </script, wo immer sie auftaucht, ob in einer Zeichenkette in Anführungszeichen oder nicht:
<script>var s = "</script>";</script> <!-- block ends early -->
<script>var s = "<\/script>";</script> <!-- correct -->
Auch bei URLs reicht Escaping allein nicht. Ein Wert, der mit javascript: beginnt, wird beim Klick ausgeführt, so sorgfältig er auch codiert wurde; prüfe das Schema also gegen eine Erlaubnisliste. Das Werkzeug HTML Entities deckt den Text- und den Attributfall ab und decodiert Entitäten auch zurück, was man meist braucht, wenn eine API etwas doppelt zu &amp; escaped hat.
Hashing: welcher Algorithmus, und wofür
| Algorithmus | Digest | Status |
|---|---|---|
| MD5 | 128 Bit | Gebrochen. Kollisionen sind trivial |
| SHA-1 | 160 Bit | Gebrochen. Kollisionen mit gewähltem Präfix sind praktisch machbar und bezahlbar |
| SHA-256, SHA-512 | 256, 512 Bit | Gut für Integrität und Signaturen |
| bcrypt, scrypt, Argon2 | unterschiedlich | Nur Passwörter |
MD5 und SHA-1 scheitern an der Kollisionsresistenz, das heißt, ein Angreifer kann zwei verschiedene Eingaben mit demselben Digest konstruieren. Überall dort, wo ein Hash stellvertretend für ein Dokument steht, sind sie unbrauchbar: Signaturen, Zertifikate, inhaltsbasierte Adressierung, Deduplizierung von allem, was ein Angreifer liefert. Für Urbilder ist keiner von beiden gebrochen, eine alte MD5-Prüfsumme fängt also weiterhin zufällige Beschädigungen ab, aber keiner gehört in neue Arbeit. Nimm SHA-256, das der Hash Generator sowohl für Dateien als auch für Text berechnet.
Passwörter sind ein anderes Problem, und ein schneller Hash ist genau deshalb das falsche Werkzeug, weil er schnell ist. Handelsübliche Hardware schafft Milliarden SHA-256-Operationen pro Sekunde, eine geleakte Tabelle mit SHA-256-Passworthashes ist also eine geleakte Tabelle mit Passwörtern. bcrypt, scrypt und Argon2id sind absichtlich langsam und einstellbar, mit einem Arbeitsfaktor (und bei den letzten beiden zusätzlich Speicherkosten), den du anhebst, sobald die Hardware besser wird, und jeder legt einen eindeutigen Zufalls-Salt in seiner Ausgabe ab.
Ein Hash ist außerdem keine Signatur. Um zu belegen, dass eine Nachricht von jemandem mit einem Schlüssel stammt, nimm HMAC-SHA-256, statt das Geheimnis und die Nachricht aneinandergeklebt zu hashen, was anfällig für Length-Extension-Angriffe ist.
Was ein Hash nicht kann
Ein Hash lässt sich nicht durch Rechnen umkehren. Er lässt sich durch Suchen umkehren, sobald die Menge der möglichen Eingaben klein genug zum Aufzählen ist: Hashe jede vierstellige PIN, und du hast sofort alle zehntausend Digests, und dasselbe gilt für jede Postleitzahl, Telefonnummer oder Adresse in einer geleakten Liste. Einen Identifier zu hashen anonymisiert ihn nicht.
Salting, also ein eindeutiger Zufallswert, der bei jedem Datensatz gespeichert und in die Eingabe eingemischt wird, macht ein einzelnes Ziel nicht schwerer zu erraten, zwingt einen Angreifer aber dazu, jeden Datensatz einzeln anzugreifen, und macht vorberechnete Tabellen wertlos. Bei Werten mit geringer Entropie ist ein Pepper (ein geheimer Schlüssel, der außerhalb der Datenbank liegt) der Teil, der wirklich hilft.
Eine unbekannte Zeichenkette erkennen
| Form | Erkennungsmerkmal | Beispiel |
|---|---|---|
| Hex | Nur 0-9a-f, gerade Länge. 32 Zeichen sind MD5, 40 sind SHA-1, 64 sind SHA-256 | 5d41402abc4b2a76b9719d911017c592 |
| Base64 | Groß und klein gemischt mit + und /, Länge ein Vielfaches von 4, endet eventuell auf = oder == | SGVsbG8sIHdvcmxkIQ== |
| Base64url | Dasselbe mit - und _, meist ohne Auffüllung | eyJzdWIiOiIxMjM0NSJ9 |
| Prozentcodiert | % gefolgt von zwei Hexziffern | a%20b%26c |
| JWT | Drei Base64url-Blöcke, durch Punkte getrennt, beginnend mit eyJ | eyJhbGciOi... |
| bcrypt | Genau 60 Zeichen, beginnend mit $2a$, $2b$ oder $2y$ und einem Kostenwert | $2b$12$... |
| Argon2 | $argon2id$v=19$m=...,t=...,p=...$salt$hash |
eyJ lohnt sich zu merken: Es ist Base64 von {", jeder Block, der so beginnt, ist also ein codiertes JSON-Objekt, ob Token oder nicht.
Erfolgreiches Decodieren ist kein Beweis dafür, dass die Vermutung richtig war, denn Base64 macht aus jeder Eingabe Bytes. Prüfe, ob das Ergebnis plausibler Text ist oder mit einer bekannten Dateisignatur beginnt: Base64, das mit iVBORw0KGgo beginnt, ist ein PNG, /9j/ ein JPEG, JVBERi0 ein PDF und UEsDB ein ZIP. Wenn die Bytes nach nichts aussehen, schick die Ausgabe durch das Werkzeug Text to Binary, um die Codes direkt zu lesen, oder prüfe, ob der Wert prozentcodiert war, bevor er Base64-codiert wurde, eine verbreitete doppelte Umhüllung.