Skip to content

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

Description

@ghaskins

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:

// 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:

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

{
  "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:

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions