fix(kernel): bound what a compressed kernel module expands to (#5294)

`.ko.gz`/`.ko.xz`/`.ko.zst` modules are spilled to a temp file so ELF parsing
can seek over them. The compressed size is bounded by the file in the image but
what it decompresses to is not, so a few KB of crafted zstd filled the temp
directory until the disk did.

`io.Copy` now stops one byte past a 256MB cap and rejects rather than
truncating, since a partial ELF reads as a corrupt module instead of a rejected
one. The partial spill is removed on the reject path.

The cap is a parameter on an inner function so the tests exercise the boundary
with a few hundred bytes instead of materializing 256MB, matching
`decompressSbomWithLimit` in the graalvm cataloger.

The cap bounds one spill, not the process: peak disk is 256MB times however many
modules are parsed at once, which is cataloger parallelism (`NumCPU*4` by
default, and unbounded when `--parallelism` is negative).

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-09-14 17:40:41 -04:00
committed by GitHub
parent 30e9e65608
commit 2be26a1af9
2 changed files with 72 additions and 1 deletions
@@ -22,6 +22,12 @@ import (
const modinfoName = ".modinfo"
// maxDecompressedModuleSize bounds what a compressed kernel module may expand to. The compressed size
// is bounded by the file in the image, what it expands to is not, and it lands in a temp file, so the
// exposure is disk. Real modules are tens of MB. This bounds one spill, not the process: peak disk is
// this times cataloger parallelism (NumCPU*4 by default, unbounded when --parallelism is negative).
const maxDecompressedModuleSize = 256 * 1024 * 1024
func parseLinuxKernelModuleFile(ctx context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
unionReader, err := unionreader.GetUnionReader(reader)
if err != nil {
@@ -61,6 +67,11 @@ func parseLinuxKernelModuleFile(ctx context.Context, _ file.Resolver, _ *generic
// the returned reader and must Close it; the underlying reader (r) is not closed by Close on the
// passthrough path — its lifecycle is the caller's.
func decompressedModuleReader(ctx context.Context, path string, r unionreader.UnionReader) (unionreader.UnionReader, error) {
return decompressedModuleReaderWithLimit(ctx, path, r, maxDecompressedModuleSize)
}
// decompressedModuleReaderWithLimit takes the bound as a parameter so tests can exercise it cheaply.
func decompressedModuleReaderWithLimit(ctx context.Context, path string, r unionreader.UnionReader, maxDecompressed int64) (unionreader.UnionReader, error) {
// fast path: plain .ko files don't need format sniffing
if strings.HasSuffix(path, ".ko") {
return &nopCloseUnionReader{UnionReader: r}, nil
@@ -97,10 +108,17 @@ func decompressedModuleReader(ctx context.Context, path string, r unionreader.Un
}
tfr := &tempFileUnionReader{File: tempFile, cleanup: fileCleanup}
if _, err := io.Copy(tempFile, rc); err != nil {
// read one past the cap so hitting it is distinguishable from a module that ends there; truncating
// would hand the parser a partial ELF, which reads as corrupt rather than rejected.
written, err := io.Copy(tempFile, io.LimitReader(rc, maxDecompressed+1))
if err != nil {
_ = tfr.Close()
return nil, fmt.Errorf("unable to write decompressed kernel module: %w", err)
}
if written > maxDecompressed {
_ = tfr.Close()
return nil, fmt.Errorf("decompressed kernel module is larger than the max allowed size (%d bytes)", maxDecompressed)
}
if _, err := tempFile.Seek(0, io.SeekStart); err != nil {
_ = tfr.Close()
return nil, fmt.Errorf("unable to rewind decompressed kernel module: %w", err)
@@ -17,6 +17,7 @@ import (
"github.com/anchore/syft/internal/tmpdir"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/internal/unionreader"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
@@ -253,6 +254,58 @@ func TestDecompressedModuleReader(t *testing.T) {
}
}
// wrapUnionReader adapts a byte slice to the reader set decompressedModuleReader takes.
func wrapUnionReader(data []byte) unionreader.UnionReader {
br := bytes.NewReader(data)
return struct {
io.ReadCloser
io.ReaderAt
io.Seeker
}{
ReadCloser: io.NopCloser(br),
ReaderAt: br,
Seeker: br,
}
}
func TestDecompressedModuleReader_boundsDecompressedSize(t *testing.T) {
koBytes := minimalKOBytes([]string{"name=test", "vermagic=5.15.0 SMP mod_unload"})
compressed := gzCompress(koBytes)
t.Run("rejects a module that expands past the cap", func(t *testing.T) {
_, err := decompressedModuleReaderWithLimit(testContext(t), "/test.ko.gz",
wrapUnionReader(compressed), int64(len(koBytes)-1))
require.ErrorContains(t, err, "larger than the max allowed size")
})
t.Run("a module at exactly the cap is accepted whole", func(t *testing.T) {
// the byte-for-byte assert is the point: a "did not error" check would miss a truncating read
got, err := decompressedModuleReaderWithLimit(testContext(t), "/test.ko.gz",
wrapUnionReader(compressed), int64(len(koBytes)))
require.NoError(t, err)
t.Cleanup(func() { _ = got.Close() })
b, err := io.ReadAll(got)
require.NoError(t, err)
assert.Equal(t, koBytes, b, "at-cap module must come back byte for byte")
})
t.Run("the partial spill is cleaned up when the cap is hit", func(t *testing.T) {
// otherwise repeated hostile modules exhaust the disk anyway, which is what the cap prevents
dir := t.TempDir()
ctx := tmpdir.WithValue(context.Background(), tmpdir.FromPath(dir))
_, err := decompressedModuleReaderWithLimit(ctx, "/test.ko.gz",
wrapUnionReader(compressed), int64(len(koBytes)-1))
require.Error(t, err)
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Empty(t, entries, "temp spill should be removed when the module is rejected")
})
}
func TestDecompressedModuleReader_TempFileRemovedOnClose(t *testing.T) {
koBytes := minimalKOBytes([]string{"name=test", "vermagic=5.15.0 SMP"})
data := gzCompress(koBytes)