Harden sandbox: local exec scripts, dynamic/env cwd reads, BASH_ENV, exact /root, pathlib glob, quoted newlines
- Block running an explicit LOCAL executable path at command position (./evil, subdir/tool) in both the argv scanner and the shell command scanner: a sandboxed snippet can create + chmod a local script with an interpreter shebang and run it, starting an unguarded child the basename scan never sees. Absolute system-bin paths (/bin, /usr/bin, ...) stay allowed and are still interpreter-checked by basename. - Fail closed on a child file-reader (cat / head / ...) with a relative argv path under a NON-literal subprocess cwd= (cwd=P that could evaluate to /etc), which cannot be proven sandbox-local; a literal benign cwd and a non-reader program stay allowed. - Treat a shell startup variable (BASH_ENV / ENV) in an explicit subprocess env= dict as a shell escape: bash / sh sources it before the -c payload runs. - Runtime backstop: treat the exact /root path (not only /root/*) as sensitive so a directory reader over the root home is denied, and wrap Path.glob / Path.rglob like Path.iterdir so a dynamically built receiver pointing at a sensitive directory is screened. - Make the newline -> ; command-separator rewrite quote-aware, and neutralize quoted separators before the command-boundary regex, so a quoted multiline string (echo "ok\nrm") is not mis-blocked; unquoted separators and command substitution ($(...) / backticks, including inside double quotes) still block. Adds TestRound27Bypasses plus runtime tests for exact /root and pathlib glob / rglob.
This commit is contained in:
parent
fbc67fd490
commit
e6d24369a9
3 changed files with 302 additions and 19 deletions
|
|
@ -229,6 +229,23 @@ def _is_versioned_interpreter(base: str) -> bool:
|
|||
return stem != base and stem in _INTERPRETER_COMMANDS
|
||||
|
||||
|
||||
# Absolute paths under a standard system bin dir are trusted as real system commands (their
|
||||
# basename is still interpreter-checked separately); every OTHER explicit path is a local file.
|
||||
_SYSTEM_BIN_PREFIXES = ("/bin/", "/usr/bin/", "/usr/local/bin/", "/sbin/", "/usr/sbin/")
|
||||
|
||||
|
||||
def _is_local_executable_path(tok: str) -> bool:
|
||||
"""True when a command word is an explicit path to a LOCAL executable file (./evil, ../x,
|
||||
subdir/tool, /tmp/x). Running such a file executes whatever its shebang names in an
|
||||
UNGUARDED child -- a sandboxed snippet can create + chmod ./evil with `#!/usr/bin/python3`
|
||||
and run it, starting an interpreter the argv basename scan never sees. A bare command name
|
||||
resolved via PATH (no slash) and an absolute system-bin path are not treated as local."""
|
||||
t = tok.replace("\\", "/")
|
||||
if "/" not in t:
|
||||
return False
|
||||
return not t.startswith(_SYSTEM_BIN_PREFIXES)
|
||||
|
||||
|
||||
# The only shell redirection targets trusted without a realpath check: standard device
|
||||
# sinks that cannot escape the workdir. Every other target (relative or absolute) fails
|
||||
# closed, because the unguarded child follows symlinks and resolves relative names against a
|
||||
|
|
@ -521,6 +538,115 @@ def _expand_ifs(command: str) -> str:
|
|||
return _IFS_RE.sub(" ", command)
|
||||
|
||||
|
||||
def _rewrite_unquoted_newlines(command: str) -> str:
|
||||
"""Rewrite only UNQUOTED newline runs to ` ; ` (a bash command separator). A newline INSIDE
|
||||
quotes is data (echo "ok\\nrm" is one argument), so a blanket regex would split a quoted
|
||||
multiline string into a spurious command position and mis-block the later line."""
|
||||
out = []
|
||||
q = None
|
||||
esc = False
|
||||
prev_nl = False
|
||||
for ch in command:
|
||||
if esc:
|
||||
out.append(ch)
|
||||
esc = False
|
||||
prev_nl = False
|
||||
continue
|
||||
if q == "'":
|
||||
out.append(ch)
|
||||
if ch == "'":
|
||||
q = None
|
||||
prev_nl = False
|
||||
continue
|
||||
if q == '"':
|
||||
out.append(ch)
|
||||
if ch == "\\":
|
||||
esc = True
|
||||
elif ch == '"':
|
||||
q = None
|
||||
prev_nl = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
out.append(ch)
|
||||
esc = True
|
||||
prev_nl = False
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
out.append(ch)
|
||||
q = ch
|
||||
prev_nl = False
|
||||
continue
|
||||
if ch in ("\r", "\n"):
|
||||
if not prev_nl:
|
||||
out.append(" ; ")
|
||||
prev_nl = True
|
||||
continue
|
||||
out.append(ch)
|
||||
prev_nl = False
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _mask_quoted_separators(command: str) -> str:
|
||||
"""Neutralize command-boundary characters that are DATA inside quotes (blank them to a
|
||||
space) so the regex command-position scan does not treat a quoted separator -- echo
|
||||
"ok\\nrm" or 'a;rm' -- as a fresh command word. Command substitution ($(...) / backticks)
|
||||
still runs inside DOUBLE quotes, so those are preserved; single-quoted text is fully
|
||||
literal. The result is used only for the boundary regex, not for tokenization."""
|
||||
out = []
|
||||
q = None
|
||||
esc = False
|
||||
i = 0
|
||||
n = len(command)
|
||||
while i < n:
|
||||
ch = command[i]
|
||||
if esc:
|
||||
out.append(ch)
|
||||
esc = False
|
||||
i += 1
|
||||
continue
|
||||
if q == "'":
|
||||
out.append(" " if ch in ";&|(\n\r`$" else ch)
|
||||
if ch == "'":
|
||||
q = None
|
||||
i += 1
|
||||
continue
|
||||
if q == '"':
|
||||
if ch == "\\":
|
||||
out.append(ch)
|
||||
esc = True
|
||||
i += 1
|
||||
continue
|
||||
if ch == '"':
|
||||
out.append(ch)
|
||||
q = None
|
||||
i += 1
|
||||
continue
|
||||
if ch == "$" and i + 1 < n and command[i + 1] == "(":
|
||||
out.append("$(") # command substitution runs inside double quotes; keep it
|
||||
i += 2
|
||||
continue
|
||||
if ch == "`":
|
||||
out.append("`")
|
||||
i += 1
|
||||
continue
|
||||
out.append(" " if ch in ";&|(\n\r" else ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch == "\\":
|
||||
out.append(ch)
|
||||
esc = True
|
||||
i += 1
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
out.append(ch)
|
||||
q = ch
|
||||
i += 1
|
||||
continue
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _iter_unquoted_chars(s):
|
||||
"""Yield (index, char) for every character OUTSIDE single / double quotes (a backslash
|
||||
escape and the char it escapes are skipped inside double quotes / unquoted text). Used to
|
||||
|
|
@ -734,10 +860,10 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
command = _expand_ifs(_normalize_ansi_c_quotes(command))
|
||||
# bash treats an unquoted newline as a command separator, but shlex's whitespace_split
|
||||
# folds it into ordinary whitespace, so `echo ok\nsed -i ...` would read `sed` as an
|
||||
# argument of `echo` and miss the write. Rewrite newlines to `;` so each line starts a
|
||||
# fresh command position; a newline INSIDE quotes stays in its token (shlex honors quotes),
|
||||
# so the `;` there is not treated as a separator.
|
||||
command = re.sub(r"[\r\n]+", " ; ", command)
|
||||
# argument of `echo` and miss the write. Rewrite UNQUOTED newlines to `;` so each line
|
||||
# starts a fresh command position; a newline inside quotes stays data (echo "ok\nrm" is one
|
||||
# argument), so it is not turned into a spurious `; rm` command position.
|
||||
command = _rewrite_unquoted_newlines(command)
|
||||
# bash performs brace expansion before command lookup, so `{touch,/tmp/x}` /
|
||||
# `{python3,-c} '...'` run the writer / interpreter even though the raw string has no
|
||||
# blocked token. Expand comma brace groups so the produced command words are scanned.
|
||||
|
|
@ -830,6 +956,10 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
# shell, the same escape as `source`, but its basename is not a blocklist word.
|
||||
if base == ".":
|
||||
blocked.add("source")
|
||||
# An explicit path to a LOCAL executable at command position (./evil, subdir/tool) runs
|
||||
# whatever its shebang names in an unguarded child, so treat it like a blocked command.
|
||||
if _is_local_executable_path(token):
|
||||
blocked.add("local-exec:" + base)
|
||||
# Wrappers (env/time/xargs/sudo) consume one command; the next non-flag,
|
||||
# non-numeric token is the real command. sudo is also in _BLOCKED_COMMANDS.
|
||||
if base in _COMMAND_PREFIXES:
|
||||
|
|
@ -856,8 +986,9 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
|
||||
# Regex catches blocked words at command boundaries shlex misses: inside
|
||||
# $(rm -rf), <(rm), backtick chains, or "foo;rm". Anchored to command-position
|
||||
# delimiters, so it doesn't match in argument position.
|
||||
lowered = command.lower()
|
||||
# delimiters, so it doesn't match in argument position. Quoted separators are neutralized
|
||||
# first so a quoted multiline string (echo "ok\nrm") is not read as a command boundary.
|
||||
lowered = _mask_quoted_separators(command).lower()
|
||||
if _BLOCKED_COMMANDS:
|
||||
words_alt = "|".join(re.escape(w) for w in sorted(_BLOCKED_COMMANDS))
|
||||
pattern = (
|
||||
|
|
@ -1223,6 +1354,8 @@ def _blocked_in_argv(str_elts: list[str | None]) -> tuple[set[str], int | None]:
|
|||
cur_wrapper = base
|
||||
idx += 1
|
||||
continue # wrapper consumes one command; the next word is the real one
|
||||
if _is_local_executable_path(tok):
|
||||
blocked.add("local-exec:" + base) # runs an unguarded shebang interpreter
|
||||
return blocked, idx # reached the executed command word
|
||||
return blocked, None
|
||||
|
||||
|
|
@ -5549,6 +5682,17 @@ def _check_signal_escape_patterns(
|
|||
)
|
||||
blocked_in_args = _check_args_for_blocked(all_call_args, _shell_maybe_true)
|
||||
|
||||
# A shell startup variable (BASH_ENV / ENV) in an explicit env= dict names a
|
||||
# script bash / sh SOURCES before the -c payload runs, executing unscanned code
|
||||
# (subprocess.run(['bash','-c','echo OK'], env={'BASH_ENV':'env.sh'})). Flag a
|
||||
# non-empty (or non-literal) value; an empty string is inert.
|
||||
_env_node = expanded_kwargs.get("env")
|
||||
if isinstance(_env_node, ast.Dict):
|
||||
for _ek, _ev in zip(_env_node.keys, _env_node.values):
|
||||
_ekey = _extract_string_from_node(_ek) if _ek is not None else None
|
||||
if _ekey in ("BASH_ENV", "ENV") and _extract_string_from_node(_ev) != "":
|
||||
blocked_in_args = blocked_in_args | {"shell-startup-env:" + _ekey}
|
||||
|
||||
# os.execl(path, a0, a1, ...) / os.execv(path, [a0, ...]) / os.spawnl(mode,
|
||||
# path, a0, ...) spread the child's argv across separate positional args (or a
|
||||
# single list), so scanning each string alone misses a mutating tail like
|
||||
|
|
@ -7623,15 +7767,49 @@ def _check_signal_escape_patterns(
|
|||
)
|
||||
# subprocess.run(['cat', 'passwd'], cwd='/etc') reads /etc/passwd in an unguarded
|
||||
# child: the argv entry is relative and /etc alone is not sensitive, so combine a
|
||||
# literal cwd= with each relative argv path before the sensitivity check.
|
||||
# literal cwd= with each relative argv path before the sensitivity check. A
|
||||
# NON-literal cwd (cwd=P) cannot be proven sandbox-local, so a relative read under
|
||||
# it fails closed (handled below).
|
||||
_sub_cwd = None
|
||||
_sub_cwd_dynamic = False
|
||||
if _is_child_exec:
|
||||
for kw in node.keywords or []:
|
||||
if kw.arg == "cwd":
|
||||
_cv = _fold_read_arg(kw.value)
|
||||
if isinstance(_cv, str):
|
||||
_sub_cwd = _cv
|
||||
elif not (isinstance(kw.value, ast.Constant) and kw.value.value is None):
|
||||
_sub_cwd_dynamic = True
|
||||
break
|
||||
# A file-reading child (cat / head / ...) with a relative argv path under a
|
||||
# non-literal cwd could read a host secret (cwd=P; P evaluates to /etc); the child
|
||||
# is unguarded, so fail closed unless the cwd is proven sandbox-local.
|
||||
if _sub_cwd_dynamic:
|
||||
_argv0 = None
|
||||
if node.args and isinstance(node.args[0], (ast.List, ast.Tuple)):
|
||||
_av = node.args[0].elts
|
||||
_p0 = _fold_read_arg(_av[0]) if _av else None
|
||||
if (
|
||||
isinstance(_p0, str)
|
||||
and os.path.basename(_p0).lower() in _SHELL_READ_COMMANDS
|
||||
):
|
||||
for _ae in _av[1:]:
|
||||
_av_s = _fold_read_arg(_ae)
|
||||
if (
|
||||
isinstance(_av_s, str)
|
||||
and _av_s
|
||||
and not _av_s.startswith("-")
|
||||
and not _av_s.startswith("/")
|
||||
and not _av_s.startswith("~")
|
||||
):
|
||||
_fs_block(
|
||||
node,
|
||||
"child reader with a relative path under a non-literal cwd",
|
||||
)
|
||||
_argv0 = True
|
||||
break
|
||||
if _argv0:
|
||||
return
|
||||
# Pathlib read on a Path(...) / join receiver: check the resolved path.
|
||||
if isinstance(f, ast.Attribute) and f.attr in _PATHLIB_READ_METHODS:
|
||||
rp = _pathlib_receiver_path(f.value)
|
||||
|
|
@ -7972,9 +8150,10 @@ def _is_sensitive_read(rp):
|
|||
return True
|
||||
# Dotfiles / caches under a root home hold credentials (/root/.bashrc, /root/.cache/...);
|
||||
# an opaque path the static /root/ rule cannot fold could read them at runtime. Restore
|
||||
# the /root/ protection here, but carve out package / library trees so importing a library
|
||||
# the /root/ protection here (including the root home ITSELF, /root, which a directory
|
||||
# reader would enumerate), but carve out package / library trees so importing a library
|
||||
# installed under a root home (site-packages, the stdlib) is not broken.
|
||||
if n.startswith("/root/") and not any(
|
||||
if (n == "/root" or n.startswith("/root/")) and not any(
|
||||
_seg in n
|
||||
for _seg in ("/site-packages/", "/dist-packages/", "/lib/python", "/lib64/python")
|
||||
):
|
||||
|
|
@ -8367,17 +8546,21 @@ try:
|
|||
for _n in ("rename", "replace", "symlink_to", "hardlink_to"):
|
||||
_wrapp(_n, True)
|
||||
|
||||
# Path.iterdir enumerates a directory; a dynamically built receiver
|
||||
# (Path(globals()['P']).iterdir()) has no literal path for the static scanner and, on
|
||||
# some CPython versions, routes through pathlib's captured original os.scandir rather
|
||||
# than the patched one, so screen the directory read here too.
|
||||
_real_iterdir = getattr(_pl.Path, "iterdir", None)
|
||||
if _real_iterdir is not None:
|
||||
@_gwraps(_real_iterdir)
|
||||
def _guarded_iterdir(self, *a, **k):
|
||||
# Path.iterdir / glob / rglob enumerate a directory; a dynamically built receiver
|
||||
# (Path(globals()['P']).iterdir(), Path(P).glob('*')) has no literal path for the static
|
||||
# scanner and, on some CPython versions, routes through pathlib's captured original
|
||||
# os.scandir rather than the patched one, so screen the directory read on the RECEIVER dir.
|
||||
def _guard_path_dir_reader(_name):
|
||||
_real = getattr(_pl.Path, _name, None)
|
||||
if _real is None:
|
||||
return
|
||||
@_gwraps(_real)
|
||||
def w(self, *a, **k):
|
||||
_deny_sensitive_read(self)
|
||||
return _real_iterdir(self, *a, **k)
|
||||
_pl.Path.iterdir = _guarded_iterdir
|
||||
return _real(self, *a, **k)
|
||||
setattr(_pl.Path, _name, w)
|
||||
for _n in ("iterdir", "glob", "rglob"):
|
||||
_guard_path_dir_reader(_n)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -1329,3 +1329,45 @@ def test_runtime_is_sensitive_read_covers_root_home():
|
|||
assert f("/root/.local/lib/python3.13/site-packages/certifi/cacert.pem") is False
|
||||
assert f("/root/miniconda3/lib/python3.13/os.py") is False
|
||||
assert f("/home/ubuntu/project/data.txt") is False
|
||||
|
||||
|
||||
def test_runtime_is_sensitive_read_covers_exact_root():
|
||||
# os.listdir('/root') (P = '/root' computed dynamically) enumerates the root home itself;
|
||||
# the runtime backstop must treat the exact /root path as sensitive, not only /root/*.
|
||||
import re as _re
|
||||
|
||||
ns = {"_re": _re}
|
||||
block = _SANDBOX_GUARD_SRC[
|
||||
_SANDBOX_GUARD_SRC.index("_SENS_EXACT = ") : _SANDBOX_GUARD_SRC.index("def _read_realpath")
|
||||
]
|
||||
exec(block, ns)
|
||||
f = ns["_is_sensitive_read"]
|
||||
assert f("/root") is True
|
||||
assert f("/root/") is True
|
||||
assert f("/root/.local/lib/python3.13/site-packages/x.py") is False
|
||||
|
||||
|
||||
@_POSIX_ONLY
|
||||
@pytest.mark.parametrize("meth", ["glob", "rglob"])
|
||||
def test_sandboxed_pathlib_glob_sensitive_dir_denied(meth):
|
||||
# Path(P).glob('*') / rglob enumerate a directory through pathlib internals; a dynamically
|
||||
# built receiver pointing (via an in-workdir symlink) at a sensitive dir must be screened
|
||||
# the same way Path.iterdir is.
|
||||
session = "backstop-glob-" + meth
|
||||
workdir = get_sandbox_workdir(session)
|
||||
link = os.path.join(workdir, "ssh_link_" + meth)
|
||||
if os.path.islink(link) or os.path.exists(link):
|
||||
os.remove(link)
|
||||
os.symlink("/etc/ssh", link) # /etc/ssh/ is a sensitive directory
|
||||
try:
|
||||
out = _python_exec(
|
||||
"from pathlib import Path\n"
|
||||
f"print('N', len(list(Path('ssh_link_{meth}').{meth}('*'))))\n",
|
||||
None,
|
||||
30,
|
||||
session,
|
||||
disable_sandbox = False,
|
||||
)
|
||||
assert "sandbox:" in out or "PermissionError" in out
|
||||
finally:
|
||||
os.remove(link)
|
||||
|
|
|
|||
|
|
@ -3068,3 +3068,61 @@ class TestRound26Bypasses:
|
|||
)
|
||||
def test_round26_benign_allowed(self, code):
|
||||
_ok(code)
|
||||
|
||||
|
||||
class TestRound27Bypasses:
|
||||
"""Twenty-seventh-round Codex findings (static portion): local executable scripts with an
|
||||
unsafe shebang, dynamic subprocess cwd for a child reader, env -> BASH_ENV / ENV shell
|
||||
startup scripts, and the quoted-newline false positive. (The runtime-guard items -- exact
|
||||
/root and pathlib glob / rglob enumeration -- are covered in test_sandbox_runtime_backstop.)"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
"import subprocess\nsubprocess.run(['./evil'])",
|
||||
"import subprocess\nsubprocess.run(['bin/evil'])",
|
||||
"import subprocess\nsubprocess.Popen(['../tools/evil', 'arg'])",
|
||||
"import os\nos.system('./evil')",
|
||||
],
|
||||
)
|
||||
def test_local_executable_script_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
"import subprocess\nP = compute_dir()\nsubprocess.check_output(['cat', 'passwd'], cwd=P)",
|
||||
"import subprocess\nsubprocess.run(['head', '-1', 'secret'], cwd=get_dir())",
|
||||
],
|
||||
)
|
||||
def test_dynamic_cwd_child_reader_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
"import subprocess\nsubprocess.run(['bash', '-c', 'echo OK'], env={'BASH_ENV': 'env.sh'})",
|
||||
"import subprocess\nsubprocess.run(['sh', '-c', 'echo OK'], env={'ENV': 'e.sh'})",
|
||||
],
|
||||
)
|
||||
def test_shell_startup_env_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# A quoted separator / newline is data, not a command boundary, so these benign
|
||||
# print/generate-text commands must NOT be blocked (round-27 P2 false positive).
|
||||
"import os\nos.system('echo \"ok\\nrm -rf /\"')",
|
||||
"import os\nos.system(\"echo 'a;rm -rf x'\")",
|
||||
"import os\nos.system('printf \"line1\\ntouch x\\n\"')",
|
||||
# Benign local relative navigation / system binaries / dynamic cwd non-reader.
|
||||
"import subprocess\nsubprocess.run(['ls', '-la'])",
|
||||
"import subprocess\nsubprocess.run(['/bin/ls'])",
|
||||
"import subprocess\nsubprocess.run(['make'], cwd=get_dir())",
|
||||
"import subprocess\nsubprocess.run(['bash', '-c', 'echo OK'], env={'BASH_ENV': ''})",
|
||||
"import subprocess\nsubprocess.run(['cat', 'data.txt'], cwd='logs')",
|
||||
],
|
||||
)
|
||||
def test_round27_benign_allowed(self, code):
|
||||
_ok(code)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue