Im ersten Teil habe ich meine beiden 8-Euro-Kameras zerlegt, die Firmware ausgelesen und mir eine Rootshell verschafft. Sehr erfreulich. Nur: Eine Shell macht aus einer Cloudkamera noch keine brauchbare lokale Kamera.
Die eigentliche Frage war ja: Kann das Ding auch einfach filmen und sich drehen, ohne dafür eine Handy-App und irgendwelche Cloudserver zu brauchen? Also geht es weiter. Diesmal nicht mit einem zusätzlichen Telnet-Port, sondern mit einem eigenen lokalen Startpfad, SSH, einer kleinen Weboberfläche und einer ziemlich pingeligen Firmware-Werkzeugkiste.
Vorweg: Das neue Image ist gebaut und offline geprüft, aber noch nicht auf die Kamera geschrieben. Die Programme wurden tatsächlich als ARM-Code ausgeführt; ein echtes Kamerabild und die Motorbewegungen sind damit noch nicht nachgewiesen. Ich möchte zuerst die Software fertig haben und erst danach wieder an den Flashchip. Ein hübsches Testbild ist schließlich noch kein erfolgreicher Kameratest. 🙂
Zwei Kameras, die sich nicht ganz einig sind
Die erste Überraschung: Meine beiden Kameras lassen sich nicht einfach als identische Geräte behandeln. Beide enthalten denselben GC1084-Sensortyp beziehungsweise dasselbe Sensorarchiv. Beim WLAN hört die Gemeinsamkeit aber auf: Kamera 1 verwendet ATBM, Kamera 2 SSV6355. Dazu kommen natürlich die jeweiligen Gerätekennungen und Konfigurationen.
Noch unangenehmer: Im vorliegenden Dump von Kamera 1 fehlen brauchbare Bootdaten. Kamera 2 sieht deutlich besser aus. Dort stimmen sowohl die Prüfsumme des Kernel-Headers als auch die der Kernel-Nutzdaten. Allerdings unterscheiden sich die beiden besten Auslesungen an genau drei Bytes, bei 0x2fffd–0x2ffff.
Drei Bytes bei acht MiB Flash. Klingt nach wenig. Ist aber kein Argument dafür, sie einfach wegzuerklären. Eine dritte Auslesung stimmt dort zwar mit der zweiten überein, hat dafür an anderen Stellen nachweislich Fehler. Mehrheitsentscheid mit einem kaputten Zeugen? Lieber nicht.
Ich arbeite deshalb mit getrennten Originalen, Arbeitsverzeichnissen und Zugangsdaten. Kein Bootloader von Kamera 2 für Kamera 1, keine fremden WLAN-Dateien und keine stillschweigend „reparierten“ Bytes. Für den nächsten Hardwaretest hat Kamera 2 Priorität.
Erst einmal eine ordentliche Werkzeugkiste
Wer schon einmal Firmware untersucht hat, kennt das: Ein Extraktor braucht eine andere Python-Version, das nächste Tool Java und das übernächste einen ARM-Compiler. Irgendwann besteht der Rechner hauptsächlich aus Abhängigkeiten.
Ich habe die Werkzeuge deshalb in einen Docker-Container gepackt. Darin liegen unter anderem Binwalk, Ghidra, REA, SquashFS-Werkzeuge, Jefferson, QEMU, ARM-Compiler und die üblichen Linux-Helfer. So lässt sich dieselbe Umgebung auch für das nächste Firmwareprojekt verwenden.
sh toolbox/build.sh
bash toolbox/run.sh -- /opt/toolbox/smoke.sh
Das erste Kommando baut die Werkzeugkiste. Das zweite prüft, ob sie wirklich funktioniert: Programme starten, ein kleines ARM-Programm kompilieren und ausführen, eine Datei mit Ghidra analysieren und ein Testarchiv mit Binwalk tatsächlich entpacken. Nur Versionsnummern auszugeben wäre etwas wenig.
Standardmäßig bekommt der Analysecontainer weder Netzwerk noch Hostgeräte oder einen privilegierten Zugriff. Für das korrekte Extrahieren von Dateieigentümern und Gerätedateien gibt es einen gezielten Root-Modus. Das ist Risikobegrenzung, keine Zauberei: Docker ist keine Garantie dafür, dass unbekannter Code auf einem produktiven NAS ungefährlich wäre. Originale gehören separat gesichert.
Binwalk findet etwas. Das ist noch keine Partitionsgrenze.
Binwalk sucht nach bekannten Signaturen in Binärdateien. Damit findet man beispielsweise den Anfang eines SquashFS-Dateisystems oder einen Kernel-Header. Für den ersten Überblick hervorragend. Die Frage, wo eine Partition wirklich endet und welche Bytes unverändert bleiben müssen, beantwortet ein Treffer allein aber nicht.
Hier muss ich auch einen Punkt aus Teil 1 präzisieren: 0x5a805c ist keine saubere JFFS2-Partitionsgrenze. Dort liegt ein Dateisystemknoten. Der Bereich beginnt bereits bei 0x5a8000. Außerdem gibt es getrennte JFFS2-Bereiche für Konfiguration und Daten. Die einfach als ein gemeinsames Dateisystem zu behandeln, wäre falsch.
Bei Kamera 2 konnte ich inzwischen die Anyka-Partitionstabelle dekodieren. Für die bearbeitbaren Dateisysteme ergibt sich folgendes Bild; die Endadressen sind jeweils exklusiv:
| Bereich | Start | Ende | Verwendung |
|---|---|---|---|
| rootfs | 0x1b3000 | 0x2b3000 | SquashFS für / |
| usr | 0x2b3000 | 0x5a8000 | SquashFS für /usr |
| config | 0x5a8000 | 0x5b8000 | JFFS2 für /etc/jffs2 |
| data | 0x5b8000 | 0x7e2000 | JFFS2 für /data |
Der Rest bis zum Ende des Flashs ist damit nicht automatisch freier Platz. Bootloader, Kernel, Bootumgebung, Geräteidentität, beide JFFS2-Bereiche und der Flashrest bleiben beim aktuellen Umbau bytegleich zum Original.
unsquashfs entpackt die komprimierten, nur lesbaren Dateisysteme; mksquashfs baut sie wieder zusammen. Für JFFS2 ist Jefferson als Extraktor in der Werkzeugkiste. Die verbindliche Auswertung übernimmt hier zusätzlich eigener Python-Code: JFFS2 enthält auch alte und gelöschte Zustände, und beim Umbau dürfen Dateirechte, Symlinks oder Geräte-Inodes nicht nebenbei verloren gehen.
Aus ein paar dd-Befehlen wird eine Image-Pipeline
Im ersten Teil habe ich geänderte Dateisysteme noch mit dd an ihre Positionen geschrieben. Das Prinzip funktioniert. Man kann damit allerdings auch ausgesprochen präzise an die falsche Stelle schreiben.
Jetzt übernimmt scripts/firmware.py die Schritte inspect, extract, repack und verify. Das Skript prüft die erwartete Originaldatei, erfasst das Layout in einem Manifest, kontrolliert die verfügbaren Slots und verweigert Überläufe. Beim anschließenden unabhängigen Vergleich werden auch die Dateiinhalte und Metadaten geprüft. Ein Image mit der richtigen Gesamtgröße kann innen trotzdem falsch sein.
Neu gebaut werden nur die ausdrücklich ausgewählten SquashFS-Bereiche rootfs und usr. Dass alle anderen Bytes unverändert geblieben sind, wird nicht nur gehofft, sondern geprüft. Elf Regressionstests decken unter anderem beschädigte Kernel-CRCs, widersprüchliche Partitionstabellen und unerlaubte Änderungen am Flashrest ab.
Ghidra, REA und die Frage: Was startet hier eigentlich?
Ghidra zerlegt die ARM-Programme in Assembler und erzeugt daraus eine lesbarere, C-ähnliche Darstellung. Das ist nicht der wiedergefundene Originalquelltext, hilft aber enorm dabei, Funktionen, Bibliotheksaufrufe und Netzwerkzugriffe zu verstehen.
REA ist in dieser Umgebung die Schnittstelle, um solche Analysen per Kommandozeile beziehungsweise MCP zugänglich zu machen; im Hintergrund arbeitet der lokale Ghidra-Provider. Dafür musste ich weder die Kamerafirmware zu einem Cloud-Dienst hochladen noch für diese CLI-Analyse einen LLM-Schlüssel konfigurieren.
Wichtig war vor allem der Startpfad: Die Herstelleranwendung übernimmt nicht nur Cloudkommunikation, sondern auch Kamerafunktionen. „Alles löschen, was verdächtig klingt“ wäre deshalb eine recht wirksame Methode, aus der Webcam einen USB-Heizkörper zu machen.
Der neue Startpfad verwendet die geräteeigenen WLAN- und Sensortreiber, startet aber die Hersteller-Cloudanwendung und deren Watchdog nicht mehr. Lokale Video- und PTZ-Programme stammen aus einer festgeschriebenen Anyka-/GC1084-Referenz. Ich habe also nicht nebenbei ein komplettes Kamera-SDK neu geschrieben. Die Programme und ihre Bibliotheken werden gezielt übernommen und geprüft.
Video und PTZ bekommen dabei getrennte Bibliotheksverzeichnisse. Gleich benannte SDK-Bibliotheken sind nicht automatisch austauschbar. Das sieht auf der Festplatte etwas doppelt aus, erspart aber sehr interessante Abstürze.
SSH statt einer dauerhaften Telnet-Backdoor
Telnet war praktisch, um überhaupt erst einen Fuß in die Tür zu bekommen. Als dauerhafter Verwaltungszugang möchte ich es nicht behalten. Der frühe Telnet-Start und der alte FTP-/Service-Startpfad entfallen deshalb im Umbau.
Stattdessen übernimmt Dropbear, ein kleiner SSH-Server, den Zugang auf Port 2222. Jede Kamera hat eigene lange Zufallspasswörter, und Web- und SSH-Passwort sind ebenfalls verschieden. Eigene Public-Keys lassen sich optional provisionieren. TCP-Weiterleitungen sind deaktiviert.
Ein Detail aus der Kategorie „das sucht man gerne länger“: Im Original gehörten bereits das Rootverzeichnis und /etc einer fremden UID und waren für die Gruppe beschreibbar. Dropbear prüft auch die Eltern seiner Autorisierungsdatei. Nur die Schlüsseldatei hübsch auf 0600 zu setzen reicht dann nicht. Die Imagevorbereitung korrigiert deshalb auch diese Verzeichnisrechte.
Für die alten Werkzeuge bleibt der Passworthash im kompatiblen MD5-crypt-Format. Das ist ein altes, schnelles Verfahren und kein Sicherheits-Upgrade. Umso wichtiger sind lange Zufallspasswörter und die Netztrennung. Individuelle SSH-Hostschlüssel sollen erst auf dem jeweiligen Gerät entstehen und im JFFS2 erhalten bleiben; Entropie und Persistenz gehören noch zur Hardwareabnahme.
Acht MiB sind ziemlich schnell voll
Natürlich passt nicht beliebig viel moderne Software in diese Firmware. Die Lösung war ein kompakter Crossbuild: Der Compiler läuft auf dem x86-Rechner, erzeugt aber Programme für die alte ARMv5TE-/Softfloat-Plattform.
Mit der kleinen C-Bibliothek musl sind SSH- und Webprogramme statisch gebaut. Sie bringen ihre benötigte C-Laufzeit damit selbst mit. Die vorhandenen Kamera-SDKs bleiben dagegen bei ihrer originalen uClibc-Umgebung. Ich tausche also nicht die komplette Systembibliothek unter einer alten Firmware aus.
Dropbear verwendet ein gemeinsames Multicall-Programm für Server und Schlüsselgenerator. Für den Webteil reicht eine stark reduzierte BusyBox mit HTTP-Server und wget. Das Ergebnis:
| Baustein | Größe |
|---|---|
| Dropbear-Multicall | 346.248 Bytes |
| BusyBox für HTTP und wget | 103.636 Bytes |
| Web-CGI für Snapshot und PTZ | 42.136 Bytes |
Damit passt der lokale Umbau in die vorhandenen Slots. Ohne Partitionen zu vergrößern und ohne den geschützten Flashrest als vermeintlich freien Speicher zu verwenden. Ob der laufenden Kamera genug RAM bleibt, ist eine andere Frage und wird später auf der Hardware gemessen.
Ein Webinterface – aber kein erfundenes Livevideo
Die neue Oberfläche läuft auf Port 8080. Sie zeigt passwortgeschützte JPEG-Snapshots und bietet die PTZ-Richtungen links, rechts, oben und unten an. Das Bild wird ungefähr alle zwei Sekunden aktualisiert; die Aktualisierung lässt sich pausieren und pausiert auch bei verborgenem Tab. Das ist ein bewusst schlichtes Webinterface, kein flüssiger Videostream im Browser.
Für Video ist separat RTSP vorgesehen. Es gibt hier weder ein zugesichertes ONVIF noch eine nachgewiesene Zoom- oder Preset-Funktion. Für dieses Projekt bedeutet PTZ-Steuerung zunächst die vorhandene Schwenk-/Neigemechanik.
Die Web-API schickt freigegebene Befehle über eine FIFO an den lokalen PTZ-Daemon. Das ist eine benannte Pipe, also ein einfacher Kommunikationskanal zwischen Prozessen. Keine beliebige Shellzeile aus dem Browser. Die Initialisierung mit mechanischem Homing verlangt eine ausdrückliche Bestätigung und wird nicht automatisch beim Booten ausgelöst.
Auch die Rückmeldung bleibt ehrlich: HTTP 202 bedeutet, dass der Befehl an die FIFO übergeben wurde. Es bedeutet nicht, dass sich der Motor schon bewegt hat oder ein Endanschlag erreicht wurde.
Ein Passwort vorne hilft nicht, wenn hinten noch eine Tür offen ist
Beim Snapshot-Backend gab es noch einen Haken: Die übernommene Videoanwendung lauschte ursprünglich auf allen Netzwerkschnittstellen an Port 3000. Ein passwortgeschütztes Webinterface auf 8080 wäre dann nur die freundliche Empfehlung, nicht die Zugriffssperre.
Mit Ghidra und der ARM-Disassembly ließ sich die Bind-Adresse finden. Ein gezielter Patch aus zwei ARM-Instruktionen beschränkt dieses Backend jetzt auf 127.0.0.1:3000. Vor dem Patch prüft die Vorbereitung den erwarteten Programmhash und die ursprünglichen Instruktionsbytes. Keine geratenen Offsets in irgendeiner ähnlichen Binärdatei.
Von außen soll der Snapshot nur über den angemeldeten Webzugang erreichbar sein. Der separate RTSP-Dienst wird durch dieses Passwort allerdings nicht geschützt. Und HTTP Basic ist ohne TLS unverschlüsselt. Das gehört in ein isoliertes, vertrauenswürdiges LAN, bei Bedarf hinter einen TLS-Reverseproxy – nicht per Portweiterleitung ins Internet.
Auch DHCP ist absichtlich langweilig: Adresse und Netzmaske ja, automatisch übernommene Standardroute und öffentliche DNS-Server nein. Für den ersten Betrieb müssen Kamera und steuernder Rechner beziehungsweise Home Assistant im selben Subnetz liegen. Eine externe Firewall bleibt trotzdem sinnvoll; „keine Standardroute“ ist kein vollständiges Sicherheitskonzept.
Die WLAN-Daten waren schon da
Die ersten Testimages hatten noch deaktiviertes WLAN. Für einen Laufzeittest am Rechner reicht das. Für eine Kamera, die später im WLAN erreichbar sein soll, eher weniger.
Im eigenen Dump von Kamera 2 lagen die bisherigen Zugangsdaten tatsächlich an zwei Stellen: in yi_cfg.ini und im WLAN-Abschnitt von anyka_cfg.ini. Beide Paare stimmten exakt überein. Daraus habe ich eine separate WPA-Konfiguration erstellt: hexkodierte SSID, abgeleiteter WPA-Schlüssel, aktiver Netzwerkblock.
Der Schlüssel wird per PBKDF2-HMAC-SHA1 mit 4096 Iterationen aus SSID und Passphrase berechnet. Das spart die Klartext-Passphrase in der neuen WPA-Datei, macht den resultierenden PSK aber nicht öffentlich teilbar. Er ist selbst ein Zugangsschlüssel. Außerdem bleiben die ursprünglichen geräteeigenen Konfigurationsbereiche bewusst unverändert und sind deshalb weiterhin privat.
Der originale ARM-WPA-Client akzeptiert die neue Konfiguration; einen absichtlich ungültigen Schlüssel lehnt er schon beim Einlesen ab.
QEMU: ARM-Programme testen, ohne die Kamera zu flashen
QEMU kann ARM-Programme auf meinem x86-Rechner ausführen. Verwendet wird hier die User-Mode-Emulation mit einer ARM926-CPU und den jeweils passenden Bibliotheken. Das ist keine komplett emulierte Anyka-Platine: Der echte Kamerakernel wird dabei nicht gebootet, und Sensor, WLAN-Chip und Motoren sind nicht plötzlich virtuell vorhanden.
Trotzdem lässt sich erstaunlich viel prüfen. Mit readelf kontrolliere ich das ELF-Format und die Bibliotheksanforderungen. strace beziehungsweise QEMUs Syscall-Trace zeigen, welche Dateien geöffnet werden, wohin ein Programm seine Sockets bindet und welche Bytes der PTZ-Daemon tatsächlich aus der FIFO liest.
Für den neuen Kamera-2-Stand wurden unter anderem diese Wege tatsächlich ausgeführt:
- SSH-Anmeldung mit dem zugewiesenen Passwort und einem echten Rootkommando; falsche Passwörter und nicht erlaubte Weiterleitungen wurden abgelehnt.
- Webzugriff mit richtigem, falschem und fehlendem Passwort; geschützte Seiten und API-Endpunkte verhalten sich entsprechend.
- PTZ-POST bis zum tatsächlichen Lesen von
leftdurch den ARM-Daemon. Shellfragmente und nicht freigegebene Befehle werden abgelehnt. - Snapshot-Proxy mit ausdrücklich bereitgestelltem JPEG-Testbild, anschließend fehlendem Backend, ungültiger Antwort, Timeout und Wiederkehr.
- Videoanwendung bis zur erwarteten Grenze der fehlenden Sensor- und Audiogeräte. Dabei ist auch die tatsächliche Loopback-Bindung des Snapshot-Backends sichtbar.
Die unveränderte Weboberfläche habe ich zusätzlich mit echtem ARM-HTTP-Server im Chromium auf Desktop- und Handybreite geprüft. Das sichtbare Bild war dabei ein Testmuster. Sehr hilfreich für Bedienung und Fehlerbehandlung – aber ich werde daraus kein „Kamera funktioniert“-Foto machen.
Und Home Assistant?
Für Home Assistant ist ein REST-Aufruf der lokalen PTZ-API vorbereitet. Ein Konfigurationsbeispiel mit einer ausdrücklich beispielhaften Kamera-IP sieht so aus:
rest_command:
kamera2_ptz:
url: "http://192.168.50.21:8080/cgi-bin/ptz.cgi"
method: POST
authentication: basic
username: admin
password: !secret kamera2_web_password
content_type: "application/json"
payload: '{"command":"{{ direction }}"}'
Zulässige Richtungen sind left, right, up und down. Der geschützte Snapshot liegt unter /cgi-bin/snapshot.cgi. Für den Videostream sind nach dem echten Bildtest die Generic-Camera-Integration oder ein Medienproxy wie go2rtc naheliegend. Die HTTP-Schnittstelle ist geprüft; eine laufende Home-Assistant-Installation habe ich damit noch nicht abgenommen.
Was jetzt tatsächlich fertig ist
Für Kamera 2 liegt ein frisch gebautes Image vor:
localcam-camera2-provisioned.UNSAFE.bin
Der separate Imagevergleich bestätigt die logische Übereinstimmung mit dem vorbereiteten Dateibaum und unveränderte geschützte Bereiche. Die ARM-Laufzeitprüfungen, SSH-Anmeldung, Web-API und alle elf vorhandenen Regressionstests sind bestanden. Die Software für lokalen Zugriff, Video und Schwenken/Neigen ist damit zusammengestellt und offline geprüft.
Das UNSAFE im Namen ist trotzdem Absicht. Vor dem Schreiben müssen die Ausleseabweichungen, eine belastbare eigene Sicherung und der Rettungsweg geklärt sein. Die Kernel-CRC beweist nicht die Unversehrtheit des Bootloaders. Auch die herstellerspezifische ENV-Prüfsumme ist noch nicht verifiziert.
Ein zusätzliches Detail aus einer verwandten Anyka-U-Boot-Quelle: Bei einer ungültigen Bootumgebung kann der Bootloader selbst saveenv auslösen. Das ist kein Nachweis für genau meinen Bootloaderbuild, aber ein guter Grund, selbst einen Diagnoseboot nicht einfach als garantiert schreibfrei anzusehen.
Danach kommt die Hardwareabnahme: UART-Boot beobachten, Mounts und WLAN prüfen, SSH-Hostkey kontrollieren, echtes RTSP und echte Snapshots decodieren, Motoren unter Aufsicht testen und RAM, Flashbelegung sowie Dauerbetrieb messen. Schließlich ohne WAN neu starten und den Netzwerkverkehr auf unerwartete Cloudkontakte prüfen. Das kann QEMU mir nicht abnehmen.
Ein Komplettimage werde ich auch nicht einfach als Download danebenlegen: Der Dump enthält Gerätekennungen und Zugangsdaten, und bei den übernommenen Hersteller-/SDK-Komponenten müssen die Lizenz- und Quelltextpflichten geklärt sein.
Also: Diesmal noch kein Jubelbild einer schwenkenden Kamera. Dafür eine wiederverwendbare Werkzeugkiste, ein nachvollziehbar gebautes lokales Firmware-Image und Tests, die nicht nur auf dem Papier existieren. Der nächste Schritt findet wieder an der Hardware statt – aber erst, wenn klar ist, wie ich im Fehlerfall zurückkomme. Die Acht-Euro-Kamera hat inzwischen jedenfalls ein ausgesprochen aufwendiges Bewerbungsgespräch hinter sich. 🙂
