Studio sandbox: close second-round review bypasses (classifier + backstop)
Fixes a further batch of P1 bypasses and analysis-time DoS vectors found in review.
Static classifier:
- Decode exec/compile bytes payloads the way CPython does (PEP 263 coding cookie
via tokenize.detect_encoding), then analyze the real source. A bytes payload whose
UTF-8 view is pure comments but whose utf-7 decode runs hidden code no longer slips
through; a payload decoding to a blocked op blocks, a benign one stays allowed.
- Resolve exec-builtin aliases assigned in nested scopes (def f(): e = exec; e(...)),
matching the shell-sink aliasing (stored-once guard keeps it low false-positive).
- Treat deserializer modules (pickle/marshal/dill/...) as dangerous dynamic-import
targets so __import__('pickle').loads(blob) is caught.
- Flag vars(os) / vars(__builtins__) as a module-__dict__ obfuscation, like os.__dict__.
- Inspect the pathlib receiver path for read methods: Path('../../.ssh/id_rsa').read_text()
/ read_bytes() / open() now check the constructor path, not only call args.
- Fold literal os.path.join / posixpath.join so open(os.path.join('/etc','passwd')).read()
is seen by the sensitive-read scanner instead of treated as opaque.
Constant-folder allocation DoS (folding runs in-process, before subprocess rlimits):
- Reject oversized f-string / str.format / %-format widths and precisions before
format() allocates the padded string.
- Reject oversized str padding-method widths (ljust/rjust/center/zfill).
- Cap list/tuple repetition (seq * n) as str/bytes repetition already was.
Runtime realpath backstop:
- Do not publish __wrapped__ on the guard wrappers (functools.wraps would expose the
original unguarded callable, e.g. open.__wrapped__(outside, 'w')).
- Guard the low-level _io.open entry point (io.open / builtins.open originate there).
- Confine os.chdir to the workdir and deny os.fchdir so a cwd escape cannot turn a
later relative read/write into a host-path access.
- Deny fd-based metadata mutators (os.fchmod / os.fchown) that could reuse a read-only
descriptor opened on an outside file.
Adds regression tests across the const-fold, aliasing, exec-recursion and runtime-
backstop suites for every item above.
This commit is contained in:
parent
47dd8fd277
commit
fbb030b019
5 changed files with 478 additions and 35 deletions
|
|
@ -61,6 +61,12 @@ class TestFuncLocalAliasBlocked:
|
|||
_ok("def run():\n f = sorted\n return f([3, 1, 2])\nrun()")
|
||||
_ok("import os\ndef run():\n s = os.system\n s('echo done')\nrun()")
|
||||
|
||||
def test_func_local_exec_alias_blocked(self):
|
||||
# An exec builtin aliased inside a function must still be unwrapped and its
|
||||
# recovered payload analyzed (exec-env aliasing walks the whole tree).
|
||||
_blocked("def f():\n e = exec\n e(\"__import__('os').system('id')\")\nf()")
|
||||
_blocked("def f():\n r = eval\n r(\"__import__('os').system('rm -rf /')\")\nf()")
|
||||
|
||||
|
||||
class TestAliasingLowFalsePositive:
|
||||
def test_reassigned_alias_not_treated_as_sink(self):
|
||||
|
|
|
|||
|
|
@ -71,6 +71,55 @@ class TestConstFoldArithAndConcat:
|
|||
assert _fold("bytes(10 ** 9)") is None
|
||||
|
||||
|
||||
class TestConstFoldAllocationDoS:
|
||||
"""Oversized format widths / sequence repetitions must refuse BEFORE the folder
|
||||
allocates the result (folding runs in the Studio process, ahead of subprocess
|
||||
rlimits)."""
|
||||
|
||||
def test_fstring_width_refused(self):
|
||||
assert _fold("f'{1:1000000000}'") is None
|
||||
|
||||
def test_str_format_width_refused(self):
|
||||
assert _fold("'{:1000000000}'.format(1)") is None
|
||||
|
||||
def test_percent_format_width_refused(self):
|
||||
assert _fold("'%1000000000d' % 1") is None
|
||||
|
||||
def test_pad_method_width_refused(self):
|
||||
assert _fold("'x'.ljust(1000000000)") is None
|
||||
assert _fold("'x'.rjust(10 ** 9)") is None
|
||||
assert _fold("'x'.center(2000000000)") is None
|
||||
assert _fold("'x'.zfill(10 ** 9)") is None
|
||||
|
||||
def test_list_tuple_repeat_refused(self):
|
||||
assert _fold("[0] * 1000000000") is None
|
||||
assert _fold("(1,) * 10 ** 9") is None
|
||||
|
||||
def test_benign_format_and_repeat_still_fold(self):
|
||||
assert _fold("f'{2 + 2}'") == "4"
|
||||
assert _fold("'{:>8}'.format('hi')") == " hi"
|
||||
assert _fold("'%05d' % 7") == "00007"
|
||||
assert _fold("'x'.ljust(10)") == "x "
|
||||
assert _fold("[0] * 8") == [0] * 8
|
||||
|
||||
|
||||
class TestConstFoldPathJoin:
|
||||
"""os.path.join / posixpath.join of string literals fold so the sensitive-read
|
||||
scanner sees the concrete path (628)."""
|
||||
|
||||
def test_os_path_join_literal(self):
|
||||
assert _fold("os.path.join('/etc', 'passwd')") == "/etc/passwd"
|
||||
|
||||
def test_posixpath_join_literal(self):
|
||||
assert _fold("posixpath.join('/etc', 'shadow')") == "/etc/shadow"
|
||||
|
||||
def test_relative_join_literal(self):
|
||||
assert _fold("os.path.join('sub', 'a.txt')") == "sub/a.txt"
|
||||
|
||||
def test_join_nonliteral_unknown(self):
|
||||
assert _fold("os.path.join('/etc', x)") is None
|
||||
|
||||
|
||||
class TestConstFoldJoinFormatFstring:
|
||||
def test_sep_join(self):
|
||||
assert _fold('".".join(["os", "system"])') == "os.system"
|
||||
|
|
|
|||
|
|
@ -289,6 +289,86 @@ def test_inject_sandbox_guard_plain_prepend_without_future():
|
|||
assert _inject_sandbox_guard(code, prelude) == prelude + code
|
||||
|
||||
|
||||
@_POSIX_ONLY
|
||||
def test_sandboxed_open_wrapped_attr_removed(tmp_path):
|
||||
# functools.wraps would publish the ORIGINAL unguarded callable on __wrapped__;
|
||||
# the guard must not expose it (open.__wrapped__(outside, 'w') would bypass).
|
||||
target = tmp_path / "wrapped_escape.txt"
|
||||
out = _python_exec(
|
||||
f"open.__wrapped__({str(target)!r}, 'w').write('x'); print('WROTE')",
|
||||
None,
|
||||
30,
|
||||
"backstop-wrapped",
|
||||
disable_sandbox = False,
|
||||
)
|
||||
assert not target.exists()
|
||||
assert "AttributeError" in out or "sandbox:" in out
|
||||
|
||||
|
||||
@_POSIX_ONLY
|
||||
def test_sandboxed_low_level_io_open_denied(tmp_path):
|
||||
# io.open / builtins.open originate from the C module _io; patching io.open leaves
|
||||
# _io.open untouched, so it must be guarded too.
|
||||
target = tmp_path / "lowio_escape.txt"
|
||||
out = _python_exec(
|
||||
f"import _io; _io.open({str(target)!r}, 'w').write('x'); print('WROTE')",
|
||||
None,
|
||||
30,
|
||||
"backstop-lowio",
|
||||
disable_sandbox = False,
|
||||
)
|
||||
assert "sandbox:" in out
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
@_POSIX_ONLY
|
||||
def test_sandboxed_chdir_escape_denied():
|
||||
# os.chdir outside the workdir would let a later relative read/write (which the
|
||||
# static scan treats as local) escape, so cwd changes are confined.
|
||||
out = _python_exec(
|
||||
"import os\nos.chdir('/etc')\nprint('CWD', os.getcwd())",
|
||||
None,
|
||||
30,
|
||||
"backstop-chdir",
|
||||
disable_sandbox = False,
|
||||
)
|
||||
assert "sandbox:" in out and "chdir" in out
|
||||
|
||||
|
||||
@_POSIX_ONLY
|
||||
def test_sandboxed_chdir_within_workdir_allowed():
|
||||
out = _python_exec(
|
||||
"import os\nos.chdir('.')\nprint('CWD-OK')",
|
||||
None,
|
||||
30,
|
||||
"backstop-chdir-ok",
|
||||
disable_sandbox = False,
|
||||
)
|
||||
assert "CWD-OK" in out
|
||||
assert "sandbox:" not in out
|
||||
|
||||
|
||||
@_POSIX_ONLY
|
||||
def test_sandboxed_fd_metadata_mutator_denied(tmp_path):
|
||||
# A read-only os.open of an outside file is allowed (reads are not confined), but
|
||||
# fd-based metadata mutators (os.fchmod/fchown) must be denied so they cannot be
|
||||
# reused to mutate host files.
|
||||
victim = tmp_path / "victim.txt"
|
||||
victim.write_text("x")
|
||||
os.chmod(victim, 0o600)
|
||||
out = _python_exec(
|
||||
"import os\n"
|
||||
f"fd = os.open({str(victim)!r}, os.O_RDONLY)\n"
|
||||
"os.fchmod(fd, 0o644); print('CHMODDED')",
|
||||
None,
|
||||
30,
|
||||
"backstop-fchmod",
|
||||
disable_sandbox = False,
|
||||
)
|
||||
assert "sandbox:" in out and "fchmod" in out
|
||||
assert oct(os.stat(victim).st_mode & 0o777) == "0o600"
|
||||
|
||||
|
||||
@_POSIX_ONLY
|
||||
def test_sandboxed_imports_still_work_under_guard():
|
||||
# The guard must not break library imports (bytecode caching failures are
|
||||
|
|
|
|||
|
|
@ -874,6 +874,47 @@ class TestAliasIntrospectionBypasses:
|
|||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
|
||||
class TestReceiverAndVarsAndDynImportBypasses:
|
||||
"""Second-round bypasses: sensitive reach through a pathlib receiver, vars() on a
|
||||
module, and dynamic import of a deserializer module."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# 572: sensitive path on the pathlib receiver, not in a call arg.
|
||||
"from pathlib import Path\nPath('../../.ssh/id_rsa').read_text()",
|
||||
"from pathlib import Path\nPath('/etc/passwd').read_bytes()",
|
||||
"from pathlib import Path\nPath('/etc/passwd').open().read()",
|
||||
# 617: vars(module) exposes the module __dict__.
|
||||
"import os\nvars(os)['system']('rm -rf /')",
|
||||
"vars(__builtins__)['eval']('x')",
|
||||
# 596: dynamic import of a deserializer module runs a reduce payload.
|
||||
"__import__('pickle').loads(blob)",
|
||||
"__import__('marshal').loads(b)",
|
||||
"import importlib\nimportlib.import_module('pickle').loads(b)",
|
||||
# 628: literal os.path.join to a host secret.
|
||||
"import os\nopen(os.path.join('/etc', 'passwd')).read()",
|
||||
],
|
||||
)
|
||||
def test_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
"from pathlib import Path\nPath('data/out.txt').read_text()",
|
||||
"from pathlib import Path\nPath('model.json').open()",
|
||||
"vars(obj)",
|
||||
"vars()",
|
||||
"import pickle\npickle.dumps(x)",
|
||||
"import importlib\nimportlib.import_module('numpy')",
|
||||
"import os\nopen(os.path.join('sub', 'a.txt'))",
|
||||
],
|
||||
)
|
||||
def test_benign_allowed(self, code):
|
||||
assert _check_code_safety(code) is None, code
|
||||
|
||||
|
||||
class TestEvalExecRecursion:
|
||||
"""Stage 2: eval/exec/compile are unwrapped, not blanket-banned. A safe
|
||||
(constant-recoverable) payload is allowed; an obfuscated escape blocks."""
|
||||
|
|
@ -946,27 +987,40 @@ class TestEvalExecRecursion:
|
|||
def test_import_concat_benign_module_allowed(self):
|
||||
_ok('__import__("hugging" + "face_hub")')
|
||||
|
||||
def test_exec_utf7_bytes_coding_cookie_blocked(self):
|
||||
# exec()/eval()/compile() honor PEP 263 coding cookies on *bytes*: a UTF-7
|
||||
# payload behind "# coding: utf-7" decodes to real Python that the UTF-8
|
||||
# static view (which is SYNTAX_BAD) never sees. An unparseable *bytes*
|
||||
# payload for an executing sink must block.
|
||||
payload = (
|
||||
b"# coding: utf-7\n"
|
||||
b"+AGkAbQBwAG8AcgB0ACAAbwBz-\n"
|
||||
b"+AG8AcwAuAHMAeQBzAHQAZQBtACgAJwBpAGQAJwAp-"
|
||||
)
|
||||
# self-check: the cookie-decoded payload really is os.system ACE.
|
||||
assert "os.system" in payload.decode("utf-7")
|
||||
def test_exec_utf7_comment_cookie_smuggle_blocked(self):
|
||||
# The exec/eval/compile sinks honor a PEP 263 coding cookie on *bytes*. Here
|
||||
# the UTF-8 view is TWO comment lines (safe), but "+AAo-" decodes (UTF-7) to a
|
||||
# newline, so exec(bytes) actually runs the hidden __import__('os') call. The
|
||||
# analyzer must decode with the cookie's codec, not read the UTF-8 view.
|
||||
sneaky = b"# coding: utf_7\n#+AAo-__import__('os').system('id')\n"
|
||||
# self-check: UTF-8 view is pure comments; the cookie decode reveals the call.
|
||||
import ast as _ast
|
||||
|
||||
_ast.parse(sneaky.decode("utf-8")) # parses (comments only) under UTF-8
|
||||
assert "__import__('os')" in sneaky.decode("utf-7")
|
||||
for sink in ("exec(%r)", "exec(compile(%r, '<s>', 'exec'))"):
|
||||
assert _check_code_safety(sink % sneaky) is not None, sink
|
||||
|
||||
def test_exec_utf7_bytes_decodes_to_blocked_op(self):
|
||||
# A bytes payload behind a coding cookie whose decoded source reaches a blocked
|
||||
# operation must block for every executing sink (eval sees a statement -> the
|
||||
# SYNTAX_BAD-bytes backstop still trips).
|
||||
payload = b"# coding: utf-7\n" + "import os\nos.system('rm -rf /')\n".encode("utf-7")
|
||||
assert "rm -rf" in payload.decode("utf-7")
|
||||
for sink in ("exec(%r)", "eval(%r)", "exec(compile(%r, '<s>', 'exec'))"):
|
||||
code = sink % payload
|
||||
assert _check_code_safety(code) is not None, code
|
||||
# bare compile() does not run, so it stays allowed (the exec of its result
|
||||
# is where the block lands).
|
||||
assert _check_code_safety("compile(%r, '<s>', 'exec')" % payload) is None
|
||||
assert _check_code_safety(sink % payload) is not None, sink
|
||||
|
||||
def test_exec_plain_bytes_payload_allowed(self):
|
||||
# Legitimate exec/eval of ASCII/UTF-8 bytes that parse cleanly stay allowed.
|
||||
_ok('exec(b"x = 1")')
|
||||
_ok('exec(b"print(1)")')
|
||||
_ok('eval(b"2 + 2")')
|
||||
# A UTF-7 payload that decodes to a benign, non-blocked call stays allowed too
|
||||
# (os.system('id') is benign -- 'id' is not a blocked command), matching the
|
||||
# plain-text exec("import os; os.system('id')") behavior.
|
||||
benign = (
|
||||
b"# coding: utf-7\n"
|
||||
b"+AGkAbQBwAG8AcgB0ACAAbwBz-\n"
|
||||
b"+AG8AcwAuAHMAeQBzAHQAZQBtACgAJwBpAGQAJwAp-"
|
||||
)
|
||||
_ok("exec(%r)" % benign)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue