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:

FunktionUUID (Kurzform)HandlePermissions
Control...96000x0012WRITE, WRITE_NO_RESP
Data...960A0x0015WRITE, WRITE_NO_RESP
Notification...96010x001BNOTIFY

Das Kommunikationsmuster ist klar: Befehle gehen an die Control-Charakteristik, Nutzdaten an die Data-Charakteristik, und das Gerät antwortet über Notifications.

GATT Profile

Read
Read
Indicate
ReadWriteWriteWithoutResponse
ReadWrite

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 q0

Der 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:

SchrittRichtungCharakteristikInhalt
1. DATSApp → BrilleControl (0x0012)Transferstart: Datengröße ankündigen
2. DATSOKBrille → AppNotification (0x001B)Gerät bereit
3. DATAApp → BrilleData (0x0015)Zeichendaten in 16-Byte-Paketen
4. DATCPApp → BrilleControl (0x0012)Transferende
5. DATCPOKBrille → AppNotification (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

IDBezeichnungCVSSSchweregradOWASP
VULN-2026-001Hardcodierter AES-Schlüssel8.1HIGHI1, I5
VULN-2026-002Unsicherer AES-ECB-Modus5.9MEDIUMI7
VULN-2026-003Fehlende Authentifizierung8.1HIGHI3
VULN-2026-004Schwaches Pairing (Just Works)6.5MEDIUM
VULN-2026-005Fehlender Replay-Schutz6.5MEDIUM

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:

  1. APK aus dem Play Store laden (öffentlich zugänglich)
  2. lib/arm64-v8a/libAES.so extrahieren
  3. Ghidra öffnen → JNI-Funktion keyExpansionDefault finden → Schlüssel lesen
  4. 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

KriteriumBewertung
GesamtrisikoHIGH
Höchster CVSS-Wert8.1
Angreifer-Aufwand (nach Weaponisierung)Niedrig
Pairing-SicherheitKRITISCH (Just Works, TK=0)
DSGVO-RelevanzKeine (keine personenbezogenen Daten)
Anzahl Schwachstellen5 (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