From 5fd5cd8718bce291a963eb1c8da4540662b96210 Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Tue, 28 Jul 2026 11:36:07 +0200 Subject: [PATCH] import: allocate the tar hardlink db before dropping privileges tar_c() keeps a database of already seen inodes so it can emit hardlinks rather than storing the same content repeatedly. It prefers to keep that database on a tmpfs superblock of its own, which is created lazily on the first hardlinkable inode and which requires CAP_SYS_ADMIN in the caller's user namespace. Failing that it falls back to a temporary directory under /var/tmp/. In the tar child forked off by import_fork_tar_c() the tmpfs branch can never succeed: the child joins the user namespace, then drops all capabilities but CAP_DAC_OVERRIDE, and only afterwards runs tar_c(), by which point CAP_SYS_ADMIN is long gone. Every export therefore silently took the /var/tmp/ fallback. That fallback is a problem beyond being the slower path. The child runs with the transient UID range assigned to the user namespace, so the directory it creates is owned by a recyclable UID on a file system that outlives the namespace, which is exactly what the nsresourced BPF-LSM policy exists to prevent. With that policy in effect the mkdtemp() is refused and the export fails with EPERM. Create the database up front, in the window after the user namespace has been joined and before the capabilities are dropped, and pass the resulting fd into tar_c(). The database then lives on a superblock that dies together with the namespace, no persistent file system is touched, and the sandbox around tar is unchanged. tar_c() keeps its lazy fallback for the callers that do not pass an fd, i.e. systemd-dissect and the test, which do not run under a transient range. The fd is borrowed, not consumed. Signed-off-by: Daan De Meyer --- src/dissect/dissect.c | 2 +- src/import/import-common.c | 11 ++++++++++- src/import/test-tar.c | 2 +- src/shared/tar-util.c | 25 ++++++++++++++++++------- src/shared/tar-util.h | 6 +++++- 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/dissect/dissect.c b/src/dissect/dissect.c index 04701429ad..b7802197db 100644 --- a/src/dissect/dissect.c +++ b/src/dissect/dissect.c @@ -1588,7 +1588,7 @@ static int action_list_or_mtree_or_copy_or_make_archive(DissectedImage *m, LoopD output_fd = STDOUT_FILENO; } - r = tar_c(dfd, output_fd, arg_target, /* flags= */ 0); + r = tar_c(dfd, output_fd, arg_target, /* hardlink_db_fd= */ -EBADF, /* flags= */ 0); if (r < 0) return r; diff --git a/src/import/import-common.c b/src/import/import-common.c index ef2b3e26ba..0b63828869 100644 --- a/src/import/import-common.c +++ b/src/import/import-common.c @@ -137,6 +137,15 @@ int import_fork_tar_c(int tree_fd, int userns_fd, PidRef *ret_pid) { if (unshare(CLONE_NEWNET) < 0) log_debug_errno(errno, "Failed to lock tar into network namespace, ignoring: %m"); + /* Allocate the hardlink db before dropping privileges: it lives on a tmpfs superblock of our + * own, which needs CAP_SYS_ADMIN to set up. Doing it later would leave tar_c() to fall back + * to a directory in /var/tmp/, which is refused for the transient UID range we run under + * here, and would leave transient owned inodes behind on a persistent file system if it + * weren't. */ + _cleanup_close_ int hardlink_db_fd = tar_hardlink_db_new(); + if (hardlink_db_fd < 0) + log_debug_errno(hardlink_db_fd, "Failed to allocate hardlink db, ignoring: %m"); + r = capability_bounding_set_drop(retain, true); if (r < 0) log_debug_errno(r, "Failed to drop capabilities, ignoring: %m"); @@ -145,7 +154,7 @@ int import_fork_tar_c(int tree_fd, int userns_fd, PidRef *ret_pid) { if (r < 0) log_warning_errno(r, "Failed to enable PR_SET_NO_NEW_PRIVS, ignoring: %m"); - if (tar_c(tree_fd, pipefd[1], /* filename= */ NULL, flags) < 0) + if (tar_c(tree_fd, pipefd[1], /* filename= */ NULL, hardlink_db_fd, flags) < 0) _exit(EXIT_FAILURE); _exit(EXIT_SUCCESS); diff --git a/src/import/test-tar.c b/src/import/test-tar.c index b7cfed9bf3..6fca4b89cb 100644 --- a/src/import/test-tar.c +++ b/src/import/test-tar.c @@ -46,7 +46,7 @@ static int run(int argc, char **argv) { return log_error_errno(fd2, "Cannot open %s: %m", argv[3]); if (create) - r = tar_c(fd2, fd1, argv[2], /* flags= */ TAR_SELINUX); + r = tar_c(fd2, fd1, argv[2], /* hardlink_db_fd= */ -EBADF, /* flags= */ TAR_SELINUX); else r = tar_x(fd1, fd2, /* flags= */ TAR_SELINUX); if (r < 0) diff --git a/src/shared/tar-util.c b/src/shared/tar-util.c index e2603680bd..c60054b2d1 100644 --- a/src/shared/tar-util.c +++ b/src/shared/tar-util.c @@ -1083,7 +1083,7 @@ int tar_x(int input_fd, int tree_fd, TarFlags flags) { return 0; } -static int make_tmpfs(void) { +int tar_hardlink_db_new(void) { /* Creates a tmpfs superblock to store our hardlink db in. We can do this if we run in our own * userns, or if we are privileged. This is preferable, since it means the db is cleaned up * automatically once we are done. Moreover, since this is a new superblock owned by us, we do not @@ -1111,6 +1111,7 @@ struct make_archive_data { TarFlags flags; int hardlink_db_fd; + bool hardlink_db_fd_owned; /* Whether we allocated the db ourselves, i.e. whether it is ours to close */ char *hardlink_db_path; int have_unique_mount_id; }; @@ -1199,7 +1200,7 @@ static int hardlink_lookup( /* We first try to create our own superblock, which works if we are in a userns, and which * doesn't require explicit clean-up */ - d->hardlink_db_fd = make_tmpfs(); + d->hardlink_db_fd = tar_hardlink_db_new(); if (d->hardlink_db_fd < 0) { log_debug_errno(d->hardlink_db_fd, "Failed to allocate tmpfs superblock for hardlink db, falling back to temporary directory: %m"); @@ -1216,6 +1217,8 @@ static int hardlink_lookup( if (d->hardlink_db_fd < 0) return log_error_errno(d->hardlink_db_fd, "Failed to make hardlink database directory: %m"); } + + d->hardlink_db_fd_owned = true; } else { _cleanup_free_ char *p = NULL; r = readlinkat_malloc(d->hardlink_db_fd, n, &p); @@ -1585,13 +1588,17 @@ static int archive_item( static void make_archive_data_done(struct make_archive_data *d) { assert(d); - if (d->hardlink_db_fd >= 0) - (void) rm_rf_children(d->hardlink_db_fd, REMOVE_PHYSICAL, /* root_dev= */ NULL); + /* Only tear down a db we allocated ourselves: rm_rf_children() consumes the fd it is given, and one + * handed to us by the caller is theirs to close. */ + if (d->hardlink_db_fd_owned && d->hardlink_db_fd >= 0) + (void) rm_rf_children(TAKE_FD(d->hardlink_db_fd), REMOVE_PHYSICAL, /* root_dev= */ NULL); unlink_and_free(d->hardlink_db_path); } -int tar_c(int tree_fd, int output_fd, const char *filename, TarFlags flags) { +/* The hardlink db fd is borrowed, not consumed: tar_c() neither closes it nor empties it again, so whoever + * passed it in stays responsible for both. */ +int tar_c(int tree_fd, int output_fd, const char *filename, int hardlink_db_fd, TarFlags flags) { int r; assert(tree_fd >= 0); @@ -1617,7 +1624,7 @@ int tar_c(int tree_fd, int output_fd, const char *filename, TarFlags flags) { _cleanup_(make_archive_data_done) struct make_archive_data data = { .archive = a, .flags = flags, - .hardlink_db_fd = -EBADF, + .hardlink_db_fd = hardlink_db_fd, .have_unique_mount_id = -1, }; @@ -1648,11 +1655,15 @@ int tar_x(int input_fd, int tree_fd, TarFlags flags) { return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "libarchive support not available."); } -int tar_c(int tree_fd, int output_fd, const char *filename, TarFlags flags) { +int tar_c(int tree_fd, int output_fd, const char *filename, int hardlink_db_fd, TarFlags flags) { assert(tree_fd >= 0); assert(output_fd >= 0); return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "libarchive support not available."); } +int tar_hardlink_db_new(void) { + return log_debug_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "libarchive support not available."); +} + #endif diff --git a/src/shared/tar-util.h b/src/shared/tar-util.h index 4ce8c57377..3e41ef1305 100644 --- a/src/shared/tar-util.h +++ b/src/shared/tar-util.h @@ -7,5 +7,9 @@ typedef enum TarFlags { TAR_OCI_WHITEOUTS = 1 << 2, /* Turn OCI/aufs whiteout inodes into overlayfs whiteouts */ } TarFlags; +/* Allocates a tmpfs superblock for tar_c() to keep its hardlink database in. Requires CAP_SYS_ADMIN in the + * caller's user namespace, hence callers that drop privileges must do this first and pass the fd along. */ +int tar_hardlink_db_new(void); + int tar_x(int input_fd, int tree_fd, TarFlags flags); -int tar_c(int tree_fd, int output_fd, const char *filename, TarFlags flags); +int tar_c(int tree_fd, int output_fd, const char *filename, int hardlink_db_fd, TarFlags flags);