Close a real sandbox escape: dir_fd= bypasses the workspace write grant - #53
Merged
Conversation
check_path() resolves a call's path argument against the process's cwd,
pinned to the workspace by os.chdir(). Every mutating os function that
accepts dir_fd=/src_dir_fd=/dst_dir_fd= resolves the same string against an
open directory descriptor instead, and cwd never enters into it — so a tool
that legitimately opens a runtime path for reading (site-packages, which has
to stay readable so imports work) can get a descriptor for it and then write
there via that descriptor while check_path validates a path that has nothing
to do with where the write actually lands.
Verified end to end: a tool opens site-packages (a legitimate read), gets a
dir_fd from it, and calls os.open("evil.pth", O_CREAT|O_WRONLY,
dir_fd=that_fd). No SandboxViolation was raised, the file landed in
site-packages, and it executed automatically on the next fresh interpreter
start in that environment — the exact .pth-drop escape the read/write grant
split (and the existing os.readlink guard) exists to close, reopened through
a syscall shape the audit-event path check never considered a path argument
for at all. os.mkdir, os.remove, and os.rename with dir_fd= reproduce the
same hole.
Fix: _install_dir_fd_guard() wraps every stdlib function that accepts one of
the three dir_fd keyword spellings (chmod, chown, link, mkdir, mkfifo,
mknod, open, remove, rename, replace, rmdir, symlink, unlink, utime) and
refuses the keyword outright, the same way the existing os.readlink guard
already refuses dir_fd for the one function it covers — there is no reliable
way to turn a directory descriptor back into the path it names, so there is
nothing to check against the grant. Derived from the stdlib's own three
keyword spellings rather than a hand-maintained per-function table, so it
does not go stale the way the fork_exec arity table already did once.
Added test_gate_dot_pth_cannot_be_planted_via_dir_fd (4 parametrized cases:
os.open, os.mkdir, os.remove, os.rename), sitting next to the existing
plain-path .pth-planting gate test it mirrors.
Verified: full suite green on Python 3.12 and 3.13; ruff clean; confirmed
normal (non-dir_fd) file operations inside the workspace are unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
check_path() resolves a call's path argument against the process's cwd, pinned to the workspace by os.chdir(). Every mutating os function that accepts dir_fd=/src_dir_fd=/dst_dir_fd= resolves the same string against an open directory descriptor instead, and cwd never enters into it — so a tool that legitimately opens a runtime path for reading (site-packages, which has to stay readable so imports work) can get a descriptor for it and then write there via that descriptor while check_path validates a path that has nothing to do with where the write actually lands.
Verified end to end: a tool opens site-packages (a legitimate read), gets a dir_fd from it, and calls os.open("evil.pth", O_CREAT|O_WRONLY, dir_fd=that_fd). No SandboxViolation was raised, the file landed in site-packages, and it executed automatically on the next fresh interpreter start in that environment — the exact .pth-drop escape the read/write grant split (and the existing os.readlink guard) exists to close, reopened through a syscall shape the audit-event path check never considered a path argument for at all. os.mkdir, os.remove, and os.rename with dir_fd= reproduce the same hole.
Fix: _install_dir_fd_guard() wraps every stdlib function that accepts one of the three dir_fd keyword spellings (chmod, chown, link, mkdir, mkfifo, mknod, open, remove, rename, replace, rmdir, symlink, unlink, utime) and refuses the keyword outright, the same way the existing os.readlink guard already refuses dir_fd for the one function it covers — there is no reliable way to turn a directory descriptor back into the path it names, so there is nothing to check against the grant. Derived from the stdlib's own three keyword spellings rather than a hand-maintained per-function table, so it does not go stale the way the fork_exec arity table already did once.
Added test_gate_dot_pth_cannot_be_planted_via_dir_fd (4 parametrized cases: os.open, os.mkdir, os.remove, os.rename), sitting next to the existing plain-path .pth-planting gate test it mirrors.
Verified: full suite green on Python 3.12 and 3.13; ruff clean; confirmed normal (non-dir_fd) file operations inside the workspace are unaffected.