Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ jobs:
include:
- test: disable-systemd
- test: check
- test: check-no-openat2
- test: enable-shared
- test: embedded-blake3
- test: system-blake3
Expand Down Expand Up @@ -129,6 +130,20 @@ jobs:
echo run tests as rootless in a user namespace
unshare -r make check ASAN_OPTIONS=detect_leaks=false || (cat test-suite.log; exit 1)
;;
check-no-openat2)
# Run the test suite with openat2(2) forced to fail with
# ENOSYS, so that the safe_openat() fallback used on kernels
# older than 5.6 is exercised. GitHub runners always have a
# much newer kernel, so this path is otherwise never tested.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
./configure --enable-embedded-blake3 --disable-dl
make
# Only the root run: without CAP_SYS_ADMIN the helper has to
# set NO_NEW_PRIVS to install its seccomp filter, and that is
# inherited by the test containers.
echo run tests as root, openat2 blocked
sudo make check-no-openat2 ASAN_OPTIONS=detect_leaks=false || (cat test-suite.log; exit 1)
;;
podman)
sudo mkdir -p /var/lib/containers /var/tmp
sudo docker build -t crun-podman tests/podman
Expand Down
16 changes: 14 additions & 2 deletions Makefile.am
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ noinst_PROGRAMS = crun
endif

if BUILD_TESTS
check_PROGRAMS = tests/init $(UNIT_TESTS) tests/tests_libcrun_fuzzer
check_PROGRAMS = tests/init $(UNIT_TESTS) tests/tests_libcrun_fuzzer tests/no_openat2

TESTS_LDADD = libcrun_testing.la $(FOUND_LIBS) $(JSON_C_LIBS)

Expand All @@ -213,6 +213,10 @@ tests_init_LDFLAGS = -static-libgcc -all-static
tests_init_CFLAGS = -g -O2
tests_init_SOURCES = tests/init.c

tests_no_openat2_LDADD =
tests_no_openat2_CFLAGS = -g -O2
tests_no_openat2_SOURCES = tests/no_openat2.c

tests_tests_libcrun_utils_CFLAGS = -I $(abs_top_builddir)/libocispec/src -I $(abs_top_srcdir)/libocispec/src $(JSON_C_CFLAGS) -I $(abs_top_builddir)/src -I $(abs_top_srcdir)/src
tests_tests_libcrun_utils_SOURCES = tests/tests_libcrun_utils.c
tests_tests_libcrun_utils_LDADD = $(TESTS_LDADD)
Expand Down Expand Up @@ -327,6 +331,14 @@ PYTHON_TESTS = tests/test_capabilities.py \

if BUILD_TESTS
TESTS = $(PYTHON_TESTS) $(UNIT_TESTS)

# Run the whole test suite with openat2(2) forced to fail with ENOSYS. This
# exercises the safe_openat() fallback used on kernels older than 5.6 and
# wherever a seccomp filter blocks openat2, which is otherwise never reached
# on a modern kernel. Run it as root: without CAP_SYS_ADMIN the helper must
# set NO_NEW_PRIVS, which the test containers inherit.
check-no-openat2: tests/no_openat2$(EXEEXT)
$(MAKE) $(AM_MAKEFLAGS) check TESTS_ENVIRONMENT='$(abs_top_builddir)/tests/no_openat2$(EXEEXT)'
endif

.version:
Expand Down Expand Up @@ -526,4 +538,4 @@ clean-local: coverage-clean
# Coverage targets must not run in parallel due to race conditions in .gcda file writes
.NOTPARALLEL: coverage-reset coverage-check coverage-html coverage-xml coverage-summary coverage-multi-env

.PHONY: coverity sync generate-rust-bindings generate-signals.c generate-mount_flags.c clang-format shellcheck coverage-clean coverage-reset coverage-check coverage-html coverage-xml coverage-summary coverage-multi-env
.PHONY: check-no-openat2 coverity sync generate-rust-bindings generate-signals.c generate-mount_flags.c clang-format shellcheck coverage-clean coverage-reset coverage-check coverage-html coverage-xml coverage-summary coverage-multi-env
8 changes: 6 additions & 2 deletions src/libcrun/chroot_realpath.c
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,13 @@ char *chroot_realpath(const char *chroot, const char *path, char resolved_path[]
new_path = got_path_root;
*new_path++ = '/';
}
else
/* Otherwise back up over this component. */
else {
/* Otherwise back up over this component, keeping the
separator, so that the expanded symlink is not
concatenated to the parent directory name. */
while (*(--new_path) != '/');
new_path++;
}
/* Safe sex check. */
if (strlen(path) + n >= PATH_MAX - 2) {
__set_errno(ENAMETOOLONG);
Expand Down
68 changes: 62 additions & 6 deletions src/libcrun/utils.c
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,11 @@ check_fd_under_path (const char *rootfs, size_t rootfslen, int fd, const char *f
char link[PATH_MAX];
int ret;

/* Every path is under "/", there is nothing to verify. The check below
would reject any path since it expects a '/' right after the rootfs. */
if (rootfslen == 0 || (rootfslen == 1 && rootfs[0] == '/'))
return 0;

get_proc_self_fd_path (fdpath, fd);
ret = TEMP_FAILURE_RETRY (readlink (fdpath, link, sizeof (link)));
if (UNLIKELY (ret < 0))
Expand All @@ -332,39 +337,86 @@ close_and_replace (int *oldfd, int newfd)
/* Defined in chroot_realpath.c */
char *chroot_realpath (const char *chroot, const char *path, char resolved_path[]);

/* DIRFD must be a file descriptor for ROOTFS itself: PATH is resolved
against ROOTFS and the result is then opened relatively to DIRFD. */
static int
safe_openat_fallback (int dirfd, const char *rootfs, const char *path, int flags,
int mode, libcrun_error_t *err)
{
cleanup_free char *parent_path = NULL;
const char *last_component = NULL;
const char *orig_path = path;
const char *path_in_chroot;
cleanup_close int fd = -1;
char resolved[PATH_MAX];
char buffer[PATH_MAX];
size_t rootfs_len = strlen (rootfs);
size_t rootfs_len = is_empty_string (rootfs) ? 0 : strlen (rootfs);
int ret;

/* chroot_realpath resolves the last component as well, so when O_NOFOLLOW
is requested resolve only the parent directory and let openat(2) deal
with the last component, otherwise a symlink would be followed even
though the caller asked not to. */
if (flags & O_NOFOLLOW)
{
char *sep;

parent_path = xstrdup (path);
sep = strrchr (parent_path, '/');
if (sep == NULL)
{
/* No parent directory, the entire path is the last component. */
last_component = path;
parent_path[0] = '\0';
}
else if (sep[1] != '\0')
{
*sep = '\0';
last_component = path + (sep - parent_path) + 1;
}
/* A trailing '/' forces the symlink to be resolved anyway, so in that
case keep resolving the entire path. */

if (last_component)
path = parent_path;
}

path_in_chroot = chroot_realpath (rootfs, path, buffer);
if (path_in_chroot == NULL)
return crun_make_error (err, errno, "cannot resolve `%s` under rootfs", path);
return crun_make_error (err, errno, "cannot resolve `%s` under rootfs", orig_path);

path_in_chroot += rootfs_len;
/* When rootfs is "/" or not set, chroot_realpath returns the path
unchanged, so drop the prefix only when it is really there. */
if (rootfs_len > 0 && strncmp (path_in_chroot, rootfs, rootfs_len) == 0)
path_in_chroot += rootfs_len;
path_in_chroot = consume_slashes (path_in_chroot);

if (last_component)
{
ret = snprintf (resolved, sizeof (resolved), "%s%s%s", path_in_chroot,
path_in_chroot[0] == '\0' ? "" : "/", last_component);
if (UNLIKELY (ret >= (int) sizeof (resolved)))
return crun_make_error (err, ENAMETOOLONG, "resolve `%s` under rootfs", orig_path);

path_in_chroot = resolved;
}

/* If the path is empty we are at the root, dup the dirfd itself. */
if (path_in_chroot[0] == '\0')
{
ret = dup (dirfd);
if (UNLIKELY (ret < 0))
return crun_make_error (err, errno, "dup `%s`", rootfs);
return crun_make_error (err, errno, "dup `%s`", rootfs_len ? rootfs : "/");
return ret;
}

ret = openat (dirfd, path_in_chroot, flags, mode);
if (UNLIKELY (ret < 0))
return crun_make_error (err, errno, "open `%s`", path);
return crun_make_error (err, errno, "open `%s`", orig_path);

fd = ret;

ret = check_fd_under_path (rootfs, rootfs_len, fd, path, err);
ret = check_fd_under_path (rootfs, rootfs_len, fd, orig_path, err);
if (UNLIKELY (ret < 0))
return ret;

Expand All @@ -384,6 +436,10 @@ safe_openat (int dirfd, const char *rootfs, const char *path, int flags, int mod
{
cleanup_close int fd = -1;

/* A container without a rootfs of its own uses the host root. */
if (is_empty_string (rootfs))
rootfs = "/";

fd = open (rootfs, flags, mode);
if (UNLIKELY (fd < 0))
return crun_make_error (err, errno, "open `%s`", rootfs);
Expand Down
104 changes: 104 additions & 0 deletions tests/no_openat2.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* crun - OCI runtime written in C
*
* Copyright (C) 2026 crun Authors
* crun is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* crun is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with crun. If not, see <http://www.gnu.org/licenses/>.
*/

/* Install a seccomp filter that makes openat2(2) fail with ENOSYS, then exec
the given command. The filter is inherited across exec() and by every
child process, so running the test suite under this helper:

./tests/no_openat2 make check

exercises the safe_openat() fallback path used on kernels older than 5.6,
as well as on any kernel where a seccomp filter blocks openat2.

Note that without CAP_SYS_ADMIN the filter can only be installed after
NO_NEW_PRIVS is set, and NO_NEW_PRIVS is inherited by the containers
created by the test suite; tests looking at noNewPrivileges fail in that
case. For this reason the test suite is run under this helper only as
root. */

#define _GNU_SOURCE

#include <errno.h>
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>

#ifndef __NR_openat2
# define __NR_openat2 437
#endif

#ifndef SECCOMP_SET_MODE_FILTER
# define SECCOMP_SET_MODE_FILTER 1
#endif

static int
install_filter (void)
{
struct sock_filter filter[] = {
/* Load the syscall number. */
BPF_STMT (BPF_LD | BPF_W | BPF_ABS, offsetof (struct seccomp_data, nr)),
/* If it is not openat2, allow it. */
BPF_JUMP (BPF_JMP | BPF_JEQ | BPF_K, __NR_openat2, 0, 1),
BPF_STMT (BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (ENOSYS & SECCOMP_RET_DATA)),
BPF_STMT (BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = {
.len = (unsigned short) (sizeof (filter) / sizeof (filter[0])),
.filter = filter,
};

if (syscall (__NR_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog) == 0)
return 0;

/* Without CAP_SYS_ADMIN the filter can only be installed after
NO_NEW_PRIVS is set. */
if (errno != EACCES)
return -1;

if (prctl (PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0)
return -1;

return syscall (__NR_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog);
}

int
main (int argc, char **argv)
{
if (argc < 2)
{
fprintf (stderr, "usage: %s COMMAND [ARGS...]\n", argv[0]);
return 2;
}

if (install_filter () < 0)
{
fprintf (stderr, "%s: cannot install seccomp filter: %s\n", argv[0], strerror (errno));
return 2;
}

execvp (argv[1], argv + 1);

fprintf (stderr, "%s: exec `%s`: %s\n", argv[0], argv[1], strerror (errno));
return 127;
}
Loading
Loading