mirror of
https://github.com/anchore/syft.git
synced 2026-11-02 23:46:35 +01: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"
|
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) {
|
func parseLinuxKernelModuleFile(ctx context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||||
unionReader, err := unionreader.GetUnionReader(reader)
|
unionReader, err := unionreader.GetUnionReader(reader)
|
||||||
if err != nil {
|
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
|
// 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.
|
// passthrough path — its lifecycle is the caller's.
|
||||||
func decompressedModuleReader(ctx context.Context, path string, r unionreader.UnionReader) (unionreader.UnionReader, error) {
|
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
|
// fast path: plain .ko files don't need format sniffing
|
||||||
if strings.HasSuffix(path, ".ko") {
|
if strings.HasSuffix(path, ".ko") {
|
||||||
return &nopCloseUnionReader{UnionReader: r}, nil
|
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}
|
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()
|
_ = tfr.Close()
|
||||||
return nil, fmt.Errorf("unable to write decompressed kernel module: %w", err)
|
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 {
|
if _, err := tempFile.Seek(0, io.SeekStart); err != nil {
|
||||||
_ = tfr.Close()
|
_ = tfr.Close()
|
||||||
return nil, fmt.Errorf("unable to rewind decompressed kernel module: %w", err)
|
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/internal/tmpdir"
|
||||||
"github.com/anchore/syft/syft/file"
|
"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"
|
||||||
"github.com/anchore/syft/syft/pkg/cataloger/generic"
|
"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) {
|
func TestDecompressedModuleReader_TempFileRemovedOnClose(t *testing.T) {
|
||||||
koBytes := minimalKOBytes([]string{"name=test", "vermagic=5.15.0 SMP"})
|
koBytes := minimalKOBytes([]string{"name=test", "vermagic=5.15.0 SMP"})
|
||||||
data := gzCompress(koBytes)
|
data := gzCompress(koBytes)
|
||||||
|
|||||||
Reference in New Issue
Block a user