Skip to content

Audit and guard tar Entry::unpack hardlink-vs-CWD footgun across all extraction sites #1163

Description

@bryan-minimal

Entry::unpack (both tar and async-tar) resolves hardlink targets against the process CWD, not the destination root. Any extraction loop built on it can be made to hardlink an arbitrary host file into the destination — an escape that no path-based containment check can detect, because the resulting path genuinely is inside the root.

One instance is already fixed (#1162, common::archive). This issue tracks auditing the remaining sites and adding a guard so it cannot be reintroduced — it is the fifth instance of "one containment rule, two implementations, only one hardened."

The footgun

Both crates expose two extraction entry points that harden against opposite halves of the same threat:

API symlink entries hardlink entries
Archive::unpack (→ Entry::unpack_in, target_base = Some(dst)) unsafe (writes targets verbatim) safe — joins to dst, calls validate_inside_dst
Entry::unpack (fields.unpack(None, ..)) safe if you check targets yourself unsafeNone => src.into_owned(), kernel resolves against process CWD

Verified in the vendored sources:

  • tar-0.4.46/src/entry.rs:203-205 (unpackNone), :528-546 (the None arm)
  • async-tar-0.6.1/src/entry.rs:253-255, :554-572identical

Both crates even comment on the asymmetry in the Some(p) arm ("this logic is only needed for hard links currently... For hard links though they're canonicalized to their existing path so we need to validate at this time"). The hazard is real but easy to miss: unpack_in's docs say "avoiding security issues"; unpack's docs say nothing, and its doc example shows file.unpack(format!("file-{}", i)) — the unsafe pattern, presented as canonical usage.

Why a checked target is not enough

A link-target check that normalizes etc/shadow and finds it "relative and ..-free" passes it, because the check reasons about the string while the kernel resolves it against the CWD. minimald's CWD in the guest is / (crates/minimald/src/guest.rs:257), running as root, so etc/shadow is a live path. Sharing an inode then defeats every path-based containment in the daemon at once — including the SFTP resolver, whose canonicalize().starts_with(workspace) sees a legitimately-contained path.

Status per site

Site Current API Hardlink-safe? Action
common::archive::extract_tar_impl per-entry loop ✅ fixed in #1162 (explicit hard_link anchored to dest_dir) done
minimald::rpc workspace unpack (crates/minimald/src/rpc.rs:913-914) async_tar::Archive::unpack ✅ safe today (routes via unpack_in) ⚠️ the pending SFTP fix converts this to a per-entry loop — it must carry the same explicit-hardlink handling or it regresses
minimald::rpc::unpack_workspace_patches per-entry, rejects all non-Regular/Continuous entries ✅ safe by construction none
remote-client, diagnostics, minimal archive paths mixed audit to do

Nothing in main is currently exploitable via this vector; the point of the issue is that the obvious fix for the symlink half introduces the hardlink half, which is exactly how this happened once already.

Proposed work

  1. Audit every .unpack( / entries() extraction site across the workspace and record which half each is hardened against.
  2. One shared extraction helper. common::archive already carries the comment "Public because more than one crate unpacks untrusted tarballs… A second implementation is how this class of bug gets reintroduced" — and a second implementation was written anyway, in minimald. Both should call one function.
  3. Mechanical guard — add clippy.toml:
    disallowed-methods = [
      { path = "tar::Entry::unpack", reason = "resolves hardlink targets against the process CWD; use unpack_in, or link explicitly anchored to the destination" },
      { path = "async_tar::Entry::unpack", reason = "same as tar::Entry::unpack" },
    ]
    CI already gates on cargo clippy -- -D warnings, so this is enforced for free. This single entry would have prevented the bug fixed in test(fuzz): archive + paths targets, and four decoder hardening fixes #1162.
  4. Fuzz oracle — path-containment oracles are structurally blind here (the escape is an inode with a contained path). The archive_extract harness now does inode accounting; keep that property when the oracle changes.
  5. Consider an upstream note to both crates suggesting the unpack doc example not model the unsafe pattern. async-tar is actively maintained (v0.6.1, June 2026) — this is an API-ergonomics report, not a maintenance concern.

Non-findings, recorded so they are not re-litigated

  • async-tar is not unmaintained. Three releases in 2026; we are on the latest (0.6.1). Do not confuse it with tokio-tar (unmaintained, forked by Astral as astral-tokio-tar) — different crate, different lineage.
  • cargo deny check advisories is clean for this dependency.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions