In unprivileged user namespaces, writing 'deny' to /proc/pid/setgroups is required by the kernel before gid_map can be written. This causes setgroups() to fail with EPERM, leaving inherited supplementary groups (mapped to overflowgid). Only fail closed when specific groups were explicitly requested (groups.size() > 0) or when setgroups denial was not expected.
These path based syscalls are not in kTracedSyscalls, and the generated policy
ends in DEFAULT ALLOW, so they run unobserved and never reach the report.
Measured against the report of a run under --seccomp_unotify, using the glibc
wrappers rather than raw syscall numbers, on a file and on a symlink to it:
truncate() shrank the file from 21 to 3 bytes inside the jail, verified from
outside. The report attributed no truncate to that path.
setfattr set an attribute on a file inside the jail; the attribute was still
there after the run, and the report attributed nothing to that path.
glibc faccessat(AT_EACCESS) produced no entry at all, and no faccessat
appeared either, so glibc had issued faccessat2.
faccessat2 is the same case as the statx entry added in c20246a: modern glibc
reaches for it first and only falls back to the traced faccessat on older
kernels. A plain access(2) is still traced by its own entry; what goes unseen
is the check made through faccessat().
truncate changes a file's contents without opening it, so nothing in the report
marks that path as written.
The l* variants are listed separately, the same way stat and lstat are, and are
exercised through a symlink so that all eleven entries are covered.
The flags argument of faccessat2 is left undecoded, the same as every other
AT_* entry in the table.
buildKafelPolicy() lists the kafel_name of every table entry. kafel resolves
those names from a per-architecture table, and those tables are not equally
complete, so a name that resolves on one architecture can be unknown on
another. kafel_compile() then fails, preparePolicy() returns false, and nsjail
does not start at all. Reproduced on amd64 by replacing one kafel_name with a
name kafel does not know:
[E] preparePolicy():160 Could not compile the default unotify seccomp policy:
3:275: Undefined identifier `no_such_syscall'
[F] main():386 Couldn't prepare sandboxing policy
exit 255
No report file is written; the process never runs.
Two entries are in that position today. Checked by taking __NR_ from the cross
compiler for each target and looking each kafel_name up in that target's kafel
table, counting only the entries the #ifdef keeps:
amd64 38 kept, 0 skipped, none unknown to kafel
aarch64 23 kept, 15 skipped, none unknown to kafel
riscv64 22 kept, 16 skipped, none unknown to kafel
i386 37 kept, 1 skipped, openat2 unknown to kafel
arm 37 kept, 1 skipped, openat2 and statx unknown to kafel
openat2 has been in the table since ee2d05e and statx since c20246a. m68k and
the mips targets were not checked, no cross compiler at hand. debian/control
says Architecture: any, so i386 and armhf are built.
The syscall number is a compile time constant of the architecture being built
for, so emitting it removes the dependency on how complete kafel's name table
happens to be. kafel accepts SYSCALL[<nr>] (parser.y) and C style comments
(lexer.l), so the name stays in the generated policy as a comment and keeps it
readable.
Measured on amd64 with one workload and a fresh build per form: the name based
policy, the numeric one, and the numeric one with the comment produce identical
syscall distributions in the report. With the numeric form a kafel_name that
kafel does not know no longer prevents startup, and the syscall is still
reported under its display_name.
Cap retained guest-to-host TCP payload across flows so a stalled sink cannot grow the supervisor toward the per-flow limit times the maximum flow count.
PR #292 rejected '.'/'..' and added O_NOFOLLOW on the legacy
createDirRecursively walk, but mnt_newapi createDirAt still used
multi-component mkdirat(), which follows intermediate symlinks.
After a -s symlink mount pointing outside the staging root, later
mounts with destinations under that symlink (e.g. link/pwned) could
create directories or attach mounts on the host before pivot_root.
Walk destinations one component at a time with O_NOFOLLOW, resolve
parent fd + basename for leaf symlinkat/move_mount/open_tree/openat,
and use AT_SYMLINK_NOFOLLOW in existsAs*At helpers.
Mount destinations were joined as newroot+"/"+dst and walked with
mkdirat/openat without rejecting "."/".." components or following
symlinks safely. An attacker who can influence mount dst (CLI, config,
or prefix_dst_env) could create directories, symlinks, or mounts outside
the intended staging root on the host before pivot_root.
Add isSafeContainmentPath(), validate destinations in both legacy and
new mount APIs, use O_NOFOLLOW while walking parents, and include a
standalone regression test.
Add statx to the seccomp user-notify traced-syscall table
(unotify/syscall_defs.h::kTracedSyscalls). That table is the single source
of truth -- the kafel policy string and the argument decoder are both
derived from it -- so this one entry is the whole change.
Modern glibc/coreutils (ls, stat) issue statx before falling back to
newfstatat/openat when probing a path. Because statx was not traced, a
stat of a path that is not present in the jail's mount namespace was never
observed at all: under a restrictive mount set, `ls /opt` and `stat /opt`
produced no access record, while openat-based reads (cat, head) were
recorded normally. Tracing statx closes that gap.
statx(dirfd, path, flags, mask, statxbuf) has arg0 = dirfd and arg1 =
path, the same shape as newfstatat, so it reuses the existing DIRFD/PATH
arg roles. Guarded by #ifdef __NR_statx (defined on x86_64 and arm64).
kafel already knows the statx syscall, so no policy-side change is needed.
Tested by running a command under --seccomp_unotify with a mount set that
excludes /opt. Before: `stat /opt` and `ls /opt` did not appear in the
report. After: /opt appears with exists_in_jail:false, exists_in_main:true
via a statx syscall, while a genuinely missing path still reports
exists_in_main:false and an allowlisted path still reports
exists_in_jail:true.
An expected EPERM after denying setgroups in a new user namespace can still leave inherited supplementary groups attached to the child. Query the remaining group count and abort before exec unless the credential state is actually clean.
Add regression coverage for both an inherited-group failure and a clean no-groups launch.
Every bind mount is forced recursive (MS_REC is OR'd on in prepareMountPoint),
so the source subtree -- including any nested submounts -- is cloned into the
jail. The requested per-mount flags (nosuid/nodev/noexec) were only applied to
the top mount:
- newapi: applyMountFlags() called mount_setattr() with AT_EMPTY_PATH only,
even though doBindMountAt() clones the tree with open_tree(AT_RECURSIVE);
- legacy: remountPt() issued a single non-recursive MS_REMOUNT|MS_BIND on dst.
As a result, a bind whose source subtree contains a nested mount (a dev-bearing
fs, or one holding a setuid binary) kept that submount's original
dev/suid/exec-permitting attributes inside the jail, so an explicitly requested
nodev/nosuid was not enforced on it. The recursive read-only pass already
applies MOUNT_ATTR_RDONLY with AT_RECURSIVE, so the restriction flags were the
only per-mount attributes left non-recursive.
Apply the flags recursively when the bind is recursive: thread a `recursive`
bool into the newapi applyMountFlags() (OR AT_RECURSIVE into mount_setattr), and
re-apply the flags to every submount under dst in the legacy remountPt() by
walking /proc/self/mountinfo.
Verified (legacy backend): with a nodev,nosuid recursive bind of a tree
containing a nested tmpfs, the nested submount inside the jail now shows
nosuid,nodev (previously it kept the host's dev/suid-honoring attributes).
applyPolicy() installed the seccomp-unotify listener only when pipefd != -1.
In modes that pass pipefd == -1 (e.g. MODE_STANDALONE_EXECVE) the install was
silently skipped, and prepareAndCommit() then returned early because the classic
seccomp filter is empty -- so a sandboxee for which seccomp_unotify was
explicitly requested ran with NO seccomp policy at all, with no error emitted.
Refuse to continue instead of silently dropping the requested policy: if
seccomp_unotify is set but there is no supervisor to receive the notification fd
(pipefd == -1), log an error and fail.
Verified: `nsjail -Me --seccomp_unotify --seccomp_string 'DEFAULT ALLOW' -- ...`
now aborts with a clear error instead of launching the process unfiltered.
The procfs nsjail auto-mounts (setupMounts) was created without
nosuid/nodev/noexec, so it was mounted dev,suid,exec. Every other
container runtime (runc, Docker, systemd-nspawn) mounts /proc
nosuid,nodev,noexec: there is no legitimate reason for /proc to honor
setuid bits, expose device nodes, or allow execution, and doing so
needlessly widens the in-jail attack surface (e.g. a setuid-root binary
or a device node reachable via a procfs path).
Both mount backends already honor these MountPt fields (mnt_newapi.cc,
mnt_legacy.cc), and the flags remain operator-overridable via an explicit
mount {} config entry.
Verified: /proc inside the jail now shows nosuid,nodev,noexec in
/proc/self/mountinfo (was rw,relatime).
The guest is untrusted and fully controls the acknowledgement number of
every TCP segment it emits. tcp_process_data() accepted any forward ACK
(acked_bytes > 0) without checking that it stayed within the send window,
so the guest could acknowledge data that was never sent.
Each such ACK advances tx_acked_offset by up to 2^31-1. When tx_buffer is
empty the erase step below is a no-op, so tx_acked_offset accumulates and
can be driven past 2^32 with a few pure ACK segments. push_to_guest() then
computes:
int32_t available = tx_buffer.size() - tx_acked_offset;
const uint8_t* data = tx_buffer.data() + tx_acked_offset + in_flight;
available is an int32_t truncation of a size_t subtraction, so an oversized
tx_acked_offset wraps it back positive and defeats the in_flight >= available
guard. data then points at least 2 GB past the heap buffer and
tcp_send_packet() reads up to NSTUN_MTU bytes from it.
The nstun network loop runs in the nsjail parent (supervisor) process, and
SIGSEGV is not handled, so the out of bounds read lets an untrusted jailed
process crash its own supervisor. The minimum out of bounds distance is 2 GB,
so under ASLR the access reliably faults rather than returning data.
Add the RFC 793 receive check (SEG.ACK <= SND.NXT): reject ACKs whose
sequence is beyond seq_to_guest. This keeps tx_acked_offset within
tx_buffer.size(), which the framing in push_to_guest() relies on.