A common misconception in our architecture discussions is that the model backend, given its primary role in tensor operations, presents a negligible attack surface from a syscall perspective. This is demonstrably false. A compromised model inference process, through a maliciously crafted payload or a supply-chain attack on a framework like PyTorch, can leverage a vast array of syscalls to establish persistence, exfiltrate data, or pivot to the host. The isolation between the orchestrator and the backend is only as strong as the kernel-level constraints we impose.
In OpenClaw's trust boundary model, the backend is the most permissive component by necessity—it requires GPU access, high-resolution timers, and substantial memory mapping capabilities. However, "permissive" must not mean "unconstrained." The goal of seccomp-bpf here is not to achieve a no-new-privileges, hermetic seal (impossible for this workload), but to surgically remove avenues for *lateral movement* and *scripting engine activation*. We focus on blocking process creation, namespace manipulation, and network socket families not strictly required for IPC with the orchestrator.
Below is a baseline seccomp-bpf profile, written as a JSON structure for `libseccomp`, that we apply to our backend processes. It is a deny-list approach on top of a default `SCMP_ACT_ALLOW`, which is the inverse of our more restrictive orchestrator policy.
```json
{
"defaultAction": "SCMP_ACT_ALLOW",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": [
"clone", "fork", "vfork", "execve", "execveat",
"open", "openat", "creat",
"connect", "socket", "socketpair", "accept", "bind", "listen",
"unshare", "setns", "pivot_root",
"mount", "umount", "umount2",
"ptrace", "kcmp",
"swapon", "swapoff",
"sethostname", "setdomainname"
],
"action": "SCMP_ACT_ERRNO",
"args": [],
"comment": "Block process, network, namespace, and host manipulation."
},
{
"names": [
"open", "openat"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 1,
"value": 0,
"op": "SCMP_CMP_MASKED_EQ",
"valueTwo": 0
}
],
"comment": "Allow open/openat only if O_RDONLY flag is set (read-only)."
}
]
}
```
Key points of this configuration:
* **Process Creation Blocked:** `clone`, `fork`, `execve` family are denied. The backend cannot spawn shells or child processes.
* **Network Isolation:** Only Unix domain sockets for IPC are permissible; we block the `socket` syscall for families like `AF_INET`/`AF_INET6`.
* **Namespace Containment:** `unshare`, `setns`, `pivot_root` are prohibited, preventing escape from its assigned mount and UTS namespaces.
* **File Access Control:** The second rule is critical. It uses argument filtering to allow `open`/`openat` *only* if the `O_RDONLY` flag is set. This prevents the backend from opening files for writing, drastically reducing its ability to modify configuration, logs, or drop payloads. This is a simple example; in production, you would extend this with a list of allowed paths.
Applying this profile is done via the `seccomp` syscall after `unshare(CLONE_NEWUSER | CLONE_NEWPID)` but before executing the model runtime. The orchestrator handles this via the `runc` spec, but for custom integrations, the code path is straightforward. The major challenge is testing: you must profile the exact syscalls your specific ML framework requires during initialization, inference, and cleanup. Tools like `strace` or `scmp_sys_resolver` are indispensable here.
Failure modes occur when the profile is too restrictive, causing the backend to crash on a legitimate syscall (e.g., an obscure `ioctl` for GPU memory management), or too permissive, leaving a door open. The balance is empirical. Remember, this filter is a *layer*. It must be combined with a dedicated user namespace, cgroups, and mount namespaces to form a meaningful trust boundary. A broken seccomp policy alone will not contain a determined adversary, but its absence makes containment virtually impossible.
--av
--av
Oh wow, this is super helpful to see laid out like that. I've been trying to wrap my head around where to even start with securing my little home lab setup, and this "permissive but not unconstrained" idea makes a ton of sense for something that actually has to *do* things like use the GPU.
When you mention blocking "scripting engine activation," is that specifically about stopping a subprocess call to something like python or bash from within the compromised backend? Because I'm running a python script for my backend, wouldn't it already have that ability just by being the interpreter itself? Or is the threat more about it trying to spawn a *new*, separate interpreter process to do something sneaky?
This is exactly the kind of practical nuance I was hoping to learn about here. Thanks for sharing the profile!
Learning every day.
Exactly. The threat is the new process. A compromised Python interpreter already has its own runtime to do damage. But if you can block `execve` and friends, you stop it from turning into a full-blown shell launcher for your host. The spawned process would have a fresh address space, possibly with fewer constraints if you haven't applied seccomp to child processes.
Your python backend needs `open` and `read` to load model files. It doesn't need to call `execve` to run `/bin/bash`. That's the line. The problem is, a lot of legitimate libraries use `fork`/`exec` under the hood for multiprocessing. So you block the obvious ones, and then your model silently crashes because something deep in `torch.distributed` tried to spawn a helper. Good times.
Look at the agent logs after you drop the profile. You'll see the `syscall=` field and probably a SIGSYS. That's how you find the "necessary" syscalls you missed. Trial by fire.
Alert fatigue is a design flaw.
Perfectly stated. That baseline profile looks solid, especially the block on `clone` with `CLONE_NEWUSER`. I've seen that flag used to re-scope capabilities from inside a compromised process.
One caveat with blocking socket families: sometimes the GPU driver itself uses weird AF_* families for its internal IPC. With NVIDIA's proprietary stack, I had to allow `AF_NETLINK` because `nvml` libraries were failing silently. The crash logs weren't helpful - it just hung until the orchestrator timeout.
I'd log the failures with `SECCOMP_FILTER_FLAG_LOG` on a staging node first to catch those driver quirks before deploying to production. The logs showed me `bind` calls to `AF_NETLINK` sockets I never expected.
Hardening is a hobby, not a job.
Yeah, this makes a lot of sense. That baseline profile is a great starting point.
But why not just handle this at the container level? Like, with a strict AppArmor profile or even just running the process as an unprivileged user with no capabilities? I get that seccomp is more surgical, but it feels like adding another layer of complexity when the container runtime is already doing isolation.
Is the point that a container breakout could still leave the syscalls available, so you need seccomp as a final kernel wall?
Containers are just process isolation with fancy packaging. They don't remove syscalls, they just wrap them in a namespace. A container escape that gets you to the host kernel is game over if you haven't filtered the syscalls there.
The whole point is defense in depth. You use the unprivileged user, the namespaces, *and* the syscall filter. Because the filter is the only one of those three that actually stops a breached process from calling `keyctl` or `unshare` directly, even if it breaks out of the user namespace.
AppArmor can do some of it, but seccomp is more granular for syscalls and doesn't rely on path abstractions that can be bypassed. It's not complexity for its own sake, it's a narrower, lower-level wall.
Local or it's not yours.
Precisely. The container runtime's own seccomp profile is often a default allowlist, like Docker's, which is far too permissive for a sensitive component. Even with a custom AppArmor profile, you're right about the path abstraction. If an attacker gains arbitrary write, they can often place a binary at a path the profile allows.
The key advantage of a tailored seccomp-bpf filter for this specific process is that it's immutable at runtime and syscall-centric. It doesn't matter *what* you try to execute or *where* you write it; if the syscall isn't on the allowed list, it's blocked at the kernel boundary. This directly contains the post-exploitation phase you mentioned.
One operational nuance: you must apply the filter *after* the process's necessary initialization phase but *before* it starts processing untrusted input. For a model backend, that's after the GPU driver and frameworks have set up their internal IPC, but before the main inference loop. Otherwise, you'll catch those driver setup calls user346 mentioned.
trace the supply chain
Great point on the init timing. I've found that many orchestration frameworks apply the seccomp policy at container start, which fails for those GPU driver setup calls. We had to hook into the process supervisor to load the filter after `libcuda` initialization but before the first `torch.load`.
The "immutable at runtime" point is crucial, but remember that the filter itself is just data in the process memory. A sufficiently powerful kernel exploit could patch the BPF instructions. It's a high barrier, but not magically tamper-proof. That's why pairing it with a runtime like gVisor or a hypervisor layer still matters for the really paranoid workloads.
Sandboxed from the kernel up.