From 01f96315ca1a735de67edb4664cc7f3161aace5a Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Fri, 10 Jul 2026 21:09:51 +0000 Subject: [PATCH] Harden sandbox exec/eval analysis: assigned-container sinks; shadowed fold helpers; namespace-dict writes; caller-alias payloads; unknown PATH vars Close five bypasses Codex found on the round-49 branch (all in the static exec/eval analyzer, plus one PATH case). - assigned-container sink: a subscript into a container bound to a single- assignment NAME (d = {'e': exec}; d['e'](payload), xs = [eval]; xs[0](...)) reached exec/eval, but the container resolvers only handled INLINE literals, so the Name form returned None and the payload was never scanned. Resolve a Name container through the const-prop env in the exec / deserialize / shell-sink resolvers (the last also caught d = [os.system]; d[0]('rm -rf /')). - shadowed fold helper: the constant folder called the real builtin / stdlib module even when the snippet rebinds the name, so str = lambda _: "__import__('os').system('touch /tmp/x')"; eval(str(1)) folded through the real str and was marked safe. Track names rebound away from their canonical builtin / module (assignment, def, param, from-import, aliased import) and refuse to fold them, leaving the payload opaque -> eval/exec fails closed. A plain `import base64` keeps the canonical module and still folds. - namespace-dict write: const-prop only tracked Name stores, so x = '2+2'; globals()['x'] = BAD; eval(x) folded x as the safe literal. Invalidate a name written through globals()/vars()/locals()[key] = ... (constant key), and fail closed on a dynamic key or a bulk update()/setdefault()/__setitem__. - caller-alias payload: exec()/eval() run in the CALLER namespace, but the payload was scanned as a fresh module, so import os; f = os.system; exec("f('rm -rf /')") saw f as unknown and passed. When a payload FREE name resolves, in the caller scope, to a shell / exec / deserialize / import alias, fail closed. A payload that references only builtins (exec("print(1)")) or binds its own names stays allowed. - unknown PATH variable: in the sandbox an unset $VAR expands to EMPTY, so PATH=$EVIL is an empty component that makes the shell search the cwd; a snippet can drop a local executable and run os.system('PATH=$EVIL evil'). Model an unknown/unset $VAR in a PATH entry as empty and fail closed when the entry then collapses to an empty or relative path; $PATH and an entry that stays absolute ($CONDA_PREFIX/bin -> /bin) are still trusted. Regression coverage: TestRound50Bypasses in tests/test_sandbox_tools.py (assigned container exec/eval + shell sinks, shadowed str/chr fold, globals/vars/update invalidation, exec/eval caller alias, PATH=$UNKNOWN) plus a round50 benign-allowed set (safe container callees, normal builtin/module folds, a namespace read, a safe caller alias, and PATH with trusted absolute entries). --- studio/backend/core/inference/tools.py | 210 ++++++++++++++++++++- studio/backend/tests/test_sandbox_tools.py | 90 +++++++++ 2 files changed, 297 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 89a29fa554..9d4322aad9 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -393,6 +393,22 @@ def _path_var_resolves_unsafe(var, assignments): return False +def _path_var_is_unknown_external(var, assignments): + """True when ``var`` is neither a workdir alias (HOME/PWD), the trusted inherited PATH, nor a + variable assigned earlier in the same command. In the sandbox such a variable is UNSET, so its + expansion is EMPTY -- not a trusted absolute path.""" + return var not in ("HOME", "PWD", "PATH") and not (assignments and var in assignments) + + +def _path_entry_empty_expansion_unsafe(entry: str, var: str) -> bool: + """Model an unknown/unset ``$var`` in a PATH ENTRY as EMPTY (the sandbox reality) and report + whether the entry then collapses to an empty or RELATIVE path (both search the cwd). A bare + ``$EVIL`` -> ``''`` and ``${X}bin`` -> ``bin`` are unsafe; ``$CONDA_PREFIX/bin`` -> ``/bin`` + stays absolute and is safe.""" + blanked = re.sub(r"\$\{?" + re.escape(var) + r"\}?", "", entry) + return blanked == "" or not blanked.startswith(("/", "%")) + + def _path_value_is_unsafe(value: str, assignments = None) -> 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), a relative directory, or one that @@ -426,12 +442,25 @@ def _path_value_is_unsafe(value: str, assignments = None) -> bool: var = re.split(r"[/}]", inner, maxsplit = 1)[0] if _path_var_resolves_unsafe(var, assignments): return True + if _path_var_is_unknown_external(var, assignments) and _path_entry_empty_expansion_unsafe( + e, var + ): + return True continue if e.startswith("$"): m = re.match(r"\$([A-Za-z_][A-Za-z0-9_]*)", e) if m and _path_var_resolves_unsafe(m.group(1), assignments): return True - continue # $PATH / $CONDA_PREFIX / $1: assume a trusted absolute expansion + # An unknown/unset $VAR expands to EMPTY in the sandbox, so a bare `$EVIL` (or one that + # leaves a relative remainder, `${X}bin`) collapses the entry to the cwd; only an entry + # that stays ABSOLUTE with the var blanked ($CONDA_PREFIX/bin -> /bin) is trusted. + if ( + m + and _path_var_is_unknown_external(m.group(1), assignments) + and _path_entry_empty_expansion_unsafe(e, m.group(1)) + ): + return True + continue # $PATH / $CONDA_PREFIX/bin / $1: a trusted absolute expansion if e.startswith(("/", "%")): continue return True # a relative directory (relbin, ./tools) @@ -3618,14 +3647,27 @@ _FOLD_MAXINT = 1 << 64 _UNKNOWN = object() # sentinel: "not statically decidable" +class _ConstEnv(dict): + """A const-prop env (name -> RHS node) that also carries the set of names REBOUND away from + their canonical builtin / stdlib module in the snippet, so the folder can refuse to fold a + shadowed helper (str = lambda _: '...'; eval(str(1))) as the real builtin.""" + + __slots__ = ("shadowed",) + + def __init__(self, *a, shadowed = None, **k): + super().__init__(*a, **k) + self.shadowed = shadowed or frozenset() + + class _FoldState: """Shared op counter + single-assignment const-prop environment.""" - __slots__ = ("ops", "names") + __slots__ = ("ops", "names", "shadowed") def __init__(self, names = None): self.ops = 0 self.names = names or {} + self.shadowed = getattr(names, "shadowed", None) or frozenset() def _fold_cap(value): @@ -4001,6 +4043,10 @@ def _fold_call(node, _state, _depth): name = f.id if name not in _FOLD_PURE_BUILTINS: return None + # A snippet that rebinds the builtin name (str = lambda _: '...'; eval(str(1))) makes the + # real-builtin fold diverge from runtime; refuse so the payload stays opaque (fail closed). + if name in _state.shadowed: + return None try: if name == "chr": if len(args) == 1 and isinstance(args[0], int) and 0 <= args[0] <= 0x10FFFF: @@ -4069,6 +4115,10 @@ def _fold_call(node, _state, _depth): return None if isinstance(owner, ast.Name): mod = owner.id + # A rebound module receiver (base64 = ; eval(base64.b64decode('...'))) would fold + # through the real stdlib module while runtime uses the user binding; refuse the fold. + if mod in _state.shadowed: + return None try: if mod == "base64" and attr in _FOLD_B64_FUNCS and len(args) >= 1: return _fold_cap(getattr(base64, attr)(args[0])) @@ -4203,6 +4253,45 @@ def _build_const_prop_env(tree): if extra is not None: disqualified.add(extra.arg) + # A write THROUGH the namespace dict (globals()['x'] = BAD, vars()['x'] = BAD, locals()[...] + # = ..., or globals().update(...) / .setdefault(...) / .__setitem__(...)) mutates a module + # variable with NO Name Store, so a folded constant would be stale and the recovered exec/eval + # payload wrong. Invalidate the affected name (constant key) or, for a dynamic key / bulk + # update, every recorded name -- the snippet is manipulating the namespace opaquely. + def _is_namespace_call(nv): + return ( + isinstance(nv, ast.Call) + and isinstance(nv.func, ast.Name) + and nv.func.id in ("globals", "vars", "locals") + ) + + _ns_write_all = False + _ns_write_names: set[str] = set() + for n in ast.walk(tree): + # globals()[key] = ... (Assign target or AugAssign target). + _subs = [] + if isinstance(n, ast.Assign): + _subs = [t for t in n.targets if isinstance(t, ast.Subscript)] + elif isinstance(n, (ast.AugAssign, ast.AnnAssign)): + if isinstance(getattr(n, "target", None), ast.Subscript): + _subs = [n.target] + for _t in _subs: + if not _is_namespace_call(_t.value): + continue + _key = _t.slice.value if isinstance(_t.slice, ast.Constant) else None + if isinstance(_key, str): + _ns_write_names.add(_key) + else: + _ns_write_all = True + # globals().update(...) / .setdefault(...) / .__setitem__(...) -- an opaque bulk write. + if ( + isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and n.func.attr in ("update", "setdefault", "__setitem__", "pop", "clear") + and _is_namespace_call(n.func.value) + ): + _ns_write_all = True + # Count how many module-level stores each recorded name really has; if more # than one Store target references it anywhere, drop it. store_counts: dict[str, int] = {} @@ -4210,7 +4299,61 @@ def _build_const_prop_env(tree): if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store): store_counts[n.id] = store_counts.get(n.id, 0) + 1 - env = {} + # Names REBOUND to something OTHER than their canonical builtin / stdlib module: a fold that + # calls the real builtin (str(...), len(...)) or hard-coded module (base64.b64decode(...)) + # would diverge from runtime, which calls the user binding. A plain `import name` keeps the + # canonical module (NOT shadowing); every other binding -- assignment, def/class, param, + # from-import, an aliased import that rebinds the name, or a loop/with/except/comprehension + # target -- is. The folder consults this set before folding a Name builtin / module receiver. + shadowed: set[str] = set() + + def _shadow_targets(t): + for nn in ast.walk(t): + if isinstance(nn, ast.Name) and isinstance(nn.ctx, (ast.Store, ast.Del)): + shadowed.add(nn.id) + + for n in ast.walk(tree): + if isinstance(n, ast.Assign): + for t in n.targets: + _shadow_targets(t) + elif isinstance(n, (ast.AugAssign, ast.AnnAssign)): + if getattr(n, "target", None) is not None: + _shadow_targets(n.target) + elif isinstance(n, ast.NamedExpr): + _shadow_targets(n.target) + elif isinstance(n, (ast.For, ast.AsyncFor)): + _shadow_targets(n.target) + elif isinstance(n, ast.comprehension): + _shadow_targets(n.target) + elif isinstance(n, ast.withitem): + if n.optional_vars is not None: + _shadow_targets(n.optional_vars) + elif isinstance(n, ast.ExceptHandler): + if n.name: + shadowed.add(n.name) + elif isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + shadowed.add(n.name) + _a = getattr(n, "args", None) + if _a is not None: + for _p in list(_a.args) + list(_a.posonlyargs) + list(_a.kwonlyargs): + shadowed.add(_p.arg) + for _extra in (_a.vararg, _a.kwarg): + if _extra is not None: + shadowed.add(_extra.arg) + elif isinstance(n, ast.ImportFrom): + for _al in n.names: + shadowed.add(_al.asname or _al.name) + elif isinstance(n, ast.Import): + for _al in n.names: + # import os as base64 rebinds `base64` to a different module; a plain + # `import base64` (asname None) keeps the canonical module and does not shadow. + if _al.asname is not None and _al.asname != _al.name: + shadowed.add(_al.asname) + + env = _ConstEnv(shadowed = frozenset(shadowed)) + if _ns_write_all: + return env # an opaque namespace mutation could rebind any recorded constant + disqualified |= _ns_write_names for name, rhs in assigned_once.items(): if name in disqualified: continue @@ -6199,6 +6342,35 @@ def _check_signal_escape_patterns( _const_env = {} _scope_idx = _ScopeAliasIndex(tree) + def _payload_free_name_hits_caller_alias(src, mode, node): + """A FREE name in an exec/eval payload that resolves, in the CALLER's scope at ``node``, to + a shell / exec-builtin / deserialize / import alias -- exec/eval run in the caller + namespace, so ``f`` in ``exec('f(...)')`` is the caller's ``f = os.system``. Returns the + offending name or None. Builtins / undefined names never resolve, so ``exec('print(1)')`` + and ``exec('x = 1')`` stay allowed.""" + try: + inner = ast.parse(src, mode = "eval" if mode == "eval" else "exec") + except Exception: + return None + bound: set[str] = set() + loaded: set[str] = set() + for n in ast.walk(inner): + if isinstance(n, ast.Name): + if isinstance(n.ctx, (ast.Store, ast.Del)): + bound.add(n.id) + elif isinstance(n.ctx, ast.Load): + loaded.add(n.id) + elif isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + bound.add(n.name) + elif isinstance(n, (ast.Import, ast.ImportFrom)): + for _al in n.names: + bound.add((_al.asname or _al.name).split(".")[0]) + for nm in loaded - bound: + for _kind in ("shell", "execb", "deser", "impf"): + if _scope_idx.resolve(nm, node, _kind): + return nm + return None + def _analyze_exec_call(node, func_id): """Stage 2 driver: recover + recurse a foldable payload, else dynamic policy.""" try: @@ -6225,6 +6397,23 @@ def _check_signal_escape_patterns( ), } ) + return + # exec()/eval() run in the CALLER namespace, so the payload -- scanned above as a + # fresh module -- can reference a caller-scope alias the inner pass cannot see + # (import os; f = os.system; exec("f('rm -rf /')")). Fail closed when a payload + # FREE name resolves to a shell / exec / deserialize / import alias at this call. + _alias = _payload_free_name_hits_caller_alias(src, mode, node) + if _alias is not None: + dynamic_exec.append( + { + "type": "dynamic_exec", + "line": getattr(node, "lineno", -1), + "description": ( + f"{func_id}() payload references caller alias {_alias!r} " + "bound to a shell / exec / deserialize sink" + ), + } + ) return if parsed_kind == "BOUND_HIT": dynamic_exec.append( @@ -6762,6 +6951,11 @@ def _check_signal_escape_patterns( return None container = sub.value + # Resolve a single-assignment NAME container (d = [os.system]; d[0]('rm -rf /')) to its + # literal, mirroring the exec-container resolver -- a shell sink hidden in an assigned + # container was otherwise missed because the callee is an indexed Name. + if isinstance(container, ast.Name) and container.id in _const_env: + container = _const_env[container.id] ci = _const_fold(sub.slice, _const_env) if isinstance(container, (ast.List, ast.Tuple)) and isinstance(ci, int): if -len(container.elts) <= ci < len(container.elts): @@ -6795,6 +6989,12 @@ def _check_signal_escape_patterns( return None container = sub.value + # Resolve a subscript into a container bound to a single-assignment NAME + # (d = {'e': exec}; d['e'](...), xs = [eval]; xs[0](...)) to the literal container, + # so the exec/eval sink hidden inside it is not missed just because the callee is an + # indexed Name rather than an inline literal. + if isinstance(container, ast.Name) and container.id in _const_env: + container = _const_env[container.id] ci = _const_fold(sub.slice, _const_env) if isinstance(container, (ast.List, ast.Tuple)) and isinstance(ci, int): if -len(container.elts) <= ci < len(container.elts): @@ -6832,6 +7032,10 @@ def _check_signal_escape_patterns( return None container = sub.value + # Resolve a single-assignment NAME container (d = {'k': pickle.loads}; d['k'](payload)) + # to its literal, mirroring the exec-container resolver above. + if isinstance(container, ast.Name) and container.id in _const_env: + container = _const_env[container.id] ci = _const_fold(sub.slice, _const_env) if isinstance(container, (ast.List, ast.Tuple)) and isinstance(ci, int): if -len(container.elts) <= ci < len(container.elts): diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py index 8340adea53..45f03bab17 100644 --- a/studio/backend/tests/test_sandbox_tools.py +++ b/studio/backend/tests/test_sandbox_tools.py @@ -4910,3 +4910,93 @@ class TestRound49Bypasses: ) def test_round49_benign_allowed(self, code): _ok(code) + + +class TestRound50Bypasses: + """Fiftieth-round Codex findings: the static exec/eval analyzer defeated by aliasing / + shadowing, and an unknown PATH variable. A subscript into an ASSIGNED container hiding an + exec/eval (or shell) sink, a rebound builtin/module used in the constant fold, a namespace-dict + write invalidating a folded constant, an exec/eval payload referencing a caller alias, and a + PATH entry that is an unset $VAR (empty -> cwd search).""" + + @pytest.mark.parametrize( + "code", + [ + # d = {'e': exec}; d['e'](payload) / xs = [eval]; xs[0](payload): the container is an + # assigned Name, not an inline literal, so the sink was missed. + "d = {'e': exec}\nd['e'](\"import os\\nos.system('touch /tmp/x')\")", + "xs = [eval]\nxs[0](\"__import__('os').system('touch /tmp/x')\")", + # same, but a shell sink hidden in an assigned container. + "import os\nd = [os.system]\nd[0]('touch /tmp/x')", + "import os\nd = {'k': os.system}\nd['k']('touch /tmp/x')", + ], + ) + def test_assigned_container_sink_blocked(self, code): + assert _check_code_safety(code) is not None, code + + @pytest.mark.parametrize( + "code", + [ + # A rebound builtin / module used in the fold diverges from runtime; refuse to fold -> + # opaque payload -> eval/exec fails closed. + "str = lambda _: \"__import__('os').system('touch /tmp/x')\"\neval(str(1))", + "def chr(_):\n return \"__import__('os').system('touch /tmp/x')\"\neval(chr(0))", + ], + ) + def test_shadowed_fold_helper_blocked(self, code): + assert _check_code_safety(code) is not None, code + + @pytest.mark.parametrize( + "code", + [ + # A write through the namespace dict invalidates the folded constant. + "x = '2+2'\nglobals()['x'] = \"__import__('os').system('touch /tmp/x')\"\neval(x)", + "x = '2+2'\nvars()['x'] = \"__import__('os').system('touch /tmp/x')\"\neval(x)", + "x = '2+2'\nglobals().update({'x': \"__import__('os').system('touch /tmp/x')\"})\neval(x)", + ], + ) + def test_namespace_write_invalidates_constant_blocked(self, code): + assert _check_code_safety(code) is not None, code + + @pytest.mark.parametrize( + "code", + [ + # exec/eval run in the caller namespace, so the payload reaches a caller alias. + "import os\nf = os.system\nexec(\"f('touch /tmp/x')\")", + "import os\ns = os.system\neval(\"s('touch /tmp/x')\")", + ], + ) + def test_exec_payload_caller_alias_blocked(self, code): + assert _check_code_safety(code) is not None, code + + @pytest.mark.parametrize( + "code", + [ + # An unknown/unset $VAR is an EMPTY PATH component -> the shell searches the cwd. + "import os\nos.system('PATH=$EVIL evil')", + "import os\nos.system('PATH=${EVIL} run')", + ], + ) + def test_unknown_path_variable_blocked(self, code): + assert _check_code_safety(code) is not None, code + + @pytest.mark.parametrize( + "code", + [ + # An assigned container to a SAFE callee, normal builtin/module folds, a namespace + # READ, an eval of a safe literal via a var, an exec whose payload references only a + # SAFE caller alias or a builtin, and a PATH with trusted absolute entries stay allowed. + "d = {'e': print}\nd['e']('hi')", + "xs = [len]\nprint(xs[0]([1, 2]))", + "import base64\nprint(base64.b64decode('aGk='))", + "s = str(42)\nprint(s)", + "g = globals()\nprint(len(g))", + "x = '1 + 1'\neval(x)", + "f = print\nexec(\"f(1)\")", + "exec(\"y = 5\")", + "import os\nos.system('PATH=/usr/local/bin:$PATH ls')", + "import os\nos.system('ls -la')", + ], + ) + def test_round50_benign_allowed(self, code): + _ok(code)