Docker Socket and Privileged Builds
- mounted /var/run/docker.sock
- privileged build containers
- rootless image builds
Scenario
Your self-hosted GitLab runner uses the Docker executor. To let jobs build images, someone added the host's Docker socket to the runner's config.toml:
[runners.docker]
privileged = false
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
Every job container can now talk to the host's Docker daemon.
-
A transitive Python dependency is hijacked. Its install script runs during
pip installin an ordinary test job. -
The script checks for
/var/run/docker.sock. Finding it, it calls the Docker API and starts a new container that mounts the host's root filesystem:docker run --rm --privileged -v /:/host alpine \ chroot /host sh -c 'cat /etc/gitlab-runner/config.toml' -
That container runs as root on the host. The script reads the runner's registration token and the cloud credentials on the VM, and adds a cron job for persistence.
-
Every later job on that runner, including release builds, now runs on a host the attacker controls.
Access to the Docker socket is root on the host. The privileged = false setting doesn't help, because the attacker asks the daemon for a new privileged container.
Real-world precedent: In January 2024, Snyk disclosed Leaky Vessels, including CVE-2024-21626 in runc. A crafted image or Dockerfile could escape to the host during docker run or docker build, which is exactly what CI runners do with untrusted code all day.
Reconnaissance
Explanation
The payload does its own reconnaissance. It checks for a Docker socket, a DOCKER_HOST variable, or signs that it is running privileged. Attackers can also read public CI configuration for docker.sock mounts and --privileged options.
Insight
CI configs that mount the socket are easy to find. So are blog posts recommending it, which is how it got into your config in the first place.
Practical
-
Search your CI configuration for socket mounts and privileged settings:
grep -rnE "docker\.sock|privileged|--privileged" \ .gitlab-ci.yml .github/workflows/ Jenkinsfile /etc/gitlab-runner/config.toml 2>/dev/null -
On GitHub Actions, look for
container:orservices:blocks withoptions: --privilegedor a socket volume. There is noprivileged: truekey; it is passed throughoptions.
Tools/Techniques
grepacross repositories, or GitHub code search within your organisation.- zizmor for wider GitHub Actions workflow review.
Metrics/Signal
- Runners and workflows that mount the Docker socket.
- Jobs that run with
--privileged.
Evaluate
Explanation
Find out which jobs can reach a Docker daemon or run privileged, and what lives on those hosts.
Insight
Docker-in-Docker (docker:dind) needs a privileged container. A socket mount needs nothing at all. Both hand the job a path to root on the runner.
Practical
-
From a test job on each runner, check what the job can see:
ls -l /var/run/docker.sock 2>/dev/null echo "DOCKER_HOST=${DOCKER_HOST:-unset}" grep CapEff /proc/self/statusA
CapEffof000001ffffffffffor similar (all bits set) means the container is privileged. -
Check the runtime version on each runner. runc before 1.1.12 is vulnerable to CVE-2024-21626:
runc --version -
Note what lives on each runner host: runner tokens, cloud credentials, caches shared with other jobs.
Tools/Techniques
- Trivy (
trivy rootfs /on a runner host) to find vulnerable runc and containerd packages. capsh --decode=<CapEff value>(from libcap) to list the capabilities a job actually has.
Metrics/Signal
- Runners on vulnerable runc versions.
- Jobs with Docker daemon access or full capabilities.
Fortify
Explanation
Build images without giving jobs a Docker daemon or root.
Insight
Kaniko used to be the standard answer, but it was archived in June 2025 and always ran as root inside its container. Rootless BuildKit and Buildah are the current options.
Practical
-
Remove the socket mount and
privileged = truefrom runner configuration. -
Build images with rootless BuildKit. GitLab's documented job:
build-image: image: name: moby/buildkit:rootless entrypoint: [""] variables: BUILDKITD_FLAGS: --oci-worker-no-process-sandbox before_script: - mkdir -p ~/.docker - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > ~/.docker/config.json script: - | buildctl-daemonless.sh build \ --frontend dockerfile.v0 \ --local context=. \ --local dockerfile=. \ --output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA,push=true -
Or use Buildah in a rootless container, which GitLab also documents for OpenShift.
-
Patch runc to 1.1.12 or later on every runner host, and keep Docker Engine and containerd current.
-
Where Docker must run, use Docker rootless mode on the runner host so the daemon itself is not root.
Tools/Techniques
Metrics/Signal
- Runners with the Docker socket mounted (target: zero).
- Image builds using rootless BuildKit or Buildah.
Limit
Explanation
If a job does reach the host, make the host cheap to lose.
Insight
A runner that lives for one job and holds no long-lived secrets is a poor prize. Compromise it and you get a VM that is about to be deleted.
Practical
- Run privileged-capable workloads (if you must) on dedicated runners with their own tags, used only by trusted branches.
- Use autoscaled runners that create a fresh VM per job and destroy it afterwards.
- Keep cloud credentials off runner hosts. Use OIDC tokens per job instead.
- Separate release runners from test runners, so a test job can never poison the host that builds releases.
Tools/Techniques
- GitLab Runner autoscaling or GitHub Actions Runner Controller.
- Runner tags (GitLab) or runner groups (GitHub) to route trusted jobs.
Metrics/Signal
- Share of runners that are single-use.
- Release jobs sharing hosts with test jobs (target: zero).
Expose
Explanation
Detect a job talking to the Docker daemon or starting privileged containers.
Insight
Legitimate test jobs almost never start containers that mount / from the host. When one does, that is your alert.
Practical
-
Watch the socket with auditd:
auditctl -w /var/run/docker.sock -p rwxa -k docker-socket -
Inspect every container created on a runner host for
--privilegedor bind mounts of/:docker events --filter type=container --filter event=create --format '{{.Actor.ID}}' | while read -r id; do docker inspect --format '{{.Name}} privileged={{.HostConfig.Privileged}} binds={{.HostConfig.Binds}}' "$id" done -
Run Falco on runner hosts with a rule for socket connections from containers. This rule uses only built-in fields, no macros:
- rule: Docker socket connection from container desc: A process inside a container connected to the Docker socket condition: > evt.type = connect and evt.dir = < and container.id != host and fd.typechar = u and fd.name contains docker.sock output: > Docker socket used from container (command=%proc.cmdline container=%container.name image=%container.image.repository) priority: WARNING
Tools/Techniques
- Falco rules.
- Linux auditd.
Metrics/Signal
- Docker socket connections from job containers.
- Containers created on runners with host root mounts.
Exercise
Explanation
Prove that a job on your runners can't reach the host.
Insight
This is quick to test and the answer is binary.
Practical
- On a non-production runner configured like the real ones, run a job that only reports whether
/var/run/docker.sockexists and itsCapEffvalue. - If the socket is reachable, run
docker psfrom the job. If that works, the job has root on the host. Stop there. - Switch the project to rootless BuildKit, remove the socket mount, and rerun. Confirm image builds still work.
- Confirm your Falco rule or auditd alert fired during step 2.
Tools/Techniques
- A disposable runner VM.
- The checks from the Evaluate stage, run as a pipeline job.
Metrics/Signal
- Runners where step 2 succeeded (target: zero).
- Time from socket access to alert.