Long story short
There’s now a script with which you can flash your sonoff device via the original internal OTA upgrade mechanism, meaning, no need to open, solder, etc. the device to get your custom firmware onto it.
This isn’t perfect (yet) — please mind the issues at the end of this post!
https://github.com/mirko/SonOTA
Credits
First things first: Credits!
The problem with credits is you usually forget somebody and that’s most likely happening here as well.
I read around quite a lot, gathered information and partially don’t even remember anymore where I read what (first).
Of course I’m impressed by the entire Tasmota project and what it enables one to do with the Itead Sonoff and similar devices.
Special thanks go to khcnz who helped me a lot in a discussion documented here.
I’d also like to mention Richard Burtons, who I didn’t interact with directly but only read his blog. That guy apparently was too bored by all the amazing tech stuff he was doing for a living, so he took a medical degree and is now working as a doctor, has a passion for horology (meaning, he’s building a turrot clock), is sailing regattas with his own rs200, decompiles and reverse-engineers proprietary bootloaders in his spare time and writes a new bootloader called rboot for the ESP8266 as a side project.
EDIT: Jan Almeroth already reversed some of the protocol in 2016 and also documented the communication between the proprietary EWeLink app and the AWS cloud. Unfortunately I only became aware of that great post after I already finished mine.
Introduction Sonoff devices
Quite recently the Itead Sonoff series — a bunch of ESP8266 based IoT homeautomation devices — was brought to my attention.
The ESP8266 is a low-power consumption SoC especially designed for IoT purposes. It’s sold by Espressif, running a 32-Bit processor featuring the Xtensa instruction set (licensed from Tensilica) and having an ASIC IP core and WiFi onboard.
Those Sonoff devices using this SoC basically expect high voltage input, therewith having an AC/DC (5V) converter, the ESP8266 SoC and a relais switching the high voltage output.
They’re sold as wall switches (“Sonoff Touch”), E27 socket adapters (“Slampher”), power sockets (“S20 smart socket”) or as just — that’s most basic cheapest model — all that in a simple case (“Sonoff Basic”).
They also have a bunch of sensoric devices, measuring temperature, power comsumption, humidty, noise levels, fine dust, etc.
Though I’m rather sceptical about the whole IoT (development) philosophy, I always was (and still am) interested into low-cost and power-saving home automation which is completely and exclusively under my control.
That implies I’m obviously not interested in some random IoT devices being necessarily connected to some Google/Amazon/Whatever cloud, even less if sensible data is transmitted without me knowing (but very well suspecting) what it’s used for.
Guess what the Itead Sonoff devices do? Exactly that! They even feature Amazon Alexa and Google Nest support! And of course you have to use their proprietary app to confgure and control your devices — via the Amazon cloud.
However, as said earlier, they’re based on the ESP8266 SoC, around which a great deal of OpenSource projects evolved. For some reason especially the Arduino community pounced on that SoC, enabling a much broader range of people to play around with and program for those devices. Whether that’s a good and/or bad thing is surely debatable.
I’ll spare you the details about all the projects I ran into, there’s plenty of cool stuff out there.
I decided to go for the Sonoff-Tasmota project which is quite actively developed and supports most of the currently available Sonoff devices.
It provides an HTTP and MQTT interface and doesn’t need any connection to the internet at all. As MQTT sever (in MQTT speech called broker) I use mosquitto which I’m running on my OpenWrt WiFi router.
Flashing custom firmware (via serial)
Flashing your custom firmware onto those devices however always requires opening them, soldering a serial cable, pulling GPIO0 down to get the SoC into programming mode (which, depending on the device type, again involes soldering) and then flash your firmware via serial.
Side note: Why do all those projects describing the flashing procedure name an “FTDI serial converter” as a requirement? Every serial TTL converter does the job.
And apart from that FTDI is not a product but a company, it’s a pretty shady one. I’d just like to remind of the “incident” where FTDI released new drivers for their chips which intentionally bricked clones of their converters.
How to manually flash via serial — even though firmware replacement via OTA (kinda) works now, you still might want unbrick or debug your device — the Tasmota wiki provides instructions for each of the supported devices.
Anyway, as I didn’t want to open and solder every device I intend to use, I took a closer look at the original firmware and its OTA update mechanism.
Protocol analysis
First thing after the device is being configured (meaning, the device got configured by the proprietary app and is therewith now having internet access via your local WiFi network) is to resolve the hostname `eu-disp.coolkit.cc` and attempt to establish a HTTPS connection.
Though the connection is SSL, it doesn’t do any server certificate verification — so splitting the SSL connection and *man-in-the-middle it is fairly easy.
As a side effect I ported the mitm project sslsplit to OpenWrt and created a seperate “interception”-network on my WiFi router. Now I only need to join that WiFi network and all SSL connections get split, its payload logged and being provided on an FTP share. Intercepting SSL connections never felt easier.
Back to the protocol: We’re assuming at this point the Sonoff device was already configured (e.g. by the official WeLink app) which means it has joined our WiFi network, acquired IP settings via DHCP and has access to the internet.
The Sonoff device sends a dispatch call as HTTPS POST request to eu-disp.coolkit.cc including some JSON encoded data about itself:
POST /dispatch/device HTTP/1.1
Host: eu-disp.coolkit.cc
Content-Type: application/json
Content-Length: 152
{
"accept": "ws;2",
"version": 2,
"ts": 119,
"deviceid": "100006XXXX",
"apikey": "6083157d-3471-4f4c-8308-XXXXXXXXXXXX",
"model": "ITA-GZ1-GL",
"romVersion": "1.5.5"
}
It expects an also JSON encoded host as an answer
HTTP/1.1 200 OK
Server: openresty
Date: Mon, 15 May 2017 01:26:00 GMT
Content-Type: application/json
Content-Length: 55
Connection: keep-alive
{
"error": 0,
"reason": "ok",
"IP": "52.29.48.55",
"port": 443
}
which is used to establish a WebSocket connection
GET /api/ws HTTP/1.1
Host: iotgo.iteadstudio.com
Connection: upgrade
Upgrade: websocket
Sec-WebSocket-Key: ITEADTmobiM0x1DaXXXXXX==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: q1/L5gx6qdQ7y3UWgO/TXXXXXXA=
which consecutively will be used for further interchange.
Payload via the established WebSocket channel continues to be encoded in JSON.
The messages coming from the device can be classified into action-requests initiated by the device (which expect ackknowledgements by the server) and acknowledgement messages for requests initiated by the server.
The first requests are action-requests coming from the device:
1) action: register
{
"userAgent": "device",
"apikey": "6083157d-3471-4f4c-8308-XXXXXXXXXXXX",
"deviceid": "100006XXXX",
"action": "register",
"version": 2,
"romVersion": "1.5.5",
"model": "ITA-GZ1-GL",
"ts": 712
}
responded by the server with
{
"error": 0,
"deviceid": "100006XXXX",
"apikey": "85036160-aa4a-41f7-85cc-XXXXXXXXXXXX",
"config": {
"hb": 1,
"hbInterval": 145
}
}
As can be seen, action-requests initiated from server side also have an apikey field which can be — as long its used consistently in that WebSocket session — any generated UUID but the one used by the device.
2) action: date
{
"userAgent": "device",
"apikey": "85036160-aa4a-41f7-85cc-XXXXXXXXXXXX",
"deviceid": "100006XXXX",
"action" :"date"
}
responded with
{
"error": 0,
"deviceid": "100006XXXX",
"apikey": "85036160-aa4a-41f7-85cc-XXXXXXXXXXXX",
"date": "2017-05-15T01:26:01.498Z"
}
Pay attention to the date format: it is some kind ISO 8601 but the parser is really picky about it. While python’s datetime.isoformat() function e.g. returns a string taking microseconds into account, the parser on the device will just fail parsing that. It also always expects the actually optional timezone being specified as UTC and only as a trailing
Z
(though according to the spec “00:00” would be valid as well).
3) action: update — the device tells the server its switch status, the MAC address of the accesspoint it is connected to, signal quality, etc.
This message also appears everytime the device status changes, e.g. it got switched on/off via the app or locally by pressing the button.
{
"userAgent": "device",
"apikey": "85036160-aa4a-41f7-85cc-XXXXXXXXXXXX",
"deviceid": "100006XXXX",
"action": "update",
"params": {
"switch": "off",
"fwVersion": "1.5.5",
"rssi": -41,
"staMac": "5C:CF:7F:F5:19:F8",
"startup": "off"
}
}
simply acknowlegded with
{
"error": 0,
"deviceid": "100006XXXX",
"apikey": "85036160-aa4a-41f7-85cc-XXXXXXXXXXXX"
}
4) action: query — the device queries potentially configured timers
{
"userAgent": "device",
"apikey": "85036160-aa4a-41f7-85cc-XXXXXXXXXXXX",
"deviceid": "100006XXXX",
"action": "query",
"params": [
"timers"
]
}
as there are no timers configured the answer simply contains a
"params":0
KV-pair{
"error": 0,
"deviceid": "100006XXXX",
"apikey": "85036160-aa4a-41f7-85cc-XXXXXXXXXXXX",
"params": 0
}
That’s it – that’s the basic handshake after the (configured) device powers up.
Now the server can tell the device to do stuff.
The sequence number is used by the device to acknowledge particular action-requests so the response can be mapped back to the actual request. It appears to be a UNIX timestamp with millisecond precision which doesn’t seem like the best source for generating a sequence number (duplicates, etc.) but seems to work well enough.
Let’s switch the relais:
{
"action": "update",
"deviceid": "100006XXXX",
"apikey": "85036160-aa4a-41f7-85cc-XXXXXXXXXXXX",
"userAgent": "app",
"sequence": "1494806715179",
"ts": 0,
"params": {
"switch": "on"
},
"from": "app"
}
{
"action": "update",
"deviceid": "100006XXXX",
"apikey": "85036160-aa4a-41f7-85cc-XXXXXXXXXXXX",
"userAgent": "app",
"sequence": "1494806715193",
"ts": 0,
"params": {
"switch": "off"
},
"from": "app"
}
As mentioned earlier, each action-request is responded with proper acknowledgements.
And — finally — what the server now also is capable doing is to tell the device to update itself:
{
"action": "upgrade",
"deviceid": "100006XXXX",
"apikey": "85036160-aa4a-41f7-85cc-XXXXXXXXXXXX",
"userAgent": "app",
"sequence": "1494802194654",
"ts": 0,
"params": {
"binList":[
{
"downloadUrl": "http://52.28.103.75:8088/ota/rom/xpiAOwgVUJaRMqFkRBsoI4AVtnozgwp1/user1.1024.new.2.bin",
"digest": "1aee969af1daf96f3f120323cd2c167ae1aceefc23052bb0cce790afc18fc634",
"name": "user1.bin"
},
{
"downloadUrl": "http://52.28.103.75:8088/ota/rom/xpiAOwgVUJaRMqFkRBsoI4AVtnozgwp1/user2.1024.new.2.bin",
"digest": "6c4e02d5d5e4f74d501de9029c8fa9a7850403eb89e3d8f2ba90386358c59d47",
"name": "user2.bin"
}
],
"model": "ITA-GZ1-GL",
"version": "1.5.5",
}
}
After successful download and verification of the image’s checksum the device returns:
{
"error": 0,
"userAgent": "device",
"apikey": "85036160-aa4a-41f7-85cc-XXXXXXXXXXXX",
"deviceid": "100006XXXX",
"sequence": "1495932900713"
}
The downloadUrl
field should be self-explanatory (the following HTTP GET request to those URLs contain some more data as CGI parameters which however can be ommitted).
The digest
is a sha256 hash of the file and the name is the partition onto which the file should be written on.
Implementing server side
After some early approaches I decided to go for a Python implementation using the tornado webserver stack.
This decision was mainly based on it providing functionality for HTTP (obviously) as well as websockets and asynchronous handling of requests.
The final script can be found here: https://github.com/mirko/SonOTA
==> Trial & Error
1st attempt
As user1.1024.new.2.bin
and user2.20124.new.2.bin
almost look the same, let’s just use the same image for both, in this case a tasmota build:
MOEP! Boot fails.
Reason: The tasmota build also contains the bootloader which the Espressif OTA mechanism doesn’t expect being in the image.
2nd attempt
Chopping off the first 0x1000
bytes which contain the bootloader plus padding (filled up with 0xAA
bytes).
MOEP! Boot fails.
Boot mode 1 and 2 / v1 and v2 image headers
The (now chopped) image and the original upgrade images appear to have different headers — even the very first byte (the files’ magic byte) differ.
The original image starts with 0xEA
while the Tasmota build starts with 0xE9
.
Apparently there are two image formats (called v1 and v2 or boot mode 1 and boot mode 2).
The former (older) one — used by Arduino/Tasmota — starts with 0xE9
, while the latter (and apparently newer one) — used by the original firmware — starts with 0xEA
.
The technical differences are very well documented by the ESP8266 Reverse Engineering Wiki project, regarding the flash format and the v1/v2 headers in particular the SPI Flash Format wiki oage.
The original bootloader only accepts images starting with 0xEA
while the bootloader provided by Arduino/Tasmota only accepts such starting with 0xE9
.
3rd attempt
Converting Arduino images to v2 images
Easier said than done, as the Arduino framework doesn’t seem to be capable of creating v2 images and none of the common tools appear to have conversion functionality.
Taking a closer look at the esptool.py project however, there seems to be (undocumented) functionality.
esptool.py has the elf2image
argument which — according source — allows switching between conversion to v1 and v2 images.
When using elf2image
and also passing the --version
parameter — which normally prints out the version string of the tool — the --version
parameter gets redefined and expects an then argument: 1
or 2
.
Besides the sonoff.ino.bin file the Tasmota project also creates an sonoff.ino.elf which can now be used in conjunction with esptool.py and the elf2image
-parameter to create v2 images.
Example: esptool.py elf2image --version 2 tmp/arduino_build_XXXXXX/sonoff.ino.elf
WORKS! MOEP! WORKS! MOEP!
Remember the upgrade
-action passed a 2-element list of download URLs to the device, having different names (user1.bin and user2.bin)?
This procedure now only works if the user1.bin image is being fetched and flashed.
Differences between user1.bin and user2.bin
The flash on the Sonoff devices is split into 2 parts (simplified!) which basically contain the same data (user1 and user2). As OTA upgrades are proven to fail sometimes for whatever reason, the upgrade will always happen on the currently inactive part, meaning, if the device is currently running the code from the user1 part, the upgrade will happen onto the user2 part.
That mechanism is not invented by Itead, but actually provided as off-the-shelf OTA solution by Espressif (the SoC manufacturer) itself.
For 1MB flash chips the user1 image is stored at offset 0x01000
while the user2 image is stored at 0x81000
.
And indeed, the two original upgrade images (user1 and user2) differ significantly.
If flashing a user2 image onto the user1 part of the flash the device refuses to boot and vice versa.
While there’s not much information about how user1.bin and user2.bin technically differ from each other, khcnz pointed me to an Espressif document stating:
user1.bin and user2.bin are [the] same software placed to different regions of [the] flash. The only difference is [the] address mapping on flash.
4th attempt
So apparently those 2 images must be created differently indeed.
Again it was khcnz who pointed me to different linker scripts used for each image within the original SDK.
Diffing
https://github.com/espressif/ESP8266_RTOS_SDK/blob/master/ld/eagle.app.v6.new.1024.app1.ld
and
https://github.com/espressif/ESP8266_RTOS_SDK/blob/master/ld/eagle.app.v6.new.1024.app2.ld
reveals that the irom0_0_seg differs (org = 0x40100000
vs. org = 0x40281010
).
As Tasmota doesn’t make use of the user1-/user2-ping-pong mechanism it conly creates images supposed to go to 0x1000
(=user1-partition).
So for creating an user2.bin image — in our case for a device having a 1MB flash chip and allocating (only) 64K for SPIFFS — we have to modify the following linker script accordingly:
--- a/~/.arduino15/packages/esp8266/hardware/esp8266/2.3.0/tools/sdk/ld/eagle.flash.1m64.ld
+++ b/~/.arduino15/packages/esp8266/hardware/esp8266/2.3.0/tools/sdk/ld/eagle.flash.1m64.ld
@@ -7,7 +7,7 @@ MEMORY
dport0_0_seg : org = 0x3FF00000, len = 0x10
dram0_0_seg : org = 0x3FFE8000, len = 0x14000
iram1_0_seg : org = 0x40100000, len = 0x8000
- irom0_0_seg : org = 0x40201010, len = 0xf9ff0
+ irom0_0_seg : org = 0x40281010, len = 0xf9ff0
}
PROVIDE ( _SPIFFS_start = 0x402FB000 );
So we will now create an user1 (without above applied modification> and an user2 (with above modification> image and converting them to v2 images with esptool.py as described above.
–> WORKS!
Depending on whether the original firmware was loaded from the user1 or user2 partition, it will fetch and flash the other image, telling the bootloader afterwards to change the active partition.
Issues
Mission accomplished? Not just yet…
Although our custom firmware is now flashed via the original OTA mechanism and running, the final setup differs in 2 major aspects (compared to if we would have flashed the device via serial):
- The bootloader is still the original one
- Our custom image might have ended up in the user2 partition
Each point alone already results in the Tasmota/Adruino OTA mechniasm not working.
Additionally — since the bootloader stays the original one — it still only expects v2 images and still messes with us with its ping-pong-mechanism.
This issue is already being addressed though and discussed on how to be solved best in the issue ticket mentioned at the very beginning.
Happy hacking!