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 <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman 2026-08-14 09:20:57 -04:00
parent 68da404bd7
commit b8381670d6
No known key found for this signature in database
2 changed files with 68 additions and 3 deletions

View File

@ -2,6 +2,7 @@ package arch
import ( import (
"bufio" "bufio"
"bytes"
"compress/gzip" "compress/gzip"
"context" "context"
"fmt" "fmt"
@ -277,15 +278,31 @@ func parsePkgFiles(pkgFields map[string]any) (*parsedData, error) {
return &entry, nil 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) { func parseMtree(r io.Reader) ([]pkg.AlpmFileRecord, error) {
var err error
var entries []pkg.AlpmFileRecord var entries []pkg.AlpmFileRecord
r, err = gzip.NewReader(r) gzReader, err := gzip.NewReader(r)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -2,6 +2,9 @@ package arch
import ( import (
"bufio" "bufio"
"bytes"
"compress/gzip"
"io"
"os" "os"
"testing" "testing"
"time" "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")
}
})
}