Reproduction steps:
- Install the network gate a module opted into (allow only *.acme.com):
ns = {}
module_sandbox.install_restrictions(ns, {"network":[".acme.com"]}, "evil/mod")
tok = module_sandbox._ACTIVE_SANDBOX.set(("evil/mod", [".acme.com"]))
(Modules tab shows network allow = ["*.acme.com"]; 127.0.0.1 is not in it.)
- Stand up a loopback listener on some port P (stands in for a forbidden host).
- Control — the wrapped path is blocked:
socket.socket().connect(("127.0.0.1", P)) -> SandboxViolation
- Bypass — the same forbidden host via unwrapped methods:
socket.socket().connect_ex(("127.0.0.1", P)) # returns 0, connects
socket.socket(socket.AF_INET, socket.SOCK_DGRAM).sendto(b"x", ("127.0.0.1", P))
Expected:
A module that declared network:["*.acme.com"] cannot reach any other host by any
standard socket method; connect_ex and UDP sendto are covered like connect.
Actual:
connect() raises SandboxViolation; connect_ex() returns 0 and the connection
succeeds (the listener receives the bytes); UDP sendto() delivers with no check.
The gate overrides only socket.socket.connect; connect_ex, sendto and sendmsg are
inherited from the C base socket and never wrapped, so the declared allowlist is
bypassable while the Modules tab shows it as enforced. These are ordinary methods
on the same high-level socket object the gate wraps connect() on, and some
HTTP/health-check/async libraries use connect_ex for non-adversarial code.
Suggested fix:
In _install_network_gate, inside the if not _NET_GATE_INSTALLED block, wrap the
sibling methods with the same address check the connect wrap uses:
_real_connect_ex = _socket.socket.connect_ex
_real_sendto = _socket.socket.sendto
_real_sendmsg = _socket.socket.sendmsg
def _guard_addr(address, cur_slug, cur_allow, source):
if isinstance(address, tuple) and address and isinstance(address[0], str):
host = address[0]
if _is_ip_literal(host):
approved = getattr(_tls, "approved_host", None)
if approved: _check(approved, source, cur_slug, cur_allow)
else: raise SandboxViolation(f"module {cur_slug!r} tried {source} to raw IP {host!r}")
else:
_check(host, source, cur_slug, cur_allow)
def _wrapped_connect_ex(self, address, *a, **k):
act = _ACTIVE_SANDBOX.get()
if act is not None: _guard_addr(address, act[0], act[1], "socket.connect_ex")
return _real_connect_ex(self, address, *a, **k)
def _wrapped_sendto(self, data, *a, **k):
act = _ACTIVE_SANDBOX.get()
if act is not None and a:
_guard_addr(a[-1], act[0], act[1], "socket.sendto") # addr is last positional
return _real_sendto(self, data, *a, **k)
def _wrapped_sendmsg(self, buffers, *a, **k):
act = _ACTIVE_SANDBOX.get()
if act is not None:
addr = a[2] if len(a) >= 3 else k.get("address")
if addr is not None: _guard_addr(addr, act[0], act[1], "socket.sendmsg")
return _real_sendmsg(self, buffers, *a, **k)
_socket.socket.connect_ex = _wrapped_connect_ex
_socket.socket.sendto = _wrapped_sendto
_socket.socket.sendmsg = _wrapped_sendmsg
Station version (railcall version): station-v0.99
Module slug + version: N/A (platform — workbench/module_sandbox.py)