diff --git a/syft/file/cataloger/executable/elf.go b/syft/file/cataloger/executable/elf.go index b9d2205cf..e7f6d588d 100644 --- a/syft/file/cataloger/executable/elf.go +++ b/syft/file/cataloger/executable/elf.go @@ -10,11 +10,12 @@ 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" ) func findELFFeatures(data *file.Executable, reader unionreader.UnionReader) error { - f, err := elf.NewFile(reader) + f, err := elfutil.NewFile(reader) if err != nil { return err } diff --git a/syft/internal/elfutil/elfutil.go b/syft/internal/elfutil/elfutil.go new file mode 100644 index 000000000..4870466f3 --- /dev/null +++ b/syft/internal/elfutil/elfutil.go @@ -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 +} diff --git a/syft/internal/elfutil/elfutil_test.go b/syft/internal/elfutil/elfutil_test.go new file mode 100644 index 000000000..3d70b69f0 --- /dev/null +++ b/syft/internal/elfutil/elfutil_test.go @@ -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)) + }) + } +} diff --git a/syft/pkg/cataloger/binary/elf_package_cataloger.go b/syft/pkg/cataloger/binary/elf_package_cataloger.go index 07cbccbe2..943550d2e 100644 --- a/syft/pkg/cataloger/binary/elf_package_cataloger.go +++ b/syft/pkg/cataloger/binary/elf_package_cataloger.go @@ -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 diff --git a/syft/pkg/cataloger/binary/internal/manager/internal/cli/commands/write_snippet.go b/syft/pkg/cataloger/binary/internal/manager/internal/cli/commands/write_snippet.go index 115805887..46f42b383 100644 --- a/syft/pkg/cataloger/binary/internal/manager/internal/cli/commands/write_snippet.go +++ b/syft/pkg/cataloger/binary/internal/manager/internal/cli/commands/write_snippet.go @@ -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 "" } diff --git a/syft/pkg/cataloger/golang/parse_go_binary.go b/syft/pkg/cataloger/golang/parse_go_binary.go index f1a905673..90ce268be 100644 --- a/syft/pkg/cataloger/golang/parse_go_binary.go +++ b/syft/pkg/cataloger/golang/parse_go_binary.go @@ -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) } diff --git a/syft/pkg/cataloger/golang/scan_binary.go b/syft/pkg/cataloger/golang/scan_binary.go index a4841a27c..e88aa91f2 100644 --- a/syft/pkg/cataloger/golang/scan_binary.go +++ b/syft/pkg/cataloger/golang/scan_binary.go @@ -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 } diff --git a/syft/pkg/cataloger/golang/scan_binary_bomb_test.go b/syft/pkg/cataloger/golang/scan_binary_bomb_test.go new file mode 100644 index 000000000..a6dc20803 --- /dev/null +++ b/syft/pkg/cataloger/golang/scan_binary_bomb_test.go @@ -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() +} diff --git a/syft/pkg/cataloger/golang/symbols.go b/syft/pkg/cataloger/golang/symbols.go index cd6b8bdd3..f7b523463 100644 --- a/syft/pkg/cataloger/golang/symbols.go +++ b/syft/pkg/cataloger/golang/symbols.go @@ -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) } diff --git a/syft/pkg/cataloger/internal/binutils/classifier.go b/syft/pkg/cataloger/internal/binutils/classifier.go index 011feaeeb..b2c0ff5e6 100644 --- a/syft/pkg/cataloger/internal/binutils/classifier.go +++ b/syft/pkg/cataloger/internal/binutils/classifier.go @@ -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 { diff --git a/syft/pkg/cataloger/internal/dotnet/bundle/bundle_elf.go b/syft/pkg/cataloger/internal/dotnet/bundle/bundle_elf.go index 8725a7d01..b65cd173e 100644 --- a/syft/pkg/cataloger/internal/dotnet/bundle/bundle_elf.go +++ b/syft/pkg/cataloger/internal/dotnet/bundle/bundle_elf.go @@ -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 } diff --git a/syft/pkg/cataloger/java/graalvm_native_image_cataloger.go b/syft/pkg/cataloger/java/graalvm_native_image_cataloger.go index c9611b9da..5ed5801b5 100644 --- a/syft/pkg/cataloger/java/graalvm_native_image_cataloger.go +++ b/syft/pkg/cataloger/java/graalvm_native_image_cataloger.go @@ -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") diff --git a/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file.go b/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file.go index 064c8c3ce..ab7f7860e 100644 --- a/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file.go +++ b/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file.go @@ -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 } diff --git a/test/rules/rules.go b/test/rules/rules.go index f6f1a0e15..fb68fb827 100644 --- a/test/rules/rules.go +++ b/test/rules/rules.go @@ -90,3 +90,16 @@ func packagesInRelationshipsAsValues(m dsl.Matcher) { Where(isRelationship(m) && hasPointerType(m)). 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") +}