Skip to content

fix(sandbox): pin the Landlock thread so a sandboxed MCP server cannot exec unconfined - #932

Merged
github-actions[bot] merged 2 commits into
mainfrom
fix/landlock-per-thread-escape
Jul 30, 2026
Merged

fix(sandbox): pin the Landlock thread so a sandboxed MCP server cannot exec unconfined#932
github-actions[bot] merged 2 commits into
mainfrom
fix/landlock-per-thread-escape

Conversation

@Dumbris

@Dumbris Dumbris commented Jul 30, 2026

Copy link
Copy Markdown
Member

Security impact

An untrusted stdio MCP server could run completely unconfined while mcpproxy logged sandbox: Landlock enforced (ABI 7), 1 rlimit(s) set. The sandbox reported success, the filesystem allowlist was never in force for the spawned process, and nothing in the logs distinguished a confined run from an escaped one. Only the rlimits (which are per-process) still applied.

This affects docker_isolation.mode = "sandbox" on Linux — the path intended for hosts where Docker is unavailable or broken (snap-Docker under AppArmor).

Mechanism

Landlock domains are per-thread. landlock_restrict_self(2) commits the new credentials on current — the calling thread — and nothing else.

internal/sandbox/sandbox_linux.go called landlockRestrictSelf on whatever OS thread the goroutine happened to occupy at that moment. internal/sandbox/runchild_unix.go then did three things before syscall.Exec:

  1. a blocking diag-pipe write (fmt.Fprintf(diag, ...)),
  2. dropPrivilegesBestEffort(),
  3. os.Environ().

Each is a chance for the goroutine to park. On a loaded machine sysmon retakes the P during the blocking syscall, the M cannot reacquire one at exitsyscall, and the goroutine is handed to a different M — a thread that never entered the Landlock domain. The execve from that thread runs the untrusted command with no confinement at all. There was no runtime.LockOSThread anywhere in the repo.

This is also exactly why the CI failure printed Landlock enforced (ABI 7) and 64 (rlimit applied, because rlimits are per-process) and WROTE_OUTSIDE in the same run — a combination only explicable by a thread split.

Fix

  • Apply now calls runtime.LockOSThread() as its first statement, deliberately never unlocked (the intended caller execs immediately, and the confinement is irreversible regardless). It lives in Apply rather than only RunChild so every caller of the public API is covered.
  • Apply records the confined tid on Report.LandlockTID. RunChild re-checks it immediately before syscall.Exec and, on mismatch, writes a diagnostic and returns exit code 4 instead of exec'ing — fail-closed. A server that does not start is recoverable; one that starts outside its sandbox is not. (Exit codes in this file: 2 bad invocation, 3 confinement unavailable, 4 thread lost, 126 exec failed, 127 not on PATH.)
  • Corrected the docs that asserted the wrong premise. sandbox.go, sandbox_linux.go and wrap.go all claimed Apply "confines the current process", which is what made the hazard invisible to review. They now state that Apply confines a thread, that only the re-exec-then-exec shape is sound, and that an in-process Apply cannot confine a multithreaded Go process.

Empirical evidence

Root-cause measurements (4-vCPU CI-like conditions): idle 0/100 escapes; with a 2 ms reschedule before exec 58/100 escapes; with runtime.LockOSThread() in Apply 0/100.

Independently re-verified for this PR on a real Linux 6.12 kernel (14 CPUs, aarch64 container), using the new TestApplyPinsThreadAcrossReschedule:

build escapes
pin removed (bug simulated) 20/20 exec'd commands wrote outside the allowlist
with the fix 0/100

The regression test is a genuine negative control — it was confirmed to fail on a binary built with the pin removed, and pass with it.

One finding worth recording: the provocation shape matters. A plain time.Sleep between Apply and execve moved the goroutine 0/50 times — the runtime hands a sleeping goroutine back to the M it parked on. A blocking syscall with every P saturated moved it 50/50. That is the production shape (the blocking diag write), and it is what the test uses; a sleep-based version of this test would have been a vacuous guard.

Tests and CI

  • New internal/sandbox/threadpin_linux_test.go: TestApplyPinsThreadAcrossReschedule re-execs a child that applies confinement, deliberately provokes a thread migration, then execs a shell writing outside the allowlist — tolerance zero. Plus TestApplyReportsRestrictedThread covering the tid the guard depends on. Both skip cleanly off Linux via sandbox.Available().
  • New internal/sandbox/runchild_unix_test.go: TestThreadLost pins the fail-closed guard's decision table (including "no domain enforced → nothing to verify"), and runs on macOS too.
  • internal/upstream/core/sandbox_linux_test.go now asserts the outside dir is not under the read-write allowlist and not under /dev, so a future temp-layout change cannot silently turn the denial check into a vacuous pass.
  • .github/workflows/sandbox-integration.yml gains a "Sandbox enforcement under scheduler load" step. The dedicated sandbox job runs on an idle runner and passed even with the bug present, so it could not discriminate; the new step saturates every CPU before running the enforcement tests. Cleanup is via trap ... EXIT INT TERM plus a timeout-minutes, so the busy loops cannot outlive the step or hang the job (verified by running the step logic against a deliberately failing command).

This also fixes the persistent TestSandboxWrapper_EndToEnd failure in the loaded "End-to-End Tests (ubuntu-latest, 1.25)" job that is currently blocking #928 — that job runs dozens of race-instrumented packages on a 4-vCPU runner, which is what made the thread split likely there but not in the unloaded dedicated sandbox job on the same runner image and kernel.

…cape

Landlock domains are per-THREAD: landlock_restrict_self(2) commits the new
credentials on the calling thread only. Apply ran on whatever OS thread the
goroutine happened to occupy, and RunChild then did a blocking diag write, a
best-effort privilege drop and os.Environ() before syscall.Exec. Each of those
can park the goroutine; on a loaded machine sysmon retakes the P and the
goroutine resumes on a different M. The execve then ran the untrusted MCP
server from a thread that never entered the domain — completely unconfined,
while the log still said "Landlock enforced".

Pin the goroutine to its thread at the top of Apply (deliberately never
unlocked; the intended caller execs immediately and the confinement is
irreversible anyway), and record the confined tid on the Report so RunChild can
re-check it immediately before execve and refuse (exit 4) rather than exec
unconfined if the pin ever fails.

The package docs asserted the wrong premise — that Apply confines the *process*
— which is what made the per-thread hazard invisible. Correct them: Apply
confines a thread, only the re-exec-then-exec shape is sound, and an in-process
Apply cannot confine a multithreaded Go process.

Measured on a 14-CPU Linux 6.12 kernel with the new regression test: 20/20
exec'd commands escaped the domain without the pin, 0/100 with it.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0378688
Status: ✅  Deploy successful!
Preview URL: https://5d0b65d0.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-landlock-per-thread-esca.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Jul 30, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 90.90909% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/sandbox/sandbox_linux.go 66.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/landlock-per-thread-escape

Available Artifacts

  • archive-darwin-amd64 (28 MB)
  • archive-darwin-arm64 (25 MB)
  • archive-linux-amd64 (16 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (28 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (22 MB)
  • installer-dmg-darwin-arm64 (20 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 30521245482 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

…s broken

Both guards for the per-thread Landlock escape passed for reasons other
than the fix working.

TestApplyPinsThreadAcrossReschedule concluded "no escape" from the
absence of escaped.txt, so an exec failure, a missing shell or a child
that died for any unrelated reason all read as a pass. The exec'd shell
now brackets the outside-write attempt with two markers written inside
the read-write allowlist, and every iteration demands both: the absence
of escaped.txt only means "Landlock denied it" once the child is proven
to have run and to have tried.

TestThreadLost only exercised the helper, so deleting the guard call
from RunChild — or moving it below syscall.Exec — left the suite green.
RunChild's Apply step is now indirected through an unexported package
var so a test can hand it a Report whose LandlockTID cannot be the
running thread, and assert RunChild returns 4 without exec'ing. The
target is an absolute path that does not exist, so a RunChild that
reached execve comes back 126 instead of replacing the test binary's
image; the companion test pins the other direction, that a held thread
still execs.

Verified on real Linux (kernel 6.12, Landlock ABI enforced, not
skipped): both mutations of the guard turn the new test red, and
breaking the escape child's exec turns the pin test red.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arming auto-merge: security fix for a real Landlock per-thread escape. Codex cross-model review CLEAN; regression tests mutation-verified on real Linux (guard deleted AND guard moved below execve both fail the new test); sandbox integration ran the escape test 30x under full CPU load; the E2E job that was persistently red is green.

@github-actions
github-actions Bot merged commit 5b31672 into main Jul 30, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants