Files
syft/syft/internal/elfutil/elfutil_test.go
T
Alex Goodman 3325493831 chore: pin the bounds from the unbounded allocation work (#5298)
* build(deps): bump go-make to v0.8.1

Picks up golangci-lint 2.13.2 (built with go1.27) in place of 2.11.4 (go1.26).
The old build panics outright on a go1.27 GOROOT with "file requires newer Go
version go1.27", and an older mise-shim build fails the quieter way: it loses
type info and reports zero findings with exit 0, so a ruleguard run comes back
green without having evaluated anything.

Also folds in what the newer linter flags:

- `reflect.Ptr` -> `reflect.Pointer` in three files, applied by --fix
- `tw.CellFormatting{MergeMode: ...}` -> `tw.CellMerging{Mode: ...}` in the
  cataloger info table, for a staticcheck SA1019 deprecation

Note the deprecation text names `CellConfig.CellMerging.Mode`, but the field is
`CellConfig.Merging`; `CellMerging` is the type. That command path has no test
coverage, so the swap was checked by rendering the same hierarchical-merge table
both ways and diffing: byte identical.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* chore(lint): tune goconst for data tables

goconst got stricter in golangci-lint 2.13 and now counts strings in map
literals and struct fields, which lights up every lookup table in the repo. It
was already enabled, so this is main going red on a linter bump rather than
anything new in the tree: 101 findings, and the default threshold of 3 means a
vendor name appearing in three rows of a table reads as a magic constant.

Raising `min-occurrences` to 5 takes that to 14. The rest split by whether the
repeated string is data or an actual constant:

- `cpegenerate` is excluded by path. It maps artifact names to vendors,
  products, and maven group ids, and repeats strings 35, 83, even 293 times, so
  no threshold reaches it. Hoisting those would trade a readable table for
  indirection.
- the two SBOM fixture builders in `format/internal/testutil` get a
  function-level nolint each. The repeated names and versions are what the
  golden files assert against.
- `mysql-cluster` and `php` were genuine repeated literals and are now
  constants.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* chore(test): extract measureAlloc into a shared helper

The allocation-budget tests added during the unbounded allocation work grew a
copy of the same helper in every package they touched: four identical
`measureAlloc` definitions plus two more spelled out inline against
`runtime.MemStats`.

Now `testutils.MeasureAlloc`, at the repo root so `internal/spillbuf` can reach
it too. The doc comment carries the two things that were only recorded in some
of the copies: TotalAlloc is process-wide so callers must not use t.Parallel,
and a budget assertion only means something if the fixture would really blow
past it unguarded, so measure the unguarded path as well.

No behavior change, the body was identical in all six.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* chore(lint): add ruleguard rules for overflow and swallowed EOF

Two rules out of the recent unbounded allocation work (#5187, #5195, #5267,
#5291, #5292, #5293, #5294).

`noOverflowingBoundsCheck` flags `offset + size > limit` (and <, <=, >=) where
an operand is unsigned, neither addend is constant, and the operands are at
least 32 bits. Unsigned addition wraps, so an offset out of a file header can
carry the sum past the wrap point and pass a check it should have failed. The
fix is to subtract against the limit instead. Constant addends are excluded
because `pos+4 <= uint32(len(buf))` is the normal way to step a cursor, and the
32-bit floor drops nibbles widened for a comparison. Would have caught four
sites across #5195 and #5291.

`noSwallowedEOF` flags `return nil` / `return nil, nil` on an io.EOF check. The
caller gets a zero value it cannot tell from real data, and anything decoding a
fixed-size struct off it parses the padding as content. That is the shape behind
#5186.

Both decide from the expression alone, and writing the fix deletes the pattern,
so neither needs a suppression anywhere in the tree. That was the bar: rules for
the allocation and decompression classes were tried and dropped, because the
bound they look for lives in a preceding statement (or, for a decompressor
constructor, always downstream), so no correct implementation can silence them.
That would have meant ~15 permanent nolints on code that is already right. The
rules here that do work don't judge whether a bound exists, they route to a
wrapper that takes one: noDirectTempFiles, noDirectELFOpen.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* test: pin allocation budgets on the alpm, deb, and changelog bounds

The bounds added for #5292, #5293 and #5294 are asserted by the error they
return. That keeps passing if the bound is removed and something else rejects
the input later, which is the failure these were written for. Each of these
measures bytes instead, and proves the fixture is really a bomb first so a
fixture that quietly stops being one fails rather than passing.

- alpm mtree: 16MB of newlines, well inside the 64MB byte cap, costs 16.5GB
  uncapped against 238MB with maxMtreeLines. That 16.5GB is the measurement the
  line cap was added for and it was recorded only in the commit message. Note
  what the cap actually buys: 300k entries still cost a few hundred MB, per
  concurrent cataloger.
- deb members: 1.28GB against 392KB. Both arms read with io.ReadAll on purpose,
  since the stream itself never holds much whatever the cap says; what the cap
  protects is a consumer that buffers, and the real one is a tar reader building
  entries.
- snap changelog: 1.28GB against 175KB. This one has no explicit cap at all. It
  is held by bufio.Scanner refusing a token past MaxScanTokenSize, which is
  incidental, so switching to io.ReadAll or a bufio.Reader to pick up long lines
  would remove it silently.

Nothing added for the kernel module reader: its copy is already
io.Copy(tempFile, io.LimitReader(rc, max+1)), so peak disk is bounded by
construction and heap is streaming. A heap budget would assert the wrong
resource, and the three existing subtests cover the cap, at-cap fidelity, and
spill cleanup.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* test: pin allocation budgets on the ai and alpm byte caps

Follows the same pattern as the alpm line cap: measure bytes, and prove the
fixture is really a bomb first so one that quietly stops being a bomb fails
rather than passes.

- safetensors: 252MB against 2.6KB. The existing over-cap case deliberately
  omits the body, since the guard rejects on the declared length before reading
  anything. That is the right test for the guard but it would keep passing if
  the check moved after the read, so this one delivers the bytes.
- gguf: 268MB against 168MB. copyHeader io.Copy's whatever it is handed, so
  maxHeaderSize holds only because every caller wraps the reader first. This
  pins that the limit is where the bound lives, so dropping the wrapper at a
  call site fails here.
- alpm byte cap: 740MB against 132MB, the counterpart to the line cap budget.

Worth knowing for anyone writing the next one of these: a single enormous line
does not exercise the alpm byte cap. go-mtree runs its own bufio.Scanner and
refuses a token past 64KB, so that fixture fails as "token too long" before the
byte cap is consulted. The fixture here uses ordinary 1KB lines, enough of them
to pass 64MB while staying under the 300k line cap, so the byte cap is actually
the thing under test.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* test: assert bytes retained instead of allocated for gguf copyHeader

the bounded arm writes into a bytes.Buffer, whose doubling growth costs a
multiple of what it holds and lands on a different power of two depending on
the platform. it measured 168MB locally and 320MB on CI, past the budget.
buf.Len() states the same property deterministically.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-09-17 14:11:16 -04:00

735 lines
27 KiB
Go

package elfutil
import (
"bytes"
"compress/zlib"
"debug/elf"
"encoding/binary"
"fmt"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/internal/testutils"
)
// 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 err error
allocated := testutils.MeasureAlloc(t, func() {
_, err = NewFile(bytes.NewReader(data))
})
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, allocated, 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))
})
}
}