STM32 ohne Source-Code über den STLink und OpenOCD debuggen

Selten kompiliert und läuft ein Programm gleich so, wie man es will. Auf die meisten Fehler macht einen der Compiler aufmerksam, doch der kann logische und Denkfehler nicht finden. Bisher haben wir uns dann immer damit beholfen, mit Serial.print() Variablen auszugeben und im Serial Monitor dessen Inhalte anzuzeigen, um zu gucken, ob sie auch den erwarteten Wert haben.

Bei der Typisierung von C kann man schon mal einen Fehler machen und z. B. eine Variable zu klein machen (z. B. int8_t statt int16_t oder int32_t). Beim Durchsehen des Codes übersieht man dann wieder und wieder, dass die Variable nicht breit genug ist und ein Überlauf auftritt. Erst bei der Ausgabe bemerkt man, dass etwa keien Werte über 255 ausgegeben werden, obwohl dem so sein sollte.

Mit einem Debugger kann ich ein Programm jederzeit anhalten und mir die Variablenwerte anzeigen lassen. Damit kann ich mir die vielen Serial.print-Zeilen sparen, die ich ständig ein- und ausbaue.

Ein weiterer Vorteil eines Debuggers ist, dass ich Befehle schrittweise ausführen kann oder den Debugger an bestimmten Stellen anhalten kann. Stürzt mein Programm z. B. immer wieder ab, weil ich z. B. einen Pointer nicht richtig gesetzt habe und etwa bei NULL und nicht im gewollten Speicherbereich rummache, dann kann ich dort einen Breakpoint setzen, wo ich den Fehler vermute und Schritt für Schritt durch den Source gehen. Bei dem Schritt, an dem dann der Absturz ist, ist dann der Fehler, den ich dann korrigieren kann.

Dieses Debuggen ist aber nur möglich, wenn ich a) den Source-Code habe und b) VS Code mit Platform IO (oder eine andere professionelle Entwicklungsumgebung) benutze. Die Arduino IDE kann nicht mit einem Debugger aufwarten.

Fast jeder Mikrocontroller lässt sich über das JTAG (Joint Test Action Group)-Interface (oder ein ähnliches) debuggen. Man kann damit auch fremden Geräten auf die Finger schauen. Viele haben sogar die Pins für das JTAG-Interface irgendwo auf der Platine herausgeführt, etwa WLAN-Steckdosen.

Das Debuggen von Fremdgeräten ist allerdings eine zeitraubende und mühselige Angelegenheit und man sollte schon ein wenig Assembler können. Das Debuggen von eigenem Code hingegen ist so einfach, wie man es sich vorstellen kann.

Demo-Sourcecode zum Debuggen

#include <Arduino.h> const char *username = "wichtigerUser"; const char *password = "geheimesPasswort"; const char PROGMEM *username2 = "wichtigerUser2"; const char PROGMEM *password2 = "geheimesPasswort2"; void einloggen (const char *user, const char *pass) { char url [300]; snprintf (url, 300, "https://admin.cool1.de/administration/login.php?user=%s&pass=%s", user, pass); // z. B. URL aufrufen zum Login } void setup() { pinMode(LED_BUILTIN, OUTPUT); einloggen (username, password); einloggen (username2, password2); } void loop() { int state=0; while (1) { state = digitalRead(LED_BUILTIN); digitalWrite(LED_BUILTIN, !state); delay(250); } } In diesem kleinen Source, der sich sowohl in der Arduino IDE als auch in VS Code/PIO kompilieren und hochladen lässt, habe ich zur Demonstration einmal ein paar Passwörter und Anmeldedaten hinterlegt, von denen wir mal ausgehen, dass sie möglichst niemand wissen soll. Das können SSID und Passwort für das WLAN sein oder die Zugangsdaten zu einem Administrationsinterface im Internet.

Ich will herausfinden, ob ich diese Daten wieder aufspüren kann, falls mir nur der Mikrocontroller in die Hände fällt, ich also den Source-Code nicht habe. Würde das gehen und wäre ich ein böser Bube, könnte ich z. B. den Temperator, Licht oder sonstigen Sensor, der irgendwo außen hängt und die Daten im WLAN ablegt, entwenden, debuggen und hätte die Zugangsdaten zum WLAN des Hauses, um dann damit böse Dinge zu tun, die böse Burschen halt so machen.

Im ersten Schritt versuchen wir das einmal mit einer STM32 Blue Pill, weil die sich leicht mit dem STLink debuggen lässt. Ein JLink-Debugger ist schon bestellt, und der sollte sich dann auch auf die JTAG-Schnittstelle meiner WLAN-Steckdosen anhängen können. Sobald der da ist, will ich mal nachschauen, ob mein WLAN-Passwort dort verschlüsselt oder unverschlüsselt abgelegt. ist.

Im Loop-Teil des Sources ist nur ein einfaches Programm mit einer Variable, die die eingebaute LED blinken lässt und die wir dann in VS Code debuggen und nachverfolgen wollen.

Zum Debuggen benutzen wir OpenOCD, auf das übrigens auch die Platform IO beim Debuggen aufsetzt.

OpenOCD

OpenOCD (Open On Chip Debugger) ist eine freie Software, um Mikrocontroller zu debuggen. Es gibt auch einen Download für Windows, aber wer sich die Platform IO, etwa für Visual Studio Code installiert hat, der findet es bereits installiert unter C:\Users\[user]\.platformio\packages\tool-openocd-esp32\bin.

OpenOCD unterstützt viele CPU-Platformen, Debug-Protokolle und Debug-Adapter, so auch den STLink, um den es heute geht und mit dem wir eine Blue Pill mit SMT32F103 debuggen wollen.

Konfiguration von OpenOCD

OpenOCD arbeitet mit Konfigurationsdateien für Adapter und CPUs. Hier müssen wir die richtigen übergeben, in diesem Fall ist das C:\Users\[user]\.platformio\packages\tool-openocd-esp32\share\openocd\scripts\interface\stlink.cfg für den STLink V2-Adapter und C:\Users\[user]\.platformio\packages\tool-openocd-esp32\share\openocd\scripts\target\stm32f1x.cfg für den STM32F103C8T6, der sich auf der Blue Pill befindet. Ich habe mir die beiden Dateien nach D:\STM32 kopiert, um sie dort ggf. editieren zu können. Außerdem muss ich dann nicht immer den ewig langen Pfad eingeben.

In den beiden Konfigurationsdateien steht, welches Protokoll für den angegebenen Adapter das richtige ist (stlink.cfg) und welche Architektur die CPU hat (stm32f1x.cfg), um die richtigen Register und Disassemblings anzeigen zu können.

Ich habe mir ein kleines Batch-Programm (D:\STM32\jtag-debug.cmd) geschrieben, damit ich nicht jedesmal alles per Hand eingeben muss: @echo -------------------------- @echo telnet localhost 4444 @echo in Win-Startmenue eingeben @echo -------------------------- c: cd C:\Users\admin\.platformio\packages\tool-openocd-esp32\bin openocd -f d:\stm32\stlink.cfg -f d:\stm32\stm32f1x.cfg d: Habt ihr alles richtig konfiguriert und den STLink angesteckt, verbindet sich OpenOCD dann mit der Plue Pill und ist bereit, über den Port 4444 über Telnet Befehle entgegenzunehmen und Ergebnisse auszugeben.

Wenn nicht, erhaltet ihr eine Fehlermeldung, die in etwa so aussieht: 10:12:20,62 d:\STM32 >> jtag-debug.cmd -------------------------- telnet localhost 4444 in Win-Startmenue eingeben -------------------------- 10:12:53,80 d:\STM32 >> c: 10:12:53,80 C:\Users\admin\.platformio\packages\tool-openocd-esp32\bin >> cd C:\Users\admin\.platformio\packages\tool-o penocd-esp32\bin 10:12:53,80 C:\Users\admin\.platformio\packages\tool-openocd-esp32\bin >> openocd -f d:\stm32\stlink.cfg -f d:\stm32\st m32f1x.cfg Open On-Chip Debugger v0.10.0-esp32-20190313 (2019-03-13-09:57) Licensed under GNU GPL v2 For bug reports, read http://openocd.org/doc/doxygen/bugs.html Info : auto-selecting first available session transport "hla_swd". To override use 'transport select '. Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD adapter speed: 1000 kHz adapter_nsrst_delay: 100 none separate Info : Listening on port 6666 for tcl connections Info : Listening on port 4444 for telnet connections Info : clock speed 1000 kHz Info : STLINK V2J31S7 (API v2) VID:PID 0483:3748 Info : Target voltage: 3.132046 Error: init mode failed (unable to connect to the target) Hier kann sich OpenOCD nicht mit der CPU auf der BluePill verbinden, was daran liegt, dass ich hier eine Version mit geflashtem STM32duino-Bootloader angesteckt und vergessen habe, die beiden gelbe Jumper abzuziehen. Erst dann ist die Blue Pill mit STM32duino im Debug Modus. Ansonsten befindet sie sich im Betriebsmodus (mit dem auch Programmieren über USB möglich ist) und kann die Debug-Anfrage nicht interpretieren.

Ist die Verbindung aber korrekt hergestellt, kann man über die Telnet-Konsole Befehle eingeben, um die CPU zu debuggen.

Hier OpenOCD direkt einzusetzen macht nur Sinn, wenn man den Source Code nicht selbst hat, etwa wenn man ein Fremdgerät vor sich liegen hat, bei dem man z. B. dessen Sicherheit untersuchen will.

Denn hier befindet man sich auf Hardware-Ebene und bekommt nur ein Assembler-Listing des Programmes angezeigt. Assember ist die besser lesbare Alternative zur Maschinensprache und das sind einfach nur lange Abfolgen von Bytes, von denen einige Befehle und andere Daten darstellen. Assembler ist da schon eine große Hilfe, da es Befehle und Daten auseinanderpflückt. Trotzdem ist Assembler nicht ganz einfach zu verstehen, erst recht nicht, wenn man die zugrunde liegende Hardware nicht kennt.

Ansonsten - wenn man den Source-Code hat - würde man zum Debuggen z. B. den eingebauten Debugger von VS Code benutzen. Der startet den Debugger von sich aus und zeigt dann den "echten" C-Source und die "echten" Variablen an, was das Leben doch sehr viel einfacher macht.

Für die Verwendung von OCD gehen wir jetzt aber davon aus, dass wir den obenstehenden Source nicht haben. Denn in VS Code übernimmt die Platform IO die Steuerung von OpenOCD und ist sehr viel komfortabler und wir müssen uns nicht mit Assembler, Registern und Speicher herumschlagen.

Wichtige OpenOCD Befehle

Alle Befehle finden sich in der OpenOCD-Dokumentation. Hier nur die wichtigsten: Schauen wir uns mal eine typische Debug-Session an...

Zuerst halten wir einmal die CPU direkt am Anfang an: > reset halt target halted due to debug-request, current mode: Thread xPSR: 0x01000000 pc: 0x080000f0 msp: 0x20005000 Hier dürften wir uns in den Initalisierungs-Routinen befinden. Um zu schauen, was vor sich geht, wollen wir ein Disassembler-Listing der nächsten Befehle haben.

Dazu müssen wir erst einmal wissen, wo das Programm steht. Dazu gibt es ein Register namens Programm Counter (PC). Lassen wir uns also die Register ausgeben: > reg ===== arm v7m registers (0) r0 (/32): 0x40011000 (1) r1 (/32): 0x20000000 (2) r2 (/32): 0x00000001 (3) r3 (/32): 0x00000010 (4) r4 (/32): 0x00000001 (5) r5 (/32): 0x0007A95D (6) r6 (/32): 0x00100000 (7) r7 (/32): 0x00000001 (8) r8 (/32): 0x40011000 (9) r9 (/32): 0x0000000D (10) r10 (/32): 0x00000000 (11) r11 (/32): 0x50192EA0 (12) r12 (/32): 0xCFEBEEFF (13) sp (/32): 0x20005000 (14) lr (/32): 0xFFFFFFFF (15) pc (/32): 0x080000F0 (16) xPSR (/32): 0x01000000 (17) msp (/32): 0x20005000 (18) psp (/32): 0x23AB8AC8 (19) primask (/1): 0x00 (20) basepri (/8): 0x00 (21) faultmask (/1): 0x00 (22) control (/2): 0x00 ===== Cortex-M DWT registers Die Register haben übrigens folgende Bedeutungen:

STM32-Register

r0 ... r12 Register Nr. 0 bis 12 zum schnellen Zwischenspeichern von Werten oder zur Parameterübergabe r13 sp Stack Pointer, aktuelle Adresse des Stapelspeichers r14 lr Link Register, beinhaltet die Rücksprungadresse nach einem Funktionsaufruf r15 pc Programm Counter, die aktuelle Adresse, an dem sich die Programmausführung befindet. Spezial-Register: xPSR Program Status Register, enthält Informationen zu Interrupts und Programmausführung msp Main Stack Pointer psp Process Stack Pointer primask Priority Mask Register basepri Base Priority Mask Register faultmask Fault Mask Register control Control Register Alle Register haben eine Breite von 32 Bit. PC ist also 0x080000F0. Lassen wir uns die nächsten 20 Befehle disassemblieren: > arm disassemble 0x080000F0 20 0x080000f0 0x2100 MOVS r1, #00 0x080000f2 0xf000b804 B.W 0x080000fe 0x080000f6 0x4b0b LDR r3, [pc, #0x2c] ; 0x08000124 0x080000f8 0x585b LDR r3, [r3, r1] 0x080000fa 0x5043 STR r3, [r0, r1] 0x080000fc 0x3104 ADDS r1, #0x04 0x080000fe 0x480a LDR r0, [pc, #0x28] ; 0x08000128 0x08000100 0x4b0a LDR r3, [pc, #0x28] ; 0x0800012c 0x08000102 0x1842 ADDS r2, r0, r1 0x08000104 0x429a CMP r2, r3 0x08000106 0xf4ffaff6 BCC.W 0x080000f6 0x0800010a 0x4a09 LDR r2, [pc, #0x24] ; 0x08000130 0x0800010c 0xf000b803 B.W 0x08000116 0x08000110 0x2300 MOVS r3, #00 0x08000112 0xf8423b04 STR r3, [r2], #4 ; 0x04 0x08000116 0x4b07 LDR r3, [pc, #0x1c] ; 0x08000134 0x08000118 0x429a CMP r2, r3 0x0800011a 0xf4ffaff9 BCC.W 0x08000110 0x0800011e 0xf000fa45 BL 0x080005ac 0x08000122 0x4770 BX r14 Hier werden fröhlich die Bytes umhergeschubst und von Registern zu Speicheradressen und zurück kopiert, Werte addiert und verglichen und bedingte Sprünge an andere Programmadressen ausgeführt. Man muss schon die ganzen Assembler-Befehle verstehen, um sich ein Bild davon machen zu können, was hier auf Hardware-Ebene abläuft um dann nach etlichen Befehlen eine Ahnung zu bekommen, wozu das Ganze gut ist. Weil man ja den Source-Code nicht hat und gar nicht weiß, was denn Ziel des Codes ist.

Da uns die Initialisierungs-Routine eigentlich herzlich egal sein kann, lassen wir das Programm mit resume weiter laufen, bis es ins Blinken hineinläuft und halten es dann mit halt wieder an > resume > halt target halted due to debug-request, current mode: Thread xPSR: 0x21000000 pc: 0x080006e4 msp: 0x20004fc8 Lassen wir uns wieder das laufende Programm deassemblieren: > arm disassemble 0x080006E4 20 0x080006e4 0xb115 CBZ r5, 0x080006ec <--+ 0? -+ ; wenn Register 5 auf Null heruntergezählt, springe nach 6ec 0x080006e6 0xbf00 NOP | | ; mache nichts 0x080006e8 0x3d01 SUBS r5, #0x01 | | ; ziehe 1 von Register 5 ab 0x080006ea 0xe7fb B 0x080006e4 --+ | ; springe wieder an den Anfang 0x080006ec 0x4640 MOV r0, r8 <---------+ ; nach sovielen Durchläufen von NOP, dass xx ms mit Nichtstun 0x080006ee 0x4649 MOV r1, r9 ; vertan sind, geht es hier weiter 0x080006f0 0x463a MOV r2, r7 0x080006f2 0x3c01 SUBS r4, #0x01 0x080006f4 0xf7ffffc8 BL 0x08000688 0x080006f8 0xb2e4 UXTB r4, r4 0x080006fa 0xe7e7 B 0x080006cc 0x080006fc 0xe8bd87f0 POP.W {r4, r5, r6, r7, r8, r9, r10, r15} ; hole die Registerinhalte wieder vom Stack 0x08000700 0x4b0d LDR r3, [pc, #0x34] ; 0x08000738 0x08000702 0x490e LDR r1, [pc, #0x38] ; 0x0800073c 0x08000704 0x681a LDR r2, [r3, #0] 0x08000706 0xf0420201 ORR r2, r2, #1 ; 0x00000001 0x0800070a 0x601a STR r2, [r3, #0] 0x0800070c 0x4a0c LDR r2, [pc, #0x30] ; 0x08000740 0x0800070e 0x6810 LDR r0, [r2, #0] 0x08000710 0x4001 ANDS r1, r0 Wir können uns vorstellen, dass das Programm mit hoher Wahrscheinlichkeit dort stoppt, wo es sich die meiste Zeit aufhält und das ist das delay(250); in unserem Source-Code.

Ich habe den oberen Teil des Assembler-Listings mal kommentiert, damit man ihn verstehen kann. Hier wird in einer Schleife Register 5 heruntergezählt, bis es auf Null ist und danach weitergemacht. Schauen wir uns, was in Register 5 gerade steht. (5) r5 (/32): 0x00031B35 0x00031B35 sind dezimal 203.573. Eine ganze Menge Durchläufe (und wir wissen jetzt auch nicht, wie der Anfangswert ist, weil wir die CPU irgendwo mitten drin angehalten haben). Nach den ganzen Durchläufen geht es dann bei 0x08000688 bzw. 0x080006cc weiter, was wir uns wieder deassemblieren könnten, um herauszufinden, dass es dort auch um Zeitverschwendungsschleifen geht. Das sind mehrere ineinander verschachtelte Schleifen, die zum Schluss sicherstellen, dass genau 250 Millisekunden vergehen, wie in delay(250); angegeben. Sich da überall durchzuwuseln ist eine Heidenarbeit. Debuggen ohne Source-Code macht also nicht wirklich Spaß.

Also machen wir das anders und lassen uns den gesamten Speicher ausgeben, um ihn dann in einem Hex-Editor anzuzeigen. Vielleicht zeigt sich dann der eine oder andere verdächtige String? Geben wir also die gesamten 128 KB Speicher der mit der Arduino IDE geflashten STM32duino-Bootloader Blue Pill in ein File aus: dump_images d:stm32duino-speicher.dmp 0 131072 und öffnen ihn dann mit einem Hex-Editor. Ich nehme da immer den UltraEdit her, der kann wunderbar auch mit großen Dateien umgehen. Es tut aber sicher auch ein kostenfreier Hex-Editor wie HxD.

Dort suchen wir mal nach dem Begriff "user" und werden bei 0x0000a330 fündig: 0000a330h: 68 74 74 70 73 3A 2F 2F 66 69 72 6D 65 6E 73 65 https://firmense 0000a340h: 72 76 65 72 2E 64 65 2F 61 64 6D 69 6E 69 73 74 rver.de/administ 0000a350h: 72 61 74 69 6F 6E 2F 6C 6F 67 69 6E 2E 70 63 70 ration/login.php 0000a360h: 3F 75 73 65 72 3D 25 73 26 70 61 73 73 3D 25 73 ?user=%s&pass=%s 0000a370h: 00 67 65 63 65 69 6D 65 73 50 61 73 73 77 6F 72 .geheimesPasswor 0000a380h: 74 32 00 77 69 63 63 74 69 67 65 72 55 73 65 72 t2.wichtigerUser 0000a390h: 32 00 67 65 63 65 69 6D 65 73 50 61 73 73 77 6F 2.geheimesPasswo 0000a3a0h: 72 74 00 77 69 63 63 74 69 67 65 72 55 73 65 72 rt.wichtigerUser (Werte ohne Gewähr, da mit OCR gescannt) Da finden wir im Klartext unseren Usernamen und Passwort wieder. Eigentlich kein Wunder, denn irgendwo muss es ja abgelegt sein und wir haben es nicht verschlüsselt.

Das Ganze bekommen wir natürlich auch in OpenOCD heraus, allerdings fehlt hier die ASCII-Ausgabe und die Suchfunktion, so dass wir uns hier sehr viel schwieriger tun. Aber es ist vorhanden: > mdb 0x0a330 160 0x0000a330: 68 74 74 70 73 3a 2f 2f 66 69 72 6d 65 6e 73 65 72 76 65 72 2e 64 65 2f 61 64 6d 69 6e 69 73 74 0x0000a350: 72 61 74 69 6f 6e 2f 6c 6f 67 69 6e 2e 70 68 70 3f 75 73 65 72 3d 25 73 26 70 61 73 73 3d 25 73 0x0000a370: 00 67 65 68 65 69 6d 65 73 50 61 73 73 77 6f 72 74 32 00 77 69 63 68 74 69 67 65 72 55 73 65 72 0x0000a390: 32 00 67 65 68 65 69 6d 65 73 50 61 73 73 77 6f 72 74 00 77 69 63 68 74 69 67 65 72 55 73 65 72 0x0000a3b0: 00 00 00 00 94 00 00 20 8c 01 00 20 30 00 00 20 00 01 00 00 94 00 00 20 8c 01 00 20 30 00 00 20 Kompiliert durch die Platform IO mit Visual Studio Code ist das Binär-Image sogar noch mitteilsamer:



Das Komplilat von Platform IO plaudert auch noch die Pfade zum Framework aus und da diese in dem User-Profile-Ordner gespeichert ist, auch den Windows-User-Name, hier admin. Eine tolle Gelegenheit, Verbrecher und andere Leute, die vielleicht aus ganz legalen Gründen anonym bleiben wollen, zu identifizieren.

Im Übrigen frage ich mich, was diese Speicherverschwendung soll. Wozu muss der Mikrocontoller bitte schön wissen, wo denn die Entwicklungs-Dateien auf meinem lokalen Rechner gespeichert sind?

Und damit nicht genug der Platzverschwendung. Das Ganze findet sich so ähnlich noch einmal an anderer Stelle im Speicher:



Der Kompiler der Arduino IDE kompiliert also besser und platzsparender.

Was beide Speicherbereiche allerdings auch noch aufwiesen: ein altes Programm, dass nicht bzw. nur teilweise überschrieben wurde. Das neue Programm war kürzer und brauchte weniger Speicher. Also wurde nur der Anfang mit dem neuen Programm gefüllt und der Rest unangetastet gelassen.



Diese Codeteile stammen aus meinem RTC-Testprogramm, dass vorher auf den Blue Pills gespeichert war (zum RTC/VBatt-Test, ob es sich um Fälschungen handelt). Auch erkenne ich Teile aus DateTime.h wieder.

Fazit

Mit OpenOCD als Tool ist ein sehr mühselig, den Programmablauf zu debuggen und zu verstehen, wenn man keinen Source-Code vorliegen hat. Doch wenn man einfach den Speicherinhalt per Dump ausgibt und dann mit anderen Tools, wie hier mit einem Hex-Editor, analysiert, kann man dem STM32 schnell einige Geheimnisse entlocken, die vielleicht gar nicht für einen bestimmt sind.

Im Umkehrschluss heißt das: Wenn einem extern angebrachte Mikrocontroller abhanden kommen, in dessen Programme sensible Daten unverschlüsselt abgelegt sind, seien WLAN-Zugangsdaten oder Logins für Internetseiten oder Account-Informationen für das LoRa-WAN, ist es keine schlechte Idee, diese Zugangsdaten zu ändern, bevor jemand die Gelegenheit ergreift, so erschlichene Daten zu missbrauchen.

Außerdem sollte man den gesamten Flash-Speicher des STM32 (außer den Bootloader natürlich) mit Nullen überschreiben, bevor man ein Programm flasht, wenn man vermeiden will, dass irgendwelche Programmteile alter Programme, die einmal auf dem STM32 waren, bekannt werden. Den Prototypen für Testzwecke behält man also am besten für immer bei sich und gibt nur einmal geflashte frische Blue-Pills an andere, etwa seine Kunden, heraus.

Video

In folgendem Video führe ich ein Debuggen live vor und verliere noch das eine oder andere Wort zu dem Thema: