mirror of
https://github.com/anchore/syft.git
synced 2026-10-11 21:57:22 +02:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user