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