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.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:
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).
Description
Summary
runsc/specutils/seccomp/seccomp.go's conversion of an OCILinuxSeccompprofile intoa BPF program reads a rule's
Names,Action, andArgs, but never readsLinuxSeccompRule.ErrnoRet(norLinuxSeccomp.DefaultErrnoRet). EverySCMP_ACT_ERRNOresult is hardcoded toEPERM:convertRules()iteratess.Syscallsand reads onlysyscall.Action,syscall.Args, andsyscall.Names—syscall.ErrnoRetis never dereferenced anywherein the package. This is not a recent regression: the
errnoAction/traceActionconstants 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 applicationinstalls on itself via
seccomp(SECCOMP_SET_MODE_FILTER, ...)— confirmed againstpkg/sentry/kernel/seccomp.go:and exercised by the existing
SeccompTest.RetErrnoReturnsErrnotest, which asserts anon-EPERM errno (
ENOTNAM) round-trips correctly for a self-installed filter. The gapis 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
clone3first inpthread_create(and other process/thread creationpaths) and falls back to legacy
clone()only whenclone3fails withENOSYSspecifically — any other errno is treated as a hard, unrecoverable failure
(
sysdeps/unix/sysv/linux/clone-internal.c). Becauseclone3's single argument is anopaque pointer to
struct clone_args, classic seccomp-BPF cannot filter it by flag theway
clone()'s scalarflagsargument can be filtered — so any OCI seccomp profilethat needs to block
clone3(CLONE_NEWUSER)specifically (while leaving ordinarythread/process creation working) has no choice but to block the syscall outright and
rely on
ENOSYSto preserve the glibc fallback onto the still-filterableclone()call.
This is exactly the fix Docker, containerd, and runc adopted when glibc 2.34 shipped
(runc's
libcontainer/seccompdoes honor per-ruleerrnoRet:libseccomp.ActErrno.SetReturnCode(int16(*errnoRet))), and it is the standard,widely-documented resolution for this entire class of compatibility issue. Because
runscsilently substitutesEPERMregardless of what the OCI profile actuallyrequests, any OCI seccomp profile written to follow this exact, standard pattern is
silently broken under
runsc— the profile author'serrnoRet: 38(or any otherrequested 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 threadinstead.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 arunscsandbox with this profile applied via--oci-seccomp. Expected:clone3failswith
ENOSYS(errno 38), glibc falls back toclone(), the thread is createdsuccessfully. Actual:
clone3fails withEPERM(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'sErrnoRetthrough toCode()instead of alwaysusing the package-level
errnoActionconstant, mirroring runc's own handling:with the analogous change for
BuildProgram's handling ofs.DefaultAction/s.DefaultErrnoRet. This looks like a small, low-risk, additive change — existingprofiles that don't set
errnoRetare unaffected (falls through to today'sEPERMdefault), and the
Code()API this would call already exists and is already exercisedby
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[].ErrnoRetfield, which is part of the runtime-spec thatrunscalready 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
confirmed by reading
runsc/specutils/seccomp/seccomp.goonmasterthat thebehavior is unchanged there as of this report.
clone3is fully implemented in the Sentry's syscall table on this andlater releases (not returning a native
ENOSYS, which would otherwise have maskedthis gap) — this appears to have been the case since
21d66119b(release-20230814.0).