medium

CVE-2026-53600

crates.io · async-tar

Summary

async-tar PAX extension-header desync enables tar entry/content smuggling

Severity
medium
EPSS
0.3% (p25)
CWE
CWE-20, CWE-843
Also known as
GHSA-35rm-7j9c-2f7m
Published
2026-07-08
Updated
2026-07-08

Advisory details

Summary

async-tar v0.6.0 mis-applies a buffered PAX size extension to an intermediary extension header (a GNU longname L, a GNU longlink K, or a PAX x/g header) instead of to the next file entry. POSIX requires a PAX extended-header record set to describe the next file entry, never an intervening extension header. Because poll_next_raw (src/archive.rs) threads the buffered PAX records into the size computation of whatever raw header it reads next — and that header can be an intermediary L — the stream cursor is advanced by an attacker-chosen amount when the L body is consumed. The parser then desyncs relative to a POSIX-correct tar parser (e.g. GNU tar), reading subsequent bytes at the wrong block boundary.

An attacker who can influence a tar stream that an async-tar consumer extracts can construct an x → L → file sequence whose entry list and on-disk result differ between async-tar and a reference parser. This enables content/entry smuggling: a file that a GNU-tar-based scanner/validator/AV sees as benign opaque data is extracted by async-tar as a different file with different bytes (e.g. an executable script), and vice versa.

Type confusion / improper validation of the specified quantity (size). CWE-20, CWE-843. Severity assessed Medium, consistent with the same defect class in the upstream tar-rs / tokio-tar lineage.

Affected code

Package: async-tar (crates.io). Affected version: 0.6.0 (latest release) and current main HEAD. Both lack the extension-header guard.

src/archive.rs, poll_next_raw (line numbers from the v0.6.0 tag, commit 45814b19295b7398e119c90c57d8c8bf70a798b6):

    let file_pos = *next;

    let mut header = current_header.take().unwrap();

    // when pax extensions are available, the size should come from there.
    let mut size = header.entry_size()?;

    // the size above will be overriden by the pax data if it has a size field.
    // same for uid and gid, which will be overridden in the header itself.
    if let Some(pax_extensions_data) = pax_extensions_data {   // <-- no is_extension_header guard
        let pax = pax_extensions(pax_extensions_data);
        for extension in pax {
            let extension = extension.map_err(|_e| other("pax extensions invalid"))?;
            let Some(key) = extension.key().ok() else { continue };
            match key {
                "size" => {
                    let size_str = extension.value()
                        .map_err(|_e| other("failed to parse pax size as string"))?;
                    size = size_str.parse::<u64>()
                        .map_err(|_e| other("failed to parse pax size"))?;
                }
                "uid" => { let v = extension.value().unwrap(); header.set_uid(v.parse().unwrap()); }
                "gid" => { let v = extension.value().unwrap(); header.set_gid(v.parse().unwrap()); }
                _ => { continue }
            }
        }
    }

    let data = EntryIo::Data(archive.clone().take(size));   // body length = mis-applied PAX size

and a few lines further down the same function:

    // Store where the next entry is, rounding up by 512 bytes.
    let size = (size + 511) & !(512 - 1);
    *next += size;                                          // cursor advance = mis-applied PAX size

The caller loop in src/archive.rs (Entries::poll_next) buffers a PAX local extension into current_pax_extensions and then calls poll_next_raw with current_pax_extensions.as_deref() for the next raw header. When that next raw header is an intermediary GNU longname (handled by the is_gnu_longname() branch a few lines later), the PAX size is applied to it, so *next advances by the spoofed size rather than the L header's own declared size. That is the desync.

The buffered PAX records are intended to apply only to the following file entry; the missing check is whether the raw header currently being sized is itself an extension header (L/K/x/g).

Impact

Differential extraction / entry smuggling. A consumer that extracts an attacker-influenced tar stream with async-tar (e.g. a server endpoint that unpacks an uploaded .tar/.tar.gz, a dependency/artifact fetcher that unpacks a remote tarball, an archive-preview/scan pipeline) will:

This breaks any security control that relies on scanning the archive with one parser and extracting with async-tar: a malware/secret scanner reading the stream with GNU tar can be made to see only benign data while async-tar writes an executable payload to disk. It can also be used to hide entries from audit/inventory tooling, or to write content to a path the reviewer believes holds something else. No attacker-controlled local state is required — only the ability to influence the bytes of the tar stream that the consumer extracts.

How input reaches the sink (reachability)

The vulnerable path is the library's primary public API for reading archives: Archive::new(reader).entries() returns an Entries stream whose poll_next drives poll_next_raw for every header. Any consumer that iterates entries (or calls unpack/unpack_in on them) of an attacker-influenced tar stream reaches the sink with no additional configuration. The reader need not be a file — it is any AsyncRead, so an upload buffer, an HTTP response body, or a decompressor output all qualify. The only precondition for the desync is that the stream contain a PAX local-extension header (x) carrying a size record immediately followed by an intermediary GNU longname (L) before the next file header — a structure the attacker fully controls in the archive bytes. Representative reachable consumers are server endpoints that unpack uploaded .tar/.tar.gz bodies, dependency/artifact fetchers that unpack remote tarballs, and archive-scan/preview pipelines.

Proof of concept

A standalone Rust consumer binary that links the published crates.io async-tar = "=0.6.0" (default-features = false, features = ["runtime-tokio"]) and runs the real Archive::new(...).entries() extraction loop (the same shape used by real downstream server consumers that unpack uploaded tarballs). It reads a tar file and writes each entry to a destination directory, printing the entry list async-tar surfaces. A second binary hand-crafts the malicious and benign tar byte streams.

Malicious archive geometry (block = 512 bytes):

B0  x  PAX local-extension header, records declare size=1024 (= 2 blocks)
B1     PAX records  ("<len> size=1024\n")
B2  L  GNU longname header, OWN declared size = 512 (= 1 block)
B3     longname block #1  = "GNU_SEES_THIS.txt\0..."   (the name GNU tar uses)
B4     a normal file header "placeholder_A" (size 512)
B5     <-- this block IS a valid tar header for the smuggled file
           "hidden_payload.sh" (size 65)
B6     smuggled payload  "#!/bin/sh\n# SMUGGLED ENTRY...\n"
B7,B8  two zero blocks (EOF)

GNU tar honours the L header's own declared size (1 block) for the longname and ignores the buffered PAX size, so it reads B3 as the longname, treats B4 as the file, and reads B5 as that file's opaque data. async-tar mis-applies the PAX size (2 blocks) to the L header, reads B3+B4 as the longname, lands its cursor on B5, parses it as a tar header, and extracts the smuggled hidden_payload.sh body (B6).

Tar-builder source (mktar.rs):

use std::io::Write;
const BLOCK: usize = 512;

fn octal(buf: &mut [u8], v: u64) {
    let s = format!("{:0width$o}", v, width = buf.len() - 1);
    let b = s.as_bytes();
    buf[..b.len()].copy_from_slice(b);
    buf[b.len()] = 0;
}

fn header(name: &[u8], size: u64, typeflag: u8) -> [u8; BLOCK] {
    let mut h = [0u8; BLOCK];
    let n = name.len().min(100);
    h[..n].copy_from_slice(&name[..n]);
 

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.