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
