UTF-8 BOM, Zero-Width-Joiner & Co: Wenn unsichtbare Zeichen deinen Code crashen

Drei Stunden Debugging, alles sieht perfekt aus, am Ende war es ein einziges unsichtbares Byte. Wer einmal eine SQL-Query, ein YAML-Manifest oder ein Python-Modul gegen ein hartnäckiges »invalid character«-Bug verteidigt hat, kennt das Gefühl. UTF-8 hat unzählige Zeichen, die im Editor nichts anzeigen — aber das Parsing zerschießen, Tests fehlschlagen lassen oder Sicherheitslücken öffnen. Die wichtigsten unsichtbaren Unicode-Übeltäter, woher sie kommen und wie du sie zuverlässig findest.

BOM: Das berüchtigte erste Byte

Das Byte Order Mark ist das prominenteste unsichtbare Zeichen — und für viele Sprachen der häufigste Stolperstein. In UTF-8 ist es die Sequenz EF BB BF (Hex), die am Anfang einer Datei stehen kann. Im Editor sieht man nichts. In Notepad++ erkennt man »UTF-8-BOM« im Status. Das Problem: BOM wurde ursprünglich für UTF-16 entwickelt, um die Byte-Reihenfolge zu signalisieren (Big-Endian vs. Little-Endian). Bei UTF-8 ist die Byte-Reihenfolge irrelevant — das BOM ist überflüssig, aber Windows-Editoren fügen es trotzdem oft hinzu.

Konkrete Schmerzen: PHP-Dateien mit BOM senden vor jedem <?php drei Bytes Output — dann scheitert jedes header() mit »headers already sent«. Bei JSON-Dateien lehnen viele Parser das BOM ab. Shell-Skripte mit Shebang #!/bin/bash nach BOM funktionieren gar nicht, weil der Kernel das BOM für magische Bytes hält und keine Skripte mit dieser Signatur ausführt. CSV-Dateien mit BOM werden von älteren Excel-Versionen wiederum benötigt, damit Umlaute korrekt angezeigt werden — ein klassisches Henne-Ei-Problem.

Zero-Width-Joiner und Zero-Width-Space

Eine ganze Familie von »null-breiten« Zeichen, die einst für komplexe Schriftsysteme erfunden wurden. Zero-Width-Space (U+200B, ZWSP) wurde geschaffen, um in chinesischen, japanischen und thailändischen Texten Zeilenumbruch-Möglichkeiten zu markieren, ohne sichtbare Lücken zu erzeugen. Zero-Width-Joiner (U+200D, ZWJ) verbindet Glyphen — er ist heute der Schlüssel zu Emoji-Kombinationen wie der bekannten Familie aus Vater, Mutter, Tochter und Sohn, die intern aus vier Personen-Emojis plus drei ZWJs besteht.

Wenn solche Zeichen in Variablennamen, Slugs oder Datei-Namen landen, beginnt das Chaos. Ein Slug hello-world mit ZWSP zwischen l und l sieht im Browser identisch aus, hat aber einen anderen Hash, eine andere SQL-Match-Eigenschaft. Wer auf User-Eingaben prüft, kann zwei scheinbar gleiche Strings als ungleich erleben. Wer Suchmaschinen-Indexierung optimiert, sieht plötzlich 404er, obwohl die URL »passt«.

Non-Breaking Space — der Untote

Das Non-Breaking Space (U+00A0, NBSP) ist sichtbar als Leerzeichen, aber syntaktisch ein anderes Zeichen als das ASCII-Space (U+0020). Wer in Word einen Text mit Tabulator-ähnlichen Strukturen schreibt, bekommt oft NBSPs eingestreut. Beim Kopieren in eine IDE, Webform oder SQL-Konsole überleben sie — und brechen dann Regex-Matches, Trim-Operationen und String-Vergleiche.

Ein Klassiker im Web: Mark-Down-Editoren rendern NBSP korrekt, aber pre-Tags zeigen den Unterschied nicht. Wer JSON-Konfigurationsdateien aus einem Word-Dokument extrahiert, sieht eine valide JSON-Datei, deren json.loads() mit »Expected ',' delimiter« scheitert. Die Lösung ist meist ein gezieltes Suchen-und-Ersetzen aller NBSPs durch normales Space — was in jedem ordentlichen Editor mit Regex möglich ist (  finden, durch U+0020 ersetzen).

Right-to-Left-Override: Die unsichtbare Bedrohung

Ein Sicherheits-Klassiker. Right-to-Left-Override (U+202E, RLO) und das passende Left-to-Right-Override (U+202D, LRO) ändern die Anzeigerichtung von Text. Ursprünglich für hebräische und arabische Schrift gedacht, lassen sie sich missbrauchen, um Dateinamen zu manipulieren. Ein Angreifer kann eine Datei document‮gpj.exe nennen — der Explorer zeigt sie als documentexe.jpg an. Doppelklick öffnet eine ausführbare Datei, das Opfer sah ein Bild.

Diese Technik wurde in der Vergangenheit für Phishing in E-Mails und für Tarnkappen-Malware eingesetzt. Moderne Antiviren-Lösungen warnen vor RLO in Dateinamen, aber nicht alle Systeme. Wer als Web-Entwickler User-Uploads verarbeitet, sollte Dateinamen normalisieren: unicodedata.normalize('NFC', filename) in Python, dann alle Bytes außer ASCII-Buchstaben, Ziffern, Bindestrich und Punkt entfernen.

Soft Hyphen und Word Joiner

Der Soft Hyphen (U+00AD, SHY) ist ein optionaler Bindestrich, der nur sichtbar wird, wenn der Text am Zeilenende umbricht. In Word-Dokumenten und Latex-Quellen taucht er oft auf, um lange deutsche Komposita zu trennen (»Kraft-fahrzeug-haftpflicht-versicherung«). Wenn so ein Text in eine SQL-INSERT wandert, hast du plötzlich Werte in der Datenbank, die nicht mit der gefundenen Suchanfrage matchen.

Der Word Joiner (U+2060, WJ) verhindert das Gegenteil: Er sagt »diese zwei Wörter dürfen nicht durch einen Umbruch getrennt werden«. Selten, aber ähnlich tückisch. Beide Zeichen sind im modernen Editor mit »Show invisible characters« sichtbar — wer im VS Code arbeitet, sollte die Erweiterung highlight-bad-chars installieren, die alle vermeintlich unsichtbaren Zeichen farbig markiert.

Combining Characters: Wenn é nicht é ist

Ein subtileres Problem: Unicode erlaubt zwei Schreibweisen für viele akzentuierte Zeichen. Das »é« kann entweder als Einzelzeichen U+00E9 (Latin Small Letter E With Acute) gespeichert werden — oder als Kombination aus U+0065 (e) plus U+0301 (Combining Acute Accent). Beide rendern identisch, sind aber binär unterschiedlich. Bei String-Vergleich, Hash-Berechnung und Datenbank-Indexierung führen sie zu Inkonsistenzen.

Die Lösung heißt Unicode-Normalisierung. Vier Formen: NFC (Composed) wandelt alles in die kompakte Form. NFD (Decomposed) wandelt alles in Buchstabe-plus-Kombinierer-Form. NFKC und NFKD sind die »Kompatibilitäts«-Varianten, die auch Ligaturen und Stilvarianten zusammenführen. Faustregel: Für die Datenbank-Speicherung NFC verwenden, für Volltext-Suche NFKD oder NFKC. Vor jedem String-Vergleich oder Hash zwischen User-Eingaben und Datenbank-Werten beide Seiten normalisieren.

Wie du sie findest

Drei Methoden, je nach Workflow. Erstens mit Hex-Editor oder xxd auf der Kommandozeile: xxd file.txt | head zeigt die ersten Bytes — BOM ist sofort erkennbar als EF BB BF am Anfang. Zweitens mit einem Linter oder Static-Analyzer, der Unicode-Hygiene prüft. shellcheck warnt vor BOM in Bash-Skripten, pylint hat einen --check-quote-consistency-Modus, viele Linter haben mittlerweile »invisible character« Regeln.

Drittens mit einem Regex. Das Pattern [​-‍⁠ ] findet die häufigsten Verdächtigen: ZWSP, ZWNJ, ZWJ, Word Joiner, BOM und NBSP. In VS Code mit Find in Files und Regex-Modus durchsucht du dein gesamtes Projekt in Sekunden. Wer eine umfassendere Liste will, findet auf graphemica.com und der offiziellen Unicode-Webseite alle Categories.

Praxis-Strategien: Wo BOM-Killer und Normalisierung hingehören

In jeder Web-Applikation, die User-Eingaben verarbeitet, sollten zwei Filter im Eingabe-Sanitizer stecken. Filter 1: BOM-Stripper. Beim ersten Byte einer hochgeladenen Datei prüfen, ob es EF BB BF ist — wenn ja, abschneiden. Filter 2: Unsichtbare-Zeichen-Filter. Vor dem Speichern in der Datenbank alle Strings durch eine Normalisierung jagen, alle Zero-Width-Zeichen entfernen (es sei denn, die Anwendung verarbeitet Emojis, die ZWJ brauchen).

Konkrete Code-Snippets sind kurz. In Python: text.encode('utf-8-sig').decode('utf-8') entfernt BOM. re.sub(r'[​-‍⁠]', '', text) entfernt die wichtigsten unsichtbaren Zeichen. unicodedata.normalize('NFC', text) normalisiert. In PHP: preg_replace('/^\\xEF\\xBB\\xBF/', '', $text) für BOM, Normalizer::normalize($text, Normalizer::FORM_C) aus der Intl-Erweiterung für die Normalisierung. In JavaScript ab ES6: text.normalize('NFC') und text.replace(/[​-‍⁠]/g, '').

Spezial-Fall: Bidirektional-Trojaner in Source Code

Eine 2021 vom Cambridge-Forscher Nicholas Boucher veröffentlichte Sicherheitslücke (CVE-2021-42574, »Trojan Source«) zeigt, wie Bidi-Override-Zeichen in Source-Code Compiler täuschen können: Der Compiler sieht eine bestimmte Logik, der menschliche Code-Reviewer eine andere. Konkretes Beispiel: Eine if-Bedingung enthält ein RLO, das die Anzeige des Codes ändert — eine Sicherheitsprüfung scheint vorhanden, ist es im kompilierten Bytecode aber nicht.

Seit 2022 erkennen die meisten Compiler und Linter solche Muster. GitHub hat 2022 eine Warnung in der Pull-Request-Ansicht eingeführt. Trotzdem schlüpfen die Zeichen immer noch in Code-Bases. Faustregel: Code-Files sollten kein Unicode-Zeichen außerhalb des ASCII-Bereichs enthalten, außer in Strings und Kommentaren. Wer Variablennamen mit kyrillischen, griechischen oder japanischen Buchstaben anlegt, lädt Probleme ein.

Was du auf CalcSI testen kannst

Wenn du verdächtige Zeichen in einem Text findest, hilft der Wort- und Zeichen-Zähler beim ersten Check — wenn die angezeigte Zahl von der erwarteten abweicht, sind unsichtbare Zeichen drin. Der Base64-Konverter ist nützlich, weil das Base64 jedes Byte sichtbar macht. Eine String mit BOM hat einen sehr charakteristischen Base64-Anfang (77u/ für die ersten drei Bytes). Der Regex-Tester erlaubt es, die oben gezeigten Patterns auf eigenen Text anzuwenden.

Wer eine binärgenaue Identifikation braucht: Der Hash-Generator berechnet MD5, SHA-1 und SHA-256 für jeden String. Zwei Strings, die optisch identisch aussehen, aber andere Hashes haben, enthalten unsichtbare Zeichen. Eine sehr schnelle Methode, um Datensätze zu vergleichen, wenn dir der Editor nicht hilft.

Schluss-Faustregeln

Drei Regeln, die viel Debug-Zeit sparen: Erstens nie aus Word, Google Docs oder Confluence direkt in Code-Editor oder Datenbank kopieren. Immer den Umweg über einen reinen Text-Editor (Notepad, gedit, vim) gehen. Zweitens in der Entwicklungsumgebung den »Show invisible characters«-Modus aktivieren. Einmal eingerichtet, siehst du jedes Unicode-Zeichen außerhalb des ASCII-Bereichs sofort. Drittens User-Eingaben immer normalisieren, bevor du sie speicherst oder vergleichst. NFC ist der Standard, der für 95 % der Anwendungen funktioniert.

Wer diese drei Regeln verinnerlicht, spart sich die nächsten »alles sieht richtig aus, funktioniert aber nicht«-Stunden. Und wer doch eine erwischt: Den Hexdump nicht vergessen — das erste Byte verrät meistens schon, was schiefläuft.

Comments