Reverse engineering the Mitsubishi heat-pump WiFi adapter
Posted on Sat 04 June 2022 in reverse-engineering
Recently we had a heat pump (A/C, air conditioner, AirCon) installed. The indoor units came with the usual IR remote, but they also had a built-in WiFi module along with a smartphone app to control them from anywhere. I'm not a big fan of connecting home appliances to clouds for a variety of reasons:
-
Running a cloud service requires a continuous stream of money to keep the service running. So either you will have to pay some sort of subscription, or the service will (eventually) stop working. There is also the possibility that the manufacturer is very altruistic, but that isn't very likely.
-
If my appliance is connected to the internet, the manufacturer can choose to alter its functionality. Or make it stop working entirely.
-
Connecting things to the cloud makes them more vulnerable to attacks. Right now, an attacker needs to be within infrared range of my unit (a few meters) to control it. Once the device phones home to its cloud, an attacker can potentially control thousands of units from their couch.
Don't get me wrong. I love when appliances offer some sort of API to control them externally. And I see why a cloud-service is a nice addition for a lot of people. But I prefer local control.
The Mitsubishi WiFi adapter
My heat pump came with a MAC-577IF2-E adapter built-in. So I set out to try and locally control the unit via WiFi. If you're in a hurry, I'll save you some time: I didn't succeed. But here is what I did find out, in the hope it can be useful for other people.
I started by looking around to interesting projects: I found the meldec GitHub project that can decode the messages sent to MELcloud. Another related project I found was HeatPump, but that focuses on communicating directly with the unit over its serial interface.
First discoveries
For setup, I switched the adapter into Access Point mode. After associating with it using the SSID & WPA-PSK printed on the box, I could connect to a web-server to configure the network settings. The page was fairly simple:
Once the details were filled in, it connected to my home WiFi network just fine.
The MAC address of the unit stats with 70:61:BE
, which is assigned to Wistron Neweb Corporation
.
After requesting an IP via DHCP, it started to phone home, of course.
First thing it did was a DNS-lookup for production.receiver.melcloud.com
.
The first request to that domain is a plain-text HTTP POST-request to /synchro
:
POST /synchro HTTP/1.1
Host: production.receiver.melcloud.com:80
Accept: */*
User-Agent: MAC-577IF-E
Pragma: no-cache
Content-type: text/plain; charset=UTF-8
Content-Length: 68
<?xml version="1.0" encoding="UTF-8"?><LSV><SYNCHRO></SYNCHRO></LSV>
The response contained the current datetime:
HTTP/1.0 200 OK
Cache-Control: private
Content-Type: text/html; charset=utf-8
Date: Sat, 4 Jun 2022 09:47:47 GMT
Server: Microsoft-IIS/10.0
X-Robots-Tag: noindex, nofollow, noimageindex
<?xml version="1.0" encoding="UTF-8"?><CSV><SYNCHRO><DATE>2022/06/04 09:47:47</DATE></SYNCHRO></CSV>
So far so good.
The next connection, however, was an encrypted HTTPS request to the same domain.
production.receiver.melcloud.com
presents an HTTPS certificate that is signed by MELCloud Root Authority
,
and thus not trusted by browsers (by default).
I tried to redirect this request to my own server, presenting a look-alike certificate chain.
But the device refused with an Unknown CA
error:
it didn't like my own spoofed CA and wanted to see the actual Mitsubishi CA.
Normally, I consider this a good thing for security, but in this particular case it's a bummer...
Inbound
There also seems to be a webserver listening on the device itself on TCP port 80. But according to nmap, there is nothing else listening on TCP (all other ports are connection-refused). Note that a normal nmap-scan crashes the web server, so you won't see it as open on a consecutive scan.
But the web server seems to be locked down: requests to /
require authentication, which I don't have/know:
> GET / HTTP/1.1
> Host: IP-address-of-unit-omitted-for-privacy
> User-Agent: curl/7.79.1
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< Content-Type: text/plain
< Content-Length: 22
< WWW-Authenticate: Basic realm="generic"
<
< Authorization Required
Some URLs do work without authentication: /license
, /common.css
, /javaScript.js
work.
Others return a 404 instead of a 401.
Digging deeper
Someone posted the firmware to GitHub. Time to brush off my reverse engineering skills.
Based on a simple strings
of the firmware, I found C:\sdk-ameba-v4.0c\component\common\mbed\targets\hal\rtl8195a\gpio_api.c
.
So it looks like the hardware may be an Ameba 1.
This houses an ARM Cortex M3 CPU clocked at 166MHz with 2.5MB of SRAM and built-in WiFi (2.4GHz only).
I used radare2 to disassemble the binary,
and kept the ARM Cortex M3 Instruction Set Reference nearby.
It looks like the SDK for this SoC is available on GitHub.
The Flash layout section of the memory layout PDF from the SDK contains some information on the boot loader process.
From the description, it seems that an image starts with a 4-byte length, a 4-byte address, and 8 bytes of 0xffffffffffffffff
.
The Memory Mapping section of the RTL8195A data sheet tells what address is mapped to what hardware.
Based on that info, the firmware contains two sections:
- A first section of 0x4aefc = 306_940 bytes, to be loaded at 0x10006000, which is in SRAM
- A second section of 0x10cea4 = 1_101_476 bytes, to be loaded at 0x30000000, which is in SDRAM
- The final 4 bytes of the file. Maybe a CRC of some sort?
While scrolling through the strings
output, the function at 0x3001de60
caught my eye:
It's the only place to reference the string 401 Unauthorized
.
The only place where this function is referenced, is around 0x30028984
.
This section loads the string Authorization
, and calls a function (I'm guessing to find this header).
Next, it checks if the returned value (?) starts with Basic
, and passes the rest of the value to 0x30028820
.
0x30028820
seems to be the "verify authorization"-function.
It seems to iterate through a linked list of entries, checking the path (?).
If the entry matches, the base64-auth-value is compared to another attribute of the entry.
If it matches, the "return a 401"-part is skipped.
Interestingly, the comparison is done by first memcmp()
ing, and later verifying that strlen()
is equal.
The actual memcpy()
implementation is in ROM, but it's possible this is vulnerable to a timing side channel
(although doing timing side channels over WiFi is hard...).
Unfortunately, this function just checks the auth-value against a pre-computed list.
So I still need to find the code that generates this list...
The list is loaded from *(*(0x30029130)+0x18)
.
0x30029130
contains 0x1004f134
, but it may be overwritten by the time we get here.
0x1004f134+0x18
= 0x1004f14c
contains 0x00000000
at bootup.
This will definitely be overwritten, since otherwise no authentication will work (the linked list has 0 entries).
And I know that /config
can be accessed with username user
and the Key-password printed on the device.
Trying another approach
0x300696bc
looks interesting: it seems to contain a list of paths & usernames.
The entries seem to have the odd size of 0xe7=231 bytes.
Path \ user | user | root | suser | admin |
---|---|---|---|---|
/network | x | x | x | |
/unitinfo | x | x | x | |
/service | x | x | x | |
/server | x | x | ||
/update | x | x | ||
/updateFinish | x | x | ||
/updateFail | x | x | ||
/default | x | x | ||
/analyze | x | |||
/apinfo | x | x | x | |
/config | x | x | x | x |
/smart | ||||
/adapter_image2.gif | ||||
/javaScript.js | ||||
/common.css | ||||
/license | ||||
/ | x | x | x |
Based on /license
, I'm assuming "no user listed" means "unauthenticated".