LED-Brille (Sonhomay LED Glasses)
AES-ECB-Verschlüsselung mit hardcodiertem Schlüssel und 5-stufigem Handshake-Protokoll
Lernziel
Nach dieser Seite verstehst du, wie ein reales IoT-Gerät — die Sonhomay LED-Brille — von Grund auf analysiert wurde: vom GATT-Aufbau über die APK-Dekompilierung und das Reverse Engineering der nativen AES-Bibliothek bis hin zur vollständigen Rekonstruktion des 5-stufigen Handshake-Protokolls. Du kannst die gefundenen Schwachstellen einordnen, bewerten und den Proof-of-Concept-Code nachvollziehen.
Das Gerät
Die Sonhomay LED-Brille ist eine programmierbare LED-Matrix-Brille, die über Bluetooth Low Energy gesteuert wird. Die zugehörige Android-App (com.pinkysinye-eho.funky-glasses-plus) erlaubt es, beliebigen Text auf der Brille anzuzeigen. Das Gerät richtet sich an Endverbraucher und ist auf bekannten Online-Plattformen erhältlich.
Auf den ersten Blick wirkt die Brille harmlos — eine bunte Spielerei. Unter der Haube steckt jedoch ein vollständiges BLE-Kommunikationsprotokoll mit AES-128-Verschlüsselung. Wie sicher ist diese Implementierung wirklich?
Was ist eine LED-Matrix?
Eine LED-Matrix ist ein Raster aus einzeln steuerbaren Leuchtpunkten. Die Brille hat ein 14x12-Raster: 14 Spalten, 12 Zeilen. Jedes Zeichen (zum Beispiel der Buchstabe "A") wird als Bitmuster in diesem Raster kodiert — 2 Bytes pro Spalte, 24 Bytes pro Zeichen.
Phase 1: GATT-Struktur und APK-Analyse
GATT-Aufbau
Das Gerät verwendet einen herstellerspezifischen GATT-Service auf Basis der UUID 0xFFF0. Über die App-Analyse und einen BLE-Scanner (nRF Connect) wurden drei Charakteristiken identifiziert:
| Funktion | UUID (Kurzform) | Handle | Permissions |
|---|---|---|---|
| Control | ...9600 | 0x0012 | WRITE, WRITE_NO_RESP |
| Data | ...960A | 0x0015 | WRITE, WRITE_NO_RESP |
| Notification | ...9601 | 0x001B | NOTIFY |
Das Kommunikationsmuster ist klar: Befehle gehen an die Control-Charakteristik, Nutzdaten an die Data-Charakteristik, und das Gerät antwortet über Notifications.
GATT Profile
APK-Analyse mit JADX
Die APK der Funky Glasses Plus App enthält 155 Java-Klassen. Die Suche nach Cipher.getInstance führt direkt zur Klasse com.pinkysinye-eho.funkyglassesplus.model.data.Agreement:
public static byte[] getEncryptData(byte[] data) {
aes.cipher(data, data);
return data;
}Der Aufruf aes.cipher() delegiert an die native Bibliothek libAES.so. Das heißt: Der eigentliche AES-Schlüssel steckt nicht im Java-Code, sondern in der nativen Bibliothek unter lib/arm64-v8a/libAES.so. Das ist ein bewusstes Versteckspiel — aber kein echtes Sicherheitsmerkmal.
Warum native Bibliotheken?
Viele Entwickler verlagern sensible Logik in native .so-Bibliotheken, weil dekompilierter Java-Code gut lesbar ist. Native Bibliotheken sind schwieriger zu lesen, aber mit Werkzeugen wie Ghidra trotzdem analysierbar. Es ist Verschleierung (Obfuskation), keine echte Sicherheit.
Phase 2: Traffic-Analyse
Ein PCAP-Mitschnitt des BLE-Traffics zwischen App und Brille zeigt verschlüsselte Pakete. Ohne den Schlüssel sind sie auf den ersten Blick unleserlich. Die Analyse liefert aber die Struktur des Protokolls: Zuerst ein Startbefehl, dann eine Bestätigung vom Gerät, dann die eigentlichen Daten, dann ein Abschlussbefehl und noch eine Bestätigung. Das ist der 5-stufige Handshake.
Wichtig: Die Pakete haben alle exakt 16 Bytes — das ist die AES-Blockgröße. Das bestätigt AES im ECB- oder CBC-Modus. CBC würde einen zufälligen Initialisierungsvektor (IV) erzeugen; da die Pakete reproduzierbar identisch sind, handelt es sich um ECB.
ECB — der unsicherste AES-Modus
AES-ECB (Electronic Codebook Mode) verschlüsselt jeden 16-Byte-Block unabhängig mit demselben Schlüssel. Das bedeutet: Identischer Klartext ergibt immer identischen Geheimtext. Ein Angreifer kann dadurch Muster erkennen, ohne den Schlüssel zu kennen — ähnlich wie ein Bild, das man mit einem simplen Mosaik zensiert: Die Umrisse bleiben erkennbar.
Phase 3: Reverse Engineering mit Ghidra
JNI-Einstiegspunkt finden
Ghidra (Version 11.1.2) öffnet libAES.so und listet alle Funktionen auf. Die JNI-Exportfunktion (JNI = Java Native Interface, die Brücke zwischen Java und nativem C-Code) heißt:
Java_csh_tiro_cc_aes_keyExpansionDefault
Adresse: 0x00101278
JNI-Exportfunktionen folgen immer dem Schema Java_<Paketname>_<Klassenname>_<Methodenname>. Dieser Name verrät: Die Funktion initialisiert den AES-Schlüssel (Key Expansion = Schlüsselerweiterung, ein Schritt des AES-Algorithmus).
Den Schlüssel lokalisieren
In der Ghidra-Disassembly sieht man folgende Sequenz in ARM64-Assembler:
; ARM64-Assembler (vereinfacht)
ldr x1, [PTR_key_00112ff0] ; Lade Zeiger auf Schlüsseldaten
ldr q0, [x1] ; Lade 128 Bit (16 Bytes) in NEON-Register q0Der GOT-Eintrag (PTR_key_00112ff0) zeigt auf die .data-Sektion bei Adresse 0x00113020. Dort liegen die 16 Bytes:
34 52 2A 5B 7A 6E 49 2C 08 09 0A 9D 8D 2A 23 F8
Das ist der AES-128-Schlüssel — 16 Bytes, hardcodiert in der .data-Sektion der Bibliothek.
Was ist ein NEON-Register?
ARM64-Prozessoren (wie in modernen Android-Geräten) haben spezielle 128-Bit-Register (q0 bis q31) für die NEON-SIMD-Einheit. Sie wurden ursprünglich für schnelle Multimedia-Berechnungen entwickelt. Da AES genau mit 128-Bit-Blöcken arbeitet, ist es ein Einzeiler: Ein einziger ldr q0 lädt den kompletten Schlüssel auf einmal.
Verifikation
Um sicherzugehen, dass der extrahierte Schlüssel korrekt ist, wird der DATS-Befehl aus dem PCAP-Mitschnitt manuell nachverschlüsselt:
Klartext (DATS-Befehl für "HELLO", 38 Bytes Nutzdaten):
07 44 41 54 53 01 00 26 00 00 00 00 00 00 00 00
AES-ECB-Verschlüsselung mit dem extrahierten Schlüssel:
e703dd68d7c0bb9ca75f0140df55e219
Dieser Wert stimmt exakt mit dem ersten Paket im PCAP-Mitschnitt überein. Der Schlüssel ist korrekt.
Phase 4: Protokoll-Rekonstruktion
Der 5-stufige Handshake
Das vollständige Protokoll zum Senden eines Textes besteht aus fünf Schritten:
| Schritt | Richtung | Charakteristik | Inhalt |
|---|---|---|---|
| 1. DATS | App → Brille | Control (0x0012) | Transferstart: Datengröße ankündigen |
| 2. DATSOK | Brille → App | Notification (0x001B) | Gerät bereit |
| 3. DATA | App → Brille | Data (0x0015) | Zeichendaten in 16-Byte-Paketen |
| 4. DATCP | App → Brille | Control (0x0012) | Transferende |
| 5. DATCPOK | Brille → App | Notification (0x001B) | Abschlussbestätigung |
DATS-Befehlsformat (Schritt 1):
[0x07, 'D', 'A', 'T', 'S', 0x01, SIZE_HI, SIZE_LO, 0x00, ...]
- Byte 0:
0x07(Befehlslänge) - Bytes 1–4: ASCII-String
DATS - Byte 5:
0x01(fester Wert) - Bytes 6–7: Datengröße als Big-Endian uint16 (z. B.
0x00 0x26= 38 Bytes) - Bytes 8–15: Nullbytes (Padding auf 16 Bytes = AES-Blockgröße)
Zeichenkodierung
Jedes Zeichen wird als 14×12-LED-Matrix kodiert: 14 Spalten × 2 Bytes pro Spalte = 28 Bytes, zuzüglich Metadaten ergibt das etwa 24 Bytes pro Zeichen (gerundet auf Effizienz). Der Text "HELLO" (5 Zeichen) ergibt 52 Bytes Nutzdaten.
Die Datenpakete (Schritt 3) folgen dem Format:
- 1 Byte: Länge des folgenden Payloads (1–15 Bytes)
- Bis zu 15 Bytes: Zeichendaten
- Auffüllen auf 16 Bytes mit Nullbytes (AES-Blockgröße)
- Zwischen jedem Paket: 50 ms Pause (vom Gerät benötigt)
Phase 5: Proof of Concept
Der folgende Python-Code demonstriert die vollständige Kontrolle über die LED-Brille ohne Kenntnis der App-Logik — nur mit dem extrahierten Schlüssel und dem rekonstruierten Protokoll:
import asyncio
from bleak import BleakClient
from Crypto.Cipher import AES
# Extrahierter AES-128-Schlüssel (aus libAES.so, .data-Sektion)
AES_KEY = bytes([0x34, 0x52, 0x2A, 0x5B, 0x7A, 0x6E, 0x49, 0x2C,
0x08, 0x09, 0x0A, 0x9D, 0x8D, 0x2A, 0x23, 0xF8])
# GATT-UUIDs (aus APK-Analyse)
CTRL_UUID = "d44bc439-abfd-45a2-b575-925416129600"
DATA_UUID = "d44bc439-abfd-45a2-b575-92541612960a"
NOTIF_UUID = "d44bc439-abfd-45a2-b575-925416129601"
def aes_ecb_encrypt(data: bytes) -> bytes:
"""Verschlüsselt einen 16-Byte-Block mit AES-ECB."""
assert len(data) == 16, "AES-ECB erwartet genau 16 Bytes"
return AES.new(AES_KEY, AES.MODE_ECB).encrypt(data)
def encode_text(text: str) -> bytes:
"""
Kodiert einen Text in das 14x12-LED-Matrix-Format.
Pseudocode: In der echten Implementierung wird jedes Zeichen
als Spalten-Bitmuster aus einer Zeichentabelle gelesen.
Hier als Platzhalter mit festen Testdaten.
"""
# Platzhalter — echte Implementierung liest aus Zeichentabelle
return bytes([0xAA] * (len(text) * 10)) # ~10 Bytes/Zeichen (vereinfacht)
async def send_text(client: BleakClient, text: str) -> None:
encoded = encode_text(text)
size = len(encoded)
# Schritt 1: DATS — Transferstart ankündigen
dats_plain = bytes([
0x07, # Befehlslänge
ord('D'), ord('A'), ord('T'), ord('S'), # "DATS"
0x01, # fester Wert
(size >> 8) & 0xFF, # Größe High-Byte
size & 0xFF, # Größe Low-Byte
]) + bytes(8) # Padding auf 16 Bytes
await client.write_gatt_char(CTRL_UUID, aes_ecb_encrypt(dats_plain))
print(f"[1] DATS gesendet (Größe: {size} Bytes)")
# Schritt 2: Auf DATSOK warten (Notification vom Gerät)
# (wird automatisch über den Notification-Handler empfangen)
# Schritt 3: DATA — Zeichendaten in 16-Byte-Paketen senden
for i in range(0, size, 15):
chunk = encoded[i:i + 15]
padded = bytes([len(chunk)]) + chunk + bytes(15 - len(chunk))
await client.write_gatt_char(DATA_UUID, aes_ecb_encrypt(padded))
await asyncio.sleep(0.05) # 50 ms Pause — Gerät benötigt Zeit
# Schritt 4: DATCP — Transferende signalisieren
datcp_plain = bytes([
0x07,
ord('D'), ord('A'), ord('T'), ord('C'), ord('P'),
0x00, 0x00,
]) + bytes(8)
await client.write_gatt_char(CTRL_UUID, aes_ecb_encrypt(datcp_plain))
print("[4] DATCP gesendet — Transfer abgeschlossen")
async def main():
target_address = "AA:BB:CC:DD:EE:FF" # MAC-Adresse der Brille
async with BleakClient(target_address) as client:
print(f"Verbunden mit {target_address}")
await send_text(client, "HELLO")
if __name__ == "__main__":
asyncio.run(main())Nur für eigene Geräte
Dieser Code darf ausschließlich mit Geräten verwendet werden, die du selbst besitzt. Das unautorisierte Senden von BLE-Befehlen an fremde Geräte kann nach §303b StGB (Computersabotage) strafbar sein. Lies den Rechtlichen Rahmen vor dem Einsatz.
Warum ist AES-ECB unsicher, obwohl AES selbst ein starker Algorithmus ist?
Schwachstellen-Analyse
Übersicht
| ID | Bezeichnung | CVSS | Schweregrad | OWASP |
|---|---|---|---|---|
| VULN-2026-001 | Hardcodierter AES-Schlüssel | 8.1 | HIGH | I1, I5 |
| VULN-2026-002 | Unsicherer AES-ECB-Modus | 5.9 | MEDIUM | I7 |
| VULN-2026-003 | Fehlende Authentifizierung | 8.1 | HIGH | I3 |
| VULN-2026-004 | Schwaches Pairing (Just Works) | 6.5 | MEDIUM | — |
| VULN-2026-005 | Fehlender Replay-Schutz | 6.5 | MEDIUM | — |
VULN-2026-001: Hardcodierter AES-Schlüssel
CVSS-Vektor: AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N = 8.1 HIGH
Der AES-128-Schlüssel 34 52 2A 5B 7A 6E 49 2C 08 09 0A 9D 8D 2A 23 F8 ist statisch in der .data-Sektion von libAES.so eingebettet. Jede Instanz der App weltweit verwendet denselben Schlüssel. Ein einziger Ghidra-Analyse-Lauf genügt, um ihn zu extrahieren.
Angriffsablauf:
- APK aus dem Play Store laden (öffentlich zugänglich)
lib/arm64-v8a/libAES.soextrahieren- Ghidra öffnen → JNI-Funktion
keyExpansionDefaultfinden → Schlüssel lesen - Beliebige BLE-Befehle an jede Brille dieses Modells senden
OWASP Mobile Top 10: I1 (Improper Credential Usage), I5 (Insecure Communication)
VULN-2026-002: Unsicherer AES-ECB-Modus
CVSS-Vektor: AV:A/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:N = 5.9 MEDIUM
AES im ECB-Modus enthüllt Datenmuster. Ein Angreifer, der zwei identische DATS-Befehle im Netzwerk sieht, weiß: Dieselbe Aktion wurde zweimal ausgeführt. Mit bekanntem Klartext (Known-Plaintext-Angriff) können Rückschlüsse auf die Protokollstruktur gezogen werden, ohne den Schlüssel zu kennen.
OWASP Mobile Top 10: I7 (Insufficient Binary Protections)
VULN-2026-003: Fehlende Authentifizierung auf Protokollebene
CVSS-Vektor: AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N = 8.1 HIGH
Das Protokoll enthält kein Challenge-Response-Verfahren. Die Brille prüft nicht, ob der Sender die App oder ein beliebiges Python-Skript ist. Wer den Schlüssel kennt (nach VULN-2026-001 trivial extrahierbar), hat vollständige Kontrolle über jede Brille dieses Modells in BLE-Reichweite.
OWASP Mobile Top 10: I3 (Insecure Authentication/Authorization)
VULN-2026-004: Schwaches Pairing (Just Works, TK=0)
CVSS-Vektor: AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N = 6.5 MEDIUM
Die Brille verwendet BLE Just Works Pairing — den einfachsten Pairing-Modus ohne PIN oder Bestätigung. Der temporäre Schlüssel (TK) ist immer null (TK = 0). Das erlaubt passives Sniffing des Long Term Key (LTK) beim Pairing-Vorgang, was alle folgenden Sessions entschlüsselbar macht.
Just Works = Kein echter Schutz
BLE Just Works Pairing wurde entwickelt, um das Verbinden so einfach wie möglich zu machen. Mit TK=0 kann ein passiver Lauscher, der den initialen Pairing-Handshake aufzeichnet, nachträglich den LTK berechnen und alle Folgesessions entschlüsseln. Selbst eine einfache 6-stellige PIN würde 10^6 mögliche TK-Werte erzeugen und die Angriffsfläche drastisch reduzieren.
VULN-2026-005: Fehlender Replay-Schutz
CVSS-Vektor: AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L = 6.5 MEDIUM
Das Protokoll enthält keine Sequenznummern, Timestamps oder Nonces (einmalige Zufallszahlen). Aufgezeichnete Pakete können beliebig oft wiedereingespielt werden. Ein Angreifer könnte zum Beispiel eine zuvor aufgezeichnete "Text anzeigen"-Sequenz wiederholen.
Hinweis: Diese Schwachstelle wurde strukturell aus der Protokollanalyse abgeleitet. Kein empirischer PoC für dieses spezifische Gerät wurde durchgeführt.
Ein Angreifer befand sich in BLE-Reichweite und zeichnete den kompletten Pairing-Vorgang zwischen Smartphone und LED-Brille auf. Was kann er damit tun?
Gesamtbewertung
| Kriterium | Bewertung |
|---|---|
| Gesamtrisiko | HIGH |
| Höchster CVSS-Wert | 8.1 |
| Angreifer-Aufwand (nach Weaponisierung) | Niedrig |
| Pairing-Sicherheit | KRITISCH (Just Works, TK=0) |
| DSGVO-Relevanz | Keine (keine personenbezogenen Daten) |
| Anzahl Schwachstellen | 5 (2× HIGH, 3× MEDIUM) |
Die LED-Brille ist ein Paradebeispiel für Security by Obscurity: Der Entwickler hat sensiblen Code in eine native Bibliothek ausgelagert, in der Hoffnung, dass niemand nachschaut. Ein Ghidra-Analyse-Lauf von wenigen Stunden widerlegt diese Annahme vollständig. Der eigentliche Schutz — AES-128 — wäre stark, wenn er korrekt eingesetzt würde. Die Kombination aus hardcodiertem Schlüssel, ECB-Modus und fehlendem Authentifizierungsprotokoll macht die Verschlüsselung jedoch wirkungslos.
Was wäre sicher gewesen?
Eine sichere Implementierung hätte benötigt: (1) Gerätespezifische Schlüssel (nicht global hardcodiert), generiert beim ersten Pairing. (2) AES-GCM statt ECB — erzeugt einen Authentifizierungs-Tag und verhindert Mustererkennung. (3) Challenge-Response-Authentifizierung im Protokoll. (4) Sequenznummern gegen Replay-Angriffe. Keiner dieser Punkte ist technisch komplex — sie erfordern nur bewusste Designentscheidungen.
Zusammenfassung
- Die Sonhomay LED-Brille verwendet AES-128-ECB mit einem global hardcodierten Schlüssel in
libAES.so— extrahierbar mit Ghidra in wenigen Stunden - Das 5-stufige Handshake-Protokoll (DATS → DATSOK → DATA → DATCP → DATCPOK) wurde vollständig rekonstruiert und mit einem Python-PoC verifiziert
- AES-ECB ist der unsicherste AES-Modus: identischer Klartext → identischer Geheimtext, Mustererkennung ohne Schlüsselkenntnis möglich
- Just Works Pairing (TK=0) erlaubt passives Sniffing des LTK beim Pairing — alle Folgesessions entschlüsselbar
- Fehlende Authentifizierung auf Protokollebene bedeutet: Wer den Schlüssel hat, steuert jede Brille dieses Modells in BLE-Reichweite
- Gesamtbewertung: HIGH (CVSS 8.1) — hoher Impact, niedriger Angreifer-Aufwand nach einmaliger Analyse