You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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)
⚠️ 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
Audit every .unpack( / entries() extraction site across the workspace and record which half each is hardened against.
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.
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" },
]
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.
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.
Entry::unpack(bothtarandasync-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:
Archive::unpack(→Entry::unpack_in,target_base = Some(dst))dst, callsvalidate_inside_dstEntry::unpack(fields.unpack(None, ..))None => src.into_owned(), kernel resolves against process CWDVerified in the vendored sources:
tar-0.4.46/src/entry.rs:203-205(unpack→None),:528-546(theNonearm)async-tar-0.6.1/src/entry.rs:253-255,:554-572— identicalBoth 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 showsfile.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/shadowand 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, soetc/shadowis a live path. Sharing an inode then defeats every path-based containment in the daemon at once — including the SFTP resolver, whosecanonicalize().starts_with(workspace)sees a legitimately-contained path.Status per site
common::archive::extract_tar_implhard_linkanchored todest_dir)minimald::rpcworkspace unpack (crates/minimald/src/rpc.rs:913-914)async_tar::Archive::unpackunpack_in)minimald::rpc::unpack_workspace_patchesRegular/Continuousentriesremote-client,diagnostics,minimalarchive pathsNothing in
mainis 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
.unpack(/entries()extraction site across the workspace and record which half each is hardened against.common::archivealready 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, inminimald. Both should call one function.clippy.toml: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.archive_extractharness now does inode accounting; keep that property when the oracle changes.unpackdoc example not model the unsafe pattern.async-taris 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-taris not unmaintained. Three releases in 2026; we are on the latest (0.6.1). Do not confuse it withtokio-tar(unmaintained, forked by Astral asastral-tokio-tar) — different crate, different lineage.cargo deny check advisoriesis clean for this dependency.