Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions grapharc/harness/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,84 @@ def fork_exec(*args: Any, **kwargs: Any) -> Any:
module.fork_exec = fork_exec


#: Every stdlib function that accepts a directory-descriptor keyword uses one
#: of exactly three spellings (`dir_fd`, `src_dir_fd`, `dst_dir_fd`), so
#: `_install_dir_fd_guard` below checks all three by name rather than needing
#: a table of which function takes which — a table would go stale the way the
#: `fork_exec` arity above already did once. `readlink` is excluded: it has
#: its own dedicated guard above, for a different reason (its *result*, not
#: just its argument, has to be resolved and checked).
_DIR_FD_MUTATORS = (
"chmod",
"chown",
"link",
"mkdir",
"mkfifo",
"mknod",
"open",
"remove",
"rename",
"replace",
"rmdir",
"symlink",
"unlink",
"utime",
)


def _install_dir_fd_guard() -> None:
"""Refuse `dir_fd=`/`src_dir_fd=`/`dst_dir_fd=` on every mutating call that
accepts one — a real, verified escape from the workspace grant.

`check_path` resolves a call's path argument against the *process's cwd*,
pinned to the workspace by the `os.chdir` below. A directory-descriptor
keyword makes the underlying syscall resolve that same string against an
open file descriptor instead; cwd never enters into it. A tool legitimately
reading a directory inside the read grant — site-packages, say — gets a
descriptor for it, and `os.open("evil.pth", O_CREAT | O_WRONLY,
dir_fd=that_fd)` then writes there while `check_path` is validating a path
that has nothing to do with where the write actually lands. Confirmed by
running it end to end: the planted file executes on the next interpreter
start in that environment — the exact site-packages `.pth`-drop escape the
read/write grant split exists to close (see the module docstring above),
reopened through a syscall shape the audit-event path check never
considered a path argument for at all.

Like the `os.readlink` guard, this refuses the capability outright rather
than trying to resolve and check it: there is no reliable way to turn a
directory descriptor back into the path it names, so there is nothing to
validate against the grant. It is a wrapper, not an audit hook, and carries
the same documented caveat for the same reason — the original stays
reachable through the closure, so it closes the accident, not a
determined adversary already unwrapping functions from inside.
"""
posix = sys.modules.get("posix") or sys.modules.get("nt")

def make_wrapper(original: Any, name: str) -> Any:
def wrapper(*args: Any, **kwargs: Any) -> Any:
for kw in ("dir_fd", "src_dir_fd", "dst_dir_fd"):
if kwargs.get(kw) is not None:
raise SandboxViolation(
f"os.{name} through a directory descriptor ({kw}) resolves "
"outside the process's cwd and cannot be checked against the "
"workspace grant, so it is refused"
)
return original(*args, **kwargs)

wrapper.__name__ = name
wrapper.__qualname__ = name
return wrapper

for name in _DIR_FD_MUTATORS:
original = getattr(os, name, None)
if original is None: # pragma: no cover - platform-dependent (mkfifo/mknod on Windows)
continue
wrapped = make_wrapper(original, name)
setattr(os, name, wrapped)
if posix is not None and hasattr(posix, name):
setattr(posix, name, wrapped)


def _is_sqlite_uri(database: Any) -> bool:
"""sqlite re-reads a `file:` name itself — percent-decoding it and honouring
an authority and query string — so `realpath` does not name the file that
Expand Down Expand Up @@ -531,6 +609,7 @@ def hook(event: str, hook_args: tuple[Any, ...]) -> None:
pass
_install_readlink_guard(check_read)
_install_fork_exec_guard(spec.name)
_install_dir_fd_guard()
sys.addaudithook(hook)
try:
result = spec.fn(**args)
Expand Down
63 changes: 63 additions & 0 deletions tests/test_harness_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,69 @@ def unplant(target: str) -> str:
assert os.path.exists(site_packages_probe), "a site-packages file was deleted from the sandbox"


@_posix
@pytest.mark.parametrize(
"op",
["os.open", "os.mkdir", "os.remove", "os.rename"],
)
def test_gate_dot_pth_cannot_be_planted_via_dir_fd(tmp_path, op, site_packages_probe):
"""The same escape as `test_gate_dot_pth_cannot_be_planted_in_site_packages`,
reached through `dir_fd=` instead of a plain path.

`check_path` resolves the path argument of a call against the process's
cwd (pinned to the workspace). Every one of these calls also accepts a
directory-descriptor keyword that makes the real syscall resolve the same
string against an *open file descriptor* instead — cwd never enters into
it. A tool legitimately opens site-packages for reading (it is in the read
grant, or the import machinery could not work), gets a descriptor for it,
and then `os.open("evil.pth", O_CREAT | O_WRONLY, dir_fd=that_fd)` writes
there while `check_path` validates a path with nothing to do with where
the write actually lands. Verified end to end before this test existed:
the planted `.pth` ran on the very next interpreter start in that
environment — this is not a theoretical gap.
"""
workspace = tmp_path / "ws"
workspace.mkdir()
pth_name = f"grapharc_pwned_dirfd_{uuid.uuid4().hex}.pth"

def plant_via_dir_fd(operation: str, name: str, victim: str) -> str:
import os as _os

dfd = _os.open(_SITE_PACKAGES, _os.O_RDONLY)
try:
if operation == "os.open":
fd = _os.open(name, _os.O_CREAT | _os.O_WRONLY, dir_fd=dfd)
_os.close(fd)
elif operation == "os.mkdir":
_os.mkdir(name, dir_fd=dfd)
elif operation == "os.remove":
_os.remove(_os.path.basename(victim), dir_fd=dfd)
elif operation == "os.rename":
_os.rename(
_os.path.basename(victim), name, src_dir_fd=dfd, dst_dir_fd=dfd
)
return "escaped"
finally:
_os.close(dfd)

harness = _sandbox(workspace, plant_via_dir_fd=plant_via_dir_fd)
try:
with pytest.raises(SandboxViolation, match="directory descriptor"):
harness.call(
"plant_via_dir_fd",
{"operation": op, "name": pth_name, "victim": site_packages_probe},
)
assert not os.path.exists(
os.path.join(_SITE_PACKAGES, pth_name)
), "a file was planted in site-packages via dir_fd"
if op in ("os.remove", "os.rename"):
assert os.path.exists(
site_packages_probe
), "a site-packages file was removed/renamed via dir_fd"
finally:
_scrub("grapharc_pwned_dirfd_") # a regression really does leave one behind


@_posix
@pytest.mark.parametrize(
"op",
Expand Down
Loading