logo

Database

Lack of data validation In async-tar

Description

async-tar PAX extension-header desync enables tar entry/content smuggling ## 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): rust 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: rust // 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: - materialize files / file contents that a POSIX-correct parser (GNU tar, libarchive/bsdtar) does not surface, and - omit or alter files that the reference parser does surface. 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 (" 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

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Component
Affected version
Patched versions