CVE-2021-3997 (C, published 2022-08-23T20:15Z)
Description: value: A flaw was found in systemd. An uncontrolled recursion in systemd-tmpfiles may lead to a denial of service at boot time when too many nested directories are created in /tmp.
Severity: nan (CVSS v3 base score: 5.5)
Weakness: CWE-674 \u2014 Uncontrolled Recursion
The product does not properly control the amount of recursion that takes place,  consuming excessive resources, such as allocated memory or the program stack.
Repository: https://github.com/systemd/systemd
Files changed:
src/shared/rm-rf.c
Commit message: shared/rm-rf: loop over nested directories instead of instead of recursing

To remove directory structures, we need to remove the innermost items first,
and then recursively remove higher-level directories. We would recursively
descend into directories and invoke rm_rf_children and rm_rm_children_inner.
This is problematic when too many directories are nested.

Instead, let's create a "TODO" queue. In the the queue, for each level we
hold the DIR* object we were working on, and the name of the directory. This
allows us to leave a partially-processed directory, and restart the removal
loop one level down. When done with the inner directory, we use the name to
unlinkat() it from the parent, and proceed with the removal of other items.

Because the nesting is increased by one level, it is best to view this patch
with -b/--ignore-space-change.

This fixes CVE-2021-3997, https://bugzilla.redhat.com/show_bug.cgi?id=2024639.
The issue was reported and patches reviewed by Qualys Team.
Mauro Matteo Cascella and Riccardo Schirone from Red Hat handled the disclosure.


Vulnerable code:

static int rm_rf_children_inner(
                const struct stat *root_dev) {
                _cleanup_close_ int subdir_fd = -1;

                /* if root_dev is set, remove subdirectories only if device is same */

                subdir_fd = openat(fd, fname, O_RDONLY|O_NONBLOCK|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW|O_NOATIME);
                q = rm_rf_children(TAKE_FD(subdir_fd), flags | REMOVE_PHYSICAL, root_dev);
        _cleanup_closedir_ DIR *d = NULL;
        assert(fd >= 0);
        /* This returns the first error we run into, but nevertheless tries to go on. This closes the passed
         * fd, in all cases, including on failure. */
        d = fdopendir(fd);
        if (!d) {
                safe_close(fd);
                return -errno;
        }
        if (!(flags & REMOVE_PHYSICAL)) {
                struct statfs sfs;
                if (fstatfs(dirfd(d), &sfs) < 0)
                        return -errno;
                if (is_physical_fs(&sfs)) {
                        /* We refuse to clean physical file systems with this call, unless explicitly
                         * requested. This is extra paranoia just to be sure we never ever remove non-state
                         * data. */
                        _cleanup_free_ char *path = NULL;
                        (void) fd_get_path(fd, &path);
                        return log_error_errno(SYNTHETIC_ERRNO(EPERM),
                                               "Attempted to remove disk file system under \"%s\", and we can't allow that.",
                                               strna(path));
                }
        }
        FOREACH_DIRENT_ALL(de, d, return -errno) {
                int is_dir;
                if (dot_or_dot_dot(de->d_name))
                        continue;
                is_dir =
                        de->d_type == DT_UNKNOWN ? -1 :
                        de->d_type == DT_DIR;
                r = rm_rf_children_inner(dirfd(d), de->d_name, is_dir, flags, root_dev);
                if (r < 0 && r != -ENOENT && ret == 0)
                        ret = r;
        }
        if (FLAGS_SET(flags, REMOVE_SYNCFS) && syncfs(dirfd(d)) < 0 && ret >= 0)
                ret = -errno;
        return rm_rf_children_inner(fd, name, -1, flags, NULL);

Fixed code:
static int rm_rf_inner_child(
                const struct stat *root_dev,
                bool allow_recursion) {
                /* If root_dev is set, remove subdirectories only if device is same */
                if (!allow_recursion)
                        return -EISDIR;

                int subdir_fd = openat(fd, fname, O_RDONLY|O_NONBLOCK|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW|O_NOATIME);
                q = rm_rf_children(subdir_fd, flags | REMOVE_PHYSICAL, root_dev);
typedef struct TodoEntry {
        DIR *dir;         /* A directory that we were operating on. */
        char *dirname;    /* The filename of that directory itself. */
} TodoEntry;

static void free_todo_entries(TodoEntry **todos) {
        for (TodoEntry *x = *todos; x && x->dir; x++) {
                closedir(x->dir);
                free(x->dirname);
        }

        freep(todos);
}

        _cleanup_(free_todo_entries) TodoEntry *todos = NULL;
        size_t n_todo = 0;
        _cleanup_free_ char *dirname = NULL; /* Set when we are recursing and want to delete ourselves */
        /* Return the first error we run into, but nevertheless try to go on.
         * The passed fd is closed in all cases, including on failure. */

        for (;;) {  /* This loop corresponds to the directory nesting level. */
                _cleanup_closedir_ DIR *d = NULL;

                if (n_todo > 0) {
                        /* We know that we are in recursion here, because n_todo is set.
                         * We need to remove the inner directory we were operating on. */
                        assert(dirname);
                        r = unlinkat_harder(dirfd(todos[n_todo-1].dir), dirname, AT_REMOVEDIR, flags);
                        if (r < 0 && r != -ENOENT && ret == 0)
                                ret = r;
                        dirname = mfree(dirname);

                        /* And now let's back out one level up */
                        n_todo --;
                        d = TAKE_PTR(todos[n_todo].dir);
                        dirname = TAKE_PTR(todos[n_todo].dirname);

                        assert(d);
                        fd = dirfd(d); /* Retrieve the file descriptor from the DIR object */
                        assert(fd >= 0);
                } else {
        next_fd:
                        assert(fd >= 0);
                        d = fdopendir(fd);
                        if (!d) {
                                safe_close(fd);
                                return -errno;
                        }
                        fd = dirfd(d); /* We donated the fd to fdopendir(). Let's make sure we sure we have
                                        * the right descriptor even if it were to internally invalidate the
                                        * one we passed. */

                        if (!(flags & REMOVE_PHYSICAL)) {
                                struct statfs sfs;

                                if (fstatfs(fd, &sfs) < 0)
                                        return -errno;

                                if (is_physical_fs(&sfs)) {
                                        /* We refuse to clean physical file systems with this call, unless
                                         * explicitly requested. This is extra paranoia just to be sure we
                                         * never ever remove non-state data. */

                                        _cleanup_free_ char *path = NULL;

                                        (void) fd_get_path(fd, &path);
                                        return log_error_errno(SYNTHETIC_ERRNO(EPERM),
                                                               "Attempted to remove disk file system under \"%s\", and we can't allow that.",
                                                               strna(path));
                                }
                        }
                }
                FOREACH_DIRENT_ALL(de, d, return -errno) {
                        int is_dir;
                        if (dot_or_dot_dot(de->d_name))
                                continue;
                        is_dir = de->d_type == DT_UNKNOWN ? -1 : de->d_type == DT_DIR;
                        r = rm_rf_inner_child(fd, de->d_name, is_dir, flags, root_dev, false);
                        if (r == -EISDIR) {
                                /* Push the current working state onto the todo list */
                                 if (!GREEDY_REALLOC0(todos, n_todo + 2))
                                         return log_oom();
                                 _cleanup_free_ char *newdirname = strdup(de->d_name);
                                 if (!newdirname)
                                         return log_oom();
                                 int newfd = openat(fd, de->d_name,
                                                    O_RDONLY|O_NONBLOCK|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW|O_NOATIME);
                                 if (newfd >= 0) {
                                         todos[n_todo++] = (TodoEntry) { TAKE_PTR(d), TAKE_PTR(dirname) };
                                         fd = newfd;
                                         dirname = TAKE_PTR(newdirname);
                                         goto next_fd;
                                 } else if (errno != -ENOENT && ret == 0)
                                         ret = -errno;
                        } else if (r < 0 && r != -ENOENT && ret == 0)
                                ret = r;
                }
                if (FLAGS_SET(flags, REMOVE_SYNCFS) && syncfs(fd) < 0 && ret >= 0)
                        ret = -errno;
                if (n_todo == 0)
                        break;
        }
        return rm_rf_inner_child(fd, name, -1, flags, NULL, true);