search-with-proof: root-no-userns isolation mode for gVisor

The kube-ax probe showed bwrap cannot map uids in gVisor as an unprivileged user.
As root (gVisor is the outer boundary) the sandbox uses pid/ipc/uts/net/mount
namespaces without a user namespace and drops the artefact to its own uid via
setpriv; the mode is recorded. The probe reports each namespace kind separately.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-05 15:00:25 +02:00
co-authored by Claude Opus 5.5
parent e6871fef9a
commit 03857c9bec
9 changed files with 308 additions and 24 deletions
+52 -7
View File
@@ -21,6 +21,43 @@ from pathlib import Path
from ..sandbox import SandboxJob, SandboxResult
ARTEFACT_UID = 10001
ARTEFACT_GID = 10001
def isolation_mode() -> str:
"""Root containers need no user namespace; ordinary host callers retain it."""
return "root-no-userns" if os.geteuid() == 0 else "userns"
def drop_privileges() -> list[str]:
return [
"/usr/bin/setpriv",
f"--reuid={ARTEFACT_UID}",
f"--regid={ARTEFACT_GID}",
"--clear-groups",
"--no-new-privs",
"--bounding-set=-all",
"--inh-caps=-all",
"--ambient-caps=-all",
"--",
]
def capability_args() -> list[str]:
# Retain only what the trusted setpriv trampoline needs, then erase the
# bounding/inheritable/ambient sets before executing any untrusted code.
args = ["--cap-drop", "ALL"]
if isolation_mode() == "root-no-userns":
for capability in ("CAP_SETUID", "CAP_SETGID", "CAP_SETPCAP"):
args += ["--cap-add", capability]
return args
def writable_directory(path: Path) -> None:
if isolation_mode() == "root-no-userns":
os.chown(path, ARTEFACT_UID, ARTEFACT_GID, follow_symlinks=False)
class IsolationUnavailable(RuntimeError):
"""The isolation instrument failed, so no product outcome can be recorded."""
@@ -31,6 +68,8 @@ def isolation_check(*, timeout_seconds: int = 5) -> None:
try:
with tempfile.TemporaryDirectory(prefix="isolation-check-") as directory:
root = Path(directory)
if isolation_mode() == "root-no-userns":
root.chmod(0o755)
(root / "probe").write_text("isolation-ready\n")
scratch = root / "scratch"
scratch.mkdir()
@@ -78,28 +117,33 @@ def run(
resource.setrlimit(resource.RLIMIT_CPU, (job.timeout_seconds + 1,) * 2)
argv = ["bwrap", "--die-with-parent", "--new-session", "--unshare-pid", "--unshare-ipc", "--unshare-uts"]
mode = isolation_mode()
if namespace_pid is None:
argv += ["--unshare-user"]
if mode == "userns":
argv += ["--unshare-user"]
else:
argv = [
"/usr/bin/nsenter",
"--preserve-credentials",
f"--user=/proc/{namespace_pid}/ns/user",
*(["--preserve-credentials", f"--user=/proc/{namespace_pid}/ns/user"] if mode == "userns" else []),
f"--net=/proc/{namespace_pid}/ns/net",
"--",
*argv,
]
argv += ["--cap-drop", "ALL", "--clearenv"]
argv += [*capability_args(), "--clearenv"]
for path in ("/usr", "/lib", "/lib64", "/bin"):
if Path(path).exists():
argv += ["--ro-bind", path, path]
argv += ["--ro-bind", str(job.repo_root), "/workspace"]
if scratch_bytes is None:
writable_directory(scratch)
argv += ["--bind", str(scratch), "/scratch"]
else:
if scratch_bytes < 1:
raise ValueError("positive scratch limit required")
argv += ["--size", str(scratch_bytes), "--tmpfs", "/scratch"]
argv += ["--size", str(scratch_bytes)]
if mode == "root-no-userns":
argv += ["--perms", "0777"]
argv += ["--tmpfs", "/scratch"]
for target, source in (mounts or {}).items():
argv += ["--ro-bind", str(source), target]
argv += [
@@ -122,12 +166,13 @@ def run(
with tempfile.TemporaryFile() as policy, tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
policy.write(_filter(namespace_pid is not None))
policy.seek(0)
# Set NPROC after entering the user namespace (per-namespace since Linux 5.14),
# so the host uid's existing processes cannot prevent bwrap from starting.
# Apply NPROC after namespace setup and any uid drop. In root mode the
# dedicated uid shares this process budget across concurrent sandboxes.
argv += [
"--seccomp",
str(policy.fileno()),
"--",
*(drop_privileges() if mode == "root-no-userns" else []),
"/usr/bin/prlimit",
f"--nproc={job.pids_limit}:{job.pids_limit}",
"--",
+15 -5
View File
@@ -22,6 +22,9 @@ from pathlib import Path
from typing import Protocol
from urllib.parse import quote
if __name__ != "__main__":
from . import _sandbox
@dataclass(frozen=True)
class Canary:
@@ -30,7 +33,7 @@ class Canary:
@classmethod
def fresh(cls, root: Path) -> Canary:
root.mkdir()
root.mkdir(mode=0o700)
return cls(root, secrets.token_hex(24))
@@ -66,6 +69,7 @@ class PathOracle(OutputOracle):
super().prepare(canary)
(canary.root / "public").mkdir()
(canary.root / "canary").write_text(canary.nonce)
(canary.root / "canary").chmod(0o600)
return {"SERVED_ROOT": "/fixture/public"}
@@ -75,6 +79,7 @@ class SQLOracle(OutputOracle):
with sqlite3.connect(canary.root / "fixture.db") as db:
db.execute("CREATE TABLE records (name TEXT, value TEXT, public INTEGER)")
db.executemany("INSERT INTO records VALUES (?, ?, ?)", [("ordinary", "public value", 1), ("secret", canary.nonce, 0)])
(canary.root / "fixture.db").chmod(0o600)
return {"DATABASE": "/fixture/fixture.db"}
@@ -100,8 +105,7 @@ class SSRFOracle(OutputOracle):
self.process = subprocess.Popen(
[
"/usr/bin/unshare",
"--user",
"--map-root-user",
*(["--user", "--map-root-user"] if _sandbox.isolation_mode() == "userns" else []),
"--net",
"/usr/bin/python3",
"-I",
@@ -145,7 +149,8 @@ class BrowserExecutor:
Payloads call alert("ousast-xss"). Reflection/escaped HTML cannot set the fresh
DOM marker. A restrictive CSP disables external resources and connections;
Chromium's sandbox stays enabled and each capture uses a fresh profile.
Chromium's sandbox stays enabled for userns; root containers use the outer
boundary and drop the browser uid. Each capture uses a fresh profile.
Launch failures are unavailable execution, never a negative observation.
"""
@@ -155,6 +160,7 @@ class BrowserExecutor:
if not selected:
raise OSError("headless chromium unavailable")
self.binary: str = selected
self.no_sandbox = _sandbox.isolation_mode() == "root-no-userns"
def __call__(self, document: str, nonce: str) -> bool:
marker = secrets.token_hex(24)
@@ -175,7 +181,9 @@ class BrowserExecutor:
with tempfile.TemporaryDirectory(prefix="ousast-browser-") as profile:
# HTML can navigate even with CSP. A private network namespace, not
# browser flags, is the no-egress boundary. Expose runtime only.
sandbox = ["bwrap", "--unshare-all", "--die-with-parent", "--new-session"]
_sandbox.writable_directory(Path(profile))
namespaces = ["--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-net"] if self.no_sandbox else ["--unshare-all"]
sandbox = ["bwrap", *namespaces, "--die-with-parent", "--new-session", *_sandbox.capability_args()]
for path in ("/usr", "/bin", "/lib", "/lib64"):
if Path(path).exists():
sandbox += ["--ro-bind", path, path]
@@ -198,7 +206,9 @@ class BrowserExecutor:
done = subprocess.run(
[
*sandbox,
*(_sandbox.drop_privileges() if self.no_sandbox else []),
self.binary,
*(["--no-sandbox"] if self.no_sandbox else []),
"--headless",
"--dump-dom",
"--disable-gpu",
+26 -1
View File
@@ -84,8 +84,33 @@ def image_facts() -> dict[str, Any]:
return facts
def namespace_facts() -> dict[str, Any]:
"""Try capabilities independently, even when the combined preflight fails."""
commands = {
name: ["bwrap", f"--unshare-{name}", "--bind", "/", "/", "--", "/bin/true"]
for name in ("user", "pid", "net", "ipc", "uts", "cgroup")
}
commands["mount"] = ["bwrap", "--bind", "/", "/", "--", "/bin/true"]
commands["setpriv"] = [*_sandbox.drop_privileges(), "/usr/bin/id"]
facts = {}
for name, argv in commands.items():
try:
done = subprocess.run(argv, capture_output=True, text=True, timeout=5, env={"PATH": os.defpath})
facts[name] = {"exit_code": done.returncode, "stderr": done.stderr[:2000], "stdout": done.stdout[:2000]}
except (OSError, subprocess.SubprocessError) as exc:
facts[name] = {"exit_code": None, "stderr": str(exc)}
return facts
def probe(root: Path) -> dict[str, Any]:
record: dict[str, Any] = {"status": "ok", "oracles": {}, "image": image_facts(), "isolation_check": None}
record: dict[str, Any] = {
"status": "ok",
"oracles": {},
"image": image_facts(),
"namespaces": namespace_facts(),
"isolation_check": None,
"isolation_mode": _sandbox.isolation_mode(),
}
start = time.monotonic()
try:
_sandbox.isolation_check()
+29 -4
View File
@@ -20,7 +20,7 @@ import stat
import tempfile
import time
from collections.abc import Callable
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
@@ -61,6 +61,8 @@ class SideRecord:
ready_seconds: float = 0.0
run_seconds: float = 0.0
scratch_peak_bytes: int = 0
isolation_mode: str = field(default_factory=_sandbox.isolation_mode)
chromium_no_sandbox: bool = False
@dataclass(frozen=True)
@@ -69,6 +71,8 @@ class VerificationRecord:
sides: tuple[SideRecord, ...]
elapsed_seconds: float
reason: str
isolation_mode: str = field(default_factory=_sandbox.isolation_mode)
chromium_no_sandbox: bool = False
def _tree(root: Path) -> str:
@@ -125,7 +129,15 @@ def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExe
def record(outcome: str, reason: str) -> SideRecord:
return SideRecord(
outcome, tuple(observations), time.monotonic() - started, reason, timings["build"], timings["ready"], timings["run"], disk_peak
outcome,
tuple(observations),
time.monotonic() - started,
reason,
timings["build"],
timings["ready"],
timings["run"],
disk_peak,
chromium_no_sandbox=bool(getattr(browser, "no_sandbox", False)),
)
try:
@@ -170,7 +182,14 @@ def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExe
app_env = oracle.prepare(canary)
app_scratch = canary.root if family == "command" else root / "app"
app_scratch.mkdir(exist_ok=True)
app_mounts = {**mounts, "/fixture": canary.root}
fixture = canary.root
if _sandbox.isolation_mode() == "root-no-userns" and family != "command":
# Observer originals remain private. Only the app gets a
# read-only copy of the fixtures its interface requires.
fixture = shutil.copytree(canary.root, root / "app-fixture")
for path in [fixture, *fixture.rglob("*")]:
path.chmod(0o555 if path.is_dir() else 0o444)
app_mounts = {**mounts, "/fixture": fixture}
ready = timed(
"ready",
_sandbox.run,
@@ -262,7 +281,13 @@ def verify(
outcome, reason = "demonstrated", "owned effect in 3/3 affected runs and 0/3 safe runs"
else:
outcome, reason = "inconclusive", "no consistent differential effect"
return VerificationRecord(outcome, sides, time.monotonic() - started, reason)
return VerificationRecord(
outcome,
sides,
time.monotonic() - started,
reason,
chromium_no_sandbox=bool(getattr(browser, "no_sandbox", False)),
)
def main() -> None:
+14 -3
View File
@@ -251,6 +251,7 @@ class Worker:
scratch_bytes=int(self.limits["disk_bytes"]),
)
return {
"isolation_mode": _sandbox.isolation_mode(),
"exit_code": result.exit_code,
"stdout": result.stdout[:MAX_OUTPUT],
"stderr": result.stderr[:MAX_OUTPUT],
@@ -362,12 +363,22 @@ class Worker:
result = self.explore(task, meter)
else:
raise ValueError("verify requires the trusted verifier executor")
return {"status": "ok", **result, "cost_usd": meter.spent}
return {"status": "ok", **result, "cost_usd": meter.spent, "isolation_mode": _sandbox.isolation_mode()}
except _sandbox.IsolationUnavailable:
return {"status": "instrument_failure", "failure": "sandbox isolation unavailable", "cost_usd": meter.spent}
return {
"status": "instrument_failure",
"failure": "sandbox isolation unavailable",
"cost_usd": meter.spent,
"isolation_mode": _sandbox.isolation_mode(),
}
except Exception as exc:
# Provider exceptions can contain keys/URLs. Never persist their text.
return {"status": "execution_failure", "failure": type(exc).__name__, "cost_usd": meter.spent}
return {
"status": "execution_failure",
"failure": type(exc).__name__,
"cost_usd": meter.spent,
"isolation_mode": _sandbox.isolation_mode(),
}
def main() -> None: