1453 Commits

Author SHA1 Message Date
robertswiecki
f100fd917c Merge pull request #310 from carrerasdarren-cell/fix/fail-closed-setsid
contain: fail closed when setsid fails
2026-08-27 23:18:02 +02:00
Darren Carreras
660c20d237 contain: fail closed when setsid fails 2026-08-27 17:15:16 -04:00
Robert Swiecki
44e08fd4e5 make indent depend 2026-08-26 21:39:07 +02:00
Robert Swiecki
60fe8833a8 Makefile: align OLD_EF submount test expectations with recursive remount
When chrooting to '/' without --rw, recursive remounting makes submounts (such as tmpfs on /run/user) read-only across both legacy and new mount APIs.
2026-08-26 21:36:45 +02:00
Robert Swiecki
5beb5202d6 user: allow unprivileged user namespace creation when setgroups is denied
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.
2026-08-26 21:33:15 +02:00
Robert Swiecki
229682c59e Merge pull request #292 from M0nd0R/fix-mount-staging-escape
Reject path traversal in mount destinations during jail staging
2026-08-26 21:20:27 +02:00
Robert Swiecki
ae5b35b2d0 Merge pull request #284 from h1-mrz/recursive-bind-mount-flags
mnt: apply per-mount nosuid/nodev/noexec recursively to recursive binds
2026-08-26 21:20:11 +02:00
robertswiecki
b0d163391c Merge pull request #283 from h1-mrz/harden-proc-mount-flags
mnt: mount the auto-provisioned /proc nosuid,nodev,noexec
2026-08-26 20:36:43 +02:00
Robert Swiecki
fc38d1257a Merge branch 'master' of github.com:google/nsjail 2026-08-26 20:20:49 +02:00
Robert Swiecki
9304f3c475 Merge pull request #281 from attaboy11/integer_parsing
Reject invalid integer command-line arguments
2026-08-26 20:19:52 +02:00
Robert Swiecki
8dd3a7343f Merge pull request #293 from carrerasdarren-cell/unotify-atomic-reports
unotify: write reports privately and atomically
2026-08-26 20:19:16 +02:00
Robert Swiecki
1a13dc98fa Merge pull request #268 from Vincent550102/reject_directory_stdio
Reject directory stdio fds unless explicitly passed
2026-08-26 20:12:26 +02:00
robertswiecki
012e316fe4 Merge pull request #299 from srkyn/fix-pdeathsig-setup-race
contain: close PR_SET_PDEATHSIG setup race
2026-08-26 20:11:25 +02:00
Robert Swiecki
30d8e4013f Merge branch 'master' of github.com:google/nsjail 2026-08-26 20:09:35 +02:00
Robert Swiecki
5d53b5c28b Merge pull request #282 from carrerasdarren-cell/nstun-cloud-local-destinations
nstun: block cloud-local service destinations
2026-08-26 20:08:34 +02:00
robertswiecki
8133324414 Merge pull request #288 from carrerasdarren-cell/fix/fail-closed-setgroups
user: fail closed when supplementary groups cannot be set
2026-08-26 20:05:57 +02:00
robertswiecki
16970c3fd9 Merge pull request #285 from h1-mrz/fail-loud-seccomp-unotify
sandbox: fail loudly when seccomp_unotify is requested but unusable
2026-08-26 20:02:59 +02:00
Robert Swiecki
79d12b54f2 Merge pull request #289 from carrerasdarren-cell/fix/nstun-policy-validation
nstun: reject invalid policy rule fields
2026-08-26 11:48:27 +02:00
Robert Swiecki
1a923df739 nstun: drop IPv6 unspecified and multicast packets in SSRF gate 2026-08-26 11:45:35 +02:00
robertswiecki
2ed350a0f2 Merge pull request #302 from mustafagozhamam/fix/nstun-parent-lifecycle
Fix NSTUN parent lifecycle cleanup
2026-08-26 09:23:13 +02:00
robertswiecki
c398ceda2f Merge pull request #300 from skaiea13-ai/codex/nstun-aggregate-budget
Limit nstun TCP receive buffers with a shared payload budget
2026-08-25 21:20:12 +02:00
robertswiecki
62f41cbfb7 Merge pull request #306 from Trithem90/fix/nstun-any-proxy-icmp
nstun: fail closed for proxy actions matched by ICMP
2026-08-25 21:19:00 +02:00
robertswiecki
3bd39a9acf Merge pull request #303 from Trithem90/fix/nstun-host-to-guest-policy
nstun: enforce HOST_TO_GUEST policy on inbound TCP and UDP
2026-08-25 21:17:38 +02:00
robertswiecki
f9a32c024b Merge pull request #308 from Nexory/unotify-trace-path-siblings
unotify: emit syscall numbers in the generated policy, and trace 11 missing path syscalls
2026-08-25 21:17:02 +02:00
robertswiecki
a36f97f24d Merge pull request #278 from sravan27/harden-cgroup-memory-cleanup
cgroup: clean up swap-only memory cgroups
2026-08-25 21:16:05 +02:00
robertswiecki
40a34d0170 Merge pull request #286 from srkyn/harden-no-new-privs-failure
contain: stop setup if PR_SET_NO_NEW_PRIVS fails
2026-08-25 21:15:26 +02:00
robertswiecki
c39f43ddcb Merge pull request #304 from Trithem90/fix/nstun-zero-network-cidr
nstun: preserve zero-network CIDR selectors
2026-08-25 21:14:35 +02:00
robertswiecki
d0363ed7d2 Merge pull request #280 from g0w6y/nstun_tcp_ack_window_validation
nstun/tcp: validate guest ACK numbers against the send window
2026-08-25 15:54:34 +02:00
Nexory
935cd6e0b8 unotify: trace faccessat2, truncate, statfs and the xattr syscalls
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.
2026-08-25 05:17:02 +02:00
Nexory
c4a2631477 unotify: emit syscall numbers in the generated kafel policy
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.
2026-08-25 05:15:15 +02:00
skaiea13-ai
b4cff719c2 Limit nstun TCP receive buffers with a shared payload budget
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.
2026-08-22 04:30:29 +09:00
Trithem90
8a751f0166 nstun: fail closed for proxy actions matched by ICMP 2026-08-21 13:57:16 +02:00
Trithem90
6673fe6758 nstun: preserve zero-network CIDR selectors 2026-08-21 12:15:24 +02:00
Trithem90
70094631bb nstun: enforce HOST_TO_GUEST policy on inbound TCP and UDP 2026-08-21 11:01:22 +02:00
Mustafa
9f166b999f Fix NSTUN parent lifecycle cleanup 2026-08-18 21:31:15 +03:00
David Sarkisyan
900f6649e2 contain: close parent-death signal setup race 2026-08-15 16:31:04 -04:00
mundur
6cd216d0aa Harden new mount API against symlink escapes during staging
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.
2026-07-28 01:36:17 +00:00
Darren Carreras
3ecde9562a unotify: write reports privately and atomically 2026-07-27 20:13:14 -04:00
mundur
9b54e97fd4 Reject path traversal in mount destinations during jail staging
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.
2026-07-27 10:53:58 +00:00
robertswiecki
5ebcc30bef Merge pull request #291 from yexinw-ctrl/statx-unotify-trace
unotify: trace statx
2026-07-27 09:09:09 +02:00
yexinw
c20246ad0a unotify: trace statx
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.
2026-07-27 00:10:11 +00:00
Darren Carreras
93c3cadb81 user: fail closed when supplementary groups cannot be set
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.
2026-07-23 21:52:17 -04:00
Darren Carreras
0d905f1da2 nstun: reject invalid policy rule fields 2026-07-21 19:59:04 -04:00
David Sarkisyan
3c9e848c61 contain: fail closed when no_new_privs cannot be set 2026-07-20 14:41:33 -04:00
h1-mrz
70fc359713 mnt: apply per-mount nosuid/nodev/noexec recursively to recursive binds
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).
2026-07-18 08:40:07 -04:00
h1-mrz
09dd8be0c0 sandbox: fail loudly when seccomp_unotify is requested but unusable
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.
2026-07-18 08:40:07 -04:00
h1-mrz
d01bfe3602 mnt: mount the auto-provisioned /proc nosuid,nodev,noexec
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).
2026-07-18 08:13:16 -04:00
Darren Carreras
bd9439b3f2 nstun: block cloud-local service destinations 2026-07-16 18:42:09 -04:00
attaboy11
0a21f80078 Reject invalid integer command-line arguments 2026-07-16 19:50:07 +01:00
g0w6y
a460c1b6a0 nstun/tcp: validate guest ACK numbers against the send window
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.
2026-07-15 20:17:22 +05:30