Mr. Go with ESP reverse engineering
We received a bunch of IR-controlled LED cubes which had been ... touched:
An ESP8266 board had been wired into the on-board IR receiver; said receiver was hooked up to U1, an unlabeled PWM driver. The thinking probably was that if the board can be remotely controlled by some IR protocol, it can be remotely controlled by a bitbanged simulacra of the same.
Reverse engineering, with a dump
I didn't have a remote that can drive the unlabeled infrared-directed LED PWM controller U1, so I tried the next best thing: dumping the firmware of the ESP8266 board attached to thea IR sensor's output pins.
esptool is espcool
https://cyberblogspot.com/how-to-save-and-restore-esp8266-and-esp32-firmware/
esptool
allows you to manipulate Espressif ESP boards connected over serial;
as far as I can tell, it resets the board, and chats with the ESP
BootROM to copy and execute a utility stub with which the host-side
esptool can interact. In this case I gathered details about the
board using the flash_id command:
mkennedy@jafar:~/blog$ esptool flash_id
esptool.py v4.7.0
Found 33 serial ports
Serial port /dev/ttyUSB0
Connecting....
Detecting chip type... Unsupported detection protocol, switching and trying again...
Connecting....
Detecting chip type... ESP8266
Chip is ESP8266EX
Features: WiFi
Crystal is 26MHz
MAC: dc:4f:22:10:94:00
Uploading stub...
Running stub...
Stub running...
Manufacturer: 20
Device: 4016
Detected flash size: 4MB
Hard resetting via RTS pin...
Knowing my ESP8266 had 4MB (0x400000 B) of integrated flash, I dumped it as such:
esptool --baud 115200 --port /dev/ttyUSB0 read_flash 0x0 0x400000 fw-backup-4M.bin
And, now strings! I saw tasmota in the output, and got excited,
before realizing that, oops, this is one I'd already flashed with
Tasmota:
mkennedy@jafar:~/blog/mr_go_led_cube$ strings fw-backup-4M.bin
:
Laboratory B # our SSID
<our WiFi password>
:
tasmota_%06X
:
I switch for another cube and try the same procedure again.
Dumping again
Most serial adapters are capable of higher baud rates -- I tested 921600 baud to speed up the second dump:
mkennedy@jafar:~/blog/mr_go_led_cube$ esptool --baud 921600 --port /dev/ttyUSB0 read_flash 0x0 0x400000 fw-backup-4M.bin
esptool.py v4.7.0
:
Changing baud rate to 921600
Changed.
4194304 (100 %)
4194304 (100 %)
Read 4194304 bytes at 0x00000000 in 51.9 seconds (646.0 kbit/s)...
Hard resetting via RTS pin...
Success! I performed this twice and took a diff, and the two dumps were identical, so we're in business.
strings ...
I ran strings on the new firmware dump, and found a whole Charlie board worth of crumb trails ...
IMG: Charlie red string meme, with impact font. Reuse a quote, replacing "Pepe Silvia" with "PETdemo"
ap": {
"enable": true,
"ssid": "cube-00",
"pass": "cubesAPpass",
"channel": 6,
"max_connections": 10,
"ip": "192.168.4.1",
"netmask": "255.255.255.0",
"gw": "192.168.4.1",
"dhcp_start": "192.168.4.2",
"dhcp_end": "192.168
.4.100",
"trigger_on_gpio": -1,
"keep_enabled": true
},
"sta": {
"enable": true,
"ssid": "PETdemo",
"pass": "packetnrg"
},
This seems relevant. On my laptop, I scanned for networks, and connected to the SSID cube-16 (matching the paper label on this board), and the password cubesAPpass got me in.
From here, I opened Firefox to 192.168.4.1, and:
IMG: 2024-09-07T13:56:32-04:00 screenshot of webpage
Networking
Using the board as an AP to my workstation's STA makes internet research harder, so let's set up an AP VIF with SSID PETdemo nearby.
Once the AP VIF was online on channel 6, I rebooted the board, and in a moment:
root@iakob:~# iw dev
:
Interface cube2
ifindex 17
wdev 0x5
addr 16:91:82:95:26:a2
ssid PETdemo
type AP
channel 6 (2437 MHz), width: 20 MHz, center1: 2437 MHz
txpower 30.00 dBm
multicast TXQ:
qsz-byt qsz-pkt flows drops marks overlmt hashcol tx-bytes tx-packets
0 0 94 0 0 0 0 10796 94
:
And our DHCP server has it!
root@gandalf:~# logread
:
Sat Sep 7 14:00:49 2024 daemon.info dnsmasq-dhcp[25723]: DHCPDISCOVER(br-lan) dc:4f:22:10:8d:70
Sat Sep 7 14:00:49 2024 daemon.info dnsmasq-dhcp[25723]: DHCPOFFER(br-lan) 10.0.7.152 dc:4f:22:10:8d:70
Sat Sep 7 14:00:49 2024 daemon.info dnsmasq-dhcp[25723]: DHCPREQUEST(br-lan) 10.0.7.152 dc:4f:22:10:8d:70
Sat Sep 7 14:00:49 2024 daemon.info dnsmasq-dhcp[25723]: DHCPACK(br-lan) 10.0.7.152 dc:4f:22:10:8d:70 cube-16
:
Not sure what I was expecting to change:
IMG: 2024-09-07T14:03:46-04:00 screenshot. It shows the webpage has not changed.
nmap agrees, there's not much server-side going on here:
mkennedy@jafar:~$ nmap -Pn 10.0.7.152/32 -vvvv
:
PORT STATE SERVICE REASON
80/tcp open http syn-ack
:
OK, so what is it doing?
root@iakob:~# tcpdump -i br-lan ether host dc:4f:22:10:8d:70
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on br-lan, link-type EN10MB (Ethernet), capture size 262144 bytes
14:06:51.662194 IP cube-16.lan.laboratoryb.org.45368 > home.laboratoryb.org.53: 43776+ A? amcqcgzs4o4el.iot.us-east-1.amazonaws.com. (59)
14:06:51.684355 IP home.laboratoryb.org.53 > cube-16.lan.laboratoryb.org.45368: 43776 NXDomain 0/1/0 (136)
14:06:56.732683 ARP, Request who-has cube-16.lan.laboratoryb.org tell home.laboratoryb.org, length 46
14:06:56.740107 ARP, Reply cube-16.lan.laboratoryb.org is-at dc:4f:22:10:8d:70 (oui Unknown), length 46
# unplug, re-plug
14:07:39.563487 dc:4f:22:10:8d:70 (oui Unknown) > Broadcast Null Unnumbered, xid, Flags [Response], length 6: 01 00
14:07:39.568304 IP 0.0.0.0.68 > 255.255.255.255.67: BOOTP/DHCP, Request from dc:4f:22:10:8d:70 (oui Unknown), length 308
14:07:39.569479 IP home.laboratoryb.org.67 > cube-16.lan.laboratoryb.org.68: BOOTP/DHCP, Reply, length 313
14:07:39.606896 IP 0.0.0.0.68 > 255.255.255.255.67: BOOTP/DHCP, Request from dc:4f:22:10:8d:70 (oui Unknown), length 308
14:07:39.611395 IP home.laboratoryb.org.67 > cube-16.lan.laboratoryb.org.68: BOOTP/DHCP, Reply, length 313
14:07:39.614915 ARP, Request who-has cube-16.lan.laboratoryb.org tell 0.0.0.0, length 28
14:07:39.941547 ARP, Request who-has cube-16.lan.laboratoryb.org tell 0.0.0.0, length 28
14:07:40.442128 ARP, Request who-has cube-16.lan.laboratoryb.org tell cube-16.lan.laboratoryb.org, length 28
14:07:40.522697 ARP, Request who-has home.laboratoryb.org tell cube-16.lan.laboratoryb.org, length 28
14:07:40.522944 ARP, Reply home.laboratoryb.org is-at b4:75:0e:69:0a:22 (oui Unknown), length 46
14:07:40.526392 IP cube-16.lan.laboratoryb.org.45358 > home.laboratoryb.org.53: 41216+ A? amcqcgzs4o4el.iot.us-east-1.amazonaws.com. (59)
14:07:40.526954 IP home.laboratoryb.org.53 > cube-16.lan.laboratoryb.org.45358: 41216 NXDomain 0/0/0 (59)
14:07:40.558685 IP cube-16.lan.laboratoryb.org > all-systems.mcast.net: igmp v2 report all-systems.mcast.net
14:07:42.492738 IP cube-16.lan.laboratoryb.org.45359 > home.laboratoryb.org.53: 41472+ A? amcqcgzs4o4el.iot.us-east-1.amazonaws.com. (59)
14:07:42.494259 IP home.laboratoryb.org.53 > cube-16.lan.laboratoryb.org.45359: 41472 NXDomain 0/0/0 (59)
... the minimalism appears to be a theme, and should not be surprising.
So, we're not going to get a backdoor in the existing firmware -- nor, honestly, is that a reasonable goal. Instead, let's focus on the IR LED controller library being used, if any.
Mongoose OS - maybe our library is packaged?
Strings on the webpage, and in the dump, indicate the use of an OS build base called Mongoose OS:
IMG: 2024-09-07T14:15:22-04:00 screenshot of a 502, womp womp, mongoose' community forum is gonezo
... oh, that Mongoose OS. Pepe Silvia is reduced to an echo.
However, the Docker image this was built on is still available:
mkennedy@jafar:~/blog/mr_go_led_cube$ strings fw-backup-4M.bin | less -SR
:
, "build_image": "docker.io/mgos/esp8266-build:2.2.1-1.5.0-r4"
:
mkennedy@jafar:~$ podman image pull mgos/esp8266-build:2.2.1-1.5.0-r4
✔ docker.io/mgos/esp8266-build:2.2.1-1.5.0-r4
Trying to pull docker.io/mgos/esp8266-build:2.2.1-1.5.0-r4...
Getting image source signatures
Copying blob b2c3620e896f done |
:
Copying config 1a393c77ce done |
Writing manifest to image destination
1a393c77ce99780a85765b0c456583e35017cd71751c518872d6c63441c043ff
Neato! What's in it?
mkennedy@jafar:~$ podman run -it 1a393c77ce99 /bin/bash
root@b98bb413dd9e:/# grep -r /opt -ie infrared
root@b98bb413dd9e:/#
Sigh.
So, we're not going to find the library by building from source. But I have the dump, and I've connected the board to my network with details from it -- maybe there's other hints in the dump!
Remember: I only need a reference implementation of the signaling used by the LED PWN driver IC. I don't actually need the code -- I can write that.
RPC: Really Patient cURL
I noted there was config previously
todo: grab strings output showing /rpc enabled.
I've never been so happy to be told to buzz off by an LED cube:
mkennedy@jafar:~$ # Q: Knock Knock?
mkennedy@jafar:~$ curl 'http://10.0.7.152/rpc'
Invalid request
mkennedy@jafar:~$ # A: Go Away!
So, how about other requests?
mkennedy@jafar:~$ curl --header "Content-Type: application/json" 'http://10.0.7.152/rpc/status-v1'
No handler for status-v1
Well, now! No handler for %.*s is straight out of the strings output, so I'm onto something here.
I ended up chucking a bunch of strings out of the dump into this curl, until I reach the successful Cube.TurnOn:
curl --header "Content-Type: application/json" -vvv 'http://10.0.7.152/rpc/Cube.TurnOn'
... at which point I am blinded by the light, which is sitting about 20cm from my face. This is quickly followed by Cube.TurnOff.
The other RPC endpoints
- Cube.SetColor
- Cube.TurnOn
- Cube.TurnOff
- Cube.ListColors
I immediately try Cube.ListColors, which produces nothing. However, some attempts later,
curl --header "Content-Type: application/json" 'http://10.0.7.152/rpc/Cube.SetColor' --data '{"color":"CUBE_BLUE"}'
... turns the LEDs blue - and among the others mentioned in the dump (CUBE_{RED,GREEN,BLUE,WHITE,ORG_RED,LT_GREEN,LT_BLUE,FLASH,ORANGE,SKY_BLUE,DK_PURPLE,STROBE,YEL_ORANG,AQUA,PURPLE,FADE,YELLOW,TEAL,PINK,SMOOTH}), the few I tested follow without issue.
Scoping things out
Now that I have a reference implementation, I can figure out what the ESP8266 sends to our unabeled U1.
I initially assumed this was a serial protocol, but got nowhere decoding it as such using our Rigol MSO5074's Decode function.
IMG: 2024-09-07T15:56:46-04:00: Rigol view. Clearly, we have some odd non-serial protocol, because we have these long-haul "0x00"s, and other than those, all "low" pulses are only one pulse long (never more).
I worked out a "baud rate" of 1800, based on a ~555us for the short pulses
IMG: 2024-09-07T15:58:53-04:00: odd pulse length of 555us.
... and after some Google-fu, found this EEVblog post, where forums users quickly identified the same protocol as the NEC protocol!
WHAT?!
I swore I tried NEC! What did I mess up?!
Perhaps the issue was not knowing what to send through NEC?
N: I attempted to decode what I had on the screen, and generated:
irsend { "Protocol": "NEC", "Bits": 32, "Data": 133710334}
... which resulted in the following view:
IMG: 2024-09-07T16:36:27-04:00: Osc showing extra nonsense.
Looks like Tasmota is eager to also generate the 38kHz carrier frequency for the NEC protocol. Ugh.
Sending it, raw!
N: Since I need a zero carrier-frequency ("always on"), I tried an adjustment of the example they posted:
IRSend raw,1,8620,4260,544,411,1496,010101101000111011001110000000001100110000000001100000000000000010001100
... they break down this example into:
1: carrier frequeny8620: header mark4260: header space544: bit mark411: zero space1496: one space
So, going back to that article ...
I'd reckon:
- Keep the carrier frequency of 1 (we don't modulate it here),
- Header mark: 9000
- header space: 4500
- bit mark: 563
- zero space: 562
- one space: 1687
So, that's
IRsend raw,1,9000,4500,563,562,1687,11111111000010000011111111000000
Unfortunately, U1 did not respond to this stream. I can think of a few reasons:
- The timing was a bit odd (a bit mark and zero-space would take 500ms and 600ms, respectively -- could be a misconfiguration of the timer in the PWM setup, or could be simply poorly timed sender code?)
- Because the D1 pin of the ESP stays at logic low by default, the NEC frame's header was not correctly sent (the start of the header mark wasn't visible). I could not see a way in Tasmota-IR to change the default level here.
After a bit more tracing with my probes, I confirmed that all U1 was doing was generating PWM to drive a set of four SOT-23 transistors (Q3-Q6) through some resistors; the through-hole pads in the center of the board were connected directly to these same lines (with a convenient GND pin at position 5). So, let's do this another way.
Goodbye, U1!
U_1 is U_Gone. Enter new problems.
I snagged a WeMoS D1 mini -- also an ESP8266 board, but with a more amenable profile -- and flashed it with WLED. I soldered a female connector onto the RGBW channel pins, plus ground, and connected the D1 Mini's D{1,2,3,4} and G pins to it.
I found out after some play that the ESP8266 must be able to pull its GPIO4 (the D2 pin) high during boot, and anything pulling it lower will cause a boot loop; the speed of said boot loop had the LEDs shining bright green.
I swapped from D[1234] to [D8765], ran a purpose-fit ground wire, and went to configuring.
Y U Reboot?
While playing with WLED, the board would occasionally reset (wtf?). I chalked this up to the voltage regulator browning out, but crashes only ever occurred while moving sliders or colorwheel positions, and the voltage spread during brightest operation was not very wide (from ~4.6?V to 5.1V):
IMG: 2024-09-08T13:34:32-04:00: Photo of oscilloscope, showing the wide voltage variation
I probed the 3.3V pin on the board, and nailed the coffin shut on that theory.
I did some further research, and it seems that WLED has issues with crashing when the WebUI is being used. Since the board does boot right back up after this, and since I wasn't about to stop using WLED, I shrugged and moved on.
Packaging and wrapping up
I reused a sacrificed USB cable to supply 5V power:
IMG: /home/mkennedy/Sync/Camera5/Camera/PXL_20240908_183148185.jpg
... and screwed it all back together:
GIF: 2024-09-08T15:28:43-04:00: Cube in heartbeat mode
