Skip to content

Fix and test the safe_openat fallback used without openat2 - #2155

Open
kolyshkin wants to merge 5 commits into
containers:mainfrom
kolyshkin:fix-safe-openat-fallback
Open

Fix and test the safe_openat fallback used without openat2#2155
kolyshkin wants to merge 5 commits into
containers:mainfrom
kolyshkin:fix-safe-openat-fallback

Conversation

@kolyshkin

Copy link
Copy Markdown
Collaborator

The safe_openat() fallback, used on kernels without openat2(2) (pre 5.6)
or where a seccomp filter blocks it, has silently diverged from the
openat2 path: nothing in CI ever runs it. This series fixes four bugs
found there and adds a way to keep the path tested.

The last commit adds tests/no_openat2, a small helper installing a seccomp
filter that makes openat2 fail with ENOSYS and then executing its
arguments. The filter is inherited across exec and by every child, so
pointing TESTS_ENVIRONMENT at it runs the whole suite through the
fallback: make check-no-openat2. This is what found the first two bugs.
It is run as root only, since without CAP_SYS_ADMIN the helper has to set
NO_NEW_PRIVS to install the filter and the test containers inherit it,
which breaks the noNewPrivileges tests.

The fixes, in order:

  1. chroot_realpath drops the separator when it expands a relative
    symlink, so <root>/dir/link with link -> file resolves to
    <root>/dirfile. Top level symlinks survive by accident, anything
    below the first level resolves to a path that does not exist.
    Symlink expansion had no test coverage at all.

  2. The fallback follows the last component even when O_NOFOLLOW was
    requested, since chroot_realpath resolves it. A dest-nofollow bind
    mount onto a symlink is created on the symlink target, and a dangling
    destination symlink makes the container fail to start. This is what
    mount-bind-mount-symlink-nofollow catches once openat2 is blocked.

  3. check_fd_under_path() expects a / right after the rootfs prefix, so
    with rootfs / it rejects every path.

  4. safe_openat() calls strlen() on the rootfs, which the krun handler
    passes as NULL for containers without a rootfs of their own.

Fixes 3 and 4 are only reachable through the fallback as well.

Tested with make check and with the suite run through the fallback; both
give identical results now.

This may be the underlying cause of #2154, which reports device creation
failing on a 4.14 kernel. The mechanism described in that issue does not
hold up (chroot_realpath returns the partially resolved path on ENOENT
rather than NULL, and stripping the rootfs prefix from an unresolvable
relative path gives back the original path), but bugs 1 and 2 above produce
exactly this class of failure on a kernel without openat2. I have not
been able to reproduce the reported error message itself.

kolyshkin and others added 5 commits July 31, 2026 19:17
When the last resolved component is a relative symlink, the code backs
up over it with

	while (*(--new_path) != '/');

leaving new_path pointing at the separator itself, so the expanded
symlink target overwrites it.  "<root>/dir/link" with "link" pointing to
"file" resolves to "<root>/dirfile" instead of "<root>/dir/file", and
"<root>/link" resolves to "<root>link".

Top level symlinks happen to survive since safe_openat_fallback() strips
the rootfs prefix and then consumes leading slashes, but any relative
symlink below the first level resolves to a path that does not exist,
and the open fails with ENOENT.

Keep the separator after backing up over the component.  This is only
reachable through the safe_openat() fallback, i.e. on kernels without
openat2(2) or when a seccomp filter blocks it.

Add unit tests for symlink expansion, which was not covered at all.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chroot_realpath() resolves the last component as well, so the fallback
used when openat2(2) is not available silently follows a symlink even
when the caller passed O_NOFOLLOW.  A "dest-nofollow" bind mount whose
destination is a symlink is then created on the symlink target instead
of on the symlink itself, and a dangling destination symlink makes the
container fail to start.

When O_NOFOLLOW is set, resolve only the parent directory and let
openat(2) deal with the last component.  A trailing '/' still forces the
symlink to be resolved, as the kernel does.

While at it, drop the rootfs prefix only when it is actually present:
chroot_realpath() returns the path unchanged when rootfs is "/", and the
unconditional "path_in_chroot += rootfs_len" removed the first character
of the path.  With the parent directory now possibly being the empty
string, that also read past the end of the buffer.

Document that dirfd must be a file descriptor for rootfs itself.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The safe_openat() fallback is only used on kernels older than 5.6 or
where a seccomp filter blocks openat2(2), so it is never exercised by
CI, and it silently diverged from the openat2 path.

Add tests/no_openat2, a small helper installing a seccomp filter that
makes openat2 fail with ENOSYS and then executing its arguments.  The
filter is inherited across exec and by every child, so pointing
TESTS_ENVIRONMENT at it runs the whole suite through the fallback.

Add a "make check-no-openat2" target using it, and run it in CI.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
check_fd_under_path() requires a '/' right after the rootfs prefix, so
when the rootfs is "/" it rejects every path: for "/proc" the character
after the prefix is 'p', and the open fails with "target `/proc` not
under the directory `/`".

This is reached through the safe_openat() fallback, i.e. on kernels
without openat2(2), for containers using the host root, as done for the
mounts set up by libcrun_container_enter_cgroup_ns() and by the krun
handler.

Return early when the rootfs is "/" or not set: every path is under it,
so there is nothing to verify.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The krun handler passes a NULL rootfs together with AT_FDCWD for
containers without a rootfs of their own, and libcrun_create_dev() and
libkrun_read_vm_config() then call safe_openat() with it.  The fallback
used when openat2(2) is not available calls strlen() on it and crashes,
and the empty path case passes it to open() directly.

Treat a NULL or empty rootfs as "/", which is what chroot_realpath()
already does.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@packit-as-a-service

Copy link
Copy Markdown

Ephemeral COPR build failed. @containers/packit-build please check.

1 similar comment
@packit-as-a-service

Copy link
Copy Markdown

Ephemeral COPR build failed. @containers/packit-build please check.

@giuseppe giuseppe left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

restarted the CI

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants