syft/syft/pkg/cataloger/golang/scan_binary.go
Alex Goodman 58a033f924
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>
2026-08-14 20:08:58 +00:00

180 lines
5.8 KiB
Go

package golang
import (
"debug/buildinfo"
"fmt"
"io"
"runtime/debug"
"strings"
"github.com/kastenhq/goversion/version"
"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"
)
type extendedBuildInfo struct {
*debug.BuildInfo
cryptoSettings []string
arch string
symbols []binarySymbol
}
// scanFile scans file to try to report the Go and module versions.
func scanFile(location file.Location, reader unionreader.UnionReader, captureSymbols bool) ([]*extendedBuildInfo, error) {
// NOTE: multiple readers are returned to cover universal binaries, which are files
// with more than one binary
readers, errs := unionreader.GetReaders(reader)
if errs != nil {
log.WithFields("error", errs).Debug("failed to open a golang binary")
return nil, fmt.Errorf("failed to open a golang binary: %w", errs)
}
var builds []*extendedBuildInfo
for _, r := range readers {
bi, err := getBuildInfo(r, location)
if err != nil {
log.WithFields("file", location.RealPath, "error", err).Trace("unable to read golang buildinfo")
continue
}
// it's possible the reader just isn't a go binary, in which case just skip it
if bi == nil {
continue
}
v, err := getCryptoInformation(r)
if err != nil {
log.WithFields("file", location.RealPath, "error", err).Trace("unable to read golang version info")
// don't skip this build info.
// we can still catalog packages, even if we can't get the crypto information
errs = unknown.Appendf(errs, location, "unable to read golang version info: %w", err)
}
v = append(v, getNativeFIPSSettings(bi.Settings)...)
arch := getGOARCH(bi.Settings)
if arch == "" {
arch, err = getGOARCHFromBin(r)
if err != nil {
log.WithFields("file", location.RealPath, "error", err).Trace("unable to read golang arch info")
// don't skip this build info.
// we can still catalog packages, even if we can't get the arch information
errs = unknown.Appendf(errs, location, "unable to read golang arch info: %w", err)
}
}
var symbols []binarySymbol
if captureSymbols {
symbols, err = getSymbols(r)
if err != nil {
log.WithFields("file", location.RealPath, "error", err).Trace("unable to read golang symbol info")
// don't skip this build info.
// we can still catalog packages, even if we can't get the symbol information
errs = unknown.Appendf(errs, location, "unable to read golang symbol info: %w", err)
}
}
builds = append(builds, &extendedBuildInfo{BuildInfo: bi, cryptoSettings: v, arch: arch, symbols: symbols})
}
return builds, errs
}
func getCryptoInformation(reader io.ReaderAt) ([]string, error) {
v, err := version.ReadExeFromReader(reader)
if err != nil {
return nil, err
}
return getCryptoSettingsFromVersion(v), nil
}
func getCryptoSettingsFromVersion(v version.Version) []string {
cryptoSettings := []string{}
if v.StandardCrypto {
cryptoSettings = append(cryptoSettings, "standard-crypto")
}
if v.BoringCrypto {
cryptoSettings = append(cryptoSettings, "boring-crypto")
}
if v.FIPSOnly {
cryptoSettings = append(cryptoSettings, "crypto/tls/fipsonly")
}
return cryptoSettings
}
func getNativeFIPSSettings(settings []debug.BuildSetting) []string {
var cryptoSettings []string
for _, s := range settings {
switch s.Key {
case "GOFIPS140":
if s.Value != "" {
cryptoSettings = append(cryptoSettings, "GOFIPS140="+s.Value)
}
case "DefaultGODEBUG":
for _, kv := range strings.Split(s.Value, ",") {
if setting, val, ok := strings.Cut(kv, "="); ok && setting == "fips140" {
cryptoSettings = append(cryptoSettings, "GODEBUG=fips140="+val)
}
}
}
}
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 {
// this can happen in cases where a malformed binary is passed in can be initially parsed, but not
// used without error later down the line. This is the case with :
// https://github.com/llvm/llvm-project/blob/llvmorg-15.0.6/llvm/test/Object/Inputs/macho-invalid-dysymtab-bad-size
err = fmt.Errorf("recovered from panic: %v", r)
}
}()
// try to read buildinfo from the binary directly
bi, err = readBuildInfo(r)
if err == nil {
return bi, nil
}
// if direct read fails and this looks like a UPX-compressed binary,
// try to decompress and read the buildinfo from the decompressed data
if isUPXCompressed(r) {
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 = readBuildInfo(decompressed)
if err == nil {
return bi, nil
}
}
}
// note: the stdlib does not export the error we need to check for
if err != nil {
if err.Error() == "not a Go executable" {
// since the cataloger can only select executables and not distinguish if they are a go-compiled
// binary, we should not show warnings/logs in this case. For this reason we nil-out err here.
err = nil
return bi, err
}
// in this case we could not read the or parse the file, but not explicitly because it is not a
// go-compiled binary (though it still might be).
return bi, err
}
return bi, err
}