This blog post will give a brief overview about how a simple IoT device can be assessed. It will show a basic methodology, what tools can be used for different tasks and how to solve problems that may arise during analyses. It is aimed at readers that are interested in how such a device can be assessed, those with general interest in reverse engineering or the ones who just want to see how to technically approach an unknown device.
This post will most likely not cover any vulnerabilities per se. However, it outlines weaknesses which affect a wide range of IoT devices so various aspects are applicable to other devices and scenarios.
The Target
The target that will be analyzed is the MAX! Cube LAN Gateway (called „Cube“ in the following) by eQ-3. It is available from different re-sellers in various bundles like the heating control system I got. Like the name suggest the Cube is just a LAN gateway that communicates with the actual “things” or “smart devices” or whatever they are called via RF (not in scope of this blog post). This blog post will focus on the Ethernet communication, mostly the communication with the management software.
Creating a Man-in-the-Middle Setup
In order to see the complete device communication, I‘ve created a simple Man-in-the-Middle setup. I used a USB NIC on my system and connected it directly to the Cube. First, I just powered on the Cube without any management client or other communication on the link just to see what packets are sent by the Cube. It was not too fancy, it just tries to get an IP via DHCP and tries to resolve ntp.homematic.com.
In order to enable Internet access for the Cube I‘ve configured my USB NIC as a router for the Cube. Without DHCP the Cube is accessible at 192.168.0.222, so I configured my device with the IP address 192.168.0.1/24 and configured the following in order to allow Internet access via NAT on the USB NIC:
sysctl net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -o enp0s25 -j MASQUERADE
iptables -A FORWARD -i enp0s20u9u3 -o enp0s25 -j ACCEPT
iptables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
Note: The device enp0s20u9u3 is my USB NIC which is connected to the Cube, enp0s25 is another NIC on my system that is connected to a router with Internet access.
The Cube will send DNS queries to the default router, so either we setup a DNS server on our machine, or just forward DNS queries to a proper nameserver (e.g. OpenDNS servers):
iptables -t nat -A PREROUTING -i enp0s20u9u3 -p udp --dport 53 -j DNAT --to 208.67.222.222:53
iptables -t nat -A PREROUTING -i enp0s20u9u3 -p tcp --dport 53 -j DNAT --to 208.67.222.222:53
With this setup we are now able to see all the packets send by the Cube e.g. by sniffing on the NIC connected to the Cube with tools like Wireshark or tcpdump.
Discover Cubes on the Network
The Cube has a network discovery feature that allows an administrator to automatically identify Cube devices on the local network. The following Wireshark screenshot shows packets that are sent by the local management software:
The software sends UDP packets to the multicast group address 224.0.01 on port 23272. On the lower right side you can the marked part of the hexdump is the payload (eQ3Max*\x00**********I). This is a so called identity message that instructs Cubes to report back to the source host of these packets with their serial number. The following screenshot shows such a response from a Cube:
Again, the response payload is marked on the lower right side (eQ3MaxApKMD1016788>I). The serial number of my device is KMD1016788, so everything works as expected. We can now easily find all Cubes on a local network by just sending out such UDP packets. But it is also possible to check if a specific host is a Cube device with a unicast packet.
We can send all of these packets ourselves, even the unicast ones. For these tasks I prefer to use Scapy. It allows us to create, send/receive and manipulate packets in an interactive Python shell which is convenient because I mostly have one of those open anyways for other tasks like calculations or data conversions. The UDP port used by the Cube is 23272. We can take the string from the previous example (which is a “identity message”, indicated by the “I” at the end of the payload) and just send it to the destination host:
>>> p = Ether()/IP(src="192.168.0.1", dst="192.168.0.222")/UDP(sport=23272, dport=23272)/Raw("eQ3Max*\x00**********I")
>>> sendp(p, iface="enp0s20u9u3")
The response payload will look like this:
00000000 65 51 33 4d 61 78 41 70 4b 4d 44 31 30 31 36 37 eQ3MaxAp KMD10167
00000010 38 38 3e 49 00 09 9c 3e 01 13 88>I...> ..
Note: The Cube will always send the response packet with source and destination port 23272. So either you always use 23272 as source port in order to get the response or do an out-of-band capture with e.g. pcap if you use random source ports (what most tools will do by default).
The identity message is actually quite helpful: all other UDP messages require the serial number of the Cube, which is included in the response to this request. With the serial number we can send other message types, e.g. the “reboot message” (indicated by the “R” at the end of the payload”):
>>> p = Ether()/IP(src="192.168.0.1", dst="192.168.0.10")/UDP(sport=23272, dport=23272)/Raw("eQ3Max*\x00KMD1016788R")
>>> sendp(p, iface="enp0s20u9u3")
As the message name already reveals, this will just reboot the device. This can be sent by any device on the local network, without any authentication.
How to Manage the Cube
There are three different ways to manage a Cube:
- The local management software: It is a EXE that you can install on Windows that will start a local web server on a random high-port that serves a Java applet… 🙂
- The remote management software: Basically the same functionality with a login interface and it’s hosted in the cloud.
- A mobile app: I haven’t looked into this one yet.
The main difference between the local and remote management software is that the remote software communication is somehow encrypted. More about this later.
The local management software is quite helpful for getting more insight into the inner workings of the Cube because, by triggering different functions we can observe the requests that are sent to the Cube. It’s always quite helpful to just execute every function (not that much to do on the Cube) that is available and capture the traffic. This will give a brief overview of what can be remotely managed on the Cube.
Communicate With the „Remote Proxy“
In order to use the remote management software, you have to configure a username and password in the local management software, that will be required for remote management. Note that the Cube can only be managed by one client at time. So the mobile app or remote management will not work when the local management software runs. As long as there is a open TCP session on port 62910 no other client can communicate with this port. Basically, every client in the local network that can reach port 62910 on the Cube can block other clients by connecting to it (e.g. with netcat).
The communication between the Cube and the remote management tool is encrypted, but it‘s not SSL/TLS… The communication is done via HTTP POST requests where just the POST body is encrypted. The HTTP headers look like this:
POST /cube HTTP/1.1
Host: smarthome.md.de
connection: close
Content-Length: 32
Opt: "http://www.eq-3.com/MAX", ns=MAX
MAX-Serial: KMD1016788
These requests are sent to http://smarthome.md.de:8080. A quite important header is the MAX-Serial. It has to include a valid serial number or else the server will just respond with 500 Internal Server Errors.
The AES Encryption
The AES encryption is used to encrypt the POST bodies sent to the remote management tool. The Cube supports the „e“ and „d“ messages which are basically „encrypt“ and „decrypt“ functions. This allows you to encrypt arbitrary strings and decrypt whatever has been encrypted by the same Cube. Here is a quick example:
~ » ncat 192.168.0.222 62910
[...]
e:TEST^M
E:kvJcZ8bVAoyXaE7gK+q2Ug==
d:kvJcZ8bVAoyXaE7gK+q2Ug==^M
D:TESTAAAAAAAAAAAAAAAAAA==
Note: The Cube needs commands to end with “\r\n” or else it won’t respond. In order to send this in tools like netcat/ncat, instead of just pressing RETURN, press CTRL+v and then RETURN (indicated by the “^M” at the end of the line.
The example shows how the cube can encrypt the string TEST, which returns the ciphertext as a Base64 encoded string kvJcZ8bVAoyXaE7gK+q2Ug==. In order to decrypt the string again you can supply it to the decrypt function which will return the plaintext string TEST. The rest of the string are just encoded Null Bytes (0x00) used to pad to the AES block size.
The strings that are typically encrypted are already Base64 encoded. So lets look at a real example: In order to use the remote login you need to set a username and password. The plaintext request that is sent from the Cube to the remote system looks like this:
H:KMD1016788,099c3e,0113
B:FOOBAR1,5a8a4a5d3c1bd612b8bf1e2fecf609f7,1,SuperSecret
The first line includes the serial number, the RF address and the firmware version. The second one includes the username (FOOBAR1), a MD5 hash, a number (1) and the actual password (SuperSecret). The MD5 hash is just a hash of password||serial_number:
~ » echo -n "SuperSecretKMD1016788"|openssl md5
(stdin)= 5a8a4a5d3c1bd612b8bf1e2fecf609f7
In order to send this payload to the remote system we have to Base64 encode:
~ » echo -n "H:KMD1016788,099c3e,0113
B:FOOBAR1,5a8a4a5d3c1bd612b8bf1e2fecf609f7,1,SuperSecret"|base64 -w0
SDpLTUQxMDE2Nzg4LDA5OWMzZSwwMTEzCkI6Rk9PQkFSMSw1YThhNGE1ZDNjMWJkNjEyYjhiZjFlMmZlY2Y2MDlmNywxLFN1cGVyU2VjcmV0
and then encrypt it:
e:SDpLTUQxMDE2Nzg4LDA5OWMzZSwwMTEzCkI6Rk9PQkFSMSw1YThhNGE1ZDNjMWJkNjEyYjhiZjFlMmZlY2Y2MDlmNywxLFN1cGVyU2VjcmV0^M
E:kGxTXPZVm8CQGcurInyvX3z4C+6zKKKcuS8Wp259XC1yKUfN8tFIfRt0s3qRliIcUGSAcuhuDzl7fpT6fWOnyysSxk9TG1cXtrcVkeNWUzgeO5poXjS5tJlXWgV64ibG
We can now just copy the Base64 string into Burpsuite’s repeater, Base64 decode it (mark it and press CTRL+Shift+b) and send it to the server. This should look like this:
We got a 200 OK from the server so our request succeeded. After this request we are now able to log into the web interface at http://smarthome.md.de/ with the username FOOBAR1 and password SuperSecret. In order to decrypt the response we can just take the response body, Base64 encode it (just mark the response body and press CTRL+b) and send it to the decrypt function of the Cube:
d:QAINuzPCglmG1nNNI/ylrbV6AXKdtBQbkNXT/pMobpXSeuP6/tZtCIq8GD5YSHjK^M
D:aTowMDAwNTFiMSwwMDAwMDAwMCxmZmZmZmZmZg0KYjpPSw0KAAAAAAAAAAAAAAAA
and decode the resulting Base64 string:
~ » echo -n "aTowMDAwNTFiMSwwMDAwMDAwMCxmZmZmZmZmZg0KYjpPSw0KAAAAAAAAAAAAAAAA"|base64 -d
i:000051b1,00000000,ffffffff
b:OK
The important question here is: how does the device encrypt the strings and what is the encryption key? When it comes to AES encryption you have to figure out:
- What’s the used key size? AES supports 128, 192 and 256 Bit keys.
- What mode of operation is used?
- Depending on the mode of operation: What’s the initialization vector (IV)?
The first question is easy to answer: on the vendor page they say it’s using AES-128. So what’s the mode of operation? Now it comes in handy that we can encrypt arbitrary strings. The most basic mode of operation is ECB: each 16 Byte blocks will be encrypted independently which results in the same ciphertext when a plaintext will be encrypted twice. I’ve tested it with a string that is the Base64 encoding of 16 * “\xff”, which is exactly the block size of the AES encryption:
e://///////////////////w==^M
E:XQfNd8PcLZgnJbwGTuTx5A==
e://///////////////////w==^M
E:XQfNd8PcLZgnJbwGTuTx5A==
We can see that encrypting the same plaintext (/////////////////////w==) twice results in the same ciphertext both times, which could mean that ECB is used. But, lets see if multiple blocks will be encrypted independently. The following example encrypts 32 * “\xff” (two blocks):
e://///////////////////w==^M
E:XQfNd8PcLZgnJbwGTuTx5A==
e://////////////////////////////////////////8=^M
E:XQfNd8PcLZgnJbwGTuTx5LM36fXWGGUjgVLWxtzwCgo=
If it would use ECB mode the ciphertext should contain the previous sequence of Bytes twice:
cryptotest » echo -n "XQfNd8PcLZgnJbwGTuTx5A=="|base64 -d |xxd
00000000: 5d07 cd77 c3dc 2d98 2725 bc06 4ee4 f1e4 ]..w..-.'%..N...
cryptotest » echo -n "XQfNd8PcLZgnJbwGTuTx5LM36fXWGGUjgVLWxtzwCgo="|base64 -d |xxd
00000000: 5d07 cd77 c3dc 2d98 2725 bc06 4ee4 f1e4 ]..w..-.'%..N...
00000010: b337 e9f5 d618 6523 8152 d6c6 dcf0 0a0a .7....e#.R......
We can see in the hexdump that the first 16 Bytes indeed are the same as the previous encryption, but the second block is totally different. An ECB encryption example would actually look like this:
cryptotest » xxd plain_16
00000000: ffff ffff ffff ffff ffff ffff ffff ffff ................
cryptotest » xxd plain_32
00000000: ffff ffff ffff ffff ffff ffff ffff ffff ................
00000010: ffff ffff ffff ffff ffff ffff ffff ffff ................
cryptotest » openssl enc -aes-128-ecb -in plain_16 -nosalt -nopad -k TEST |xxd
00000000: cb30 66d5 3db8 89f6 da4b 5831 d29c 6b9f .0f.=....KX1..k.
cryptotest » openssl enc -aes-128-ecb -in plain_32 -nosalt -nopad -k TEST |xxd
00000000: cb30 66d5 3db8 89f6 da4b 5831 d29c 6b9f .0f.=....KX1..k.
00000010: cb30 66d5 3db8 89f6 da4b 5831 d29c 6b9f .0f.=....KX1..k.
We now know that it cannot be ECB. The second guess would be that the encryption is done in CBC mode. CBC uses an IV that will be XOR‘ed with the first block prior to applying AES and all subsequent blocks will be XOR’ed with the previous block’s ciphertext. This construct is supposed to prevent encrypting the same plaintext twice resulting in the same ciphertext both times. In order to archive this the IV has to be random for each encryption. So the educated guess here is that the Cube uses CBC with a static IV.
But before we can decrypt anything we need the encryption key. My guess at this point is that the key is somehow based on the serial number because the remote server won’t accept ciphertexts if the MAX-serial header contains another (even if valid) serial number. However, the key never hits the wire, there is no handshake in the communication where the Cube and the remote server agree upon any encryption parameters. Somehow the key can be calculated based on the serial number.
Besides the software aspects I also took a look at what is inside of the actual Cube hardware. Turns out the board itself is pretty small and besides the serial number not very informative. But, when you turn around the board it gets interesting…
There are several QR codes on the back that include stuff like the MAC address, RF address, serial number, and… a KEY…?
The KEY QR code contains a MD5 hash prefixed with a “k” (probably identifying it as “key”) which is not of any interest. So lets try to decrypt a ciphertext with this key:
~ » echo -n "XQfNd8PcLZgnJbwGTuTx5LM36fXWGGUjgVLWxtzwCgo="|base64 -d |openssl enc -aes-128-cbc -d -nopad -nosalt -K 98bbce3f1b25df8e6894b779456d330e -iv 00 |xxd
00000000: c975 1589 ed36 536c c975 1589 ed36 536c .u...6Sl.u...6Sl
00000010: ffff ffff ffff ffff ffff ffff ffff ffff ................
Great! You can see that the second block has been properly decrypted. The first block should actually be the same but it looks totally random. In the previous command I used just Null Bytes as IV (-iv 00). Remember after decrypting the last block in CBC mode, the last block will be XOR’ed with the IV. Here we can see that the first block (which will be processed last during decryption) has been XOR’ed with a wrong value which results in a different plaintext.
However, getting the IV in this situation is rather easy: we know the plaintext. XOR’ing the first block of the previous output with the plaintext (which is 16 * “\xff”) should give us the proper IV. Open a Python shell and just do the XOR:
>>> hex(0xc9751589ed36536cc9751589ed36536c^0xffffffffffffffffffffffffffffffff)
'0x368aea7612c9ac93368aea7612c9ac93L'
Now lets try again to decrypt our ciphertext with the proper IV:
~ » echo -n "XQfNd8PcLZgnJbwGTuTx5LM36fXWGGUjgVLWxtzwCgo="|base64 -d |openssl enc -aes-128-cbc -d -nopad -nosalt -K 98bbce3f1b25df8e6894b779456d330e -iv 368aea7612c9ac93368aea7612c9ac93 |xxd
00000000: ffff ffff ffff ffff ffff ffff ffff ffff ................
00000010: ffff ffff ffff ffff ffff ffff ffff ffff ................
There you can see our plaintext has been fully recovered! We can also decrypt the remote server response:
~ » echo -n "QAINuzPCglmG1nNNI/ylrbV6AXKdtBQbkNXT/pMobpXSeuP6/tZtCIq8GD5YSHjK"|base64 -d |openssl enc -aes-128-cbc -d -nopad -nosalt -K 98bbce3f1b25df8e6894b779456d330e -iv 368aea7612c9ac93368aea7612c9ac93
i:000051b1,00000000,ffffffff
b:OK
Now we know everything we need to encrypt and decrypt strings for the Cube and the remote server ourselves. I’ve also implemented a small Python script that makes it easier to encrypt/decrypt strings with proper padding.
Automate Network Discovery
In my opinion an essential part of analyzing unknown devices is to let other people work with the gained information, either to support further research by others or let people discover such devices with common tools. When it comes to discovering devices on networks the tool of choice is Nmap. Apart from pure port scanning it provides a wide range of signatures for known services and it’s functionality can be easily extended by NSE scripts.
NSE scripts are written in Lua which is a quite straight-forward and easy-to-understand scripting language. The easiest way to start writing your own scripts is to take a look at existing ones that essentially do the same as you want to archive (scripts are typically located at /usr/share/nmap/scripts or just online). E.g. for the identity request we just need something that sends a UDP packet and retrieves the response payload. A really simple and easy-to-understand example is e.g. the daytime.nse script which essentially looks like this:
portrule = shortport.port_or_service(13, "daytime", {"tcp", "udp"})
action = function(host, port)
local status, result = comm.exchange(host, port, "dummy", {lines=1})
if status then
return result
end
end
After defining some metadata at the beginning, the actual script starts with a portrule which is required in every NSE script. It defines when the script should be run against a service. In this case the script could run against port 13 either TCP or UDP if the port has been identified as open. The second important thing in a NSE script is the action, which can be seen as the main() function of the NSE script. The action always takes two arguments: host and port. It should be noted that these are not just a string containing the hostname or IP address and a port number. Each is a table containing additional information like the MAC address in the host table (host.mac_addr) or the protocol (TCP or UDP) in the port table (port.protocol).
This script uses the exchange() function from the comm module which is one of many LUA modules provided by Nmap. It just sends a payload and returns the response. If a script should return any information to the user it just has to return it, either as a plain string or as a LUA table.
As an easy example for the first Nmap script I will just write one that connects to the Cube on the TCP port 62910 and parse the first line sent back by the device in order to print the serial number, RF address and firmware version of the Cube. This is quite simple because as soon as someone connects to the port the device just sends back a string that looks like this:
H:KMD1016788,099c3e,0113,00000000,7ee2b5d7,00,32,100408,002c,03,0000
[...]
So our script just needs to connect to the port, get the response and parse the values KMD1016788 (serial number), 099c3e (RF address), 0113 (firmware version). As a template I typically start with something like this:
local shortport = require "shortport"
local stdnse = require "stdnse"
description = [[
]]
author = "CHANGEME"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"discovery", "safe"}
portrule = shortport.portnumber(0, "tcp")
action = function(host, port)
end
Then it’s easy to add the required functionality. Because we just need to connect to a port to get the response without sending anything the easiest way is to use a plain socket. Nmap makes it pretty easy to communicate with sockets in NSE scripts:
local sock = nmap.new_socket()
local status, err = sock:connect(host, port, "tcp")
if not status then
stdnse.debug1("%s", err)
return
end
local status, data = sock:receive()
if not status or not data then
stdnse.debug1("%s", "Could not receive any data")
return
end
This gives us the response in the variable ret which we can parse in order to extract the required information:
local output = stdnse.output_table()
local serial, rf_address, firmware
for serial,rf_address,firmware in data:gmatch("H:(%u%u%u%d%d%d%d%d%d%d),(%x%x%x%x%x%x),(%d%d%d%d),") do
output["MAX Serial:"] = serial
output["RF Address"] = rf_address
output["Firmware Version"] = firmware
end
We now have a output table that requires all the information the should be returned to the user. As I already mentioned this is as easy as put a “return output” at the end of the action. The complete script can be found here.
We can test the script by just running it against the Cube to see if it works:
max-cube/nse » nmap --script maxcube-info.nse -Pn -p 62910 192.168.0.222
Starting Nmap 7.12SVN ( https://nmap.org ) at 2016-04-08 01:11 CEST
Nmap scan report for 192.168.0.222
Host is up (0.0051s latency).
PORT STATE SERVICE
62910/tcp open unknown
| maxcube-info:
| MAX Serial:: KMD1016788
| RF Address: 099c3e
|_ Firmware Version: 0113
So the script works. But it can be quite unrealiable: like I already mentioned when there is an open TCP connection on port 62910 (e.g. the management software is running or someone is connected via netcat), Nmap won’t be able to communicate with the port and the script will not work.
Some helpful tips:
- The stdnse module provides debug() functions that allow you to print any debug output in the script. It will be visible when at least one -d command line argument will be supplied.
- The –script-trace command line argument provides more detailed output for debugging NSE scripts.
- In order to run a script on all ports that have been identified as open, prefix script names with a +. E.g. instead of “–script myscript.nse” use “–script +myscript.nse“
What could be done next?
There are still two big questions to be answered:
Where is the encryption key coming from?
I think the encryption key (which looks like a MD5 hash) is the hash of the serial number concatenated with secret string only the vendor knows. Because somehow the vendor can distinguish between ciphertexts, which means that every device most likely has a own key (I only had one device to test).
A different theory: I found the key in a QR code on the circuit board. Which could indicate that the key has had to be known during the manufacturing of the device. The encryption key could be totally random and is probably not even the hash of any plaintext that includes the serial number. But this would mean that the vendor would need to have a list with all serial numbers mapped to the corresponding key that has been inserting during manufacturing.
How is the firmware.enc file encrypted?
Cube provides an update functionality where you can send pieces of a new firmware version via UDP packets (remember, that’s unauthenticated). The firmware files however are not readable and it looks like they will be decrypted on the device, because the management software can only parse the files but not decrypt them. I just took the parsing routines from the file and wrote a quick’n’dirty fronted but the firmware itself seems to be encrypted.
Modifying the firmware would be pretty cool I guess. 🙂
Write additional Nmap scripts
As we saw at the beginning of the post, there are at least two other ways to identify Cubes on the network: with either multicast or unicast UDP packets. However, these would be slightly more tricky as the response’s source port will be static (always 23272). So if you would e.g. use Nmap’s comm.exchange() function in a script you won’t get any response in the function, because it would use a random source port.
The solution would be to send out the multicast packet and use pcap to capture the response. There are already some scripts in Nmap that do this, e.g. a script I wrote for discovering KNX devices with a similar technique.
Conclusions
Analyzing yet another IoT devices shows that the majority of these devices shares typical vulnerabilities like missing or insufficient authentication. Also, it is typically trivial to identify such devices on the network because they have strange properties like fixed source ports in response packets or only a single TCP connection is possible. So I hope that researchers who also analyze unknown devices start making their research useful for everyone by contributing to projects like Nmap.
Excellent post, congratulations!
Great post, more of these on IoT please.
Just one criticism (only one? Then you probably did a really good job 😉 ):
From http://www.lua.org/about.html: Please do not write it as “LUA”
Thank you for the feedback guys, I really appreciate it!