From 997c7247f2b4315cf61b648f16092cd4120cb0e5 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Fri, 10 Jul 2026 16:41:10 +0000 Subject: [PATCH] Harden sandbox: builtins-qualified exec sinks and dotted network imports in workdir modules; keyword-only compile payload; sponge child writer Close four bypasses Codex found on the round-42 branch: - builtins-qualified exec sinks in an imported workdir module: the import vetter only rejected the BARE eval/exec/compile/__import__ names, so a workdir helper doing `import builtins; builtins.eval("...")` ran arbitrary code at import unscanned. Recognize the execution builtins reached as an attribute of the builtins module (or an alias), for both the direct call and the assign-only reference (e = builtins.eval; e(...)). Requiring a builtins root keeps a benign .compile()/.eval() on another object (model.compile, df.eval) from being misread as a sink. - keyword-only compile() payload: compile() accepts its source as the source= keyword, and a standalone compile() with no positional arg reached the payload-recovery early return (no node.args -> NO_PAYLOAD), so its code object was executed via the fn.__code__ = c; fn() gadget entirely unscanned. Recover the source= keyword before returning NO_PAYLOAD (eval / exec take no keyword arguments in CPython, so an empty node.args there is genuinely payload-less). A benign keyword-only compile is analyzed, not blanket-blocked. - dotted stdlib network imports in a workdir module: the vetter left the urllib / http tops out so urllib.parse stays benign, but that also let a helper `import urllib.request` (or http.client) open outbound connections the static network policy never saw. Refuse the network submodules by their full dotted name (urllib.request, urllib.robotparser, http.client, xmlrpc.client), covering the import, `import ... as`, `from urllib.request import ...`, and `from urllib import request` forms, while urllib.parse and the bare tops remain importable. - sponge child writer: sponge (moreutils) soaks up stdin and writes it to a file argument (printf x | sponge /tmp/probe), an unguarded-child write outside the workdir. Add it to the child-writer denylist next to tee / patch / mktemp. Regression coverage: TestRound43Bypasses in tests/test_sandbox_tools.py (keyword-only compile via the __code__ / FunctionType / exec(compile()) gadgets, sponge child writer, plus a benign keyword-only compile that stays allowed) and three workdir-module vetter cases in tests/test_sandbox_runtime_backstop.py (builtins.eval sink denied, urllib.request denied, urllib.parse still allowed). --- studio/backend/core/inference/tools.py | 61 +++++++++++++++- .../tests/test_sandbox_runtime_backstop.py | 70 +++++++++++++++++++ studio/backend/tests/test_sandbox_tools.py | 52 ++++++++++++++ 3 files changed, 180 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 19e3648689..1ba69e8dba 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -225,6 +225,9 @@ _CHILD_WRITE_COMMANDS = frozenset( # mktemp creates a file / dir at a caller-chosen template path (mktemp # /tmp/x.XXXXXX, mktemp -d), writing outside the workdir in an unguarded child. "mktemp", + # sponge (moreutils) soaks up stdin and writes it to a file argument + # (printf x | sponge /tmp/probe), an unguarded-child write outside the workdir. + "sponge", } ) _BLOCKED_COMMANDS_COMMON = ( @@ -4957,9 +4960,20 @@ def _recover_exec_payload(node, func_id, const_env, compiled_env): bytes, so a bytes payload that fails to parse as UTF-8 Python is treated as an obfuscation vector by the caller rather than a harmless SyntaxError. """ - if not node.args: + if node.args: + arg0 = node.args[0] + elif func_id == "compile": + # compile(source=..., filename=..., mode=...) passes its payload as the source= + # keyword with no positional args. compile() alone does not execute, but its code + # object runs via a gadget (types.FunctionType(c)(); fn.__code__ = c; fn()), so the + # keyword-only source must be analyzed exactly like the positional form rather than + # slipping through as having no payload. (eval / exec take no keyword arguments in + # CPython, so an empty node.args there is genuinely payload-less.) + arg0 = _compile_source_node(node) + if arg0 is None: + return ("NO_PAYLOAD", None, None, False) + else: return ("NO_PAYLOAD", None, None, False) - arg0 = node.args[0] base_mode = "eval" if func_id == "eval" else "exec" # exec(compile("...", ...)) / eval(compile("...", "", "eval")) -- the compile source may be @@ -10192,6 +10206,13 @@ try: "socket", "ssl", "ftplib", "smtplib", "telnetlib", "poplib", "imaplib", "nntplib", "requests", "httpx", "aiohttp", "urllib3", "pycurl", "websocket", "websockets", "paramiko", }) + # Network-capable stdlib SUBMODULES whose bare top (urllib / http / xmlrpc) is benign + # (urllib.parse, http.cookies) but whose dotted form opens outbound connections the static + # network policy never saw (urllib.request.urlopen, http.client.HTTPConnection). Matched on + # the full dotted name so the benign siblings stay importable. + _GUARD_NET_DOTTED = frozenset({ + "urllib.request", "urllib.robotparser", "http.client", "xmlrpc.client", + }) _GUARD_EXEC_ATTRS = frozenset({ "system", "popen", "popen2", "popen3", "popen4", "startfile", "execl", "execle", "execlp", "execlpe", "execv", "execve", "execvp", "execvpe", @@ -10214,22 +10235,39 @@ try: return True # unparseable workdir module -> fail closed # Pre-pass: record os / posix import ALIASES (import os as o) so an aliased sink reference # that is only assigned (s = o.system) -- not directly called -- is still recognized. + # Also record builtins aliases (import builtins as b) so the execution builtins reached + # as an attribute (builtins.eval / b.exec) are recognized alongside the bare names. _recv = set(_GUARD_EXEC_RECEIVERS) + _bi = {"builtins", "__builtins__"} for _nd in _gast.walk(_tree): if isinstance(_nd, _gast.Import): for _al in _nd.names: if _al.name in ("os", "posix"): _recv.add(_al.asname or _al.name) + elif _al.name == "builtins": + _bi.add(_al.asname or _al.name) for _nd in _gast.walk(_tree): if isinstance(_nd, _gast.Import): for _al in _nd.names: _top = _al.name.split(".")[0] if _top in _GUARD_EXEC_MODS or _top in _GUARD_NET_MODS: return True + # import urllib.request / import http.client -- benign top, network submodule. + if _al.name in _GUARD_NET_DOTTED: + return True elif isinstance(_nd, _gast.ImportFrom): - _mroot = (_nd.module or "").split(".")[0] + _mod = _nd.module or "" + _mroot = _mod.split(".")[0] if _mroot in _GUARD_EXEC_MODS or _mroot in _GUARD_NET_MODS: return True + # from urllib.request import urlopen -- the module itself is a network submodule. + if _mod in _GUARD_NET_DOTTED: + return True + # from urllib import request / from http import client -- the network submodule is + # bound by name, so the dotted target is (package + . + imported name). + for _al in _nd.names: + if (_mod + "." + _al.name) in _GUARD_NET_DOTTED: + return True # `from os import system` / `from os import *` binds a BARE sink name into the # module namespace; a later bare system('id') call has no os. attribute to catch. if _mroot in _GUARD_EXEC_RECEIVERS: @@ -10244,12 +10282,29 @@ try: if isinstance(_nd.func, _gast.Name) and _nd.func.id in ( "eval", "exec", "compile", "__import__"): return True + # builtins.eval(...) / b.exec(...) -- the execution builtins reached as an + # attribute of the builtins module (or an alias). Require a builtins root so a + # benign .compile()/.eval() on some other object (model.compile, df.eval) is + # not misread as a sink. + if ( + isinstance(_nd.func, _gast.Attribute) + and _nd.func.attr in ("eval", "exec", "compile", "__import__") + and _guard_attr_root(_nd.func.value) in _bi + ): + return True elif isinstance(_nd, _gast.Attribute): # A sink-named attribute REFERENCE (even uncalled) rooted at os / posix / an # os alias (x = os.system, s = o.system). A same-named attribute on an unrelated # object (p.system = 'linux') is NOT a sink, so require a sink-module receiver. if _nd.attr in _GUARD_EXEC_ATTRS and _guard_attr_root(_nd.value) in _recv: return True + # A builtins-rooted execution-builtin REFERENCE (e = builtins.eval; e(...)), + # even uncalled, is the same sink as calling it directly. + if ( + _nd.attr in ("eval", "exec", "compile", "__import__") + and _guard_attr_root(_nd.value) in _bi + ): + return True # A workdir module that touches the import machinery (sys.meta_path / # sys.path_hooks / sys.path_importer_cache) can remove THIS vetter, then a # sibling `import evil` loads unscanned. The top-level analyzer blocks such diff --git a/studio/backend/tests/test_sandbox_runtime_backstop.py b/studio/backend/tests/test_sandbox_runtime_backstop.py index 9456c55f79..1a3ead09a7 100644 --- a/studio/backend/tests/test_sandbox_runtime_backstop.py +++ b/studio/backend/tests/test_sandbox_runtime_backstop.py @@ -928,6 +928,76 @@ def test_sandboxed_os_alias_workdir_module_denied(): os.remove(os.path.join(workdir, "evilalias.py")) +@_POSIX_ONLY +def test_sandboxed_builtins_exec_workdir_module_denied(): + # import builtins; builtins.eval("__import__('os').system(...)") -- the execution builtins + # reached as an attribute of the builtins module (not the bare eval/exec name) must be + # recognized as an exec sink so a workdir helper cannot run arbitrary code at import. + session = "backstop-workdir-builtins" + workdir = get_sandbox_workdir(session) + with open(os.path.join(workdir, "evilbi.py"), "w") as f: + f.write("import builtins\nbuiltins.eval(\"__import__('os').system('echo PWNED_BI')\")\n") + try: + out = _python_exec( + "import evilbi; print('REACHED_' + 'BODY')", + None, + 30, + session, + disable_sandbox = False, + ) + assert "PWNED_BI" not in out + assert "REACHED_BODY" not in out + assert "sandbox:" in out or "ImportError" in out + finally: + os.remove(os.path.join(workdir, "evilbi.py")) + + +@_POSIX_ONLY +def test_sandboxed_dotted_network_workdir_module_denied(): + # import urllib.request -- the bare top (urllib) is benign, but the dotted network submodule + # opens outbound connections the static policy never saw, so the vetter refuses it. + session = "backstop-workdir-urlreq" + workdir = get_sandbox_workdir(session) + with open(os.path.join(workdir, "evilurl.py"), "w") as f: + f.write("print('URL_REACHED')\nimport urllib.request\n") + try: + out = _python_exec( + "import evilurl; print('REACHED_' + 'BODY')", + None, + 30, + session, + disable_sandbox = False, + ) + assert "URL_REACHED" not in out + assert "REACHED_BODY" not in out + assert "sandbox:" in out or "ImportError" in out + finally: + os.remove(os.path.join(workdir, "evilurl.py")) + + +@_POSIX_ONLY +def test_sandboxed_benign_urllib_parse_workdir_module_allowed(): + # The benign urllib sibling (urllib.parse) is NOT a network submodule and must still import + # from a workdir helper -- the dotted-network refusal keys on the full dotted name. + session = "backstop-workdir-urlparse" + workdir = get_sandbox_workdir(session) + with open(os.path.join(workdir, "okparse.py"), "w") as f: + f.write("import urllib.parse\nVALUE = urllib.parse.quote('a b')\nprint('PARSE_OK')\n") + try: + out = _python_exec( + "import okparse; print('REACHED', okparse.VALUE)", + None, + 30, + session, + disable_sandbox = False, + ) + assert "PARSE_OK" in out + assert "REACHED a%20b" in out + assert "sandbox:" not in out + finally: + os.remove(os.path.join(workdir, "okparse.py")) + + @_POSIX_ONLY def test_sandboxed_realpath_monkeypatch_write_escape_denied(tmp_path): # Sandboxed code reassigns os.path.realpath to a lambda that echoes an in-workdir diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py index 65c9355d68..f563e73b3c 100644 --- a/studio/backend/tests/test_sandbox_tools.py +++ b/studio/backend/tests/test_sandbox_tools.py @@ -4442,3 +4442,55 @@ class TestRound42Bypasses: ) def test_compile_source_keyword_payload_blocked(self, code): assert _check_code_safety(code) is not None, code + + +class TestRound43Bypasses: + """Forty-third-round Codex findings. The keyword-only compile(source=...) reaching the + __code__ execution gadget and the sponge child-writer are static; the two workdir-module + import-vetter items (builtins-qualified exec sinks, dotted stdlib network imports) are covered + in test_sandbox_runtime_backstop.py.""" + + @pytest.mark.parametrize( + "code", + [ + # A standalone compile() takes its source as the source= keyword with no positional + # arg, so it slipped past the payload recovery (no node.args -> NO_PAYLOAD) and its + # code object was executed via the fn.__code__ = c; fn() gadget entirely unscanned. + "c = compile(source=\"__import__('os').system('touch /tmp/p')\", filename='', mode='exec')\n" + "f = lambda: None\n" + "f.__code__ = c\n" + "f()", + # keyword-only compile feeding types.FunctionType(code)() -- same source= path. + "import types\n" + "c = compile(source=\"__import__('os').system('touch /tmp/p')\", filename='', mode='exec')\n" + "types.FunctionType(c, {})()", + # keyword-only exec(compile(source=...)) still recovers the source. + "exec(compile(source=\"__import__('os').system('touch /tmp/p')\", filename='', mode='exec'))", + ], + ) + def test_compile_source_keyword_gadget_blocked(self, code): + assert _check_code_safety(code) is not None, code + + @pytest.mark.parametrize( + "code", + [ + # sponge (moreutils) writes stdin to a file argument in an unguarded child, so + # printf x | sponge /tmp/probe escapes the workdir the same as tee / patch. + "import os\nos.system('printf x | sponge /tmp/probe')", + "import subprocess\nsubprocess.run(['sponge', '/tmp/x'])", + ], + ) + def test_sponge_child_writer_blocked(self, code): + assert _check_code_safety(code) is not None, code + + @pytest.mark.parametrize( + "code", + [ + # A keyword-only compile of a BENIGN literal must still be allowed -- the fix analyzes + # the source, it does not blanket-block keyword-only compile. + "c = compile(source='X = 1', filename='', mode='exec')\nf = lambda: None\nf.__code__ = c\nf()", + "co = compile(source='result = sum(range(10))', filename='', mode='exec')\nns = {}\neval(co, ns)", + ], + ) + def test_round43_benign_compile_allowed(self, code): + _ok(code)