Roxy-WI through 5.2.2.0 pre-auth RCE

09-08-2021 - rekter0

hap-wi roxy-wi is a web interface(user-friendly web GUI, alerting, monitoring and secure) for managing HAProxy, Nginx and Keepalived servers.
Versions affected v4 and v5 through v5.2.2.0
CVE identifier CVE-2021-38167 CVE-2021-38168 CVE-2021-38169

# Summary

While looking for a solution to manage multiple reverse proxies, i found roxy-wi in DO marketplace, since it was open source, i'v poked around and found multiple SQL injections and command injections leading to RCE

# Vulnerability analysis

Inside /app/sql.py some SQL statements have user controlled input supplied directly into SQL queries

## Unauthenticated SQLi

when an attacker request any of the pages inside /app folder, authentication is checked via funct.check_login()
/app/funct.py

def check_login():
    import sql
    import http.cookies
>    cookie = http.cookies.SimpleCookie(os.environ.get("HTTP_COOKIE"))
>    user_uuid = cookie.get('uuid')
    ref = os.environ.get("REQUEST_URI")

    sql.delete_old_uuid()

    if user_uuid is not None:
>        sql.update_last_act_user(user_uuid.value)
        if sql.get_user_name_by_uuid(user_uuid.value) is None:
            print('<meta http-equiv="refresh" content="0; url=login.py?ref=%s">' % ref)
            return False
    else:
        print('<meta http-equiv="refresh" content="0; url=login.py?ref=%s">' % ref)
        return False

check_login() takes uuid cookie value and try to update expiration timestamp for the given uuid with sql.update_last_act_user()
/app/sql.py

def update_last_act_user(uuid):
    cursor = conn.cursor()
    session_ttl = get_setting('session_ttl')

    if mysql_enable == '1':
>        sql = """ update uuid set exp = now()+ INTERVAL %s day where uuid = '%s' """ % (session_ttl, uuid)
    else:
>        sql = """ update uuid set exp = datetime('now', '+%s days') where uuid = '%s' """ % (session_ttl, uuid)
    try:
        cursor.execute(sql)
    except Exception as e:
        funct.out_error(e)

uuid cookie value is directly supplied into the query, so an unauthenticated attacker can perform a blind SQL injection to dump the database or extract a valid uuid to bypass authentication

## Authenticated SQLi

There was many, one example of authenticated SQLi via reaching select_servers function
/app/sql.py

def select_servers(**kwargs):
    cursor = conn.cursor()
    sql = """select * from servers where enable = '1' ORDER BY groups """

    if kwargs.get("server") is not None:
>        sql = """select * from servers where ip='%s' """ % kwargs.get("server")
    if kwargs.get("full") is not None:
        sql = """select * from servers ORDER BY hostname """
    if kwargs.get("get_master_servers") is not None:
        sql = """select id,hostname from servers where master = 0 and type_ip = 0 and enable = 1 ORDER BY groups """
    if kwargs.get("get_master_servers") is not None and kwargs.get('uuid') is not None:
        sql = """ select servers.id, servers.hostname from servers 
            left join user as user on servers.groups = user.groups 
            left join uuid as uuid on user.id = uuid.user_id 
            where uuid.uuid = '%s' and servers.master = 0 and servers.type_ip = 0 and servers.enable = 1 ORDER BY servers.groups 
            """ % kwargs.get('uuid')
    if kwargs.get("id"):
        sql = """select * from servers where id='%s' """ % kwargs.get("id")
    if kwargs.get("hostname"):
        sql = """select * from servers where hostname='%s' """ % kwargs.get("hostname")
    if kwargs.get("id_hostname"):
        sql = """select * from servers where hostname='%s' or id = '%s' or ip = '%s'""" % (kwargs.get("id_hostname"), kwargs.get("id_hostname"), kwargs.get("id_hostname"))
    if kwargs.get("server") and kwargs.get("keep_alive"):
        sql = """select active from servers where ip='%s' """ % kwargs.get("server")
    try:
        cursor.execute(sql)
    except Exception as e:
        funct.out_error(e)
    else:
        return cursor.fetchall()

there's multiple injection points from user supplied input here
one way to reach this is from hapservers.py
/app/hapservers.py

[...]
[...]
>serv = form.getvalue('serv')
 service = form.getvalue('service')
[...]
[...]
 if funct.check_is_server_in_group(serv):
>            servers = sql.select_servers(server=serv)

this could be exploited by least privilege account such as guest

## Command injection:

Most cmds in different functions inside /app/funct.py and /api/api_funct.py are prone to command injection or second order from settings stored in the database which are user supplied.
one of many examples of a second order command injection here:
/app/funct.py

def get_all_stick_table():
    import sql
    hap_sock_p = sql.get_setting('haproxy_sock_port')
>    cmd = 'echo "show table"|nc %s %s |awk \'{print $3}\' | tr -d \'\n\' | tr -d \'[:space:]\'' % (serv, hap_sock_p)
>    output, stderr = subprocess_execute(cmd)
    return output[0]

haproxy_sock_port is stored in settings table, and an authenticated user can change it from https://[%HOST%]/app/users.py#settings then calls options.py page with param table_select=All to execute an arbitrary system command

# Impact

Combining SQL injection and command injection allows unauthenticated attacker to achieve pre-auth RCE and compromise the underlying systems.

# Timeline

07-08-2021 - Reported
07-08-2021 - Vendor confirmed
09-08-2021 - SQLi fixed


rce - python - roxy-wi - sql injection - command injection - cgi

CONTACT



rekter0 © 2024