studio/sandbox: close dynamic-import + /proc/self symlink bypasses

Closes two static-bypass classes flagged during round-3 review:

1. __import__('os').system(...) / importlib.import_module('os').popen(...)
   bypassed the bare os.system / subprocess.* gate because the receiver
   was an ast.Call rather than an ast.Name in os_aliases. Adds
   _resolve_dynamic_module_name() so:
     * inline __import__('os').system(...)
     * inline importlib.import_module('os').system(...)
     * import importlib; mod = importlib.import_module('os'); mod.popen(...)
     * m = __import__('subprocess'); m.run([...], shell=True)
   all flow through the same shell-escape detection as
   import os; os.system(...). Legit dynamic imports of safe modules
   (json, pathlib, ...) remain allowed.

2. /proc/<pid>/cwd and /proc/<pid>/root are symlinks to the process
   working directory and the filesystem root. The form
   open(/proc/self/cwd/../../etc/shadow) bypassed
   _normalize_path_separators because .. was collapsed against the
   literal path, not the symlinked target. The form
   open(/proc/self/root/etc/shadow) bypassed any chroot-style
   defence. Adds matching entries to _ABSOLUTE_SENSITIVE so the bash
   gate and the AST open() gate both block any access via these
   symlink prefixes. Legitimate /proc/self/status etc. introspection
   still flows.

Tests:
  TestFollowup_DynamicImportShellEscape (2 cases, 10 parametrised)
  TestFollowup_ProcSelfSymlinkTraversal (3 cases, 16 parametrised)

  pytest studio/backend/tests/test_sandbox_hardening.py -q
    -> 382 passed in 0.67s (was 356).
This commit is contained in:
Daniel Han 2026-05-22 08:13:37 +00:00
commit d64c2a10d4
2 changed files with 162 additions and 0 deletions

View file

@ -194,6 +194,15 @@ _ABSOLUTE_SENSITIVE = (
# threads; ``cmdline`` and ``auxv`` carry env-derived strings too.
r"/proc/(?:self|thread-self|\d+)/(?:environ|mem|maps|auxv|cmdline)",
r"/proc/(?:self|thread-self|\d+)/task/\d+/(?:environ|mem|maps|auxv|cmdline)",
# ``/proc/<pid>/cwd`` and ``/proc/<pid>/root`` are symlinks to the
# process cwd and the filesystem root respectively. Reading via
# ``/proc/self/cwd/X`` is equivalent to reading ``X`` but bypasses
# any path normalisation that worked on the literal text; reading
# ``/proc/self/root/etc/shadow`` opens ``/etc/shadow`` even under
# chroot. Block any access via these symlink prefixes; there is no
# legitimate LLM-tool-use reason to dereference them.
r"/proc/(?:self|thread-self|\d+)/(?:cwd|root)(?:/|\Z)",
r"/proc/(?:self|thread-self|\d+)/task/\d+/(?:cwd|root)(?:/|\Z)",
r"/proc/kcore",
r"/proc/kallsyms",
r"/var/spool/cron/[^\s'\"]*",
@ -1505,6 +1514,32 @@ def _check_signal_escape_patterns(code: str):
return func.attr
return None
def _resolve_dynamic_module_name(node):
"""Return the module string for dynamic import expressions.
Recognises:
* ``__import__('os')``
* ``importlib.import_module('os')``
* bare ``import_module('os')`` (after ``from importlib import
import_module``)
Returns the literal first-argument string when matched, else
``None``. Used to ensure ``__import__('os').system(...)`` and
``m = importlib.import_module('os'); m.system(...)`` flow
through the same shell-escape gate as ``import os; os.system(...)``.
"""
if not isinstance(node, ast.Call) or not node.args:
return None
arg0 = node.args[0]
if not (isinstance(arg0, ast.Constant) and isinstance(arg0.value, str)):
return None
f = node.func
if isinstance(f, ast.Name) and f.id in ("__import__", "import_module"):
return arg0.value
if isinstance(f, ast.Attribute) and f.attr == "import_module":
return arg0.value
return None
# Keyword argument names that carry command content (as opposed to
# control flags like check=True, text=True, capture_output=True).
_CMD_KWARGS = frozenset({"args", "command", "executable", "path", "file"})
@ -1606,6 +1641,22 @@ def _check_signal_escape_patterns(code: str):
self.generic_visit(node)
self.loop_depth -= 1
def visit_Assign(self, node):
# Track ``m = __import__('os')`` and
# ``m = importlib.import_module('os')`` so a subsequent
# ``m.system(...)`` / ``m.popen(...)`` flows through the
# os/subprocess alias detection unchanged.
dyn = _resolve_dynamic_module_name(node.value)
if dyn == "os":
for tgt in node.targets:
if isinstance(tgt, ast.Name):
self.os_aliases.add(tgt.id)
elif dyn == "subprocess":
for tgt in node.targets:
if isinstance(tgt, ast.Name):
self.subprocess_aliases.add(tgt.id)
self.generic_visit(node)
def visit_Call(self, node):
func = node.func
@ -1718,6 +1769,17 @@ def _check_signal_escape_patterns(code: str):
shell_func = f"os.{func.attr}"
elif func.value.id in self.subprocess_aliases:
shell_func = f"subprocess.{func.attr}"
else:
# Inline dynamic import:
# __import__('os').system(...)
# importlib.import_module('os').popen(...)
# No intermediate name binding so the Name branch
# above misses it; resolve the receiver here.
dyn = _resolve_dynamic_module_name(func.value)
if dyn == "os":
shell_func = f"os.{func.attr}"
elif dyn == "subprocess":
shell_func = f"subprocess.{func.attr}"
elif isinstance(func, ast.Name):
# Check from-import aliases: from os import system; system(...)
shell_func = self.shell_exec_aliases.get(func.id)

View file

@ -1307,3 +1307,103 @@ class TestR3Finding22_RequestsRequestPositionalKeyword:
)
def test_request_method_then_kw_trusted_url_allowed(self, code):
assert not _is_blocked(code), f"trusted method+kw blocked: {code!r}"
# ---------------------------------------------------------------------------
# Followup — dynamic import bypass + /proc/self/cwd-root symlink traversal
# ---------------------------------------------------------------------------
class TestFollowup_DynamicImportShellEscape:
"""``__import__('os').system(...)`` and
``importlib.import_module('os').popen(...)`` bypass the bare
``os.system`` gate because the receiver is a Call, not a Name in
``os_aliases``. Same for the assign form
``m = __import__('os'); m.system(...)``. The visitor now resolves
both shapes back to the canonical alias before the shell-escape
check runs."""
@pytest.mark.parametrize(
"code",
[
"__import__('os').system('" + SUDO + " whoami')",
"__import__('os').popen('cat ~/.ssh/id_rsa')",
"import importlib; importlib.import_module('os').system('"
+ SUDO
+ " whoami')",
"from importlib import import_module; import_module('os').system('"
+ SUDO
+ " whoami')",
"m = __import__('os'); m.system('" + SUDO + " whoami')",
"m = __import__('subprocess'); m.run(['" + SUDO + "', 'whoami'], shell=True)",
"import importlib; mod = importlib.import_module('os'); "
"mod.popen('cat ~/.aws/credentials')",
],
)
def test_dynamic_import_shell_escape_blocked(self, code):
assert _is_blocked(code), f"dynamic-import shell escape leaked: {code!r}"
@pytest.mark.parametrize(
"code",
[
# Legit: importing other modules and calling safe methods.
"import importlib; m = importlib.import_module('json'); m.dumps({'a':1})",
"__import__('json').dumps({'a': 1})",
"from importlib import import_module; pl = import_module('pathlib'); "
"pl.Path('/tmp/x').exists()",
],
)
def test_dynamic_import_legit_allowed(self, code):
assert not _is_blocked(code), f"legit dynamic import blocked: {code!r}"
class TestFollowup_ProcSelfSymlinkTraversal:
"""``/proc/<pid>/cwd`` and ``/proc/<pid>/root`` are symlinks to the
process cwd and filesystem root. Without explicit detection,
``/proc/self/cwd/../../etc/shadow`` escapes lexical ``..``
normalisation, and ``/proc/self/root/etc/shadow`` opens
``/etc/shadow`` regardless of any chroot or relative-path
defence."""
@pytest.mark.parametrize(
"cmd",
[
"cat /proc/self/cwd/../../etc/shadow",
"cat /proc/self/root/etc/shadow",
"cat /proc/self/root/etc/sudoers",
"cat /proc/thread-self/cwd/secret.txt",
"cat /proc/1/root/etc/shadow",
"cat /proc/1/cwd/secrets.env",
"cat /proc/self/task/1/root/etc/shadow",
],
)
def test_proc_self_symlink_traversal_bash_blocked(self, cmd):
assert _find_sensitive_paths(cmd), f"symlink traversal leaked: {cmd!r}"
@pytest.mark.parametrize(
"code",
[
"open('/proc/self/root/etc/shadow').read()",
"open('/proc/self/cwd/../../etc/shadow').read()",
"open('/proc/1/root/etc/sudoers')",
"import pathlib; pathlib.Path('/proc/self/root/etc/shadow').read_text()",
"from pathlib import Path; "
"Path('/proc/thread-self/root/etc/shadow').open()",
],
)
def test_proc_self_symlink_traversal_open_blocked(self, code):
assert _is_blocked(code), f"symlink traversal open leaked: {code!r}"
@pytest.mark.parametrize(
"code",
[
# ``/proc/self/status`` is still useful for legit
# introspection (e.g. checking the sandbox PID).
"open('/proc/self/status').read()",
"open('/proc/self/stat').read()",
"open('/proc/cpuinfo').read()",
"open('/proc/meminfo').read()",
],
)
def test_proc_legit_introspection_allowed(self, code):
assert not _is_blocked(code), f"legit /proc read blocked: {code!r}"