Files
syft/syft/internal/elfutil/elfutil_test.go
T
Alex Goodman 1d25dfe4da fix(golang): bound allocations from UPX-packed binary headers (#5195)
* 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>
2026-09-10 09:21:19 -04:00

737 lines
27 KiB
Go

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: &section{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.
// the sentinel is what the callers key their reporting on, so it is asserted here rather
// than only from the packages downstream that consume it
assert.ErrorIs(t, err, ErrDeclaredSizeExceeded)
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: &section{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: &section{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")
}
// TestCheckAllSections covers the standalone gate, used by callers whose debug/elf parse happens inside
// another package (goversion, here) rather than through NewFile.
func TestCheckAllSections(t *testing.T) {
tests := []struct {
name string
data func(t *testing.T) []byte
// wantErr is empty when CheckAllSections must return nil.
wantErr string
// nameTableMisses marks a fixture CheckSectionNameTable alone does not reject, since the oversized
// section here is one debug/elf only expands lazily, after the parse CheckAllSections goes on to do.
nameTableMisses bool
}{
{
name: "non-ELF reader",
data: func(t *testing.T) []byte { return []byte("not an elf") },
},
{
name: "valid ident but a body elf.NewFile cannot parse",
data: func(t *testing.T) []byte {
full := buildELF(t, elf.ELFCLASS64, binary.LittleEndian, nil, buildOpts{})
truncated := full[:binary.Size(elf.Header64{})+4]
_, err := elf.NewFile(bytes.NewReader(truncated))
require.Error(t, err, "fixture is supposed to be rejected by debug/elf")
return truncated
},
},
{
name: "clean small ELF",
data: func(t *testing.T) []byte {
data := buildELF(t, elf.ELFCLASS64, binary.LittleEndian,
[]section{{name: ".text", typ: elf.SHT_PROGBITS, flags: elf.SHF_ALLOC}}, buildOpts{})
_, err := elf.NewFile(bytes.NewReader(data))
require.NoError(t, err, "fixture is supposed to be a file debug/elf accepts")
return data
},
},
{
name: "oversized section name table",
data: func(t *testing.T) []byte {
return buildELF(t, elf.ELFCLASS64, binary.LittleEndian, nil,
buildOpts{nameTable: &section{compressed: true, declaredSize: overLimit}})
},
wantErr: "section name table",
},
{
name: "oversized .symtab, expanded lazily rather than during the parse",
data: func(t *testing.T) []byte {
data := buildELF(t, elf.ELFCLASS64, binary.LittleEndian, []section{
{name: ".symtab", typ: elf.SHT_SYMTAB, link: 2, compressed: true, declaredSize: overLimit},
{name: ".strtab", typ: elf.SHT_STRTAB},
}, buildOpts{})
_, err := elf.NewFile(bytes.NewReader(data))
require.NoError(t, err, "fixture is supposed to be a file debug/elf accepts")
return data
},
wantErr: `".symtab"`,
nameTableMisses: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := tt.data(t)
if tt.nameTableMisses {
require.NoError(t, CheckSectionNameTable(bytes.NewReader(data)),
"the name table check alone doesn't reach a section debug/elf only expands lazily")
}
err := CheckAllSections(bytes.NewReader(data))
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.ErrorIs(t, err, ErrDeclaredSizeExceeded)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
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))
})
}
}