LiSa-LLM threat model¶
What is protected, and from what¶
The main attacker this design assumes is a malicious model file: a crafted GGUF that exploits a memory-safety bug in the model parser or tensor code and gets code execution in the process that loads it. The sandbox's goal is that such code can do nothing useful: no host files, no network, no other processes, no persistence, no privilege escalation.
The second attacker is a network client sending hostile prompts or requests: - Validation: the broker checks every request (types, ranges, sizes, a 1 MiB body limit) before anything reaches the model. The request decoder is fuzzed. - Prompts: they are untrusted data that never become control tokens. Raw prompts are tokenized with special-token parsing off, and chat messages that spell a special token are rejected. - Authentication: API keys are held only as SHA-256 digests and compared in constant time. Off loopback, the server refuses placeholder or short keys. - Exhaustion: clients can't exhaust the broker. There is a bounded first-come, first-served queue (429 when full), per-key token-bucket rate limits, read, write and keep-alive timeouts, a capped thread pool and a cap on pending connections. - TLS: forward-secret AEAD suites only on TLS 1.2, optional TLS 1.3 minimum, optional client certificates (mTLS), HSTS.
The third case is a compromised worker, the expected result of a successful model exploit. The broker treats everything the worker sends as untrusted: - Framing: frames are size-checked (16 MiB) and parsed as JSON. Requests are multiplexed: every reply must carry the id of a request the broker sent, or the worker is killed. - Wrong shapes: a message of the wrong shape counts as a protocol violation. The worker is killed and restarted rather than letting the channel fall out of step. - Testing: this decoder is fuzzed too, and a test runs a deliberately hostile worker. - Output: the worker can still return arbitrary text, which the broker forwards to the client as model output.
Architecture¶
network (HTTP/TLS, API key)
│
┌─────────────────▼─────────────────┐ trusted: never parses the model
│ broker (lisa) │ opens the model fd, verifies SHA-256,
│ OpenAI API, validation, auth │ restarts crashed workers
└─────────────────┬─────────────────┘
socketpair (length-prefixed JSON, 16 MiB frame limit) + model fd
┌─────────────────▼─────────────────┐
│ reaper (outside the PID ns) │ seccomp: wait4/exit only
└─────────────────┬─────────────────┘
┌─────────────────▼─────────────────┐ untrusted input: parses the GGUF
│ worker (PID 1 of its namespaces) │
└───────────────────────────────────┘
The worker is started by re-executing the server binary (/proc/self/exe --sandbox-worker)
with exactly two descriptors (the IPC socket on fd 3, the model on fd 4) and an empty
environment. It locks itself down before reading the model.
Worker isolation layers¶
| Layer | What it does |
|---|---|
| User namespace | Runs as "root" only inside its own namespace, mapped to the broker's unprivileged UID. |
Mount namespace + pivot_root |
The root is a 16 KiB read-only tmpfs. It contains only the model, bind-mounted read-only, nosuid/nodev/noexec. The old root is detached, and there is no /proc, /sys or /dev. |
| Model binding | The verified descriptor is reopened inside the namespace, and its inode must match. A path swapped after verification is refused. |
| Network namespace | No interfaces except a downed loopback. |
| PID, IPC, UTS, cgroup namespaces | Can't see or signal host processes. Own hostname, own SysV IPC. |
| Capabilities | Bounding, ambient, effective, permitted and inheritable sets are all empty. |
no_new_privs, non-dumpable |
setuid binaries don't help (none are reachable anyway). The process can't be ptraced by peers. |
| Landlock (kernel ≥ 5.13) | All filesystem access is denied except reading the model. TCP bind/connect is denied (ABI 4). Abstract Unix sockets and signals to processes outside the sandbox are denied (ABI 6). Best effort, depending on the kernel's ABI. |
| seccomp-bpf, stage 1 (load) | Allow-list for loading the model. Default action: kill the whole process. |
| seccomp-bpf, stage 2 (serve) | A narrower list once the model is loaded: computing, memory, futexes and the IPC socket. openat fails with EACCES. No socket, connect, execve, fork, mount, unshare, ptrace, kill or setuid. Threads only: clone3 returns ENOSYS so glibc falls back to clone, and clone is allowed only with CLONE_THREAD. |
| Resource limits | RLIMIT_DATA = memory_limit_bytes (worker heap), 64 open files, no core dumps. With sandbox_cgroup: memory.max, memory.swap.max=0, pids.max. |
| Lifetime | PR_SET_PDEATHSIG: the worker dies with the broker. The broker logs every worker death (a seccomp kill shows up as SIGSYS) and restarts it at most 3 times a minute. |
The allow-lists were derived from strace runs of real workloads: F32 and Q4_K models,
1–4 threads, streaming, cancellation and chat templates. The test suite runs escape probes
inside a live worker and expects every one to be killed by seccomp.
What it does not protect against¶
- Kernel vulnerabilities reachable through the allowed system calls. User namespaces themselves widen the kernel attack surface. That is why some distributions restrict them, and the sandbox needs them.
- Denial of service by the model: a malicious model can burn CPU or return wrong
tokens. Memory is capped (
RLIMIT_DATA, or a cgroup when configured), and CPU time is not. Network clients are rate-limited and queued, but a large volume of traffic from many addresses without keys still needs a front-end limiter. - The broker itself: it handles network input with cpp-httplib and is not sandboxed. Run it as an unprivileged user. The Docker image runs as UID 10001. Its own request decoding is fuzzed, and its memory safety is checked under ASan/UBSan in CI.
- Model confidentiality or integrity at rest:
model_sha256checks the file once at startup. Anyone who can write to the model file can change what gets loaded on the next start. - GPU inference: GPU drivers need device nodes and
ioctl, so sandboxed CUDA inference is refused at startup. Usesandbox_enabled: falsefor GPU offload, ideally inside a container.
Requirements and deployment notes¶
- Linux with unprivileged user namespaces.
- Ubuntu 23.10+ restricts them through AppArmor. Run
sysctl kernel.apparmor_restrict_unprivileged_userns=0or install an AppArmor profile forlisa. - In Docker, pass
--security-opt seccomp=unconfined --security-opt apparmor=unconfined. These only relax Docker's own profiles; LiSa installs a stricter filter in the worker. - Otherwise set
sandbox_enabled: false, which logs a warning. - If the sandbox can't be set up, the server refuses to start. It never silently runs unsandboxed.
sandbox_seccomp_log: truelogs blocked system calls (audit log) instead of killing the worker. Use it only to diagnose a kill on an unusual platform.