Hi,

I’ve been looking into the tar header parsing code in GNU Midnight Commander 
(which is derived from GNU tar), and I figured that when reading an archive, 
tar sizes several memory allocations directly from the unvalidated numbers in 
the headers.

My understanding is that the ensuing crashes don't have a security impact, 
since I couldn’t achieve amplification, but let me know if you see it 
differently and want to get CVE(s) issued. Nevertheless, I would argue that 
these aborts ought to be fixed, since it’s not an xalloc_die() under memory 
pressure, but rather a crash on invalid/corrupt input, which tar properly 
diagnoses in other instances.

I could identify the following sites that are currently missing validation:

1. the data of a GNU long name ('L') or long link ('K') header - read_header in 
src/list.c;
2. the data of a POSIX extended header ('x', 'g', or Solaris 'X’) - 
xheader_read in src/xheader.c;
3. the sparse map, from GNU.sparse.numblocks for PAX sparse formats 0.0 and 0.1 
(sparse_numblocks_decoder in src/xheader.c), and from the entry count at the 
start of the member data for format 1.0 (pax_decode_header in src/sparse.c).

In practice, this means that a crafted archive of a few KiB can make tar exit 
with "memory exhausted" instead of a corrupt-archive diagnostic. With the 
current master (8723e7e, 2026-10-09), tar -tf aborts on each of those, run 
without any resource limit:

- 'L' header with a base-256 size of 2**62
- 'x' or 'g' header with a base-256 size of 2**62
- GNU.sparse.numblocks=2**60 (format 0.0 or 0.1)
- format 1.0 map that declares 2**60 entries

The outcome of parsing an 8 GiB octal size header in a 3 KiB archive depends on 
the overcommit settings.

I came up with LLM-assisted patches including tests, which I’m attaching to 
this report in the hope that they will be useful. I have reviewed and tested 
them, but I’m neither familiar with tar’s code base and coding style, nor am I 
particularly proficient in C. So feel free to adopt any portion of those, or 
discard them altogether in favor of a different solution.

In the first patch, I took a “cheap” approach of bounding the header size to 64 
MiB. Unfortunately, I don’t have a feeling of what is legitimate and what 
isn’t. Is it thinkable to have valid archives with such large headers, i.e. 
full of xattr sets or PAX 0.1 sparse maps of very fragmented files? If so, what 
is a better limit, 256 MiB? Or even 1 GiB?

In the second patch, I explore limiting the header size by the size of the 
file, but then one would still have to fall back to a fixed limit when the file 
size is not known, such as for pipes, and this is a very typical usage pattern 
for tar.

Finally, the third patch tries to bound the sparse story, and this is when it 
felt like it’s really getting out of hand in terms of intrusiveness, so I 
started thinking that crashing with a memory allocation error might be actually 
a valid way to handle the problem.

I’m afraid I’m not in a position to make good calls on that, so caveat emptor.

In as far as the tests are concerned, below is the way to reproduce the crafted 
archives, which are embedded into the patches. I appreciate that crafted blobs 
might not look particularly appealing, especially in the light of the xz supply 
chain scandal. OTOH, I think it’s a good way for anyone to reproduce the 
behavior. I attach a Python script to craft the archives, and the commands 
below should let you reproduce the blobs deterministically.

  % python3 mklmem.py arch

  % shasum -a 256 arch/*.tar
    03b554ac076437a013412b605b8b93c4223f350ab09e9a0f6b003ac3081f9560 
arch/lmem01.tar
    d99f5d70f49d56e1b8990b2104e1faa7a67c35b503fe67a4973a0f474ebf1310 
arch/lmem02g.tar
    5302720d303a56f030229aefa5685937099ecf132a3a01eb90686367629aca57 
arch/lmem02.tar
    9800cbb218f28de6638e65d12a72bc053d8175b7c0c47f8a35c22808c3581e65 
arch/lmem03.tar
    a33b70a573ffc5cc652d47a874a88bc837a9a8958494940030d16a97c33c31c8 
arch/lmem04m.tar
    07f9b1efa89cdc62e151d954fc939ab614b834c1a2f86c787982f9a33f2e285c 
arch/lmem04.tar
    5d8f1d50a33c3ad8718b8c97b4ad5be7fd15b1a6efc511b37c372f3491faa76c 
arch/lmem05.tar

  % for a in arch/lmem*.tar; do echo "== $a"; xz -c -e -T 1 
--block-size=16777216 --memlimit-compress=0 < "$a" | base64 -w 79; done

Please keep me on CC on replies, as I’m not subscribed to the list.

Many thanks!


All the best,
Yury Zaytsev

Attachment: 0001-tar-validate-long-name-and-extended-header-sizes.patch
Description: Binary data

Attachment: 0002-tar-bound-header-sizes-by-the-archive-size-when-know.patch
Description: Binary data

Attachment: 0003-tar-don-t-size-sparse-maps-from-unvalidated-counts.patch
Description: Binary data

#!/usr/bin/env python3
"""Generate the crafted archives embedded in GNU tar tests/lmem0[1-5].at.

usage: python3 mklmem.py OUTDIR

Every byte is set explicitly, so the output is identical on every run and
every platform.  Members have mode 0644, uid/gid 0, mtime 0 and no data
unless stated otherwise.  Sparse member data are 512 bytes of genfile's
default pattern (byte I is I & 255).

  lmem01.tar   a, GNU long name ('L') header declaring 2^62 bytes, b
  lmem02.tar   a, POSIX extended ('x') header declaring 2^62 bytes, b
  lmem02g.tar  a, POSIX global ('g') header declaring 2^62 bytes, b
  lmem03.tar   a, 'x' header declaring 1 MiB (less than HEADER_SIZE_MAX,
               more than the archive holds), b
  lmem04.tar   PAX sparse 0.0 member f whose GNU.sparse.numblocks is 2^60,
               followed by the two offset/numbytes pairs that tar itself
               would write: 512 bytes of data at 0, and a 0-byte entry
               at the real size 1024 that ends the map
  lmem04m.tar  The same as PAX sparse 0.1, whose GNU.sparse.map carries
               both pairs
  lmem05.tar   PAX sparse 1.0 member f whose in-data map declares 2^60
               entries, followed by one valid entry
"""
import os
import sys

BLOCK = 512
HUGE_HEADER = 1 << 62
HUGE_COUNT = 1 << 60   # times sizeof (struct sp_array) overflows size_t
GNU_MAGIC = b"ustar  \0"
DATA = bytes(i & 255 for i in range(BLOCK))  # genfile's default pattern
POSIX_MAGIC = b"ustar\x0000"


def octal(n, width):
    """Octal numeric field of WIDTH bytes, NUL terminated."""
    return b"%0*o\0" % (width - 1, n)


def base256(n, width=12):
    """GNU base-256 numeric field: 0x80 flag byte, then big-endian value."""
    return b"\x80" + n.to_bytes(width - 1, "big")


def header(name, typeflag, size_field, magic):
    h = bytearray(BLOCK)
    h[0:len(name)] = name
    h[100:108] = octal(0o644, 8)
    h[108:116] = octal(0, 8)
    h[116:124] = octal(0, 8)
    h[124:136] = size_field
    h[136:148] = octal(0, 12)
    h[156:157] = typeflag
    h[257:265] = magic
    h[148:156] = b" " * 8
    h[148:156] = b"%06o\0 " % sum(h)
    return bytes(h)


def pad(data):
    return data + b"\0" * (-len(data) % BLOCK)


def member(name, data=b"", magic=POSIX_MAGIC, typeflag=b"0"):
    return header(name, typeflag, octal(len(data), 12), magic) + pad(data)


def record(kv):
    """One POSIX extended header record: "LEN KEYWORD=VALUE\\n"."""
    body = b" " + kv + b"\n"
    n = len(body)
    while len(b"%d" % n) + len(body) != n:
        n = len(b"%d" % n) + len(body)
    return b"%d" % n + body


def xheader(name, typeflag, records, size_field=None):
    data = b"".join(record(r) for r in records)
    if size_field is None:
        size_field = octal(len(data), 12)
    return header(name, typeflag, size_field, POSIX_MAGIC) + pad(data)


EOA = b"\0" * (2 * BLOCK)

ARCHIVES = {
    "lmem01.tar":
        member(b"a", magic=GNU_MAGIC)
        + header(b"././@LongLink", b"L", base256(HUGE_HEADER), GNU_MAGIC)
        + pad(b"c")
        + member(b"b", magic=GNU_MAGIC) + EOA,
    "lmem02.tar":
        member(b"a")
        + xheader(b"./PaxHeaders/c", b"x", [b"path=c"], base256(HUGE_HEADER))
        + member(b"b") + EOA,
    "lmem02g.tar":
        member(b"a")
        + xheader(b"./GlobalHead.0.1", b"g", [b"comment=c"],
                  base256(HUGE_HEADER))
        + member(b"b") + EOA,
    "lmem03.tar":
        member(b"a")
        + xheader(b"./PaxHeaders/c", b"x", [b"path=c"], octal(1 << 20, 12))
        + member(b"b") + EOA,
    "lmem04.tar":
        xheader(b"./PaxHeaders/f", b"x",
                [b"GNU.sparse.size=1024",
                 b"GNU.sparse.numblocks=%d" % HUGE_COUNT,
                 b"GNU.sparse.offset=0",
                 b"GNU.sparse.numbytes=512",
                 b"GNU.sparse.offset=1024",
                 b"GNU.sparse.numbytes=0"])
        + member(b"f", DATA) + EOA,
    "lmem04m.tar":
        xheader(b"./PaxHeaders/f", b"x",
                [b"GNU.sparse.size=1024",
                 b"GNU.sparse.numblocks=%d" % HUGE_COUNT,
                 b"GNU.sparse.name=f",
                 b"GNU.sparse.map=0,512,1024,0"])
        + member(b"./GNUSparseFile.0/f", DATA) + EOA,
    "lmem05.tar":
        xheader(b"./PaxHeaders/f", b"x",
                [b"GNU.sparse.major=1", b"GNU.sparse.minor=0",
                 b"GNU.sparse.name=f", b"GNU.sparse.realsize=1024"])
        + member(b"./GNUSparseFile.0/f",
                 pad(b"%d\n0\n512\n" % HUGE_COUNT) + DATA)
        + EOA,
}

outdir = sys.argv[1]
os.makedirs(outdir, exist_ok=True)
for fname, blob in ARCHIVES.items():
    assert len(blob) % BLOCK == 0, fname
    with open(os.path.join(outdir, fname), "wb") as f:
        f.write(blob)

Reply via email to