From b8381670d698d2dcd2e3276b8fe2899413a1119a Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Fri, 14 Aug 2026 09:20:57 -0400 Subject: [PATCH] fix(arch): bound the decompressed mtree listing The ALPM cataloger handed a gzip stream straight to `mtree.ParseSpec`, which materializes every entry before it returns any of them. Nothing capped the decompressed size, so a crafted mtree in a scanned image expanded until the process died. 64KB of input is enough to produce 64MB, and the ratio scales. Reads through a limit now and rejects a listing that exceeds it. The read goes one byte past the cap so that tripping it is distinguishable from a listing that simply ends there, since silently truncating would produce a package missing most of its files. The existing error path already surfaces this as an unknown. The cap is well above any real package: an mtree names every file with its digests, and even the largest packages land far under it. Signed-off-by: Alex Goodman --- syft/pkg/cataloger/arch/parse_alpm_db.go | 23 +++++++-- syft/pkg/cataloger/arch/parse_alpm_db_test.go | 48 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/syft/pkg/cataloger/arch/parse_alpm_db.go b/syft/pkg/cataloger/arch/parse_alpm_db.go index 6e409824b..1d4066c23 100644 --- a/syft/pkg/cataloger/arch/parse_alpm_db.go +++ b/syft/pkg/cataloger/arch/parse_alpm_db.go @@ -2,6 +2,7 @@ package arch import ( "bufio" + "bytes" "compress/gzip" "context" "fmt" @@ -277,15 +278,31 @@ func parsePkgFiles(pkgFields map[string]any) (*parsedData, error) { return &entry, nil } +// maxMtreeSize bounds the decompressed mtree listing. An mtree names every file in a package along +// with its digests, so even a very large package lands well under this. The cap is here because gzip +// reaches roughly 1032:1, so without it a small crafted member expands until the process dies, and +// mtree.ParseSpec materializes every entry before returning any of them. +const maxMtreeSize = 64 * 1024 * 1024 + func parseMtree(r io.Reader) ([]pkg.AlpmFileRecord, error) { - var err error var entries []pkg.AlpmFileRecord - r, err = gzip.NewReader(r) + gzReader, err := gzip.NewReader(r) if err != nil { return nil, err } - specDh, err := mtree.ParseSpec(r) + + // read one byte past the cap so that hitting it is distinguishable from a listing that simply ends + // there. Truncating instead would hand back a package silently missing most of its files. + data, err := io.ReadAll(io.LimitReader(gzReader, maxMtreeSize+1)) + if err != nil { + return nil, err + } + if len(data) > maxMtreeSize { + return nil, fmt.Errorf("mtree file is larger than the max allowed size (%d bytes)", maxMtreeSize) + } + + specDh, err := mtree.ParseSpec(bytes.NewReader(data)) if err != nil { return nil, err } diff --git a/syft/pkg/cataloger/arch/parse_alpm_db_test.go b/syft/pkg/cataloger/arch/parse_alpm_db_test.go index a046fff88..be4fc0d65 100644 --- a/syft/pkg/cataloger/arch/parse_alpm_db_test.go +++ b/syft/pkg/cataloger/arch/parse_alpm_db_test.go @@ -2,6 +2,9 @@ package arch import ( "bufio" + "bytes" + "compress/gzip" + "io" "os" "testing" "time" @@ -217,3 +220,48 @@ func TestMtreeParse(t *testing.T) { } } + +// gzipOfSize returns a gzip member that decompresses to exactly n bytes. The payload is +// highly compressible, which is the whole point: the caller supplies kilobytes and the +// decompressed stream is whatever size it asks for. +func gzipOfSize(t *testing.T, n int64) io.Reader { + t.Helper() + + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + chunk := make([]byte, 32*1024) + for remaining := n; remaining > 0; { + size := int64(len(chunk)) + if remaining < size { + size = remaining + } + written, err := w.Write(chunk[:size]) + require.NoError(t, err) + remaining -= int64(written) + } + require.NoError(t, w.Close()) + + return bytes.NewReader(buf.Bytes()) +} + +func Test_parseMtree_boundsDecompressedSize(t *testing.T) { + t.Run("rejects a listing past the cap", func(t *testing.T) { + r := gzipOfSize(t, maxMtreeSize+1) + + _, err := parseMtree(r) + + require.ErrorContains(t, err, "larger than the max allowed size") + }) + + t.Run("a listing at the cap is not rejected on size", func(t *testing.T) { + // guards the off-by-one: at exactly the cap the size check must not fire, so whatever + // happens next is the mtree parser's business and not ours + r := gzipOfSize(t, maxMtreeSize) + + _, err := parseMtree(r) + + if err != nil { + require.NotContains(t, err.Error(), "larger than the max allowed size") + } + }) +}