mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-21 20:18:06 +02:00
[pitboss] phase 03: M3 — Docker backend + sandbox-escape regression suite
This commit is contained in:
parent
3a4f1b177b
commit
a8b9dcd72b
36 changed files with 1778 additions and 27 deletions
20
tests/dynamic_fixtures/escape/cgroup_escape.py
Normal file
20
tests/dynamic_fixtures/escape/cgroup_escape.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Escape attempt: write to cgroup hierarchy to escape resource limits.
|
||||
|
||||
Requires CAP_SYS_ADMIN. Expected outcome: PermissionError.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
CGROUP_PROCS = "/sys/fs/cgroup/cgroup.procs"
|
||||
CGROUP_V1 = "/sys/fs/cgroup/memory/cgroup.procs"
|
||||
|
||||
target = CGROUP_PROCS if os.path.exists(CGROUP_PROCS) else CGROUP_V1
|
||||
|
||||
try:
|
||||
with open(target, "w") as f:
|
||||
f.write(str(os.getpid()))
|
||||
print(f"NYX_ESCAPE_SUCCESS: wrote pid to {target}")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
27
tests/dynamic_fixtures/escape/chroot_escape.py
Normal file
27
tests/dynamic_fixtures/escape/chroot_escape.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Escape attempt: call chroot() to change root directory (classic jail escape).
|
||||
|
||||
Requires CAP_SYS_CHROOT. Expected outcome: EPERM (--cap-drop=ALL).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
os.chroot("/")
|
||||
# Classic chroot escape: chroot to / then navigate up.
|
||||
for _ in range(32):
|
||||
try:
|
||||
os.chdir("..")
|
||||
except OSError:
|
||||
break
|
||||
os.chroot(".")
|
||||
try:
|
||||
with open("etc/shadow", "r") as f:
|
||||
content = f.read(64)
|
||||
print(f"NYX_ESCAPE_SUCCESS: escaped chroot, read /etc/shadow: {content[:32]!r}")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError):
|
||||
print("PARTIAL: chroot succeeded but could not read /etc/shadow", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
14
tests/dynamic_fixtures/escape/device_file_access.py
Normal file
14
tests/dynamic_fixtures/escape/device_file_access.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"""Escape attempt: open /dev/mem to read physical memory.
|
||||
|
||||
Requires CAP_SYS_RAWIO. Expected outcome: PermissionError.
|
||||
"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open("/dev/mem", "rb") as f:
|
||||
data = f.read(64)
|
||||
print(f"NYX_ESCAPE_SUCCESS: read {len(data)} bytes from /dev/mem")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
15
tests/dynamic_fixtures/escape/dns_leak.py
Normal file
15
tests/dynamic_fixtures/escape/dns_leak.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Escape attempt: perform a DNS lookup to leak container identity.
|
||||
|
||||
Expected outcome: socket.gaierror (--network none, no DNS resolver).
|
||||
"""
|
||||
import socket
|
||||
import sys
|
||||
|
||||
try:
|
||||
# Try to resolve an external hostname; requires network access.
|
||||
ip = socket.gethostbyname("google.com")
|
||||
print(f"NYX_ESCAPE_SUCCESS: DNS resolved google.com -> {ip}")
|
||||
sys.exit(0)
|
||||
except (socket.gaierror, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
20
tests/dynamic_fixtures/escape/egress_non_allowlisted.py
Normal file
20
tests/dynamic_fixtures/escape/egress_non_allowlisted.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Escape attempt: establish a TCP connection to a non-allowlisted external host.
|
||||
|
||||
Expected outcome: ConnectionRefusedError or OSError (--network none blocks egress).
|
||||
"""
|
||||
import socket
|
||||
import sys
|
||||
|
||||
# Cloudflare DNS — well-known, definitely not the OOB oracle.
|
||||
TARGET = ("1.1.1.1", 443)
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(3)
|
||||
s.connect(TARGET)
|
||||
s.close()
|
||||
print(f"NYX_ESCAPE_SUCCESS: connected to {TARGET}")
|
||||
sys.exit(0)
|
||||
except (ConnectionRefusedError, OSError, socket.timeout) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
22
tests/dynamic_fixtures/escape/env_injection.py
Normal file
22
tests/dynamic_fixtures/escape/env_injection.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""Escape attempt: detect host-only env variables leaking into the sandbox.
|
||||
|
||||
Docker base images set HOME/PATH/LANG themselves, so those alone are not a
|
||||
host leak. We probe for variables that only exist on a developer host:
|
||||
USER/LOGNAME/SHELL (set by login shells) and SSH_*/XDG_* (set by the
|
||||
developer's session). Exit 0 only when one of those host-only markers is
|
||||
present in the sandbox env.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
host_only = ["USER", "LOGNAME", "SHELL", "SSH_CONNECTION", "SSH_TTY", "XDG_SESSION_ID"]
|
||||
leaked = [k for k in host_only if k in os.environ]
|
||||
|
||||
if leaked:
|
||||
print(f"NYX_ESCAPE_SUCCESS: host env vars leaked: {leaked}")
|
||||
sys.exit(0)
|
||||
|
||||
visible = list(os.environ.keys())[:5]
|
||||
print(f"BLOCKED: host-only env vars absent; visible sample: {visible}",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
17
tests/dynamic_fixtures/escape/file_write_outside_workdir.py
Normal file
17
tests/dynamic_fixtures/escape/file_write_outside_workdir.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"""Escape attempt: write a file outside the sandboxed workdir.
|
||||
|
||||
Expected outcome: PermissionError or write succeeds but test checks host.
|
||||
Exit 0 only when escape succeeds (file written outside container).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
marker = "/tmp/nyx_escape_file_write_marker"
|
||||
try:
|
||||
with open(marker, "w") as f:
|
||||
f.write("NYX_ESCAPE_SUCCESS")
|
||||
print("NYX_ESCAPE_SUCCESS: wrote to host /tmp")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
23
tests/dynamic_fixtures/escape/fork_bomb.py
Normal file
23
tests/dynamic_fixtures/escape/fork_bomb.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Escape attempt: fork bomb to exhaust host process table.
|
||||
|
||||
Expected outcome: killed by PID limit (pids-limit in docker) or timeout.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
# Attempt a fork bomb; limited by docker --pids-limit or sandbox timeout.
|
||||
pids = []
|
||||
for _ in range(512):
|
||||
pid = os.fork()
|
||||
if pid == 0:
|
||||
# Child: loop forever
|
||||
while True:
|
||||
pass
|
||||
pids.append(pid)
|
||||
# Should not reach here in a properly sandboxed environment.
|
||||
print("NYX_ESCAPE_SUCCESS: fork bomb created 512 children")
|
||||
sys.exit(0)
|
||||
except (BlockingIOError, PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
17
tests/dynamic_fixtures/escape/hardlink_escape.py
Normal file
17
tests/dynamic_fixtures/escape/hardlink_escape.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"""Escape attempt: create a hardlink to a sensitive file outside the workdir.
|
||||
|
||||
Expected outcome: PermissionError (EPERM — cross-device or protected hardlink).
|
||||
Linux protects hardlinks to files owned by other users since 3.6+.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
os.link("/etc/shadow", "/tmp/shadow_hardlink")
|
||||
with open("/tmp/shadow_hardlink", "r") as f:
|
||||
data = f.read(64)
|
||||
print(f"NYX_ESCAPE_SUCCESS: hardlinked /etc/shadow: {data[:32]!r}")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
39
tests/dynamic_fixtures/escape/host_pid_visibility.py
Normal file
39
tests/dynamic_fixtures/escape/host_pid_visibility.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Escape attempt: enumerate host PIDs via /proc to find and signal host processes.
|
||||
|
||||
Expected outcome: only container PIDs visible; cannot signal PID 1 outside ns.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
|
||||
# Collect all numeric /proc entries (visible PIDs).
|
||||
pids = []
|
||||
for entry in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
pid = int(os.path.basename(entry))
|
||||
pids.append(pid)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# A correctly isolated container sees very few PIDs (its own + init).
|
||||
# The host typically has hundreds of PIDs. More than 100 visible = host leak.
|
||||
if len(pids) > 100:
|
||||
print(f"NYX_ESCAPE_SUCCESS: saw {len(pids)} host PIDs via /proc")
|
||||
sys.exit(0)
|
||||
|
||||
# Try to signal PID 1 (init). In a proper PID namespace, PID 1 is the
|
||||
# container's init, not systemd/launchd. Sending SIGCONT to it is safe.
|
||||
try:
|
||||
os.kill(1, 0) # signal 0 = check if process exists (no actual signal)
|
||||
# PID 1 exists; check if it looks like a host process.
|
||||
with open("/proc/1/cmdline", "rb") as f:
|
||||
cmdline = f.read().replace(b"\x00", b" ").strip()
|
||||
if b"systemd" in cmdline or b"launchd" in cmdline or b"init" in cmdline:
|
||||
print(f"NYX_ESCAPE_SUCCESS: host init visible: {cmdline[:64]}")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
print(f"BLOCKED: only {len(pids)} PIDs visible, host PID 1 not accessible",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
22
tests/dynamic_fixtures/escape/icmp_flood.py
Normal file
22
tests/dynamic_fixtures/escape/icmp_flood.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""Escape attempt: send ICMP packets to a host on the network.
|
||||
|
||||
Requires CAP_NET_RAW and network access. Expected outcome: PermissionError
|
||||
(--cap-drop=ALL) or OSError (--network none).
|
||||
"""
|
||||
import socket
|
||||
import sys
|
||||
import struct
|
||||
|
||||
try:
|
||||
# ICMP ping requires AF_INET + SOCK_RAW + IPPROTO_ICMP
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP)
|
||||
s.settimeout(1)
|
||||
# ICMP echo request: type=8, code=0, checksum=0, id=0, seq=0
|
||||
packet = struct.pack("bbHHh", 8, 0, 0, 0, 0)
|
||||
s.sendto(packet, ("8.8.8.8", 0))
|
||||
s.close()
|
||||
print("NYX_ESCAPE_SUCCESS: sent ICMP packet to 8.8.8.8")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
28
tests/dynamic_fixtures/escape/ipc_shm_escape.py
Normal file
28
tests/dynamic_fixtures/escape/ipc_shm_escape.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Escape attempt: create a System V shared memory segment accessible host-wide.
|
||||
|
||||
Expected outcome: success creates IPC segment inside the container IPC namespace
|
||||
(isolated by --ipc private default); OR EPERM if IPC syscalls are blocked.
|
||||
"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
import sysv_ipc
|
||||
key = sysv_ipc.ftok("/tmp", ord('N'))
|
||||
shm = sysv_ipc.SharedMemory(key, sysv_ipc.IPC_CREAT, size=4096)
|
||||
shm.write(b"NYX_IPC_ESCAPE_TEST" + b"\x00" * (4096 - 20))
|
||||
# If we can create IPC, check if it's in an isolated namespace.
|
||||
# A properly isolated container won't share this with the host.
|
||||
# We can only verify this from the host side, so just report success.
|
||||
shm.detach()
|
||||
shm.remove()
|
||||
# IPC created successfully but inside the container namespace — not an escape.
|
||||
print("BLOCKED: IPC shm created but confined to container IPC namespace",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ImportError:
|
||||
# sysv_ipc not available — not an escape.
|
||||
print("BLOCKED: sysv_ipc module not available", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
32
tests/dynamic_fixtures/escape/kernel_module_load.py
Normal file
32
tests/dynamic_fixtures/escape/kernel_module_load.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Escape attempt: load a kernel module via finit_module/init_module syscall.
|
||||
|
||||
Requires CAP_SYS_MODULE. Expected outcome: EPERM (--cap-drop=ALL + seccomp).
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import sys
|
||||
|
||||
NR_finit_module = 313 # x86_64
|
||||
|
||||
try:
|
||||
libc_name = ctypes.util.find_library("c")
|
||||
if not libc_name:
|
||||
raise OSError("libc not found")
|
||||
libc = ctypes.CDLL(libc_name, use_errno=True)
|
||||
# Pass fd=-1 to trigger EBADF rather than loading, but the capability
|
||||
# check happens first on a properly hardened kernel.
|
||||
ret = libc.syscall(NR_finit_module, -1, b"", 0)
|
||||
if ret == -1:
|
||||
errno = ctypes.get_errno()
|
||||
if errno in (1, 13): # EPERM or EACCES
|
||||
raise PermissionError(errno, "finit_module blocked")
|
||||
# EBADF means we got past the capability check — capability not dropped.
|
||||
if errno == 9:
|
||||
print("NYX_ESCAPE_SUCCESS: finit_module capability not blocked")
|
||||
sys.exit(0)
|
||||
raise OSError(errno, f"finit_module errno={errno}")
|
||||
print("NYX_ESCAPE_SUCCESS: finit_module succeeded")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
32
tests/dynamic_fixtures/escape/keyctl_abuse.py
Normal file
32
tests/dynamic_fixtures/escape/keyctl_abuse.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Escape attempt: use keyctl to read host kernel keyring entries.
|
||||
|
||||
Expected outcome: EPERM from seccomp (keyctl is often denied in docker).
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import sys
|
||||
|
||||
NR_keyctl = 250 # x86_64
|
||||
KEYCTL_SEARCH = 10
|
||||
|
||||
try:
|
||||
libc_name = ctypes.util.find_library("c")
|
||||
if not libc_name:
|
||||
raise OSError("libc not found")
|
||||
libc = ctypes.CDLL(libc_name, use_errno=True)
|
||||
# KEY_SPEC_USER_KEYRING = -4
|
||||
ret = libc.syscall(NR_keyctl, KEYCTL_SEARCH, -4, b"user", b"nyx_test_key", 0)
|
||||
if ret == -1:
|
||||
errno = ctypes.get_errno()
|
||||
if errno in (1, 13, 38): # EPERM, EACCES, ENOSYS
|
||||
raise PermissionError(errno, f"keyctl blocked errno={errno}")
|
||||
# ENOKEY (126) = not found but syscall allowed — partial escape
|
||||
if errno == 126:
|
||||
print("NYX_ESCAPE_SUCCESS: keyctl syscall allowed (key not found but accessible)")
|
||||
sys.exit(0)
|
||||
raise OSError(errno, f"keyctl errno={errno}")
|
||||
print(f"NYX_ESCAPE_SUCCESS: keyctl returned {ret}")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
26
tests/dynamic_fixtures/escape/mount_ns_abuse.py
Normal file
26
tests/dynamic_fixtures/escape/mount_ns_abuse.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""Escape attempt: bind-mount a host path into the container.
|
||||
|
||||
Requires CAP_SYS_ADMIN. Expected outcome: EPERM (--cap-drop=ALL).
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import sys
|
||||
import os
|
||||
|
||||
MS_BIND = 4096
|
||||
|
||||
try:
|
||||
libc_name = ctypes.util.find_library("c")
|
||||
if not libc_name:
|
||||
raise OSError("libc not found")
|
||||
libc = ctypes.CDLL(libc_name, use_errno=True)
|
||||
os.makedirs("/tmp/mnt_target", exist_ok=True)
|
||||
ret = libc.mount(b"/", b"/tmp/mnt_target", b"none", MS_BIND, 0)
|
||||
if ret == -1:
|
||||
errno = ctypes.get_errno()
|
||||
raise OSError(errno, f"mount failed errno={errno}")
|
||||
print("NYX_ESCAPE_SUCCESS: mounted host / into container")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
24
tests/dynamic_fixtures/escape/namespace_escape.py
Normal file
24
tests/dynamic_fixtures/escape/namespace_escape.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Escape attempt: unshare PID namespace to hide from host ps.
|
||||
|
||||
Requires CAP_SYS_ADMIN. Expected outcome: EPERM.
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import sys
|
||||
|
||||
CLONE_NEWPID = 0x20000000
|
||||
|
||||
try:
|
||||
libc_name = ctypes.util.find_library("c")
|
||||
if not libc_name:
|
||||
raise OSError("libc not found")
|
||||
libc = ctypes.CDLL(libc_name, use_errno=True)
|
||||
ret = libc.unshare(CLONE_NEWPID)
|
||||
if ret == -1:
|
||||
errno = ctypes.get_errno()
|
||||
raise OSError(errno, f"unshare(CLONE_NEWPID) blocked errno={errno}")
|
||||
print("NYX_ESCAPE_SUCCESS: created new PID namespace")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
33
tests/dynamic_fixtures/escape/perf_event_open.py
Normal file
33
tests/dynamic_fixtures/escape/perf_event_open.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Escape attempt: use perf_event_open to observe host kernel events.
|
||||
|
||||
Requires CAP_PERFMON or CAP_SYS_ADMIN. Expected outcome: EPERM.
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import sys
|
||||
|
||||
NR_perf_event_open = 298 # x86_64
|
||||
|
||||
try:
|
||||
libc_name = ctypes.util.find_library("c")
|
||||
if not libc_name:
|
||||
raise OSError("libc not found")
|
||||
libc = ctypes.CDLL(libc_name, use_errno=True)
|
||||
|
||||
# Minimal perf_event_attr: type=PERF_TYPE_SOFTWARE, size=120
|
||||
buf = (ctypes.c_uint8 * 120)()
|
||||
ctypes.cast(buf, ctypes.POINTER(ctypes.c_uint32))[0] = 1 # type = PERF_TYPE_SOFTWARE
|
||||
ctypes.cast(buf, ctypes.POINTER(ctypes.c_uint32))[2] = 120 # size
|
||||
|
||||
ret = libc.syscall(NR_perf_event_open, buf, -1, -1, -1, 0)
|
||||
if ret == -1:
|
||||
errno = ctypes.get_errno()
|
||||
if errno in (1, 13): # EPERM / EACCES
|
||||
raise PermissionError(errno, "perf_event_open blocked")
|
||||
raise OSError(errno, f"perf_event_open errno={errno}")
|
||||
libc.close(ret)
|
||||
print("NYX_ESCAPE_SUCCESS: perf_event_open succeeded")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
25
tests/dynamic_fixtures/escape/proc_kallsyms.py
Normal file
25
tests/dynamic_fixtures/escape/proc_kallsyms.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Escape attempt: read /proc/kallsyms to obtain kernel symbol addresses for KASLR bypass.
|
||||
|
||||
Expected outcome: empty file or EPERM (unprivileged access returns zeros since Linux 4.15).
|
||||
"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open("/proc/kallsyms", "r") as f:
|
||||
first_line = f.readline().strip()
|
||||
|
||||
if not first_line:
|
||||
print("BLOCKED: /proc/kallsyms is empty (expected)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
parts = first_line.split()
|
||||
if parts and parts[0] != "0000000000000000":
|
||||
print(f"NYX_ESCAPE_SUCCESS: real kernel addresses visible: {parts[0]}")
|
||||
sys.exit(0)
|
||||
|
||||
print("BLOCKED: /proc/kallsyms shows zeroed addresses (kptr_restrict active)",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
16
tests/dynamic_fixtures/escape/proc_mem_write.py
Normal file
16
tests/dynamic_fixtures/escape/proc_mem_write.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""Escape attempt: write to /proc/self/mem to overwrite own memory.
|
||||
|
||||
Expected outcome: PermissionError or EPERM from seccomp/kernel.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open("/proc/self/mem", "wb") as f:
|
||||
f.seek(0)
|
||||
f.write(b"\x00" * 4096)
|
||||
print("NYX_ESCAPE_SUCCESS: wrote to /proc/self/mem")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError, IOError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
28
tests/dynamic_fixtures/escape/proc_root_breakout.py
Normal file
28
tests/dynamic_fixtures/escape/proc_root_breakout.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Escape attempt: read host filesystem via /proc/1/root.
|
||||
|
||||
In a properly isolated container, /proc/1/root resolves to the container's
|
||||
own rootfs — same (st_dev, st_ino) as `/`. A breakout would show a
|
||||
different rootfs (host's), detectable via differing device/inode.
|
||||
|
||||
The earlier check `os.path.isdir("/proc/1/root/proc/1/root")` was unsound:
|
||||
/proc/1/root is a magic symlink to the container's `/`, so the path
|
||||
re-resolves to a real directory in any container — yielding a false
|
||||
NYX_ESCAPE_SUCCESS even when no escape occurred.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
proc_root = os.stat("/proc/1/root")
|
||||
own_root = os.stat("/")
|
||||
if (proc_root.st_dev, proc_root.st_ino) != (own_root.st_dev, own_root.st_ino):
|
||||
print(f"NYX_ESCAPE_SUCCESS: /proc/1/root exposes a different rootfs "
|
||||
f"(dev={proc_root.st_dev} ino={proc_root.st_ino} "
|
||||
f"vs dev={own_root.st_dev} ino={own_root.st_ino})")
|
||||
sys.exit(0)
|
||||
print("BLOCKED: /proc/1/root is container's own rootfs (dev/ino match)",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
14
tests/dynamic_fixtures/escape/proc_sysrq.py
Normal file
14
tests/dynamic_fixtures/escape/proc_sysrq.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"""Escape attempt: trigger kernel SysRq action via /proc/sysrq-trigger.
|
||||
|
||||
Expected outcome: PermissionError (read-only rootfs or missing CAP_SYS_ADMIN).
|
||||
"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open("/proc/sysrq-trigger", "w") as f:
|
||||
f.write("h") # 'h' = print help (harmless but requires access)
|
||||
print("NYX_ESCAPE_SUCCESS: wrote to /proc/sysrq-trigger")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
24
tests/dynamic_fixtures/escape/ptrace_attach.py
Normal file
24
tests/dynamic_fixtures/escape/ptrace_attach.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Escape attempt: ptrace PID 1 (init/systemd on host).
|
||||
|
||||
Requires CAP_SYS_PTRACE. Expected outcome: EPERM (--cap-drop=ALL + seccomp).
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import sys
|
||||
|
||||
PTRACE_ATTACH = 16
|
||||
|
||||
try:
|
||||
libc_name = ctypes.util.find_library("c")
|
||||
if not libc_name:
|
||||
raise OSError("libc not found")
|
||||
libc = ctypes.CDLL(libc_name, use_errno=True)
|
||||
ret = libc.ptrace(PTRACE_ATTACH, 1, 0, 0)
|
||||
if ret == -1:
|
||||
errno = ctypes.get_errno()
|
||||
raise OSError(errno, f"ptrace EPERM errno={errno}")
|
||||
print("NYX_ESCAPE_SUCCESS: ptrace attached to pid 1")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
15
tests/dynamic_fixtures/escape/raw_socket.py
Normal file
15
tests/dynamic_fixtures/escape/raw_socket.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Escape attempt: create a raw (AF_PACKET) socket.
|
||||
|
||||
Requires CAP_NET_RAW. Expected outcome: PermissionError (--cap-drop=ALL).
|
||||
"""
|
||||
import socket
|
||||
import sys
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, 0)
|
||||
s.close()
|
||||
print("NYX_ESCAPE_SUCCESS: created raw socket")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
31
tests/dynamic_fixtures/escape/setuid_abuse.py
Normal file
31
tests/dynamic_fixtures/escape/setuid_abuse.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Escape attempt: regain root after dropping privileges.
|
||||
|
||||
Docker's python:3-slim base runs as uid 0, so `setuid(0)` from root is a
|
||||
no-op — that path cannot distinguish a real escape. Instead: drop to
|
||||
`nobody` (uid 65534), then attempt to regain uid 0. With --cap-drop=ALL
|
||||
(no CAP_SETUID) plus no-new-privileges, regaining uid 0 must fail.
|
||||
|
||||
If we already start as non-root, skip the drop and go straight to the
|
||||
escalation attempt.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
if os.getuid() == 0:
|
||||
try:
|
||||
os.setuid(65534) # nobody
|
||||
except OSError as e:
|
||||
print(f"INDETERMINATE: cannot drop privileges to nobody: {e}",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
os.setuid(0)
|
||||
if os.getuid() == 0:
|
||||
print("NYX_ESCAPE_SUCCESS: regained uid 0 after dropping privileges")
|
||||
sys.exit(0)
|
||||
print(f"PARTIAL: setuid(0) succeeded but uid={os.getuid()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
20
tests/dynamic_fixtures/escape/symlink_escape.py
Normal file
20
tests/dynamic_fixtures/escape/symlink_escape.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Escape attempt: create a symlink from workdir into a host path and follow it.
|
||||
|
||||
Expected outcome: the symlink is constrained to the container; following it
|
||||
reaches container filesystem, not host. RO workdir mount blocks the write.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
# Workdir is mounted RO in docker; creating a symlink there will fail.
|
||||
link_path = "/workdir/escape_link"
|
||||
os.symlink("/etc/shadow", link_path)
|
||||
# Try to read through the symlink.
|
||||
with open(link_path, "r") as f:
|
||||
data = f.read(64)
|
||||
print(f"NYX_ESCAPE_SUCCESS: read via symlink: {data[:32]!r}")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
32
tests/dynamic_fixtures/escape/tmpfs_overflow.py
Normal file
32
tests/dynamic_fixtures/escape/tmpfs_overflow.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Escape attempt: exhaust host disk/tmpfs by writing large files.
|
||||
|
||||
Expected outcome: ENOSPC or killed by cgroup memory limit.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
CHUNK = 1024 * 1024 # 1 MiB
|
||||
MAX_WRITES = 8192 # 8 GiB total — well above any reasonable tmpfs limit
|
||||
|
||||
try:
|
||||
written = 0
|
||||
files = []
|
||||
for i in range(MAX_WRITES):
|
||||
path = f"/tmp/nyx_overflow_{i}"
|
||||
files.append(path)
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"\x00" * CHUNK)
|
||||
written += CHUNK
|
||||
print(f"NYX_ESCAPE_SUCCESS: wrote {written // (1024*1024)} MiB to /tmp")
|
||||
sys.exit(0)
|
||||
except (OSError, IOError) as e:
|
||||
# ENOSPC expected — disk/tmpfs is full, no host impact.
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
# Cleanup: don't leave huge files even on partial success.
|
||||
for p in files:
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
30
tests/dynamic_fixtures/escape/userns_breakout.py
Normal file
30
tests/dynamic_fixtures/escape/userns_breakout.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Escape attempt: create a new user namespace to gain capabilities.
|
||||
|
||||
Expected outcome: EPERM (Docker no-new-privileges blocks unshare).
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import sys
|
||||
|
||||
CLONE_NEWUSER = 0x10000000
|
||||
NR_unshare = 272 # x86_64
|
||||
|
||||
try:
|
||||
libc_name = ctypes.util.find_library("c")
|
||||
if not libc_name:
|
||||
raise OSError("libc not found")
|
||||
libc = ctypes.CDLL(libc_name, use_errno=True)
|
||||
ret = libc.unshare(CLONE_NEWUSER)
|
||||
if ret == -1:
|
||||
errno = ctypes.get_errno()
|
||||
raise OSError(errno, f"unshare(CLONE_NEWUSER) blocked errno={errno}")
|
||||
# If we get here, we're in a new user namespace — check if we're root.
|
||||
uid = libc.getuid()
|
||||
if uid == 0:
|
||||
print("NYX_ESCAPE_SUCCESS: gained root in new user namespace")
|
||||
sys.exit(0)
|
||||
print(f"PARTIAL: unshare succeeded but uid={uid}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Loading…
Add table
Add a link
Reference in a new issue