Harden sandbox: PATH-relative exec, git alias dispatch, executable=, alias body, trap terminator, interactive/BASH_ENV shells
Close six static-classifier bypasses in studio/backend/core/inference/tools.py:
- Unsafe PATH search list: a PATH prefix or env mapping with a relative / cwd
entry (PATH=. cmd, PATH=.:$PATH, env={'PATH': '.'}) lets a bare command word
resolve to a workdir shebang, defeating the bare-name PATH exemption. New
_path_value_is_unsafe flags such assignments in the shell-prefix, standalone,
and subprocess env= forms.
- git shell-dispatch alias: git -c alias.X=!CMD X and git config alias.X !CMD run
CMD through an unguarded shell while the scanner sees only git. Detect the !
marker on an alias config value in both the shell-string path and the argv path
(git added to the argv-tail rescan set).
- executable= override: subprocess(argv, executable=PROG) runs PROG with argv[1:]
as its flags, so scanning argv and executable separately misses
run(['x','-i','s/a/b/','/f'], executable='/usr/bin/sed'). Reconstruct PROG +
argv tail and scan the effective command line.
- alias body: alias x='touch f'; x runs the alias body at execution time under a
command word the scanner cannot resolve; scan the body of each alias definition.
- trap -- terminator: trap -- 'CMD' EXIT left the handler unscanned because the
handler operand was read as the -- token. Skip trap options / -- in both the
blocked-command and sensitive-read trap scans.
- interactive / persisted-startup shells: bash -i (and combined -ic) sources rc
files before the -c payload, and an exported BASH_ENV / ENV in a separate
command persists for later shells; flag both as unscanned startup.
Regression coverage: TestRound34Bypasses in tests/test_sandbox_tools.py.
This commit is contained in:
parent
8be5765b04
commit
c2e10298b0
2 changed files with 213 additions and 7 deletions
|
|
@ -229,7 +229,7 @@ _SHELL_BINARIES = frozenset({"bash", "sh", "zsh", "dash", "ksh", "csh", "tcsh",
|
|||
# Utilities whose LATER argv elements are actions / write flags, not inert arguments
|
||||
# (find -exec/-delete, sed -i / w, sort -o). A non-shell argv resolving to one of these is
|
||||
# re-scanned as a reconstructed command line so those dangerous flags are caught.
|
||||
_ARGV_TAIL_SCAN_COMMANDS = frozenset({"find", "sed", "gsed", "ssed", "perl", "sort"})
|
||||
_ARGV_TAIL_SCAN_COMMANDS = frozenset({"find", "sed", "gsed", "ssed", "perl", "sort", "git"})
|
||||
|
||||
|
||||
def _is_versioned_interpreter(base: str) -> bool:
|
||||
|
|
@ -262,6 +262,22 @@ def _is_local_executable_path(tok: str) -> bool:
|
|||
return not norm.startswith(_SYSTEM_BIN_PREFIXES)
|
||||
|
||||
|
||||
def _path_value_is_unsafe(value: str) -> bool:
|
||||
"""True when a PATH search list would let a BARE (no-slash) command resolve to a workdir
|
||||
executable: any entry that is ``.``, empty (``:`` = cwd), or a relative directory. An
|
||||
absolute (``/...``), ``~``-rooted, or variable (``$PATH`` / ``%VAR%``) entry is safe. Used
|
||||
so a ``PATH=. evil`` prefix / ``env={'PATH': '.'}`` cannot smuggle an unguarded shebang
|
||||
past the bare-name PATH exemption in _is_local_executable_path."""
|
||||
for entry in value.replace("\\", "/").split(":"):
|
||||
e = entry.strip()
|
||||
if e in ("", "."):
|
||||
return True
|
||||
if e.startswith(("/", "~", "$", "%")):
|
||||
continue
|
||||
return True # a relative directory (relbin, ./tools)
|
||||
return False
|
||||
|
||||
|
||||
# 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
|
||||
|
|
@ -1140,9 +1156,16 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
# the quoted handler is unscanned shell code. Scan the handler operand of a command-position
|
||||
# `trap` recursively; a reset (trap - EXIT) / ignore (trap '' EXIT) has nothing to run.
|
||||
for i in _cmd_word_idx:
|
||||
if _token_basename(tokens[i]) != "trap" or i + 1 >= len(tokens):
|
||||
if _token_basename(tokens[i]) != "trap":
|
||||
continue
|
||||
_h = tokens[i + 1]
|
||||
# Skip trap options / the -- terminator (trap -- 'CMD' EXIT, trap -p) so the handler
|
||||
# operand is not mistaken for -- and left unscanned.
|
||||
_j = i + 1
|
||||
while _j < len(tokens) and tokens[_j].startswith("-") and len(tokens[_j]) > 1:
|
||||
_j += 1
|
||||
if _j >= len(tokens):
|
||||
continue
|
||||
_h = tokens[_j]
|
||||
if _h and _h != "-" and _h not in _SHELL_SEPARATORS and _h not in _SHELL_KEYWORDS_AS_SEP:
|
||||
blocked |= _find_blocked_commands(_h)
|
||||
|
||||
|
|
@ -1158,11 +1181,18 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
continue
|
||||
_has_c = False
|
||||
_script = None
|
||||
_interactive = False
|
||||
for k in range(i + 1, len(tokens)):
|
||||
t = tokens[k]
|
||||
if t in _SHELL_SEPARATORS or t in _SHELL_KEYWORDS_AS_SEP:
|
||||
break
|
||||
tl = t.lower()
|
||||
# An interactive shell (bash -i, sh -i, or a combined short flag like -ic) SOURCES
|
||||
# the user's rc files (.bashrc / ENV) before running any -c payload, executing
|
||||
# unscanned workdir startup code in the unguarded child. Treat -i as unscanned
|
||||
# startup like BASH_ENV.
|
||||
if tl.startswith("-") and not tl.startswith("--") and "i" in tl[1:]:
|
||||
_interactive = True
|
||||
if tl == "-c" or (tl.startswith("-") and not tl.startswith("--") and tl.endswith("c")):
|
||||
_has_c = True
|
||||
break
|
||||
|
|
@ -1173,6 +1203,8 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
continue # other shell flags: -l, -x, --login, --norc, ...
|
||||
_script = t # first non-flag operand is the script file
|
||||
break
|
||||
if _interactive:
|
||||
blocked.add("shell-interactive-rc:" + _token_basename(tok))
|
||||
# Any command-position shell WITHOUT an inline `-c` payload runs unscanned code:
|
||||
# a script file (bash s.sh), stdin via -s, or a bare shell that reads stdin
|
||||
# (`printf 'evil' | bash`). Only the `-c '...'` form is statically analyzable, so
|
||||
|
|
@ -1192,6 +1224,59 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
if _an in ("BASH_ENV", "ENV") and _av != "":
|
||||
blocked.add("shell-startup-env:" + _an)
|
||||
|
||||
# A BASH_ENV / ENV assignment set in a SEPARATE command (export BASH_ENV=env.sh; bash -c
|
||||
# '...', or a standalone BASH_ENV=env.sh) persists for later shells in the same session and
|
||||
# is sourced before their -c payload, so the per-shell prefix backscan above misses it.
|
||||
# Flag a non-empty BASH_ENV / ENV assignment anywhere it is exported / set.
|
||||
for _ei, _et in enumerate(tokens):
|
||||
if not _ASSIGNMENT_RE.match(_et):
|
||||
continue
|
||||
_an, _, _av = _et.partition("=")
|
||||
if _an in ("BASH_ENV", "ENV") and _av != "":
|
||||
blocked.add("shell-startup-env:" + _an)
|
||||
# PATH=. cmd / export PATH=.:$PATH: a search list with a relative / cwd entry lets a bare
|
||||
# command word resolve to a workdir shebang (the bare-name PATH exemption assumes a
|
||||
# trusted PATH). Flag an unsafe PATH assignment.
|
||||
elif _an == "PATH" and _path_value_is_unsafe(_av):
|
||||
blocked.add("unsafe-path-assign")
|
||||
|
||||
# git -c alias.X='!CMD' X / git config alias.X '!CMD': a git alias whose value starts with
|
||||
# `!` runs CMD through an unguarded shell, but the scanner sees only `git`. Flag the shell-
|
||||
# dispatch alias form (the ! marker) so the aliased writer / reader is not smuggled past.
|
||||
for i in _cmd_word_idx:
|
||||
if _token_basename(tokens[i]) != "git":
|
||||
continue
|
||||
_seg = []
|
||||
for k in range(i + 1, len(tokens)):
|
||||
if tokens[k] in _SHELL_SEPARATORS or tokens[k] in _SHELL_KEYWORDS_AS_SEP:
|
||||
break
|
||||
_seg.append(tokens[k])
|
||||
_joined = " ".join(_seg)
|
||||
if re.search(r"alias\.[^=\s]+=\s*!", _joined):
|
||||
blocked.add("git-shell-alias")
|
||||
else:
|
||||
for _k, _t in enumerate(_seg):
|
||||
if _t.startswith("alias.") and _k + 1 < len(_seg) and _seg[_k + 1].startswith("!"):
|
||||
blocked.add("git-shell-alias")
|
||||
break
|
||||
|
||||
# alias x='touch /tmp/p'; ...; x (with expand_aliases) runs the alias BODY at execution
|
||||
# time, but the command word `x` is unknown to the scanner. Scan the body of each alias
|
||||
# definition so a blocked writer / interpreter in it is caught at the definition site.
|
||||
for i in _cmd_word_idx:
|
||||
if _token_basename(tokens[i]) != "alias":
|
||||
continue
|
||||
for k in range(i + 1, len(tokens)):
|
||||
t = tokens[k]
|
||||
if t in _SHELL_SEPARATORS or t in _SHELL_KEYWORDS_AS_SEP:
|
||||
break
|
||||
if t.startswith("-"):
|
||||
continue # alias -p (print)
|
||||
if "=" in t:
|
||||
_body = t.split("=", 1)[1]
|
||||
if _body:
|
||||
blocked |= _find_blocked_commands(_body)
|
||||
|
||||
# Output redirection (> / >> / &> / N>) runs in an unguarded child shell that follows
|
||||
# symlinks before any Python guard, so no filename target can be trusted: a relative
|
||||
# single-component name (> out) may be a pre-existing symlink to an outside file, a
|
||||
|
|
@ -4645,8 +4730,13 @@ def _scan_command_string_for_reads(
|
|||
if _r is not None:
|
||||
return _r
|
||||
# trap 'CMD' SIG: the quoted handler runs as shell code on EXIT / a signal; scan it.
|
||||
if _ft == "trap" and _fi + 1 < len(ptoks):
|
||||
_th = ptoks[_fi + 1]
|
||||
# Skip trap options / the -- terminator (trap -- 'CMD' EXIT, trap -p) so the handler
|
||||
# operand is not mistaken for -- and left unscanned.
|
||||
if _ft == "trap":
|
||||
_tj = _fi + 1
|
||||
while _tj < len(ptoks) and ptoks[_tj].startswith("-") and len(ptoks[_tj]) > 1:
|
||||
_tj += 1
|
||||
_th = ptoks[_tj] if _tj < len(ptoks) else None
|
||||
if _th and _th != "-" and _th not in _READ_SCAN_SEPARATORS:
|
||||
_r = _scan_command_string_for_reads(
|
||||
_th,
|
||||
|
|
@ -5236,8 +5326,9 @@ def _check_signal_escape_patterns(
|
|||
if _cmd_base in _SHELL_BINARIES:
|
||||
found |= _check_shell_argv(arg.elts[_cmd_idx:])
|
||||
# find -exec/-delete, sed -i, sort -o interpret LATER argv elements as
|
||||
# actions / write flags, so reconstruct a command line from the argv
|
||||
# tail and reuse the full scanner (which handles those forms).
|
||||
# actions / write flags, and git -c alias.X=!CMD hides a shell dispatch
|
||||
# in a config operand, so reconstruct a command line from the argv tail
|
||||
# and reuse the full scanner (which handles those forms).
|
||||
elif _cmd_base in _ARGV_TAIL_SCAN_COMMANDS:
|
||||
found |= _find_blocked_commands(
|
||||
" ".join(
|
||||
|
|
@ -5991,6 +6082,25 @@ def _check_signal_escape_patterns(
|
|||
)
|
||||
blocked_in_args = _check_args_for_blocked(all_call_args, _shell_maybe_true)
|
||||
|
||||
# subprocess(..., executable=PROG) makes PROG the real program while the argv
|
||||
# TAIL still supplies its flags/args, so scanning executable and argv separately
|
||||
# misses run(['x', '-i', 's/a/b/', '/f'], executable='/usr/bin/sed') (child runs
|
||||
# sed -i). Reconstruct PROG + argv[1:] and scan the effective command line.
|
||||
_exe_node = expanded_kwargs.get("executable")
|
||||
_exe = _extract_string_from_node(_exe_node) if _exe_node is not None else None
|
||||
if (
|
||||
_exe is not None
|
||||
and not _shell_maybe_true
|
||||
and node.args
|
||||
and isinstance(node.args[0], (ast.List, ast.Tuple))
|
||||
):
|
||||
_tail = [_extract_string_from_node(e) for e in node.args[0].elts[1:]]
|
||||
_combined = [_exe] + _tail
|
||||
if all(_c is not None for _c in _combined):
|
||||
blocked_in_args = blocked_in_args | _find_blocked_commands(
|
||||
" ".join(shlex.quote(_c) for _c in _combined)
|
||||
)
|
||||
|
||||
# A shell startup variable (BASH_ENV / ENV) in the env= mapping 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
|
||||
|
|
@ -6020,6 +6130,13 @@ def _check_signal_escape_patterns(
|
|||
_extract_string_from_node(_ev) != ""
|
||||
):
|
||||
blocked_in_args = blocked_in_args | {"shell-startup-env:" + _ekey}
|
||||
elif (
|
||||
_ekey == "PATH"
|
||||
and isinstance(_extract_string_from_node(_ev), str)
|
||||
and _path_value_is_unsafe(_extract_string_from_node(_ev))
|
||||
):
|
||||
# env={'PATH': '.'} lets a bare argv[0] resolve to a workdir exec.
|
||||
blocked_in_args = blocked_in_args | {"unsafe-path-assign"}
|
||||
elif _ek is not None and _ekey is None:
|
||||
_opaque_key = True # a computed key could be BASH_ENV / ENV
|
||||
if _opaque_key and _is_shell_child:
|
||||
|
|
@ -6036,6 +6153,12 @@ def _check_signal_escape_patterns(
|
|||
blocked_in_args = blocked_in_args | {
|
||||
"shell-startup-env:" + _kw2.arg
|
||||
}
|
||||
elif (
|
||||
_kw2.arg == "PATH"
|
||||
and isinstance(_extract_string_from_node(_kw2.value), str)
|
||||
and _path_value_is_unsafe(_extract_string_from_node(_kw2.value))
|
||||
):
|
||||
blocked_in_args = blocked_in_args | {"unsafe-path-assign"}
|
||||
elif _kw2.arg is None and _is_shell_child:
|
||||
blocked_in_args = blocked_in_args | {"shell-startup-env:opaque"}
|
||||
elif _is_shell_child:
|
||||
|
|
|
|||
|
|
@ -3579,3 +3579,86 @@ class TestRound33Bypasses:
|
|||
)
|
||||
def test_round33_benign_allowed(self, code):
|
||||
_ok(code)
|
||||
|
||||
|
||||
class TestRound34Bypasses:
|
||||
"""Thirty-fourth-round Codex findings (follow-ups on the round-32/33 shell + startup-env
|
||||
handling): a PATH-controlled bare executable, a git shell-dispatch alias, an executable=
|
||||
override, bash alias expansion, a trap handler after the -- terminator, and shell startup
|
||||
scripts via export BASH_ENV / an interactive shell."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# PATH with a relative / cwd entry lets a bare command resolve to a workdir shebang.
|
||||
"import os\nos.system('PATH=. evil')",
|
||||
"import os\nos.system('PATH=.:$PATH evil')",
|
||||
"import subprocess\nsubprocess.run(['evil'], env={'PATH': '.'})",
|
||||
"import subprocess\nsubprocess.run(['evil'], env=dict(PATH='tools'))",
|
||||
],
|
||||
)
|
||||
def test_unsafe_path_bare_exec_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# A git alias whose value starts with ! runs through an unguarded shell.
|
||||
"import os\nos.system(\"git -c alias.x='!touch /tmp/p' x\")",
|
||||
"import os\nos.system(\"git config alias.x '!rm -rf /'\")",
|
||||
"import subprocess\nsubprocess.run(['git', '-c', 'alias.x=!touch /tmp/p', 'x'])",
|
||||
],
|
||||
)
|
||||
def test_git_shell_alias_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# executable= is the real program; its dangerous flags still come from the argv tail.
|
||||
"import subprocess\nsubprocess.run(['x', '-i', 's/a/b/', '/tmp/f'], executable='/usr/bin/sed')",
|
||||
"import subprocess\nsubprocess.Popen(['x', 's.sh'], executable='/bin/bash')",
|
||||
],
|
||||
)
|
||||
def test_executable_override_rescan_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# An alias body runs at expansion time; trap after -- runs on exit; both are scanned.
|
||||
"import os\nos.system('alias x=\"touch /tmp/p\"; x')",
|
||||
"import os\nos.system(\"trap -- 'touch /tmp/p' EXIT\")",
|
||||
"import os\nos.system(\"trap -- 'cat /etc/passwd' EXIT\")",
|
||||
],
|
||||
)
|
||||
def test_alias_body_and_trap_dashdash_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# export BASH_ENV / an interactive shell source workdir startup code before -c.
|
||||
"import os\nos.system(\"export BASH_ENV=env.sh; bash -c 'echo ok'\")",
|
||||
"import os\nos.system(\"bash -i -c 'echo ok'\")",
|
||||
"import subprocess\nsubprocess.run(\"bash -ic 'echo ok'\", shell=True)",
|
||||
],
|
||||
)
|
||||
def test_shell_startup_script_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# Benign git / PATH / executable / trap-reset / non-interactive shell must still pass.
|
||||
"import os\nos.system('git status')",
|
||||
"import os\nos.system('git clone https://github.com/x/y')",
|
||||
"import os\nos.system('PATH=/opt/conda/bin:$PATH ls -la')",
|
||||
"import subprocess\nsubprocess.run(['cat', 'notes.txt'], executable='/bin/cat')",
|
||||
"import os\nos.system('trap - EXIT')",
|
||||
"import os\nos.system(\"bash -c 'echo hi'\")",
|
||||
"import os\nos.system('git config user.name me')",
|
||||
],
|
||||
)
|
||||
def test_round34_benign_allowed(self, code):
|
||||
_ok(code)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue