`runsc`'s OCI seccomp converter silently discards per-rule `errnoRet`, breaking the standard clone3/glibc ENOSYS-fallback pattern
- Dominant language
- Go
- Stars
- 19.3k
- Forks
- 2k
- Avg merge
- 3d 5h
- Merged PRs (30d)
- 264
Description
### Description
## Summary
`runsc/specutils/seccomp/seccomp.go`'s conversion of an OCI `LinuxSeccomp` profile into
a BPF program reads a rule's `Names`, `Action`, and `Args`, but never reads
`LinuxSeccompRule.ErrnoRet` (nor `LinuxSeccomp.DefaultErrnoRet`). Every
`SCMP_ACT_ERRNO` result is hardcoded to `EPERM`:
```go
// runsc/specutils/seccomp/seccomp.go
var (
killThreadAction = seccomp.KillThread
trapAction = seccomp.Trap
// runc always returns EPERM as the errorcode for SECCOMP_RET_ERRNO
errnoAction = seccomp.ReturnError.Code(uint16(unix.EPERM))
// runc always returns EPERM as the errorcode for SECCOMP_RET_TRACE
traceAction = seccomp.Trace.Code(uint16(unix.EPERM))
allowAction = seccomp.Allow
)
func convertAction(act specs.LinuxSeccompAction) (seccomp.Action, error) {
switch act {
...
case specs.ActErrno:
return errnoAction, nil // always EPERM — errnoRet is never consulted
...
}
```
`convertRules()` iterates `s.Syscalls` and reads only `syscall.Action`,
`syscall.Args`, and `syscall.Names` — `syscall.ErrnoRet` is never dereferenced anywhere
in the package. This is not a recent regression: the `errnoAction`/`traceAction`
constants date to the original commit adding OCI seccomp support
(`dcd532e2e`, 2020-09-16) and have never read a custom errno.
The mechanism to do this correctly already exists and works — `SetReturnCode`/`.Code()`
takes any `uint16`, and the Sentry honors an arbitrary errno for a filter an application
installs on itself via `seccomp(SECCOMP_SET_MODE_FILTER, ...)` — confirmed against
`pkg/sentry/kernel/seccomp.go`:
```go
case linux.SECCOMP_RET_ERRNO:
// "Results in the lower 16-bits of the return value being passed to
// userland as the errno without executing the system call."
t.Arch().SetReturn(-uintptr(result.Data()))
```
and exercised by the existing `SeccompTest.RetErrnoReturnsErrno` test, which asserts a
non-EPERM errno (`ENOTNAM`) round-trips correctly for a self-installed filter. The gap
is specifically in the OCI-spec → BPF *converter*, not in the Sentry's own seccomp
enforcement.
## Why this matters: it breaks the standard clone3/glibc compatibility pattern
glibc ≥ 2.34 tries `clone3` first in `pthread_create` (and other process/thread creation
paths) and falls back to legacy `clone()` **only** when `clone3` fails with `ENOSYS`
specifically — any other errno is treated as a hard, unrecoverable failure
(`sysdeps/unix/sysv/linux/clone-internal.c`). Because `clone3`'s single argument is an
opaque pointer to `struct clone_args`, classic seccomp-BPF cannot filter it by flag the
way `clone()`'s scalar `flags` argument can be filtered — so any OCI seccomp profile
that needs to block `clone3(CLONE_NEWUSER)` specifically (while leaving ordinary
thread/process creation working) has no choice but to block the syscall outright and
rely on `ENOSYS` to preserve the glibc fallback onto the still-filterable `clone()`
call.
This is exactly the fix Docker, containerd, and runc adopted when glibc 2.34 shipped
(runc's `libcontainer/seccomp` does honor per-rule `errnoRet`:
`libseccomp.ActErrno.SetReturnCode(int16(*errnoRet))`), and it is the standard,
widely-documented resolution for this entire class of compatibility issue. Because
`runsc` silently substitutes `EPERM` regardless of what the OCI profile actually
requests, any OCI seccomp profile written to follow this exact, standard pattern is
silently broken under `runsc` — the profile author's `errnoRet: 38` (or any other
requested errno) is accepted without error and then discarded, with no signal that
anything went wrong until a modern-glibc guest tries to create a thread and gets
`RuntimeError: can't start new thread` instead.
## Minimal reproducer
```json
{
"defaultAction": "SCMP_ACT_ALLOW",
"architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_AARCH64"],
"syscalls": [
{"names": ["clone3"], "action": "SCMP_ACT_ERRNO", "errnoRet": 38}
]
}
```
Run any glibc ≥ 2.34 binary that creates a thread (e.g.
`python3 -c "import threading; threading.Thread(target=lambda: None).start()"`) inside a
`runsc` sandbox with this profile applied via `--oci-seccomp`. Expected: `clone3` fails
with `ENOSYS` (errno 38), glibc falls back to `clone()`, the thread is created
successfully. Actual: `clone3` fails with `EPERM` (errno 1), which glibc treats as fatal;
thread creation fails.
(We can supply a full, runnable bundle/config.json if useful — happy to attach one.)
## Suggested fix
In `convertAction`, thread the rule's `ErrnoRet` through to `Code()` instead of always
using the package-level `errnoAction` constant, mirroring runc's own handling:
```go
func convertAction(act specs.LinuxSeccompAction, errnoRet *uint) (seccomp.Action, error) {
switch act {
...
case specs.ActErrno:
if errnoRet != nil {
return seccomp.ReturnError.Code(uint16(*errnoRet)), nil
}
return errnoAction, nil // default EPERM, unchanged
...
}
```
with the analogous change for `BuildProgram`'s handling of `s.DefaultAction` /
`s.DefaultErrnoRet`. This looks like a small, low-risk, additive change — existing
profiles that don't set `errnoRet` are unaffected (falls through to today's `EPERM`
default), and the `Code()` API this would call already exists and is already exercised
by `SeccompTest.RetErrnoReturnsErrno`.
## Relationship to existing issues
We found #12557 ("Support returning ENOSYS in seccomp rules") open and describing a
closely related problem — but that issue is specifically about gVisor's *own*
host-facing seccomp filters (the filter gVisor installs on itself), not the OCI-profile
path a sandboxed *application* runs under. This report is about the latter: the OCI
`LinuxSeccomp.Syscalls[].ErrnoRet` field, which is part of the runtime-spec that `runsc`
already accepts and validates the shape of, but doesn't act on. We think these are
related but distinct gaps, and #12557's fix would not, on its own, resolve this one.
## Environment
- Reproduced against gVisor release-20260721.0 (the version we happened to have pinned);
confirmed by reading `runsc/specutils/seccomp/seccomp.go` on `master` that the
behavior is unchanged there as of this report.
- Confirmed `clone3` is fully implemented in the Sentry's syscall table on this and
later releases (not returning a native `ENOSYS`, which would otherwise have masked
this gap) — this appears to have been the case since `21d66119b`
(release-20230814.0).
Contributor guide
Research direction
Start in runsc/specutils/seccomp/seccomp.go, reading convertAction, convertRules, and BuildProgram to trace how ErrnoRet and DefaultErrnoRet are handled. Use SeccompTest.RetErrnoReturnsErrno as the existing errno reference. Done means OCI SCMP_ACT_ERRNO rules preserve requested errno values while profiles without errnoRet retain EPERM behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, linux
- Domain
- operating-systems, security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100