Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ guarantees; **no release should be treated as a hardened security boundary**.
- `scripts/benchmark.py` for reproducible spawn/round-trip measurements.
- `pyisolate[operator]` optional-dependency group for the Kubernetes operator.

### Fixed
- eBPF programs are now built in a shape the kernel can load. They were
compiled without `-g`, so the objects carried no BTF and libbpf could not
parse their BTF-defined `.maps` sections; every `lsm/*` handler declared
typed parameters without libbpf's `BPF_PROG` wrapper, so it read `r2` --
a register an LSM program's caller never sets -- which the verifier rejects;
and `resource_guard.bpf.c` declared two `struct { int dummy; }` placeholders
in `.maps`, which are not map definitions libbpf can parse. Handlers now
unpack the LSM context array by index, `socket_connect` copies `sa_family`
with `bpf_probe_read_kernel` rather than dereferencing an untyped kernel
pointer, and the placeholder maps and their dead no-op programs are gone.

### Changed
- CI covers CPython 3.14: the unit matrix gains `3.14`, and a new
`sub-interpreter cells / py3.14t` job runs the sub-interpreter backend on a
Expand All @@ -48,6 +60,9 @@ guarantees; **no release should be treated as a hardened security boundary**.

### Known gaps
- The broker `request` op is surfaced but not yet executed end-to-end.
- The eBPF programs compile to loadable objects and are covered by ELF-level
tests, but load/attach against a live verifier is still only exercised by
the root-gated `PYISOLATE_LIVE_BPF_TESTS=1` tests, not by CI.
- A running sub-interpreter cell cannot be reclaimed: one that overruns its
wall-time deadline is abandoned, and its thread stays pinned until the
process exits. Cells enforce a wall-time deadline and no other quota;
Expand Down
48 changes: 18 additions & 30 deletions pyisolate/bpf/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,21 @@ def _run(self, cmd: list[str], *, raise_on_error: bool = False) -> bool:
) from exc
return False

#: ``-g`` is not optional. All three programs use BTF-defined maps (the
#: ``struct { ... } name SEC(".maps")`` idiom), which are described entirely
#: by their BTF type -- libbpf cannot parse the ``.maps`` section of an
#: object built without it -- and LSM programs additionally need BTF to
#: resolve their attach target. An object compiled without ``-g`` fails to
#: load on every kernel, so the flag belongs in one place rather than in
#: three copies of the command.
COMPILE_FLAGS: tuple[str, ...] = ("-target", "bpf", "-g", "-O2")

@classmethod
def _compile_command(cls, source: Path, obj: Path) -> list[str]:
"""Return the ``clang`` invocation that builds *source* into *obj*."""

return ["clang", *cls.COMPILE_FLAGS, "-c", str(source), "-o", str(obj)]

def load(
self,
*,
Expand Down Expand Up @@ -152,36 +167,9 @@ def load(

strict_mode = mode == "hardened"

dummy_compile = [
"clang",
"-target",
"bpf",
"-O2",
"-c",
str(self._src),
"-o",
str(self._obj),
]
filter_compile = [
"clang",
"-target",
"bpf",
"-O2",
"-c",
str(self._filter_src),
"-o",
str(self._filter_obj),
]
guard_compile = [
"clang",
"-target",
"bpf",
"-O2",
"-c",
str(self._guard_src),
"-o",
str(self._guard_obj),
]
dummy_compile = self._compile_command(self._src, self._obj)
filter_compile = self._compile_command(self._filter_src, self._filter_obj)
guard_compile = self._compile_command(self._guard_src, self._guard_obj)
ok = True
compile_cmd = dummy_compile
if self._src not in self._skel_cache or (
Expand Down
71 changes: 5 additions & 66 deletions pyisolate/bpf/resource_guard.bpf.c
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ struct __sk_buff {
__u32 len;
};

/* Resource guard events are consumed by pyisolate.watchdog.ResourceWatchdog.
* The supervisor resolves cgroup_id/name to a sandbox and performs the
* userspace kill/quarantine path immediately; Python tracemalloc accounting is
* diagnostic only and is not used as the security decision point.
*/
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 22);
Expand All @@ -71,46 +76,6 @@ struct {
__type(key, __u64);
__type(value, struct resource_account);
} cgroup_accounting SEC(".maps");
/* Resource guard event consumed by pyisolate.watchdog.ResourceWatchdog.
* The supervisor resolves cgroup_id/name to a SandboxThread and performs the
* userspace kill/quarantine path immediately; Python tracemalloc accounting is
* diagnostic only and is not used as the security decision point.
*/
enum breach_reason {
BREACH_CPU = 1,
BREACH_RSS = 2,
};

struct quota_t {
unsigned long cpu_quota_ns;
unsigned long rss_quota_bytes;
};

struct usage_t {
unsigned long cpu_time_ns;
unsigned long rss_bytes;
};

struct event_t {
unsigned long cgroup_id;
unsigned long cpu_time_ns;
unsigned long rss_bytes;
unsigned int reason;
};

/* Map placeholders. The production CO-RE object uses BPF_MAP_TYPE_HASH for
* quota/usage keyed by cgroup id and BPF_MAP_TYPE_RINGBUF for events. Keeping
* the declarations header-free preserves the lightweight test build while
* documenting the kernel/userspace contract.
*/
struct {
int dummy;
} quotas SEC(".maps");

struct {
int dummy;
} usage SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 16384);
Expand Down Expand Up @@ -200,32 +165,6 @@ int account_sched_switch(struct sched_switch_args *ctx)
return 0;
}

static __inline int emit_breach(unsigned long cgroup_id,
unsigned long cpu_time_ns,
unsigned long rss_bytes,
unsigned int reason)
{
/* Real implementation reserves event_t on the ring buffer and submits it.
* Tests inject equivalent dictionaries through BPFManager.open_ring_buffer.
*/
(void)cgroup_id;
(void)cpu_time_ns;
(void)rss_bytes;
(void)reason;
return 0;
}

SEC("perf_event")
int on_cpu(void *ctx)
{
/* Production path increments per-cgroup CPU usage, compares it to
* quota_t.cpu_quota_ns, and emits BREACH_CPU before userspace can rely on
* guest cooperation.
*/
(void)ctx;
return emit_breach(0, 0, 0, BREACH_CPU);
}

SEC("tracepoint/exceptions/page_fault_user")
int account_user_page_fault(struct page_fault_args *ctx)
{
Expand Down
97 changes: 74 additions & 23 deletions pyisolate/bpf/syscall_filter.bpf.c
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,27 @@
* Every decision is keyed by bpf_get_current_cgroup_id(), so enforcement follows
* the sandbox cgroup even when guest code bypasses Python wrappers and performs
* syscalls directly through libc or native extensions.
*
* Calling convention
* ------------------
* A BPF LSM program is called with ONE argument: a pointer to an array of
* u64 holding the hook's arguments, with the previous LSM's return value
* appended at index <number of hook args>. libbpf's BPF_PROG macro hides this
* by generating a wrapper that unpacks the array into typed parameters; this
* file is built without libbpf headers, so each handler takes the context
* array directly and unpacks it by index. Declaring the typed parameters
* without that wrapper does NOT work: the second parameter reads r2, which an
* LSM program never sets, and the verifier rejects the program with
* "R2 !read_ok" before it can be attached.
*
* Kernel pointers in the context array are opaque here for the same reason.
* Direct loads through a kernel pointer are only allowed for BTF-typed
* pointers (i.e. with vmlinux.h), so fields are read with
* bpf_probe_read_kernel instead of being dereferenced.
*/

typedef unsigned char __u8;
typedef unsigned short __u16;
typedef unsigned int __u32;
typedef unsigned long long __u64;

Expand Down Expand Up @@ -39,12 +57,18 @@ typedef unsigned long long __u64;
#define __uint(name, val) int (*name)[val]
#define __type(name, val) val *name

union bpf_attr;

struct sockaddr {
unsigned short sa_family;
char sa_data[14];
};
/* Offset of the previous LSM decision within the context array, which equals
* the arity of the hook. Naming them keeps each handler's indexing checkable
* against include/linux/lsm_hook_defs.h. */
#define PYI_RET_file_open 1 /* (struct file *file) */
#define PYI_RET_file_truncate 1 /* (struct file *file) */
#define PYI_RET_socket_create 4 /* (family, type, protocol, kern) */
#define PYI_RET_socket_connect 3 /* (struct socket *, struct sockaddr *, int) */
#define PYI_RET_task_alloc 2 /* (struct task_struct *, unsigned long) */
#define PYI_RET_bprm_check_security 1 /* (struct linux_binprm *bprm) */
#define PYI_RET_ptrace_access_check 2 /* (struct task_struct *child, unsigned int mode) */
#define PYI_RET_sb_mount 5 /* (dev_name, path, type, flags, data) */
#define PYI_RET_bpf 3 /* (int cmd, union bpf_attr *attr, unsigned int size) */

struct pyisolate_policy {
__u32 deny_mask;
Expand Down Expand Up @@ -88,6 +112,7 @@ static void *(*bpf_map_lookup_elem)(void *map, const void *key) = (void *)1;
static long (*bpf_ringbuf_output)(void *ringbuf, void *data, __u64 size, __u64 flags) = (void *)130;
static __u64 (*bpf_get_current_cgroup_id)(void) = (void *)80;
static __u64 (*bpf_get_current_pid_tgid)(void) = (void *)14;
static long (*bpf_probe_read_kernel)(void *dst, __u32 size, const void *src) = (void *)113;

static __u32 policy_mask_for_op(__u32 op)
{
Expand Down Expand Up @@ -133,83 +158,109 @@ static int pyisolate_check(__u32 op, __u32 aux)
}

SEC("lsm/file_open")
int BPF_PROG_filter_file_open(void *file, int ret)
int filter_file_open(__u64 *ctx)
{
int ret = (int)ctx[PYI_RET_file_open];

if (ret)
return ret;
return pyisolate_check(PYI_OP_FILE_OPEN, 0);
}

SEC("lsm/file_truncate")
int BPF_PROG_filter_file_truncate(void *file, int ret)
int filter_file_truncate(__u64 *ctx)
{
int ret = (int)ctx[PYI_RET_file_truncate];

if (ret)
return ret;
return pyisolate_check(PYI_OP_FILE_TRUNCATE, 0);
}

SEC("lsm/socket_create")
int BPF_PROG_filter_socket_create(int family, int type, int protocol, int kern, int ret)
int filter_socket_create(__u64 *ctx)
{
int ret = (int)ctx[PYI_RET_socket_create];
__u32 family = (__u32)ctx[0];

if (ret)
return ret;
if (family == AF_INET || family == AF_INET6)
return pyisolate_check(PYI_OP_SOCKET_CREATE, (__u32)family);
return pyisolate_check(PYI_OP_SOCKET_CREATE, family);
return 0;
}

SEC("lsm/socket_connect")
int BPF_PROG_filter_socket_connect(void *sock, struct sockaddr *address, int addrlen, int ret)
int filter_socket_connect(__u64 *ctx)
{
int ret = (int)ctx[PYI_RET_socket_connect];
const void *address = (const void *)ctx[1];
__u16 family = 0;

if (ret)
return ret;
if (address && (address->sa_family == AF_INET || address->sa_family == AF_INET6))
return pyisolate_check(PYI_OP_SOCKET_CONNECT, (__u32)address->sa_family);
if (!address)
return 0;
/* sa_family is the first field of struct sockaddr. The pointer is not
* BTF-typed here, so it has to be copied rather than dereferenced. */
if (bpf_probe_read_kernel(&family, sizeof(family), address) != 0)
return 0;
if (family == AF_INET || family == AF_INET6)
return pyisolate_check(PYI_OP_SOCKET_CONNECT, family);
return 0;
}

SEC("lsm/task_alloc")
int BPF_PROG_filter_task_alloc(void *task, unsigned long clone_flags, int ret)
int filter_task_alloc(__u64 *ctx)
{
int ret = (int)ctx[PYI_RET_task_alloc];

if (ret)
return ret;
return pyisolate_check(PYI_OP_TASK_ALLOC, 0);
}

SEC("lsm/bprm_check_security")
int BPF_PROG_filter_exec(void *bprm, int ret)
int filter_exec(__u64 *ctx)
{
int ret = (int)ctx[PYI_RET_bprm_check_security];

if (ret)
return ret;
return pyisolate_check(PYI_OP_EXEC, 0);
}

SEC("lsm/ptrace_access_check")
int BPF_PROG_filter_ptrace(void *child, unsigned int mode, int ret)
int filter_ptrace(__u64 *ctx)
{
int ret = (int)ctx[PYI_RET_ptrace_access_check];
__u32 mode = (__u32)ctx[1];

if (ret)
return ret;
return pyisolate_check(PYI_OP_PTRACE, mode);
}

SEC("lsm/sb_mount")
/* BPF programs receive at most five register arguments, so the opaque ``data``
* blob of the sb_mount hook is omitted here; the filter only needs the prior
* LSM decision (``ret``) and denies all mounts regardless of arguments. */
int BPF_PROG_filter_mount(const char *dev_name, const void *path, const char *type,
unsigned long flags, int ret)
int filter_mount(__u64 *ctx)
{
int ret = (int)ctx[PYI_RET_sb_mount];

if (ret)
return ret;
/* Denies all mounts regardless of arguments, so none are unpacked. */
return pyisolate_check(PYI_OP_MOUNT, 0);
}

SEC("lsm/bpf")
int BPF_PROG_filter_bpf(int cmd, union bpf_attr *attr, unsigned int size, int ret)
int filter_bpf(__u64 *ctx)
{
int ret = (int)ctx[PYI_RET_bpf];
__u32 cmd = (__u32)ctx[0];

if (ret)
return ret;
return pyisolate_check(PYI_OP_BPF, (__u32)cmd);
return pyisolate_check(PYI_OP_BPF, cmd);
}

char _license[] SEC("license") = "GPL";
Loading
Loading