mirror of
https://github.com/anchore/syft.git
synced 2026-11-01 23:16:30 +01:00
Prevent unnecessary allocations when parsing compressed ELF sections (#5187)
* fix(elf): bound compressed ELF section reads `debug/elf` takes a section's decompressed size from that section's own compression header, and a highly compressible stream really does deliver the bytes that header promises, so `internal/saferio` does not help: it faithfully allocates every one of them. A 2MB input file drives `elf.NewFile` to allocate over 10GB and return no error, which is a fatal OOM rather than a recoverable panic. Which sections get read is not up to the caller. `elf.NewFile` always reads the section-name string table, and `File.Symbols` reads `.symtab` plus whatever section its `Link` field points at, so being selective about sections is not enough to avoid it. New `elfutil.NewFile` is a drop-in for `elf.NewFile` that rejects a declared decompressed size over 128MB. Every production call site goes through it, and a ruleguard rule keeps the next one from going direct. The check runs in two parts, since `debug/elf` expands sections at two different times. The section-name string table is the only one `elf.NewFile` expands itself, so it is checked against the raw bytes before the call; everything else is expanded lazily by `(*Section).Open` and is checked after the parse, where names, types and decompressed sizes are already resolved. Only the sections syft can actually reach are bounded, which keeps the guard from costing real binaries. DWARF is excluded since nothing calls `File.DWARF`, so a large compressed `.debug_info` no longer skips the whole file, and sections `debug/elf` will not decompress anyway (`SHF_ALLOC`, `SHT_NOBITS`) are left alone. The legacy `.zdebug` form is matched on the section name the way `debug/elf` gates it rather than on the `ZLIB` magic, so an ordinary section starting with those four bytes is not mistaken for a compressed one. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix(elf): gate debug/buildinfo behind the compressed-section check `debug/buildinfo.Read` opens ELF files with `debug/elf` itself, and `elf.NewFile` expands the section-name string table as it parses, so the golang cataloger was still reachable by the same bomb `elfutil` exists to stop. A 261KB fixture drove 1.4GB of allocation through `buildinfo.Read` and returned no error. `elfutil.CheckSectionNameTable` is now exported for that case: callers that cannot use `NewFile` because the `debug/elf` call is made for them inside another package. Both `buildinfo.Read` call sites go through it, including the UPX-decompressed one. Also corrects claims that did not hold up: - the package doc's 2MB-to-10GB figure is not reachable with zlib (~1000:1), so it now carries the measured 510KB-to-2.6GB, and names zstd's 32767:1 since that is what makes the small inputs possible - `.go.buildinfo` was listed as a hot-path section elfutil covers, but it is read through `debug/buildinfo` and never touches `Section.Data` - the graalvm comment claimed routing size rejections away from `*elf.FormatError` improved reporting; both branches are skipped by the caller and only the FormatError branch logs, so it did the opposite - `sharedLibraries` logged short and truncated files as real ELF failures, since `debug/elf` returns a bare `io.EOF` rather than an `*elf.FormatError` for those Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * added test comments around the negative cases Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * additional tests Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * better decomposition and comments Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * use a less brittle constant for error detection Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
@@ -3,7 +3,6 @@ package binary
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"debug/elf"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"github.com/anchore/syft/internal/unknown"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/internal/elfutil"
|
||||
"github.com/anchore/syft/syft/internal/unionreader"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
@@ -146,7 +146,7 @@ func getELFNotes(r file.LocationReadCloser) (*elfBinaryPackageNotes, error) {
|
||||
return nil, fmt.Errorf("unable to get union reader for binary: %w", err)
|
||||
}
|
||||
|
||||
f, err := elf.NewFile(unionReader)
|
||||
f, err := elfutil.NewFile(unionReader)
|
||||
if f == nil || err != nil {
|
||||
log.WithFields("file", r.Location.Path(), "error", err).Trace("unable to parse binary as ELF")
|
||||
return nil, nil
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"github.com/anchore/syft/syft/internal/elfutil"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/binary/internal/manager/internal"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/binary/internal/manager/internal/config"
|
||||
)
|
||||
@@ -203,7 +204,7 @@ const (
|
||||
)
|
||||
|
||||
func getPlatformElf(f *os.File) string {
|
||||
elfFile, err := elf.NewFile(f)
|
||||
elfFile, err := elfutil.NewFile(f)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package golang
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"debug/elf"
|
||||
"debug/macho"
|
||||
"debug/pe"
|
||||
"errors"
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/internal/elfutil"
|
||||
"github.com/anchore/syft/syft/internal/unionreader"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/generic"
|
||||
@@ -398,7 +398,7 @@ func getGOARCHFromBin(r io.ReaderAt) (string, error) {
|
||||
var arch string
|
||||
switch {
|
||||
case bytes.HasPrefix(ident, []byte("\x7FELF")):
|
||||
f, err := elf.NewFile(r)
|
||||
f, err := elfutil.NewFile(r)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unrecognized file format: %w", err)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/unknown"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/internal/elfutil"
|
||||
"github.com/anchore/syft/syft/internal/unionreader"
|
||||
)
|
||||
|
||||
@@ -122,6 +123,17 @@ func getNativeFIPSSettings(settings []debug.BuildSetting) []string {
|
||||
return cryptoSettings
|
||||
}
|
||||
|
||||
// readBuildInfo bounds the reader before handing it to debug/buildinfo, which opens ELF files with
|
||||
// debug/elf itself rather than through elfutil. elf.NewFile expands the section-name string table as it
|
||||
// parses, so an unbounded read here is reachable no matter how little of the file buildinfo goes on to
|
||||
// look at.
|
||||
func readBuildInfo(r io.ReaderAt) (*debug.BuildInfo, error) {
|
||||
if err := elfutil.CheckSectionNameTable(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildinfo.Read(r)
|
||||
}
|
||||
|
||||
func getBuildInfo(r io.ReaderAt, location file.Location) (bi *debug.BuildInfo, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
@@ -133,7 +145,7 @@ func getBuildInfo(r io.ReaderAt, location file.Location) (bi *debug.BuildInfo, e
|
||||
}()
|
||||
|
||||
// try to read buildinfo from the binary directly
|
||||
bi, err = buildinfo.Read(r)
|
||||
bi, err = readBuildInfo(r)
|
||||
if err == nil {
|
||||
return bi, nil
|
||||
}
|
||||
@@ -144,7 +156,7 @@ func getBuildInfo(r io.ReaderAt, location file.Location) (bi *debug.BuildInfo, e
|
||||
log.WithFields("path", location.RealPath).Trace("detected UPX-compressed Go binary, attempting decompression to read the build info")
|
||||
decompressed, decompErr := decompressUPX(r)
|
||||
if decompErr == nil {
|
||||
bi, err = buildinfo.Read(decompressed)
|
||||
bi, err = readBuildInfo(decompressed)
|
||||
if err == nil {
|
||||
return bi, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package golang
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"debug/buildinfo"
|
||||
"debug/elf"
|
||||
"encoding/binary"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/file"
|
||||
)
|
||||
|
||||
// Test_getBuildInfo_compressedSectionBomb covers the reason readBuildInfo exists: debug/buildinfo opens
|
||||
// ELF files with debug/elf itself, and elf.NewFile expands the section-name string table as it parses,
|
||||
// so an oversized compression header there is an unbounded allocation on a path elfutil.NewFile never
|
||||
// sees. The fixture is a real zlib stream, so it delivers every byte its header promises.
|
||||
func Test_getBuildInfo_compressedSectionBomb(t *testing.T) {
|
||||
const declared = 256 << 20 // comfortably over elfutil's bound, small enough to allocate in a test
|
||||
|
||||
bomb := elfWithCompressedNameTable(t, declared)
|
||||
t.Logf("%d byte fixture declares a %d byte section name table", len(bomb), declared)
|
||||
|
||||
// the unguarded path is the thing being defended against: prove the fixture really is a bomb
|
||||
unguarded := measureAlloc(t, func() {
|
||||
_, err := buildinfo.Read(bytes.NewReader(bomb))
|
||||
t.Logf("buildinfo.Read err: %v", err)
|
||||
})
|
||||
assert.Greater(t, unguarded, uint64(declared), "fixture did not actually deliver the declared bytes")
|
||||
|
||||
guarded := measureAlloc(t, func() {
|
||||
_, err := getBuildInfo(bytes.NewReader(bomb), file.NewLocation("bomb"))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "over the")
|
||||
})
|
||||
assert.Less(t, guarded, uint64(32<<20), "getBuildInfo allocated far more than the input warrants")
|
||||
t.Logf("unguarded allocated %d bytes, guarded allocated %d bytes", unguarded, guarded)
|
||||
}
|
||||
|
||||
func measureAlloc(t *testing.T, fn func()) uint64 {
|
||||
t.Helper()
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
fn()
|
||||
runtime.ReadMemStats(&after)
|
||||
return after.TotalAlloc - before.TotalAlloc
|
||||
}
|
||||
|
||||
// elfWithCompressedNameTable builds a minimal ELF64 whose only real section is a SHF_COMPRESSED
|
||||
// .shstrtab declaring `declared` decompressed bytes and genuinely delivering them.
|
||||
func elfWithCompressedNameTable(t *testing.T, declared uint64) []byte {
|
||||
t.Helper()
|
||||
|
||||
// the decompressed name table only has to start with the section names; the rest is padding that
|
||||
// exists purely to make the declared size real
|
||||
payload := make([]byte, declared)
|
||||
copy(payload, "\x00.shstrtab\x00")
|
||||
|
||||
var compressed bytes.Buffer
|
||||
zw := zlib.NewWriter(&compressed)
|
||||
_, err := zw.Write(payload)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, zw.Close())
|
||||
|
||||
ehsize := uint64(binary.Size(elf.Header64{}))
|
||||
shentsize := uint64(binary.Size(elf.Section64{}))
|
||||
chdrsize := uint64(binary.Size(elf.Chdr64{}))
|
||||
shoff := ehsize
|
||||
bodyOff := shoff + 2*shentsize
|
||||
|
||||
var ident [16]byte
|
||||
copy(ident[:], elf.ELFMAG)
|
||||
ident[elf.EI_CLASS] = byte(elf.ELFCLASS64)
|
||||
ident[elf.EI_DATA] = byte(elf.ELFDATA2LSB)
|
||||
ident[elf.EI_VERSION] = byte(elf.EV_CURRENT)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
require.NoError(t, binary.Write(buf, binary.LittleEndian, elf.Header64{
|
||||
Ident: ident, Type: uint16(elf.ET_REL), Machine: uint16(elf.EM_X86_64),
|
||||
Version: uint32(elf.EV_CURRENT), Shoff: shoff, Ehsize: uint16(ehsize),
|
||||
Shentsize: uint16(shentsize), Shnum: 2, Shstrndx: 1,
|
||||
}))
|
||||
// the null section
|
||||
require.NoError(t, binary.Write(buf, binary.LittleEndian, elf.Section64{}))
|
||||
// the name table: sh_size is the on-disk size, so it has to cover the whole zlib stream
|
||||
require.NoError(t, binary.Write(buf, binary.LittleEndian, elf.Section64{
|
||||
Name: 1, Type: uint32(elf.SHT_STRTAB), Flags: uint64(elf.SHF_COMPRESSED),
|
||||
Off: bodyOff, Size: chdrsize + uint64(compressed.Len()), Addralign: 1,
|
||||
}))
|
||||
require.NoError(t, binary.Write(buf, binary.LittleEndian, elf.Chdr64{
|
||||
Type: uint32(elf.COMPRESS_ZLIB), Size: declared, Addralign: 1,
|
||||
}))
|
||||
buf.Write(compressed.Bytes())
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package golang
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"debug/elf"
|
||||
"debug/gosym"
|
||||
"debug/macho"
|
||||
"encoding/binary"
|
||||
@@ -11,6 +10,8 @@ import (
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/syft/syft/internal/elfutil"
|
||||
)
|
||||
|
||||
// mainPackage is the import path the linker assigns to the binary's main package.
|
||||
@@ -274,7 +275,7 @@ func readPclntab(r io.ReaderAt) (pclntab []byte, textStart uint64, err error) {
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(string(ident), "\x7FELF"):
|
||||
f, err := elf.NewFile(r)
|
||||
f, err := elfutil.NewFile(r)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("unable to parse ELF binary: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"debug/macho"
|
||||
"debug/pe"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/cpe"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/internal/elfutil"
|
||||
"github.com/anchore/syft/syft/internal/unionreader"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
@@ -365,7 +367,17 @@ func sharedLibraries(context MatcherContext) ([]string, error) {
|
||||
}
|
||||
defer internal.CloseAndLogError(contents, context.Location.RealPath)
|
||||
|
||||
e, _ := elf.NewFile(contents)
|
||||
e, err := elfutil.NewFile(contents)
|
||||
if err != nil {
|
||||
// this function tries ELF, then Mach-O, then PE, so "not an ELF" is the expected case and stays
|
||||
// quiet. debug/elf reports a wrong magic as an *elf.FormatError, but a file too short to hold a
|
||||
// header as a bare EOF, so both count as "not an ELF" here. Anything else means a real ELF was
|
||||
// dropped, and nothing downstream would report it.
|
||||
var fmtErr *elf.FormatError
|
||||
if !errors.As(err, &fmtErr) && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
log.WithFields("file", context.Location.RealPath, "error", err).Debug("unable to parse ELF binary")
|
||||
}
|
||||
}
|
||||
if e != nil {
|
||||
symbols, err := e.ImportedLibraries()
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/anchore/syft/syft/internal/elfutil"
|
||||
"github.com/anchore/syft/syft/internal/unionreader"
|
||||
)
|
||||
|
||||
@@ -21,7 +22,7 @@ func ExtractDepsJSONFromELFBundle(r unionreader.UnionReader) (string, error) {
|
||||
}
|
||||
|
||||
func findBundleHeaderOffsetInELF(r unionreader.UnionReader) (int64, error) {
|
||||
elfFile, err := elf.NewFile(r)
|
||||
elfFile, err := elfutil.NewFile(r)
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/format/cyclonedxjson"
|
||||
"github.com/anchore/syft/syft/internal/elfutil"
|
||||
"github.com/anchore/syft/syft/internal/unionreader"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
@@ -141,10 +142,11 @@ func fileError(filename string, err error) (nativeImage, error) {
|
||||
// newElf reads a Native Image from an ELF executable.
|
||||
func newElf(filename string, r io.ReaderAt) (nativeImage, error) {
|
||||
// First attempt to read an ELF file.
|
||||
bi, err := elf.NewFile(r)
|
||||
bi, err := elfutil.NewFile(r)
|
||||
|
||||
if err != nil {
|
||||
var fmtErr *elf.FormatError
|
||||
// note: a size rejection from elfutil is not an *elf.FormatError, so it takes the branch below
|
||||
if errors.As(err, &fmtErr) {
|
||||
// this is not an elf file
|
||||
log.WithFields("filename", filename, "error", err).Trace("not an ELF binary")
|
||||
|
||||
@@ -2,7 +2,6 @@ package kernel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"debug/elf"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
"github.com/anchore/syft/internal/tmpdir"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/internal/elfutil"
|
||||
"github.com/anchore/syft/syft/internal/unionreader"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/generic"
|
||||
@@ -168,7 +168,7 @@ func parseLinuxKernelModuleMetadata(r unionreader.UnionReader) (p *pkg.LinuxKern
|
||||
p = &pkg.LinuxKernelModule{
|
||||
Parameters: make(map[string]pkg.LinuxKernelModuleParameter),
|
||||
}
|
||||
f, err := elf.NewFile(r)
|
||||
f, err := elfutil.NewFile(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user