#14688·gvisor

`runsc`'s OCI seccomp converter silently discards per-rule `errnoRet`, breaking the standard clone3/glibc ENOSYS-fallback pattern

Author: ghaskinsCreated Sep 10, 2026Updated Sep 12, 2026
Labelstype: bug

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.Namessyscall.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).