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).
This commit is contained in:
parent
f11fbdcb7f
commit
997c7247f2
3 changed files with 180 additions and 3 deletions
|
|
@ -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("...", "<s>", "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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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='<s>', 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='<s>', 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='<s>', 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='<s>', mode='exec')\nf = lambda: None\nf.__code__ = c\nf()",
|
||||
"co = compile(source='result = sum(range(10))', filename='<s>', mode='exec')\nns = {}\neval(co, ns)",
|
||||
],
|
||||
)
|
||||
def test_round43_benign_compile_allowed(self, code):
|
||||
_ok(code)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue