mirror of
https://github.com/anchore/syft.git
synced 2026-08-19 16:48:27 +02: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:
parent
04a6fa1b41
commit
58a033f924
@ -10,11 +10,12 @@ import (
|
|||||||
"github.com/anchore/syft/internal/log"
|
"github.com/anchore/syft/internal/log"
|
||||||
"github.com/anchore/syft/internal/unknown"
|
"github.com/anchore/syft/internal/unknown"
|
||||||
"github.com/anchore/syft/syft/file"
|
"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/internal/unionreader"
|
||||||
)
|
)
|
||||||
|
|
||||||
func findELFFeatures(data *file.Executable, reader unionreader.UnionReader) error {
|
func findELFFeatures(data *file.Executable, reader unionreader.UnionReader) error {
|
||||||
f, err := elf.NewFile(reader)
|
f, err := elfutil.NewFile(reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
422
syft/internal/elfutil/elfutil.go
Normal file
422
syft/internal/elfutil/elfutil.go
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
/*
|
||||||
|
Package elfutil provides an ELF opener that rejects sections whose compression headers declare an
|
||||||
|
implausible decompressed size.
|
||||||
|
|
||||||
|
debug/elf sizes a section's buffer from the section's own compression header, and a highly compressible
|
||||||
|
stream really does deliver the bytes that header promises, so internal/saferio's chunked read does not
|
||||||
|
help: it appends its way to every declared byte, and Go's 1.25x slice growth puts the lifetime cost near
|
||||||
|
five times that. 512MB of zeros is 510KB of zlib, and that file drives elf.NewFile through 2.6GB of
|
||||||
|
allocation and returns no error, which is a fatal Go OOM rather than a recoverable panic. Zlib caps out
|
||||||
|
near 1000:1, but debug/elf also accepts zstd, which passes 32000:1 on zeroed input, so the input needed
|
||||||
|
to declare a given size is smaller still.
|
||||||
|
|
||||||
|
The check runs in two parts, because debug/elf expands sections at two different times:
|
||||||
|
|
||||||
|
- the section-name string table is the one section elf.NewFile expands on its own, so it has to be
|
||||||
|
bounded against the raw bytes before the call is made. That is CheckSectionNameTable, and the walk
|
||||||
|
of the section table it needs is why this package decodes any ELF structures by hand at all.
|
||||||
|
- every other section is expanded lazily by (*Section).Open, so those are bounded after the parse,
|
||||||
|
where names, types and decompressed sizes are resolved already.
|
||||||
|
|
||||||
|
Only the sections syft can actually drive debug/elf into decompressing are bounded. DWARF is deliberately
|
||||||
|
excluded: syft never asks for it, so a binary carrying a large compressed .debug_info is cataloged rather
|
||||||
|
than skipped over a section nobody reads.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://www.sco.com/developers/gabi/latest/ch4.sheader.html
|
||||||
|
- https://groups.google.com/g/generic-abi/c/satyPkuMisk
|
||||||
|
- https://docs.oracle.com/en/operating-systems/solaris/oracle-solaris/11.4/linkers-libraries/gnu-style-section-compression.html
|
||||||
|
- https://sourceware.org/pipermail/binutils/2015-July/089559.html
|
||||||
|
- https://cs.opensource.google/go/go/+/refs/tags/go1.26.5:src/debug/elf/file.go
|
||||||
|
- https://go.dev/doc/go1.19#linker
|
||||||
|
*/
|
||||||
|
package elfutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"debug/elf"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
intFile "github.com/anchore/syft/internal/file"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxDeclaredSectionSize bounds the decompressed size any single reachable section may declare.
|
||||||
|
//
|
||||||
|
// The bound only ever applies to a compressed section, and the gABI allows compression only on
|
||||||
|
// non-allocable ones, so nothing syft reads on the hot path (.data, .text, notes) can reach it. The
|
||||||
|
// realistic worst case is a compressed .symtab/.strtab on a very large binary, well under this. The
|
||||||
|
// trade-off is that a binary whose symbol or string table decompresses past this is skipped rather than
|
||||||
|
// cataloged; that is the correct direction to fail, since the alternative is OOM-killing the whole scan.
|
||||||
|
//
|
||||||
|
// Note that this is a per-section bound, not a per-file one: a file may hold several sections that each
|
||||||
|
// sit just under it. That is deliberate, since the sections syft actually reads are few.
|
||||||
|
const maxDeclaredSectionSize uint64 = 128 * intFile.MB
|
||||||
|
|
||||||
|
// legacyZlibHeaderSize is the size of the .zdebug header: the "ZLIB" magic plus a big-endian size.
|
||||||
|
const legacyZlibHeaderSize = 12
|
||||||
|
|
||||||
|
// sectionsReadByName are the sections syft asks debug/elf for by name. Everything else it reaches by
|
||||||
|
// section type, which reachableSections covers separately. Not every one of these ends in a read of the
|
||||||
|
// contents (.text is used for its address, .symtab only for a nil check), but bounding any name syft asks
|
||||||
|
// for is cheaper to keep true than tracking which lookups reach Data.
|
||||||
|
//
|
||||||
|
// Add to this list when a cataloger starts reading a new section by name. The ruleguard rule only stops a
|
||||||
|
// new debug/elf call site; it cannot see a new (*Section).Data call behind an already-bounded open.
|
||||||
|
var sectionsReadByName = map[string]struct{}{
|
||||||
|
".symtab": {},
|
||||||
|
".data": {},
|
||||||
|
".text": {},
|
||||||
|
".gopclntab": {},
|
||||||
|
".note.package": {},
|
||||||
|
".modinfo": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// wire sizes of the structures we decode, which differ between the two ELF classes
|
||||||
|
var (
|
||||||
|
sizeSection32 = int64(binary.Size(elf.Section32{}))
|
||||||
|
sizeSection64 = int64(binary.Size(elf.Section64{}))
|
||||||
|
sizeChdr32 = int64(binary.Size(elf.Chdr32{}))
|
||||||
|
sizeChdr64 = int64(binary.Size(elf.Chdr64{}))
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewFile parses an ELF file, rejecting it if a section syft may read declares a decompressed size this
|
||||||
|
// process should not be asked to allocate. It is a drop-in for elf.NewFile, except that a rejection is
|
||||||
|
// reported as a plain error rather than an *elf.FormatError, so callers that distinguish "not an ELF"
|
||||||
|
// from "bad ELF" treat it as the latter.
|
||||||
|
func NewFile(r io.ReaderAt) (*elf.File, error) {
|
||||||
|
if err := CheckSectionNameTable(r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := elf.NewFile(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkReachableSections(f); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkReachableSections bounds every section syft can drive debug/elf into decompressing. This runs
|
||||||
|
// after the parse because (*Section).Open expands lazily, so nothing has been allocated yet.
|
||||||
|
func checkReachableSections(f *elf.File) error {
|
||||||
|
for i := range reachableSections(f) {
|
||||||
|
s := f.Sections[i]
|
||||||
|
declared, claimed := declaredSectionSize(s)
|
||||||
|
if claimed && declared > maxDeclaredSectionSize {
|
||||||
|
return fmt.Errorf("elf section %q declares %d decompressed bytes, over the %d byte limit",
|
||||||
|
s.Name, declared, maxDeclaredSectionSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reachableSections reports which sections syft can actually reach. It marks by type generously, since
|
||||||
|
// the debug/elf accessors all go through SectionByType, which returns only the first section of a type: a
|
||||||
|
// second SHT_SYMTAB is bounded here despite being unreachable. DWARF is absent by design: nothing in syft
|
||||||
|
// calls File.DWARF, so debug/elf is never asked to expand a .debug_* section.
|
||||||
|
func reachableSections(f *elf.File) map[int]struct{} {
|
||||||
|
reach := make(map[int]struct{})
|
||||||
|
mark := func(i int) {
|
||||||
|
if i >= 0 && i < len(f.Sections) {
|
||||||
|
reach[i] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, s := range f.Sections {
|
||||||
|
switch s.Type {
|
||||||
|
case elf.SHT_SYMTAB, elf.SHT_DYNSYM, elf.SHT_DYNAMIC:
|
||||||
|
// File.Symbols, File.DynamicSymbols and File.DynString each read one of these plus the
|
||||||
|
// string table its Link field points at
|
||||||
|
mark(i)
|
||||||
|
mark(int(s.Link))
|
||||||
|
case elf.SHT_GNU_VERSYM, elf.SHT_GNU_VERDEF, elf.SHT_GNU_VERNEED:
|
||||||
|
// File.DynamicSymbols pulls the symbol version tables in behind the caller's back, via
|
||||||
|
// gnuVersionInit. Strictly they are only reachable alongside a .dynsym, and VERDEF/VERNEED
|
||||||
|
// only alongside a VERSYM, but they are marked unconditionally rather than tracked: the only
|
||||||
|
// file that loses out carries an oversized version table and no dynamic symbols at all,
|
||||||
|
// which is not something a toolchain emits.
|
||||||
|
mark(i)
|
||||||
|
}
|
||||||
|
if _, ok := sectionsReadByName[s.Name]; ok {
|
||||||
|
mark(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reach
|
||||||
|
}
|
||||||
|
|
||||||
|
// declaredSectionSize reports the decompressed size a section claims, if it claims one at all.
|
||||||
|
//
|
||||||
|
// ELF grew two separate ways of saying "this section is compressed", and (*Section).Open honors both, so
|
||||||
|
// this dispatches the same way Open does and in the same order. That correspondence is the whole design:
|
||||||
|
// wherever Open returns a decompressing reader, this reports the size that reader will expand to. Missing
|
||||||
|
// one is the bug this package exists to prevent, so when a Go release changes Open, this is what has to be
|
||||||
|
// re-read against it.
|
||||||
|
//
|
||||||
|
// It deliberately over-reports in one place, an unrecognized ch_type: Open hands back an error reader for
|
||||||
|
// anything but zlib and zstd, so nothing is allocated, but the type is not exposed on elf.Section and
|
||||||
|
// re-reading the header for it would only spare a file debug/elf cannot decompress anyway.
|
||||||
|
func declaredSectionSize(s *elf.Section) (uint64, bool) {
|
||||||
|
// SHT_NOBITS marks a section that occupies no bytes in the file, .bss being the familiar one: it
|
||||||
|
// declares a size the loader zero-fills at run time rather than a size stored anywhere. Its sh_offset
|
||||||
|
// is only a conceptual placement and routinely lands inside some other section's bytes. Two things
|
||||||
|
// follow, and both say to test this before looking at compression at all:
|
||||||
|
// - Open tests it first too, ahead of any compression, and hands back a reader that fails every
|
||||||
|
// read. Nothing is allocated for the declared size, however large it is.
|
||||||
|
// - the gABI forbids SHF_COMPRESSED here, but elf.NewFile does not enforce that and parses a
|
||||||
|
// compression header anyway. The Size it leaves on the section was decoded from whatever bytes lie
|
||||||
|
// at that borrowed offset, so it is not a figure to bound anything against.
|
||||||
|
if s.Type == elf.SHT_NOBITS {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Flags&elf.SHF_COMPRESSED != 0 {
|
||||||
|
return chdrDeclaredSize(s)
|
||||||
|
}
|
||||||
|
return zdebugDeclaredSize(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chdrDeclaredSize reports the size declared by the modern compression form: an SHF_COMPRESSED flag,
|
||||||
|
// plus an Elf32_Chdr or Elf64_Chdr at the head of the section giving the algorithm and the decompressed
|
||||||
|
// size. This is the form standardized in the gABI and the one any current toolchain emits.
|
||||||
|
func chdrDeclaredSize(s *elf.Section) (uint64, bool) {
|
||||||
|
// SHF_ALLOC marks a section the loader maps into the running process, and nothing decompresses a
|
||||||
|
// section on its way into memory, so the gABI makes the two flags mutually exclusive. Open enforces
|
||||||
|
// that rather than tolerating it: a section setting both gets an error reader, never a decompressing
|
||||||
|
// one, so a size declared alongside SHF_ALLOC is never allocated.
|
||||||
|
if s.Flags&elf.SHF_ALLOC != 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
// elf.NewFile decodes the Chdr during the parse and overwrites Size with ch_size, leaving the on-disk
|
||||||
|
// figure in FileSize. So the number a hostile file controls is already here, and no second read of the
|
||||||
|
// file is needed.
|
||||||
|
return s.Size, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// zdebugDeclaredSize reports the size declared by the legacy compression form. Before SHF_COMPRESSED was
|
||||||
|
// added to the gABI, GNU tooling signaled a compressed debug section by renaming it, .debug_info to
|
||||||
|
// .zdebug_info, and prefixing the payload with a header of its own: the magic "ZLIB" followed by the
|
||||||
|
// decompressed size. Toolchains default to the gABI form now, Go's own linker having emitted .zdebug up
|
||||||
|
// to 1.19, but this is not vestigial: nothing ties the prefix to a debug section, so a crafted file can
|
||||||
|
// hang it on a section syft does read, an SHT_SYMTAB named .zdebug_x being the obvious one.
|
||||||
|
func zdebugDeclaredSize(s *elf.Section) (uint64, bool) {
|
||||||
|
// no flag distinguishes this form, so the section name is the only signal, and Open gates on the name
|
||||||
|
// before it so much as looks at the bytes. Matching that gate exactly is what keeps an ordinary
|
||||||
|
// section whose contents happen to begin with "ZLIB" out of scope: Open will never decompress it, so
|
||||||
|
// neither may this.
|
||||||
|
if !strings.HasPrefix(s.Name, ".zdebug") {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
// unlike the Chdr above, nothing in the parse has looked at this header, so read it here. Reading off
|
||||||
|
// the section is only safe because SHF_COMPRESSED is clear on this path, since elf.NewFile leaves the
|
||||||
|
// embedded ReaderAt nil for a compressed one. Open treats a short read or absent magic as "not really
|
||||||
|
// compressed after all" and serves the bytes as they lie.
|
||||||
|
var hdr [legacyZlibHeaderSize]byte
|
||||||
|
if n, _ := s.ReadAt(hdr[:], 0); n != legacyZlibHeaderSize || string(hdr[:4]) != "ZLIB" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
// the size is big-endian whatever byte order the rest of the file uses. The header predates the gABI
|
||||||
|
// mechanism and was simply defined that way, carrying no endianness of its own.
|
||||||
|
return binary.BigEndian.Uint64(hdr[4:]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckSectionNameTable bounds the one section elf.NewFile expands on its own. Anything it cannot walk is
|
||||||
|
// passed through untouched, leaving elf.NewFile to describe the file. It does not repeat every check
|
||||||
|
// elf.NewFile makes ahead of that expansion, though, so a file that is both malformed and oversized can
|
||||||
|
// come back with this error rather than an *elf.FormatError.
|
||||||
|
//
|
||||||
|
// NewFile calls this itself. It is exported for the callers that cannot use NewFile because the
|
||||||
|
// debug/elf call is made for them inside another package, debug/buildinfo being the one syft reaches:
|
||||||
|
// gate the reader on this and the eager section-name table read is bounded, which is everything that
|
||||||
|
// package expands today, since it reaches .go.buildinfo through the program headers instead.
|
||||||
|
func CheckSectionNameTable(r io.ReaderAt) error {
|
||||||
|
class, order, ok := identify(r)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sh, ok := sectionNameTableHeader(r, class, order)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// the name table's own name is not resolved at the point elf.NewFile reads it, so debug/elf cannot
|
||||||
|
// take the legacy .zdebug path for this section: only a compression header can apply
|
||||||
|
if sh.flags&uint64(elf.SHF_COMPRESSED) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
declared, err := compressedSize(r, class, order, sh.offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil //nolint:nilerr // truncated section; elf.NewFile will say so
|
||||||
|
}
|
||||||
|
if declared > maxDeclaredSectionSize {
|
||||||
|
return fmt.Errorf("elf section name table declares %d decompressed bytes, over the %d byte limit",
|
||||||
|
declared, maxDeclaredSectionSize)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// identify decodes the class and byte order every later read depends on, out of the file's 16
|
||||||
|
// identification bytes. It reports false for anything that is not an ELF this package understands, so
|
||||||
|
// elf.NewFile remains the single source of format errors.
|
||||||
|
func identify(r io.ReaderAt) (elf.Class, binary.ByteOrder, bool) {
|
||||||
|
var ident [16]byte
|
||||||
|
if _, err := io.ReadFull(io.NewSectionReader(r, 0, int64(len(ident))), ident[:]); err != nil {
|
||||||
|
return 0, nil, false
|
||||||
|
}
|
||||||
|
if string(ident[:4]) != elf.ELFMAG {
|
||||||
|
return 0, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
class := elf.Class(ident[elf.EI_CLASS])
|
||||||
|
if class != elf.ELFCLASS32 && class != elf.ELFCLASS64 {
|
||||||
|
return 0, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch elf.Data(ident[elf.EI_DATA]) {
|
||||||
|
case elf.ELFDATA2LSB:
|
||||||
|
return class, binary.LittleEndian, true
|
||||||
|
case elf.ELFDATA2MSB:
|
||||||
|
return class, binary.BigEndian, true
|
||||||
|
}
|
||||||
|
return 0, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionNameTableHeader locates the header of the section syft's own parse cannot reach, the one
|
||||||
|
// elf.NewFile expands for itself. It reports false whenever the file names no name table or is malformed
|
||||||
|
// enough that nothing gets decompressed: either way there is nothing to bound, and describing the file is
|
||||||
|
// elf.NewFile's job rather than this package's.
|
||||||
|
func sectionNameTableHeader(r io.ReaderAt, class elf.Class, order binary.ByteOrder) (sectionHeader, bool) {
|
||||||
|
shoff, shentsize, shnum, shstrndx, err := fileHeader(r, class, order)
|
||||||
|
if err != nil || shoff <= 0 || shentsize < sectionHeaderSize(class) {
|
||||||
|
return sectionHeader{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// a zero e_shnum means the real count lives in section 0's size field, and an e_shstrndx of
|
||||||
|
// SHN_XINDEX means the real string table index lives in that same header's link field
|
||||||
|
if shnum == 0 {
|
||||||
|
sh, err := readSectionHeader(r, class, order, shoff)
|
||||||
|
if err != nil {
|
||||||
|
return sectionHeader{}, false
|
||||||
|
}
|
||||||
|
shnum = sh.size
|
||||||
|
if shstrndx == uint64(elf.SHN_XINDEX) {
|
||||||
|
shstrndx = uint64(sh.link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// an index of zero means the file has no section name table, so elf.NewFile returns before reading one.
|
||||||
|
// An out-of-range index expands nothing either, though elf.NewFile only rejects it when e_shnum was
|
||||||
|
// non-zero; reached through SHN_XINDEX it indexes out of range and panics instead. That is a
|
||||||
|
// recoverable panic the task executor already contains, unlike the OOM this package exists to stop.
|
||||||
|
if shstrndx == 0 || shstrndx >= shnum {
|
||||||
|
return sectionHeader{}, false
|
||||||
|
}
|
||||||
|
// the index is file-controlled, so guard the multiply and then the sum against a table the file
|
||||||
|
// places past the end of the address space
|
||||||
|
if shstrndx > uint64(math.MaxInt64)/uint64(shentsize) {
|
||||||
|
return sectionHeader{}, false
|
||||||
|
}
|
||||||
|
off := shoff + int64(shstrndx)*shentsize
|
||||||
|
if off < 0 {
|
||||||
|
return sectionHeader{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
sh, err := readSectionHeader(r, class, order, off)
|
||||||
|
if err != nil {
|
||||||
|
return sectionHeader{}, false
|
||||||
|
}
|
||||||
|
return sh, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionHeader is the handful of fields we need out of an Elf32_Shdr or an Elf64_Shdr.
|
||||||
|
type sectionHeader struct {
|
||||||
|
flags uint64
|
||||||
|
offset int64
|
||||||
|
size uint64
|
||||||
|
link uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileHeader returns the section table location and shape out of the ELF file header.
|
||||||
|
func fileHeader(r io.ReaderAt, class elf.Class, order binary.ByteOrder) (shoff, shentsize int64, shnum, shstrndx uint64, err error) {
|
||||||
|
if class == elf.ELFCLASS32 {
|
||||||
|
var h elf.Header32
|
||||||
|
if err := binary.Read(io.NewSectionReader(r, 0, int64(binary.Size(h))), order, &h); err != nil {
|
||||||
|
return 0, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
return int64(h.Shoff), int64(h.Shentsize), uint64(h.Shnum), uint64(h.Shstrndx), nil
|
||||||
|
}
|
||||||
|
var h elf.Header64
|
||||||
|
if err := binary.Read(io.NewSectionReader(r, 0, int64(binary.Size(h))), order, &h); err != nil {
|
||||||
|
return 0, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
// Shoff is a uint64, so a file may claim an offset that does not fit in an int64; the caller rejects
|
||||||
|
// a negative result rather than wrapping into a plausible-looking one
|
||||||
|
return int64(h.Shoff), int64(h.Shentsize), uint64(h.Shnum), uint64(h.Shstrndx), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSectionHeader decodes one section header. Reads go through binary.Read, which uses io.ReadFull, so
|
||||||
|
// a short read is an error rather than a partially-zeroed header.
|
||||||
|
func readSectionHeader(r io.ReaderAt, class elf.Class, order binary.ByteOrder, off int64) (sectionHeader, error) {
|
||||||
|
sr := io.NewSectionReader(r, off, sectionHeaderSize(class))
|
||||||
|
if class == elf.ELFCLASS32 {
|
||||||
|
var sh elf.Section32
|
||||||
|
if err := binary.Read(sr, order, &sh); err != nil {
|
||||||
|
return sectionHeader{}, err
|
||||||
|
}
|
||||||
|
return sectionHeader{
|
||||||
|
flags: uint64(sh.Flags),
|
||||||
|
offset: int64(sh.Off),
|
||||||
|
size: uint64(sh.Size),
|
||||||
|
link: sh.Link,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
var sh elf.Section64
|
||||||
|
if err := binary.Read(sr, order, &sh); err != nil {
|
||||||
|
return sectionHeader{}, err
|
||||||
|
}
|
||||||
|
return sectionHeader{
|
||||||
|
flags: sh.Flags,
|
||||||
|
offset: int64(sh.Off),
|
||||||
|
size: sh.Size,
|
||||||
|
link: sh.Link,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compressedSize reads the decompressed size out of a section's SHF_COMPRESSED header. Chdr64 carries a
|
||||||
|
// blank field that binary.Size counts and binary.Read skips, so the decoded offsets match what debug/elf
|
||||||
|
// reaches for with unsafe.Offsetof.
|
||||||
|
func compressedSize(r io.ReaderAt, class elf.Class, order binary.ByteOrder, off int64) (uint64, error) {
|
||||||
|
if off < 0 {
|
||||||
|
return 0, fmt.Errorf("negative section offset")
|
||||||
|
}
|
||||||
|
sr := io.NewSectionReader(r, off, compressionHeaderSize(class))
|
||||||
|
if class == elf.ELFCLASS32 {
|
||||||
|
var ch elf.Chdr32
|
||||||
|
if err := binary.Read(sr, order, &ch); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint64(ch.Size), nil
|
||||||
|
}
|
||||||
|
var ch elf.Chdr64
|
||||||
|
if err := binary.Read(sr, order, &ch); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return ch.Size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionHeaderSize(class elf.Class) int64 {
|
||||||
|
if class == elf.ELFCLASS32 {
|
||||||
|
return sizeSection32
|
||||||
|
}
|
||||||
|
return sizeSection64
|
||||||
|
}
|
||||||
|
|
||||||
|
func compressionHeaderSize(class elf.Class) int64 {
|
||||||
|
if class == elf.ELFCLASS32 {
|
||||||
|
return sizeChdr32
|
||||||
|
}
|
||||||
|
return sizeChdr64
|
||||||
|
}
|
||||||
652
syft/internal/elfutil/elfutil_test.go
Normal file
652
syft/internal/elfutil/elfutil_test.go
Normal file
@ -0,0 +1,652 @@
|
|||||||
|
package elfutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/zlib"
|
||||||
|
"debug/elf"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// section describes one section to place in a fixture. The compression headers are deliberately
|
||||||
|
// separable from the payload so a fixture can claim far more than it can deliver, which is the whole
|
||||||
|
// shape of the attack under test.
|
||||||
|
type section struct {
|
||||||
|
name string
|
||||||
|
typ elf.SectionType
|
||||||
|
flags elf.SectionFlag
|
||||||
|
link uint32
|
||||||
|
body []byte
|
||||||
|
compressed bool // emit a SHF_COMPRESSED chdr ahead of body
|
||||||
|
legacyZlib bool // emit a 12-byte .zdebug-style ZLIB header ahead of body
|
||||||
|
declaredSize uint64
|
||||||
|
offsetOf string // borrow another section's file offset, for SHT_NOBITS fixtures
|
||||||
|
shSize uint64 // override sh_size, since an SHT_NOBITS section's size is not its span in the file
|
||||||
|
}
|
||||||
|
|
||||||
|
type buildOpts struct {
|
||||||
|
// nameTable overrides the generated .shstrtab, so a fixture can compress the one section
|
||||||
|
// elf.NewFile expands on its own.
|
||||||
|
nameTable *section
|
||||||
|
// extendedShnum parks the real section count in section 0 and zeroes e_shnum.
|
||||||
|
extendedShnum bool
|
||||||
|
// xindexShstrndx sets e_shstrndx to SHN_XINDEX so the real index comes from section 0's link.
|
||||||
|
xindexShstrndx bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildELF assembles a minimal ELF: a null section, then secs, then a generated .shstrtab that
|
||||||
|
// e_shstrndx points at.
|
||||||
|
func buildELF(t *testing.T, class elf.Class, order binary.ByteOrder, secs []section, opts buildOpts) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
nameTable := section{name: ".shstrtab", typ: elf.SHT_STRTAB}
|
||||||
|
if opts.nameTable != nil {
|
||||||
|
nameTable = *opts.nameTable
|
||||||
|
nameTable.name = ".shstrtab"
|
||||||
|
nameTable.typ = elf.SHT_STRTAB
|
||||||
|
}
|
||||||
|
|
||||||
|
all := append([]section{{}}, secs...)
|
||||||
|
all = append(all, nameTable)
|
||||||
|
shstrndx := len(all) - 1
|
||||||
|
|
||||||
|
// the name table's body is the names of every section, so it has to be built before the payloads
|
||||||
|
var names bytes.Buffer
|
||||||
|
names.WriteByte(0)
|
||||||
|
nameOff := make([]uint32, len(all))
|
||||||
|
for i, s := range all {
|
||||||
|
if s.name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nameOff[i] = uint32(names.Len())
|
||||||
|
names.WriteString(s.name)
|
||||||
|
names.WriteByte(0)
|
||||||
|
}
|
||||||
|
all[shstrndx].body = names.Bytes()
|
||||||
|
// a fixture that compresses the name table without rigging a size gets an honest one, so the file is
|
||||||
|
// readable rather than merely parseable
|
||||||
|
if all[shstrndx].compressed && all[shstrndx].declaredSize == 0 {
|
||||||
|
all[shstrndx].declaredSize = uint64(names.Len())
|
||||||
|
all[shstrndx].body = deflate(t, names.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
is32 := class == elf.ELFCLASS32
|
||||||
|
ehsize, shentsize := binary.Size(elf.Header64{}), binary.Size(elf.Section64{})
|
||||||
|
machine := elf.EM_X86_64
|
||||||
|
if is32 {
|
||||||
|
ehsize, shentsize = binary.Size(elf.Header32{}), binary.Size(elf.Section32{})
|
||||||
|
machine = elf.EM_386
|
||||||
|
}
|
||||||
|
|
||||||
|
shoff := ehsize
|
||||||
|
dataOff := shoff + len(all)*shentsize
|
||||||
|
|
||||||
|
// lay the payloads out and remember where each landed, so an SHT_NOBITS fixture can point at one
|
||||||
|
payloads := make([][]byte, len(all))
|
||||||
|
offsets := make(map[string]uint64)
|
||||||
|
cursor := uint64(dataOff)
|
||||||
|
for i, s := range all {
|
||||||
|
payloads[i] = sectionContent(t, class, order, s)
|
||||||
|
offsets[s.name] = cursor
|
||||||
|
cursor += uint64(len(payloads[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
var ident [16]byte
|
||||||
|
copy(ident[:], elf.ELFMAG)
|
||||||
|
ident[elf.EI_CLASS] = byte(class)
|
||||||
|
ident[elf.EI_DATA] = byte(elf.ELFDATA2LSB)
|
||||||
|
if order == binary.BigEndian {
|
||||||
|
ident[elf.EI_DATA] = byte(elf.ELFDATA2MSB)
|
||||||
|
}
|
||||||
|
ident[elf.EI_VERSION] = byte(elf.EV_CURRENT)
|
||||||
|
|
||||||
|
shnum := uint16(len(all))
|
||||||
|
if opts.extendedShnum {
|
||||||
|
shnum = 0
|
||||||
|
}
|
||||||
|
strndx := uint16(shstrndx)
|
||||||
|
if opts.xindexShstrndx {
|
||||||
|
strndx = uint16(elf.SHN_XINDEX)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
if is32 {
|
||||||
|
write(t, buf, order, elf.Header32{
|
||||||
|
Ident: ident, Type: uint16(elf.ET_REL), Machine: uint16(machine), Version: uint32(elf.EV_CURRENT),
|
||||||
|
Shoff: uint32(shoff), Ehsize: uint16(ehsize), Shentsize: uint16(shentsize),
|
||||||
|
Shnum: shnum, Shstrndx: strndx,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
write(t, buf, order, elf.Header64{
|
||||||
|
Ident: ident, Type: uint16(elf.ET_REL), Machine: uint16(machine), Version: uint32(elf.EV_CURRENT),
|
||||||
|
Shoff: uint64(shoff), Ehsize: uint16(ehsize), Shentsize: uint16(shentsize),
|
||||||
|
Shnum: shnum, Shstrndx: strndx,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
off := uint64(dataOff)
|
||||||
|
for i, s := range all {
|
||||||
|
size := uint64(len(payloads[i]))
|
||||||
|
if s.shSize != 0 {
|
||||||
|
size = s.shSize
|
||||||
|
}
|
||||||
|
secOff := off
|
||||||
|
if s.offsetOf != "" {
|
||||||
|
secOff = offsets[s.offsetOf]
|
||||||
|
}
|
||||||
|
flags := s.flags
|
||||||
|
if s.compressed {
|
||||||
|
// the content header and the flag always travel together in a real file
|
||||||
|
flags |= elf.SHF_COMPRESSED
|
||||||
|
}
|
||||||
|
link := s.link
|
||||||
|
// section 0 carries the extended count and the extended name table index
|
||||||
|
if i == 0 {
|
||||||
|
if opts.extendedShnum {
|
||||||
|
size = uint64(len(all))
|
||||||
|
}
|
||||||
|
if opts.xindexShstrndx {
|
||||||
|
link = uint32(shstrndx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is32 {
|
||||||
|
write(t, buf, order, elf.Section32{
|
||||||
|
Name: nameOff[i], Type: uint32(s.typ), Flags: uint32(flags), Link: link,
|
||||||
|
Off: uint32(secOff), Size: uint32(size), Addralign: 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
write(t, buf, order, elf.Section64{
|
||||||
|
Name: nameOff[i], Type: uint32(s.typ), Flags: uint64(flags), Link: link,
|
||||||
|
Off: secOff, Size: size, Addralign: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
off += uint64(len(payloads[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, dataOff, buf.Len(), "fixture layout drifted from the declared offsets")
|
||||||
|
for _, p := range payloads {
|
||||||
|
buf.Write(p)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionContent wraps the body in whichever compression header the fixture asks for.
|
||||||
|
func sectionContent(t *testing.T, class elf.Class, order binary.ByteOrder, s section) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case s.legacyZlib:
|
||||||
|
hdr := make([]byte, legacyZlibHeaderSize)
|
||||||
|
copy(hdr, "ZLIB")
|
||||||
|
binary.BigEndian.PutUint64(hdr[4:], s.declaredSize)
|
||||||
|
return append(hdr, s.body...)
|
||||||
|
case s.compressed:
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
if class == elf.ELFCLASS32 {
|
||||||
|
write(t, buf, order, elf.Chdr32{
|
||||||
|
Type: uint32(elf.COMPRESS_ZLIB), Size: uint32(s.declaredSize), Addralign: 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
write(t, buf, order, elf.Chdr64{
|
||||||
|
Type: uint32(elf.COMPRESS_ZLIB), Size: s.declaredSize, Addralign: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
buf.Write(s.body)
|
||||||
|
return buf.Bytes()
|
||||||
|
default:
|
||||||
|
return s.body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(t *testing.T, w io.Writer, order binary.ByteOrder, v any) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, binary.Write(w, order, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deflate returns a real zlib stream, so a fixture using it actually delivers the bytes it promises.
|
||||||
|
func deflate(t *testing.T, payload []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
zw := zlib.NewWriter(buf)
|
||||||
|
_, err := zw.Write(payload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, zw.Close())
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// classes is the matrix every structural case runs under, since the header layouts differ per class and
|
||||||
|
// the compression header's size field is a different width in each.
|
||||||
|
var classes = []struct {
|
||||||
|
name string
|
||||||
|
class elf.Class
|
||||||
|
order binary.ByteOrder
|
||||||
|
}{
|
||||||
|
{"64-bit little-endian", elf.ELFCLASS64, binary.LittleEndian},
|
||||||
|
{"64-bit big-endian", elf.ELFCLASS64, binary.BigEndian},
|
||||||
|
{"32-bit little-endian", elf.ELFCLASS32, binary.LittleEndian},
|
||||||
|
{"32-bit big-endian", elf.ELFCLASS32, binary.BigEndian},
|
||||||
|
}
|
||||||
|
|
||||||
|
const overLimit = maxDeclaredSectionSize + 1
|
||||||
|
|
||||||
|
func TestNewFile_RejectsOversizedReachableSections(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
secs []section
|
||||||
|
opts buildOpts
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "compressed .symtab",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".symtab", typ: elf.SHT_SYMTAB, link: 2, compressed: true, declaredSize: overLimit},
|
||||||
|
{name: ".strtab", typ: elf.SHT_STRTAB},
|
||||||
|
},
|
||||||
|
wantErr: `".symtab"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the string table a .symtab links to",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".symtab", typ: elf.SHT_SYMTAB, link: 2},
|
||||||
|
{name: ".strtab", typ: elf.SHT_STRTAB, compressed: true, declaredSize: overLimit},
|
||||||
|
},
|
||||||
|
wantErr: `".strtab"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compressed .dynsym",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".dynsym", typ: elf.SHT_DYNSYM, link: 2, compressed: true, declaredSize: overLimit},
|
||||||
|
{name: ".dynstr", typ: elf.SHT_STRTAB},
|
||||||
|
},
|
||||||
|
wantErr: `".dynsym"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compressed .dynamic",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".dynamic", typ: elf.SHT_DYNAMIC, compressed: true, declaredSize: overLimit},
|
||||||
|
},
|
||||||
|
wantErr: `".dynamic"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compressed .note.package, reached by name rather than type",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".note.package", typ: elf.SHT_NOTE, compressed: true, declaredSize: overLimit},
|
||||||
|
},
|
||||||
|
wantErr: `".note.package"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compressed .modinfo, read by the kernel module cataloger",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".modinfo", typ: elf.SHT_PROGBITS, compressed: true, declaredSize: overLimit},
|
||||||
|
},
|
||||||
|
wantErr: `".modinfo"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// File.DynamicSymbols reads these via gnuVersionInit without the caller naming them
|
||||||
|
name: "compressed .gnu.version alongside a .dynsym",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".dynsym", typ: elf.SHT_DYNSYM, link: 2},
|
||||||
|
{name: ".dynstr", typ: elf.SHT_STRTAB},
|
||||||
|
{name: ".gnu.version", typ: elf.SHT_GNU_VERSYM, compressed: true, declaredSize: overLimit},
|
||||||
|
},
|
||||||
|
wantErr: `".gnu.version"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compressed .gnu.version_r alongside a .dynsym",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".dynsym", typ: elf.SHT_DYNSYM, link: 2},
|
||||||
|
{name: ".dynstr", typ: elf.SHT_STRTAB},
|
||||||
|
{name: ".gnu.version_r", typ: elf.SHT_GNU_VERNEED, compressed: true, declaredSize: overLimit},
|
||||||
|
},
|
||||||
|
wantErr: `".gnu.version_r"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compressed .gnu.version_d alongside a .dynsym",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".dynsym", typ: elf.SHT_DYNSYM, link: 2},
|
||||||
|
{name: ".dynstr", typ: elf.SHT_STRTAB},
|
||||||
|
{name: ".gnu.version_d", typ: elf.SHT_GNU_VERDEF, compressed: true, declaredSize: overLimit},
|
||||||
|
},
|
||||||
|
wantErr: `".gnu.version_d"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// the one section elf.NewFile expands itself, so this has to be caught before the parse
|
||||||
|
name: "compressed section name table",
|
||||||
|
opts: buildOpts{nameTable: §ion{compressed: true, declaredSize: overLimit}},
|
||||||
|
wantErr: "section name table",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// debug/elf gates the legacy form on the name, so a reachable section named this way reaches it
|
||||||
|
name: "legacy .zdebug header on a section reached by type",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".zdebug_symtab", typ: elf.SHT_SYMTAB, link: 2, legacyZlib: true, declaredSize: overLimit},
|
||||||
|
{name: ".strtab", typ: elf.SHT_STRTAB},
|
||||||
|
},
|
||||||
|
wantErr: `".zdebug_symtab"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
for _, c := range classes {
|
||||||
|
t.Run(tt.name+"/"+c.name, func(t *testing.T) {
|
||||||
|
data := buildELF(t, c.class, c.order, tt.secs, tt.opts)
|
||||||
|
_, err := NewFile(bytes.NewReader(data))
|
||||||
|
require.Error(t, err)
|
||||||
|
// the fixtures are hand-assembled ELF bytes, so a bad one would satisfy require.Error on
|
||||||
|
// its own. Naming the section proves the rejection is the one we set up, and naming the
|
||||||
|
// limit proves it came from this package rather than from debug/elf.
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
assert.Contains(t, err.Error(), fmt.Sprint(maxDeclaredSectionSize))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewFile_AcceptsWhatDebugELFWouldNotExpand covers the false-negative direction: rejecting a whole
|
||||||
|
// binary over a section debug/elf is never driven to decompress drops real packages from the SBOM.
|
||||||
|
//
|
||||||
|
// Every fixture here is a file debug/elf parses without complaint, and every one must come back from
|
||||||
|
// NewFile without complaint too. An oversized claim is only worth rejecting when debug/elf can actually
|
||||||
|
// be made to allocate against it, so a claim it will never act on has to be ignored outright rather than
|
||||||
|
// merely tolerated.
|
||||||
|
func TestNewFile_AcceptsWhatDebugELFWouldNotExpand(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
secs []section
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// only File.DWARF expands a .debug_* section, and nothing in syft calls it, so this claim is
|
||||||
|
// never acted on. reachableSections leaves DWARF out on purpose: compressed debug info is
|
||||||
|
// routinely enormous in a legitimate binary, and bounding it would skip the binary entirely.
|
||||||
|
//
|
||||||
|
// non-allocable and SHF_COMPRESSED is deliberately the one shape debug/elf will expand, so the
|
||||||
|
// name is the only thing keeping this accepted. Adding SHF_ALLOC would make it pass for the
|
||||||
|
// allocable case's reason below and stop saying anything about DWARF.
|
||||||
|
name: "oversized compressed DWARF",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".debug_info", typ: elf.SHT_PROGBITS, compressed: true, declaredSize: overLimit},
|
||||||
|
},
|
||||||
|
why: "syft never calls File.DWARF, so debug/elf is never asked to expand it",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// the same section in the older form toolchains emitted before SHF_COMPRESSED existed. The
|
||||||
|
// header is different, the reader that would expand it is the same one syft never calls.
|
||||||
|
//
|
||||||
|
// SHF_COMPRESSED is deliberately absent: with it set, debug/elf takes the modern path and the
|
||||||
|
// legacy header is never consulted, so the .zdebug name gate would go untested.
|
||||||
|
name: "oversized legacy .zdebug DWARF",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".zdebug_info", typ: elf.SHT_PROGBITS, legacyZlib: true, declaredSize: overLimit},
|
||||||
|
},
|
||||||
|
why: "same, via the legacy form",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// reachable by type and named the way debug/elf gates the legacy form on, so the name half of
|
||||||
|
// that gate passes and the magic is the only thing left to reject the claim. Without the magic
|
||||||
|
// check the 0xff bytes behind it decode as a declared size of 2^64-1.
|
||||||
|
name: "a .zdebug-named reachable section whose bytes are not a legacy header",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".zdebug_symtab", typ: elf.SHT_SYMTAB, link: 2,
|
||||||
|
body: append([]byte("NOPE"), bytes.Repeat([]byte{0xff}, 32)...)},
|
||||||
|
{name: ".strtab", typ: elf.SHT_STRTAB},
|
||||||
|
},
|
||||||
|
why: "the legacy form needs the magic as well as the name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// the other half of the same gate: fewer bytes present than a legacy header needs. The magic is
|
||||||
|
// intact and some of the size field is present, so a short read has to read as "no claim"
|
||||||
|
// rather than as a size decoded out of a partly-filled buffer, which here would be huge.
|
||||||
|
name: "a .zdebug-named reachable section too short to hold a legacy header",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".zdebug_symtab", typ: elf.SHT_SYMTAB, link: 2,
|
||||||
|
body: append([]byte("ZLIB"), bytes.Repeat([]byte{0xff}, 4)...)},
|
||||||
|
{name: ".strtab", typ: elf.SHT_STRTAB},
|
||||||
|
},
|
||||||
|
why: "a truncated legacy header is not a claim",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// an ordinary uncompressed note that happens to open with those four bytes. debug/elf takes
|
||||||
|
// the legacy path only for a .zdebug-prefixed name, so it reads this as content and nothing
|
||||||
|
// here is a size claim at all. A check that sniffed for the magic instead would decode the
|
||||||
|
// 0xff padding behind it as a declared size and throw the binary away.
|
||||||
|
//
|
||||||
|
// .note.package because it has to be a section syft actually reads, or reachableSections skips
|
||||||
|
// it and the case passes without exercising anything. No flags for the same reason the name is
|
||||||
|
// not .zdebug-prefixed: either one would put debug/elf on a path where the bytes are a header.
|
||||||
|
name: "an uncompressed section whose bytes happen to start with ZLIB",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".note.package", typ: elf.SHT_NOTE, body: append([]byte("ZLIB"), bytes.Repeat([]byte{0xff}, 32)...)},
|
||||||
|
},
|
||||||
|
why: "debug/elf gates the legacy form on the section name, not the magic, so this is not a claim at all",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// a section that occupies memory at runtime cannot be compressed on disk, so (*Section).Open
|
||||||
|
// hands back the raw bytes rather than expanding one that says it is. The claim is real and
|
||||||
|
// oversized, and still costs nothing.
|
||||||
|
//
|
||||||
|
// this is the ".symtab" reject case with one bit added: .data is read by name just as .symtab
|
||||||
|
// is, so SHF_ALLOC is the only reason the two outcomes differ.
|
||||||
|
name: "an allocable compressed section",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".data", typ: elf.SHT_PROGBITS, flags: elf.SHF_COMPRESSED | elf.SHF_ALLOC,
|
||||||
|
compressed: true, declaredSize: overLimit},
|
||||||
|
},
|
||||||
|
why: "debug/elf refuses to decompress an allocable section",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// the hardest one to see: sh_offset points into .rodata's payload, so elf.NewFile reads that
|
||||||
|
// compression header and .data comes back carrying an oversized Size of its own. Reading Size
|
||||||
|
// alone would reject here, but a SHT_NOBITS section holds no file bytes and its reader yields
|
||||||
|
// none, so the size is never allocated against. sh_size is set by hand because such a section
|
||||||
|
// has a size without occupying any of the file, and a zero one is a file debug/elf rejects.
|
||||||
|
//
|
||||||
|
// SHF_COMPRESSED has to be set for the size to be a claim at all, and .data has to be the
|
||||||
|
// borrower rather than the donor, since the donor is unreachable by name and never checked.
|
||||||
|
name: "an SHT_NOBITS section borrowing another section's compression header",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".rodata", typ: elf.SHT_PROGBITS, compressed: true, declaredSize: overLimit},
|
||||||
|
{name: ".data", typ: elf.SHT_NOBITS, flags: elf.SHF_COMPRESSED, offsetOf: ".rodata",
|
||||||
|
shSize: 4096},
|
||||||
|
},
|
||||||
|
why: "debug/elf never yields bytes for SHT_NOBITS, whatever Size it ends up carrying",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// a string table is reached through some symbol table's sh_link, never on its own, so one
|
||||||
|
// nothing points at is one nothing reads. This is the reject test's linked-.strtab case with
|
||||||
|
// the .symtab removed, and it is the case an implementation that bounded every SHT_STRTAB
|
||||||
|
// rather than following the links would fail.
|
||||||
|
name: "an oversized string table that no symbol table links",
|
||||||
|
secs: []section{
|
||||||
|
{name: ".strtab", typ: elf.SHT_STRTAB, compressed: true, declaredSize: overLimit},
|
||||||
|
},
|
||||||
|
why: "reachability runs through sh_link, not section type",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
for _, c := range classes {
|
||||||
|
t.Run(tt.name+"/"+c.name, func(t *testing.T) {
|
||||||
|
data := buildELF(t, c.class, c.order, tt.secs, buildOpts{})
|
||||||
|
|
||||||
|
// "elfutil accepted it" only means something if debug/elf accepts it too, so a fixture
|
||||||
|
// that drifts into being malformed fails here rather than passing for the wrong reason
|
||||||
|
_, err := elf.NewFile(bytes.NewReader(data))
|
||||||
|
require.NoError(t, err, "fixture is supposed to be a file debug/elf accepts")
|
||||||
|
|
||||||
|
_, err = NewFile(bytes.NewReader(data))
|
||||||
|
require.NoError(t, err, tt.why)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewFile_AcceptsAndReadsSectionUnderTheLimit guards the risk the bound introduces: a legitimately
|
||||||
|
// compressed section must still be readable, not merely accepted.
|
||||||
|
func TestNewFile_AcceptsAndReadsSectionUnderTheLimit(t *testing.T) {
|
||||||
|
for _, c := range classes {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
payload := bytes.Repeat([]byte("syft"), 4096)
|
||||||
|
data := buildELF(t, c.class, c.order, []section{
|
||||||
|
{name: ".note.package", typ: elf.SHT_NOTE, flags: elf.SHF_COMPRESSED,
|
||||||
|
compressed: true, declaredSize: uint64(len(payload)), body: deflate(t, payload)},
|
||||||
|
}, buildOpts{})
|
||||||
|
|
||||||
|
f, err := NewFile(bytes.NewReader(data))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := f.Section(".note.package").Data()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, payload, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewFile_AcceptsAndReadsCompressedSectionNameTable covers the other half of CheckSectionNameTable.
|
||||||
|
// That check runs before the parse, off a hand-decoded section header rather than anything debug/elf has
|
||||||
|
// resolved, so a version of it that rejected every compressed name table outright, or that read the size
|
||||||
|
// out of the wrong offset, would still pass every rejection case. Resolving the names proves the file was
|
||||||
|
// left intact and not merely let through.
|
||||||
|
func TestNewFile_AcceptsAndReadsCompressedSectionNameTable(t *testing.T) {
|
||||||
|
for _, c := range classes {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
data := buildELF(t, c.class, c.order,
|
||||||
|
[]section{{name: ".note.package", typ: elf.SHT_NOTE}},
|
||||||
|
buildOpts{nameTable: §ion{compressed: true}})
|
||||||
|
|
||||||
|
f, err := NewFile(bytes.NewReader(data))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, f.Section(".note.package"), "section names did not survive the name table")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewFile_BombDoesNotAllocate is the end-to-end case: a real zlib stream that genuinely delivers
|
||||||
|
// every byte its header promises, so without the bound debug/elf allocates all of them.
|
||||||
|
func TestNewFile_BombDoesNotAllocate(t *testing.T) {
|
||||||
|
const declared = maxDeclaredSectionSize + 1
|
||||||
|
compressed := deflate(t, make([]byte, declared))
|
||||||
|
t.Logf("%d declared bytes compress to %d", declared, len(compressed))
|
||||||
|
|
||||||
|
data := buildELF(t, elf.ELFCLASS64, binary.LittleEndian, []section{
|
||||||
|
{name: ".symtab", typ: elf.SHT_SYMTAB, link: 2, flags: elf.SHF_COMPRESSED,
|
||||||
|
compressed: true, declaredSize: declared, body: compressed},
|
||||||
|
{name: ".strtab", typ: elf.SHT_STRTAB},
|
||||||
|
}, buildOpts{})
|
||||||
|
|
||||||
|
var before, after runtime.MemStats
|
||||||
|
runtime.GC()
|
||||||
|
runtime.ReadMemStats(&before)
|
||||||
|
|
||||||
|
_, err := NewFile(bytes.NewReader(data))
|
||||||
|
|
||||||
|
runtime.ReadMemStats(&after)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "over the")
|
||||||
|
|
||||||
|
// the guard has to reject before the allocation, not after it. The margin is loose on purpose: this
|
||||||
|
// is asserting "nothing near the declared size was allocated", not a precise budget.
|
||||||
|
const margin = 32 * 1024 * 1024
|
||||||
|
assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(margin),
|
||||||
|
"parsing allocated far more than the input warrants")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewFile_MatchesDebugELFOnStructuralEdges pins the invariant the passthrough returns depend on:
|
||||||
|
// wherever elfutil declines to check, elf.NewFile must still be the one deciding.
|
||||||
|
func TestNewFile_MatchesDebugELFOnStructuralEdges(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
secs []section
|
||||||
|
opts buildOpts
|
||||||
|
}{
|
||||||
|
{name: "plain file", secs: []section{{name: ".text", typ: elf.SHT_PROGBITS, flags: elf.SHF_ALLOC}}},
|
||||||
|
{name: "extended section count", opts: buildOpts{extendedShnum: true}},
|
||||||
|
{name: "extended name table index", opts: buildOpts{xindexShstrndx: true}},
|
||||||
|
{name: "both extended forms", opts: buildOpts{extendedShnum: true, xindexShstrndx: true}},
|
||||||
|
{
|
||||||
|
name: "symtab linking a nonexistent string table",
|
||||||
|
secs: []section{{name: ".symtab", typ: elf.SHT_SYMTAB, link: 99}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
for _, c := range classes {
|
||||||
|
t.Run(tt.name+"/"+c.name, func(t *testing.T) {
|
||||||
|
data := buildELF(t, c.class, c.order, tt.secs, tt.opts)
|
||||||
|
|
||||||
|
_, want := elf.NewFile(bytes.NewReader(data))
|
||||||
|
_, got := NewFile(bytes.NewReader(data))
|
||||||
|
|
||||||
|
if want == nil {
|
||||||
|
assert.NoError(t, got, "elfutil rejected a file debug/elf accepts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.Error(t, got, "elfutil accepted a file debug/elf rejects")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewFile_ExtendedShnumOverflowStillChecks covers the case a signed conversion used to swallow: a
|
||||||
|
// count that does not fit an int64 must not read as "no sections" and skip the check.
|
||||||
|
func TestNewFile_ExtendedShnumOverflowStillChecks(t *testing.T) {
|
||||||
|
data := buildELF(t, elf.ELFCLASS64, binary.LittleEndian, nil,
|
||||||
|
buildOpts{nameTable: §ion{compressed: true, declaredSize: overLimit}})
|
||||||
|
|
||||||
|
// rewrite section 0's size to a count that overflows int64, and zero e_shnum so it is consulted
|
||||||
|
// e_shnum sits at 0x3c in an Elf64_Ehdr, and sh_size at +32 in the Elf64_Shdr that follows it
|
||||||
|
shoff := binary.Size(elf.Header64{})
|
||||||
|
binary.LittleEndian.PutUint16(data[0x3c:], 0)
|
||||||
|
binary.LittleEndian.PutUint64(data[shoff+32:], uint64(1)<<63)
|
||||||
|
|
||||||
|
_, err := NewFile(bytes.NewReader(data))
|
||||||
|
require.Error(t, err, "a bogus section count must not disable the check")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFile_PassesThroughNonELF(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
}{
|
||||||
|
{"empty", nil},
|
||||||
|
{"shorter than e_ident", []byte{0x7f, 'E'}},
|
||||||
|
{"bad magic", bytes.Repeat([]byte{0xab}, 128)},
|
||||||
|
{"bad class", func() []byte {
|
||||||
|
b := bytes.Repeat([]byte{0}, 128)
|
||||||
|
copy(b, elf.ELFMAG)
|
||||||
|
b[elf.EI_CLASS] = 9
|
||||||
|
return b
|
||||||
|
}()},
|
||||||
|
{"bad byte order", func() []byte {
|
||||||
|
b := bytes.Repeat([]byte{0}, 128)
|
||||||
|
copy(b, elf.ELFMAG)
|
||||||
|
b[elf.EI_CLASS] = byte(elf.ELFCLASS64)
|
||||||
|
b[elf.EI_DATA] = 9
|
||||||
|
return b
|
||||||
|
}()},
|
||||||
|
{"truncated after the header", func() []byte {
|
||||||
|
data := buildELF(t, elf.ELFCLASS64, binary.LittleEndian, nil, buildOpts{})
|
||||||
|
return data[:binary.Size(elf.Header64{})+4]
|
||||||
|
}()},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, want := elf.NewFile(bytes.NewReader(tt.data))
|
||||||
|
_, got := NewFile(bytes.NewReader(tt.data))
|
||||||
|
|
||||||
|
require.Error(t, want, "fixture is supposed to be rejected by debug/elf")
|
||||||
|
require.Error(t, got)
|
||||||
|
// the rejection must be debug/elf's, phrased its way, not ours
|
||||||
|
assert.NotContains(t, got.Error(), "over the")
|
||||||
|
assert.Equal(t, fmt.Sprint(want), fmt.Sprint(got))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,7 +3,6 @@ package binary
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"debug/elf"
|
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -14,6 +13,7 @@ import (
|
|||||||
"github.com/anchore/syft/internal/unknown"
|
"github.com/anchore/syft/internal/unknown"
|
||||||
"github.com/anchore/syft/syft/artifact"
|
"github.com/anchore/syft/syft/artifact"
|
||||||
"github.com/anchore/syft/syft/file"
|
"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/internal/unionreader"
|
||||||
"github.com/anchore/syft/syft/pkg"
|
"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)
|
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 {
|
if f == nil || err != nil {
|
||||||
log.WithFields("file", r.Location.Path(), "error", err).Trace("unable to parse binary as ELF")
|
log.WithFields("file", r.Location.Path(), "error", err).Trace("unable to parse binary as ELF")
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.yaml.in/yaml/v3"
|
"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"
|
||||||
"github.com/anchore/syft/syft/pkg/cataloger/binary/internal/manager/internal/config"
|
"github.com/anchore/syft/syft/pkg/cataloger/binary/internal/manager/internal/config"
|
||||||
)
|
)
|
||||||
@ -203,7 +204,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func getPlatformElf(f *os.File) string {
|
func getPlatformElf(f *os.File) string {
|
||||||
elfFile, err := elf.NewFile(f)
|
elfFile, err := elfutil.NewFile(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package golang
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"debug/elf"
|
|
||||||
"debug/macho"
|
"debug/macho"
|
||||||
"debug/pe"
|
"debug/pe"
|
||||||
"errors"
|
"errors"
|
||||||
@ -22,6 +21,7 @@ import (
|
|||||||
"github.com/anchore/syft/internal/log"
|
"github.com/anchore/syft/internal/log"
|
||||||
"github.com/anchore/syft/syft/artifact"
|
"github.com/anchore/syft/syft/artifact"
|
||||||
"github.com/anchore/syft/syft/file"
|
"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/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"
|
||||||
@ -398,7 +398,7 @@ func getGOARCHFromBin(r io.ReaderAt) (string, error) {
|
|||||||
var arch string
|
var arch string
|
||||||
switch {
|
switch {
|
||||||
case bytes.HasPrefix(ident, []byte("\x7FELF")):
|
case bytes.HasPrefix(ident, []byte("\x7FELF")):
|
||||||
f, err := elf.NewFile(r)
|
f, err := elfutil.NewFile(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("unrecognized file format: %w", err)
|
return "", fmt.Errorf("unrecognized file format: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/anchore/syft/internal/log"
|
"github.com/anchore/syft/internal/log"
|
||||||
"github.com/anchore/syft/internal/unknown"
|
"github.com/anchore/syft/internal/unknown"
|
||||||
"github.com/anchore/syft/syft/file"
|
"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/internal/unionreader"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -122,6 +123,17 @@ func getNativeFIPSSettings(settings []debug.BuildSetting) []string {
|
|||||||
return cryptoSettings
|
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) {
|
func getBuildInfo(r io.ReaderAt, location file.Location) (bi *debug.BuildInfo, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
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
|
// try to read buildinfo from the binary directly
|
||||||
bi, err = buildinfo.Read(r)
|
bi, err = readBuildInfo(r)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return bi, 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")
|
log.WithFields("path", location.RealPath).Trace("detected UPX-compressed Go binary, attempting decompression to read the build info")
|
||||||
decompressed, decompErr := decompressUPX(r)
|
decompressed, decompErr := decompressUPX(r)
|
||||||
if decompErr == nil {
|
if decompErr == nil {
|
||||||
bi, err = buildinfo.Read(decompressed)
|
bi, err = readBuildInfo(decompressed)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return bi, nil
|
return bi, nil
|
||||||
}
|
}
|
||||||
|
|||||||
101
syft/pkg/cataloger/golang/scan_binary_bomb_test.go
Normal file
101
syft/pkg/cataloger/golang/scan_binary_bomb_test.go
Normal file
@ -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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"debug/elf"
|
|
||||||
"debug/gosym"
|
"debug/gosym"
|
||||||
"debug/macho"
|
"debug/macho"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
@ -11,6 +10,8 @@ import (
|
|||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anchore/syft/syft/internal/elfutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mainPackage is the import path the linker assigns to the binary's main package.
|
// 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 {
|
switch {
|
||||||
case strings.HasPrefix(string(ident), "\x7FELF"):
|
case strings.HasPrefix(string(ident), "\x7FELF"):
|
||||||
f, err := elf.NewFile(r)
|
f, err := elfutil.NewFile(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("unable to parse ELF binary: %w", err)
|
return nil, 0, fmt.Errorf("unable to parse ELF binary: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"debug/macho"
|
"debug/macho"
|
||||||
"debug/pe"
|
"debug/pe"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
@ -22,6 +23,7 @@ import (
|
|||||||
"github.com/anchore/syft/internal/log"
|
"github.com/anchore/syft/internal/log"
|
||||||
"github.com/anchore/syft/syft/cpe"
|
"github.com/anchore/syft/syft/cpe"
|
||||||
"github.com/anchore/syft/syft/file"
|
"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/internal/unionreader"
|
||||||
"github.com/anchore/syft/syft/pkg"
|
"github.com/anchore/syft/syft/pkg"
|
||||||
)
|
)
|
||||||
@ -365,7 +367,17 @@ func sharedLibraries(context MatcherContext) ([]string, error) {
|
|||||||
}
|
}
|
||||||
defer internal.CloseAndLogError(contents, context.Location.RealPath)
|
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 {
|
if e != nil {
|
||||||
symbols, err := e.ImportedLibraries()
|
symbols, err := e.ImportedLibraries()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
|
"github.com/anchore/syft/syft/internal/elfutil"
|
||||||
"github.com/anchore/syft/syft/internal/unionreader"
|
"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) {
|
func findBundleHeaderOffsetInELF(r unionreader.UnionReader) (int64, error) {
|
||||||
elfFile, err := elf.NewFile(r)
|
elfFile, err := elfutil.NewFile(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/anchore/syft/syft/artifact"
|
"github.com/anchore/syft/syft/artifact"
|
||||||
"github.com/anchore/syft/syft/file"
|
"github.com/anchore/syft/syft/file"
|
||||||
"github.com/anchore/syft/syft/format/cyclonedxjson"
|
"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/internal/unionreader"
|
||||||
"github.com/anchore/syft/syft/pkg"
|
"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.
|
// newElf reads a Native Image from an ELF executable.
|
||||||
func newElf(filename string, r io.ReaderAt) (nativeImage, error) {
|
func newElf(filename string, r io.ReaderAt) (nativeImage, error) {
|
||||||
// First attempt to read an ELF file.
|
// First attempt to read an ELF file.
|
||||||
bi, err := elf.NewFile(r)
|
bi, err := elfutil.NewFile(r)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var fmtErr *elf.FormatError
|
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) {
|
if errors.As(err, &fmtErr) {
|
||||||
// this is not an elf file
|
// this is not an elf file
|
||||||
log.WithFields("filename", filename, "error", err).Trace("not an ELF binary")
|
log.WithFields("filename", filename, "error", err).Trace("not an ELF binary")
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package kernel
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"debug/elf"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@ -15,6 +14,7 @@ import (
|
|||||||
"github.com/anchore/syft/internal/tmpdir"
|
"github.com/anchore/syft/internal/tmpdir"
|
||||||
"github.com/anchore/syft/syft/artifact"
|
"github.com/anchore/syft/syft/artifact"
|
||||||
"github.com/anchore/syft/syft/file"
|
"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/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"
|
||||||
@ -168,7 +168,7 @@ func parseLinuxKernelModuleMetadata(r unionreader.UnionReader) (p *pkg.LinuxKern
|
|||||||
p = &pkg.LinuxKernelModule{
|
p = &pkg.LinuxKernelModule{
|
||||||
Parameters: make(map[string]pkg.LinuxKernelModuleParameter),
|
Parameters: make(map[string]pkg.LinuxKernelModuleParameter),
|
||||||
}
|
}
|
||||||
f, err := elf.NewFile(r)
|
f, err := elfutil.NewFile(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,3 +90,16 @@ func packagesInRelationshipsAsValues(m dsl.Matcher) {
|
|||||||
Where(isRelationship(m) && hasPointerType(m)).
|
Where(isRelationship(m) && hasPointerType(m)).
|
||||||
Report("pointer used as a value for From/To field in artifact.Relationship (use values instead)")
|
Report("pointer used as a value for From/To field in artifact.Relationship (use values instead)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nolint:unused
|
||||||
|
func noDirectELFOpen(m dsl.Matcher) {
|
||||||
|
// debug/elf sizes a compressed section's buffer from that section's own header, so opening an
|
||||||
|
// attacker-supplied binary with it directly is an unbounded allocation. elfutil.NewFile is a
|
||||||
|
// drop-in that bounds the sections syft can actually reach.
|
||||||
|
m.Match(
|
||||||
|
`elf.NewFile($_)`,
|
||||||
|
`elf.Open($_)`,
|
||||||
|
).
|
||||||
|
Where(!m.File().PkgPath.Matches(`/syft/internal/elfutil$`)).
|
||||||
|
Report("do not open ELF files with debug/elf directly; use elfutil.NewFile, which bounds declared decompressed section sizes")
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user