mirror of
https://github.com/anchore/syft.git
synced 2026-10-31 22:46:36 +01:00
* fix(golang): bound UPX decompression by what the input can justify A UPX block's `sz_unc` was passed straight to `make([]byte, n)`, so four bytes of attacker input spanned the full uint32 range. A ~300KB file could drive multi-GB resident memory, which Go reports as a fatal runtime OOM that no `recover` on this path can contain. Syft parses binaries out of arbitrary images, so that is reachable from any scan. Two bounds do the work, and UPX's own invariants make them exact: - all blocks together reconstruct `p_filesize`, so a running remainder caps the sum. Bounding only per-block would leave the total at (block count x original size), so the remainder is the part that actually closes it - `p_filesize` sizes the output buffer, so it is capped both absolutely and against the size of the file on disk. The absolute cap alone left a ~60 byte header able to claim 500MB; the ratio alone cannot work either, since LZMA encodes a run of N equal bytes in O(log N) and a `go:embed` of 120MB of zeros legitimately packs 209x The output buffer is also allocated on the first block that survives validation rather than up front, so a header claiming a large size with nothing decodable behind it costs nothing. Alongside that, several ways the block loop could be steered off its own buffer: - `parseELFPTLoadOffsets` bounds-checked program headers with `phStart+phentsize > len(buf)`, which overflows for a `p_offset` near 2^64 and lets an out-of-range entry through into a slice index. Switched to the subtraction form, and a `phentsize` shorter than an ELF64 program header is rejected since the reads use fixed offsets - block placement had the same overflow shape, and on failure it skipped the copy and fell through. `outputOffset` derives from the rejected offset, so a `p_offset` at the uint64 ceiling wrapped it to 0 and the next block landed on the reconstructed ELF header, still returning success. Out-of-range placement now ends the block chain, keeping the blocks already placed since those often carry `.go.buildinfo` - the UPX 2-byte LZMA header carries `lc`/`lp` as nibbles and `pb` as three bits, so they can hold values LZMA does not permit. Folded into the props byte they wrap (`lc=15, lp=15, pb=7` gives 465, which truncates to 209) and the stream mis-decodes instead of failing - blocks decode directly into a slice of the output buffer instead of a per-block buffer that is then copied, so one block no longer doubles peak memory - the block count is capped: the size budget alone still allows millions of 12-byte blocks, each spinning up an LZMA reader Verified against the `image-small-upx` fixture, a real `upx --best --lzma` binary: four blocks, max `sz_unc` equal to `p_blocksize` exactly, blocks summing to 0.68 of `p_filesize`, and a 1.96x expansion against the packed file, so every bound holds with headroom on genuine output. Note `p_blocksize` is deliberately not used as a ceiling on `sz_unc`. It adds nothing the remainder does not already cover, and real output sits at exactly `p_blocksize`, so it would run with zero headroom against a value the format does not promise. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix(golang): report UPX binaries we cannot unpack as unknowns A packed Go binary we fail to unpack means its packages are silently missing from the SBOM. `getBuildInfo` checked the decompression error for nil and discarded it, so a rejected file surfaced only the original `buildinfo.Read` error, and that one is deliberately silenced because it is usually just "not a Go binary". `decompressUPX` now marks the cases worth reporting with `errUPXDecompress`: it got past the header and the method dispatch and still could not unpack the file. Everything else stays quiet, which matters more than it sounds. `upx` defaults to NRV2B unless `--lzma` is passed and only LZMA is implemented here, so treating any decompression failure as reportable would attach a golang-cataloger unknown to every packed non-Go binary in an image. Making the reportable case opt in rather than the quiet case a growing list also means a guard added later is silent by default. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * refactor(golang): drop the vendored xcoff parser The only thing the golang cataloger read from it was `TargetMachine`, which is the same two magic bytes `getGOARCHFromBin` had already matched on to dispatch there. So a full XCOFF walk (string table, symbol table, every relocation table) ran to recover a value that was already in hand. `getGOARCHFromBin` reads those two bytes directly now. That is also strictly more correct: `xcoff.NewFile` bailed with "no symbol table" when `symptr == 0`, so a stripped XCOFF binary reported no arch at all. Fixtures move to `testdata/xcoff/`, since they are no longer a package's own testdata. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * feat(internal/file): report a reader's real size behind one helper Bounds get written as "does this claim exceed what the reader actually holds", and the subtlety is that a reader's own answer cannot be trusted: an `io.SectionReader` reports the nominal length it was built with, so one constructed with `1<<63-1` will happily claim to hold 8EB. Confirming the last byte is readable is what separates a real size from a nominal one. Returns `(int64, bool)` rather than a bare size, since there are five ways to not know and collapsing them to 0 makes every caller reinvent "0 means stop bounding". The doc also names the wrapper trap: a type embedding `io.ReaderAt` as an interface promotes only `ReadAt`, so it answers no size at all however large the reader beneath it is, and a bound written against it silently does nothing. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix(golang): bound every section a reader can expand, not just the name table `CheckSectionNameTable` bounds the one section `elf.NewFile` expands while parsing, which is enough for a caller that only parses. It is not enough for one that goes on to read sections: those are expanded lazily, so a 260KB ELF declaring a compressed `.symtab` drove 1.3GB of allocation through `goversion`, which opens the file with `debug/elf` inside its own package and cannot be routed through `elfutil.NewFile`. `CheckAllSections` is that gate, and is deliberately named as the superset so the relationship to the narrow one is structural rather than documented. Also exports `ErrDeclaredSizeExceeded`. A refusal here costs the SBOM a package, so callers report it as an unknown, and matching on an error string to decide that is not something to build a reporting policy on. A ruleguard rule now flags `buildinfo.Read` and `version.ReadExeFromReader` outside `scan_binary.go`. Both open `debug/elf` internally and are only bounded there by the wrappers that gate the reader first, which the existing rule on `elf.NewFile` could not see through. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * feat(internal/spillbuf): sparse offset-addressed buffer that spills to disk For output that arrives out of order, at offsets the input itself declares: a decompressor placing extents, an archive rebuilding a file from chunks. A plain `[]byte` cannot do that without either pre-sizing to a length the input claims or growing to the furthest offset it names, and both hand a hostile input an allocation knob. The load-bearing property is that it reports only what it actually stored. `Size` is the contiguous run written from offset zero and a read past it is `io.EOF`, never the zeros an unwritten region would hand back for free. Sparse storage serving holes as real content is what lets a write offset stand in for output nothing produced, so anything sizing an allocation against this reader (`saferio` in `debug/elf` does exactly that) stays bounded by work actually done. `FirstGap` answers where the next write fits, so callers filling holes do not need the extent bookkeeping and cannot re-derive that rule wrongly, and it only reports gaps lying entirely inside `[0, within)`. The extent type is unexported for the same reason. The rest of the contract worth stating: reads and writes after `Close` fail with `os.ErrClosed` rather than looking like an empty buffer, and a write that fails leaves the buffer unchanged. Memory is bounded by the limit rather than by how much is written: filling 1MB and filling 64MB cost about the same. What lands on disk past that limit is the caller's bound to set, not this package's. Growth is geometric and capped, because sizing the tier to exactly what each write needs is quadratic once a caller streams through it in small chunks. The golang cataloger is the only consumer today, and nothing else in the tree has this shape. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix(golang): rebuild UPX binaries through spillbuf and read the reconstruction everywhere Two amplification paths were still reachable. A 128 byte input allocated 16MB by trusting `sz_cpr` before weighing it against the bytes the file actually holds, and the reconstruction was assembled in a `[]byte` sized from the header's declared original size, so a 301KB input allocated 300MB. The reconstruction is a `spillbuf.Buffer` now, which is what makes the second one go away: blocks are placed at the offsets the file declares, nothing is pre-sized from a claim, and the reader's length is the contiguous run actually rebuilt. That last part is the invariant everything downstream leans on. A reader reporting the declared size would serve the holes between placed blocks as zeros it never stored, and a 64 byte block parked at `p_filesize-64` then made a 300KB input hand out a 64MB reader: measured, that drove 269MB through `getBuildInfo` and returned nothing, silently. Placement and storage are separate concerns. `upx.go` places blocks at the offsets the file declares and asks the buffer how much was rebuilt and where the next gap is; it knows nothing about temp files or memory limits. The remaining bounds are ratios against the input wherever they can be, since an absolute ceiling only ever drops a large legitimate binary. The one absolute left is on `p_filesize`, because padding an input is nearly free, and the LZMA dictionary is paid for in its own block's `sz_cpr`. Interface change: `scanFile` takes a context and hands back the reconstruction for each binary that turned out to be packed, and the caller owns it. Everything after the build info read (crypto settings, arch, symbols, the version scan) reads it, so a packed binary no longer reports its packages with no symbols at all. Also narrows what gets reported. This cataloger runs on every executable in an image, so reporting every `getBuildInfo` error attached an unknown to every corrupt ELF, odd PE and non-Go Mach-O slice in it. Now only four gaps, the ones that cost the SBOM something syft could have had: a packed file we could not decode (`errUPXDecompress`), a header the size bounds refused (`errUPXSizeRefused`), a reconstruction that came up short (`errUPXPartial`), and an ELF expansion `elfutil` declined (`ErrDeclaredSizeExceeded`). An unimplemented UPX method stays quiet on purpose: `upx` defaults to NRV2B and only LZMA is implemented here, so reporting it would attach an unknown to most packed non-Go binaries in an image. That filtering is a new pattern. No other cataloger picks which of its errors become unknowns, and the reason this one does is that it runs on every executable in an image rather than on files a glob already selected. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * chore: ignore local spec/ planning notes `/specs` was already ignored; `/spec` is the same thing under the singular name and was not, so working notes landed in commits. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * refactor(golang): name what the unpack hands back, not where it stores it the scan's signatures said `*spillbuf.Buffer` all the way down, which told every reader below the scan that it cares whether the reconstruction lives in memory or on disk. it does not. two interfaces instead, each naming only what its side actually uses: - `unpackedContents` for the consumers: `ReaderAt`, `Closer`, and a `Size` that is the contiguous run rebuilt from zero - `blockSink` for the decoder, which additionally needs `WriterAt` and `FirstGap` to place blocks this costs one thing worth flagging. `Close` on a nil `*spillbuf.Buffer` was nil-safe, so callers just deferred it; widened into an interface, a nil pointer becomes a non-nil interface and the same call panics. `closeUnpacked` absorbs that, and it is now the only way an owner releases contents. `readerFor`, `seekerFor` and `closeUnpacked` are the three places that resolve the nil. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * docs(elfutil): say that CheckAllSections is the decompression-bomb gate the doc led with "bounds every section syft can drive debug/elf into decompressing", which describes the mechanism without ever naming what it is defending against. a reader landing on the call site could not tell that this is the decompression-bomb check. state the attack instead: a compressed section header declares its own decompressed size, debug/elf believes that number and allocates it on open, and nothing forces it to match what the compressed bytes actually yield. the 260KB ELF that drove 1.3GB of allocation now lives in the doc rather than only at the one call site that happened to mention it. comments only. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * docs(golang): move the overflow note onto the bounds test it describes the "subtraction form" note sat above the phStart assignment with ten lines of wrap analysis between it and the `if` it was talking about, so the subtraction it names reads as missing. it is `hdrLen-phStart < phentsize`. move it onto that test and name the form it is avoiding (`phStart+phentsize > hdrLen`, which can overflow past the buffer end) so the referent is not several paragraphs away. comments only. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * chore(golang): drop the internal/ README left over from xcoff that README existed to record where the vendored xcoff package came from and that Go keeps it internal, which is a provenance note worth having while third-party code sat there. dropping xcoff took the reason with it, and repurposing it to point at gotestdata just made it redundant: gotestdata has its own README covering why it is under internal/, why it is not testdata/, and what belongs in each. internal/ now holds one self-documenting directory. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
470 lines
21 KiB
Go
470 lines
21 KiB
Go
/*
|
|
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"
|
|
"errors"
|
|
"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
|
|
|
|
// ErrDeclaredSizeExceeded marks a file this package refused to parse because a section declared more
|
|
// decompressed bytes than maxDeclaredSectionSize allows.
|
|
//
|
|
// Exported so a caller can tell "syft declined to expand this" apart from "this file is broken". The
|
|
// distinction matters where the two are reported differently: the refusal is a real gap in the SBOM and
|
|
// worth surfacing, while a corrupt or non-Go executable is neither, and the golang cataloger sees far
|
|
// more of the latter than the former.
|
|
var ErrDeclaredSizeExceeded = errors.New("declared decompressed size over the limit")
|
|
|
|
// 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
|
|
}
|
|
|
|
// CheckAllSections rejects ELF decompression bombs before debug/elf can expand one, without keeping the
|
|
// parse.
|
|
//
|
|
// The attack it stops: a compressed section's header declares its own decompressed size, and debug/elf
|
|
// believes that number, allocating it up front when the section is opened. Nothing forces the declared
|
|
// size to match what the compressed bytes actually yield, so a small file can name an enormous one. In
|
|
// the case this was written for, a 260KB ELF declaring a compressed .symtab drove 1.3GB of allocation.
|
|
// This walks the section headers and refuses any reachable section declaring more than
|
|
// maxDeclaredSectionSize, returning ErrDeclaredSizeExceeded.
|
|
//
|
|
// It is a strict superset of CheckSectionNameTable, which is what "All" in the name marks: reach for that
|
|
// narrower one only where a full elf.NewFile parse on every file is not worth paying for. Like it, this
|
|
// is for a caller whose debug/elf call is made inside another package, but it is the whole check rather
|
|
// than the eager half: goversion reads .symtab and the string table it links, and those are expanded
|
|
// lazily, so the name table bound alone leaves them unbounded.
|
|
//
|
|
// Anything that is not an ELF this package understands passes through untouched, since a caller reaching
|
|
// for this parses other containers too (goversion takes PE and Mach-O). A file elf.NewFile cannot parse
|
|
// passes through as well: describing a malformed file is the caller's job, not this gate's.
|
|
func CheckAllSections(r io.ReaderAt) error {
|
|
if _, _, ok := identify(r); !ok {
|
|
return nil
|
|
}
|
|
if err := CheckSectionNameTable(r); err != nil {
|
|
return err
|
|
}
|
|
f, err := elf.NewFile(r)
|
|
if err != nil {
|
|
return nil //nolint:nilerr // the caller's own parse reports the format error
|
|
}
|
|
return checkReachableSections(f)
|
|
}
|
|
|
|
// 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("%w: elf section %q declares %d decompressed bytes, over the %d byte limit",
|
|
ErrDeclaredSizeExceeded, 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.
|
|
//
|
|
// This is deliberately the eager half only, so it is the wrong gate for a caller whose parser goes on to
|
|
// read sections of its own. Use CheckAllSections for those: a package that reads symbols expands sections
|
|
// this never looks at, and the difference is the whole reason both exist.
|
|
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("%w: elf section name table declares %d decompressed bytes, over the %d byte limit",
|
|
ErrDeclaredSizeExceeded, 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
|
|
}
|