Fix: graalvm unbounded allocation (#5291)

* fix(java): bound user-controlled reads in the graalvm native-image cataloger

Sizes, offsets and lengths in a native-image binary all come from the file
being parsed, and several were acted on directly.

The PE export directory sized a `make([]byte, Size)` from a uint32 in the
optional header, so an 8KB file claiming 4GB reserved 4GB before reading a
byte. It now reads what the file actually holds, bounded by the declared size,
and still requires the whole directory to be present. This also fixes a latent
bug: `unionreader.readerAtAdapter.ReadAt` can return a short read with a nil
error, and the old code discarded the count, so squashfs-sourced binaries could
parse zero padding as export data.

The bounds checks in `decompressSbom` and the PE export walk validated only the
end of a range, computed by adding to a value out of the file. Those sums wrap,
and a wrapped sum compares as in range while the slice that follows it panics.
They now subtract from the known-good length instead. The three
`address - sectionBase` subtractions are unsigned and underflowed on an address
below the base; they share one guarded helper now.

The embedded SBOM decompresses through an `io.LimitedReader`, since the
compressed bytes are bounded by the file but what they expand to is not. The
limit is checked before the decoder's own error, which would otherwise report a
size problem as "not a cyclonedx json document". Hitting it is logged, since
the caller reports parse failures at trace level and a dropped SBOM would
otherwise go unnoticed.

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

* fix(java): stop at the first format that parses a native image

The format loop tried ELF, Mach-O and PE against every reader and kept going
after one of them parsed, so a file that parses as more than one format
contributes its packages once per format.

No input parses as two formats today, so this is not a behavior change in
practice: `debug/pe` rejects an ELF on the optional-header magic, and both
`debug/elf` and `debug/macho` reject anything without their own magic. It
removes the possibility rather than a live bug.

Note this does not deduplicate across readers, which is the outer loop: a
universal Mach-O carrying the same native image for two architectures still
yields its packages twice.

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

* fix(java): bound the graalvm export directory read against the file

Follow-up to the hardening earlier in this branch, from review.

The export directory read was still bounded only by file size, so a large
mostly-empty file (cheap to ship inside a compressed layer) was read into
memory in full and then discarded. It now weighs the declared size against an
absolute cap and against the bytes really present before allocating anything,
matching `readDataFromRVA` in `syft/pkg/cataloger/internal/dotnet/pe`. A 64MB
file declaring a 4GB export directory went from ~140MB allocated to ~3.6KB.

Also from the same review:

- dropped the decompressed-SBOM ceiling from 100MB to 16MB. Decoding costs
  roughly 48x the JSON size in allocation and 6x retained, so the old ceiling
  was worth several GB of churn per file, and catalogers run concurrently.

- `symbolOffsets` is now single-address `symbolOffset`. Two same-typed address
  arguments in a row could be transposed at a call site and would produce a
  silently wrong SBOM rather than an error.

- `newMachO` now returns an error on a real read failure. It was falling
  through and dropping the file with no error and no log at all.

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

* fix(java): reject a short read of the graalvm export directory

Second round of review on this branch.

The export directory read introduced earlier in the branch only consulted the
byte count when `ReadAt` also returned an error, so a reader handing back fewer
bytes with a nil error was accepted and the tail of the buffer stayed zeroed,
then got parsed as a valid export directory. The count decides now, whatever
the error says. `io.ReadFull` would also cover it but spins forever on a reader
that keeps answering `(0, nil)`, which is the same hostile input this is meant
to reject, so the single read stays.

Also in here:

- the decompressed SBOM is buffered and size-checked before the decoder is
  invoked, instead of inferring the overrun from `io.LimitedReader.N` after the
  fact. That check was only correct because the cyclonedx decoder happens to
  drain its reader before parsing, and nothing would have caught it if that
  changed.

- four bounds guards, the `newMachO` error path and the SBOM ceiling had no
  test that failed when they were removed. They do now, verified by deleting
  each one and watching a named test go red. The ceiling in particular could be
  raised to 2GB with the suite still green, since every case derived its sizes
  from the constant.

- `nativeImagePE.reader` is dead now that the read takes the reader as an
  argument.

- three comments claimed guarantees the code does not make: that `symbolOffset`
  cannot be called with transposed arguments, that a rejected file cannot go
  unrecorded when it is only logged at trace level, and a set of measured
  allocation multipliers that nothing pins.

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

* chore(java): trim the graalvm cataloger comments

Comment pass over everything this branch added. Cut about a quarter of the
comment lines, mostly restatement and detail that was never going to stay true.

Dropped: a production comment naming a test, the measured allocation multiples
behind the two size ceilings, cross-references to the equivalent constants in
`dotnet/pe` and `elfutil`, and test docs that restated their own test name
before saying anything.

Kept the parts that fail silently if someone tidies them away: why the read is
a single `ReadAt` rather than `io.ReadFull`, the note that `readExportDirectory`
uses an RVA directly as a file offset, and the fixture notes explaining why a
`VirtualAddress` is deliberately huge or why no symbol sits at index 0.

Three that were stale rather than verbose:

- `nativeImagePE`'s doc still said it holds the underlying reader, which this
  branch deleted.

- `TestFetchSbomSymbols_FindsAllThreeSymbols`' doc had ended up above
  `TestFetchSbomSymbols_AddressBaseOutOfRangeGuard`, documenting the wrong
  function.

- a comment referenced a commit sha that no longer exists on this branch.

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

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-09-14 13:49:19 -04:00
committed by GitHub
parent 356e601cd5
commit c55e1cf2fd
4 changed files with 992 additions and 75 deletions
@@ -14,6 +14,7 @@ import (
"unsafe"
"github.com/anchore/syft/internal"
intFile "github.com/anchore/syft/internal/file"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/mimetype"
"github.com/anchore/syft/syft/artifact"
@@ -63,10 +64,9 @@ type exportContentPE struct {
addressOfSvmVersion uint32
}
// A nativeImagePE must maintain the underlying reader to fetch information unavailable in the Golang API.
// nativeImagePE carries the raw export directory, which debug/pe does not expose.
type nativeImagePE struct {
file *pe.File
reader io.ReaderAt
exportSymbols pe.DataDirectory
exports []byte
t exportTypesPE
@@ -83,6 +83,16 @@ const nativeImageMissingSymbolsError = "one or more symbols are missing from the
const nativeImageInvalidIndexError = "parsing the executable file generated an invalid index"
const nativeImageMissingExportedDataDirectoryError = "exported data directory is missing"
// nativeImageMaxDecompressedSbomSize bounds the decompressed SBOM rather than the compressed bytes,
// since gzip turns a few kilobytes into gigabytes. Real SBOMs run to single-digit MB even at 20k
// components, and decoding costs several times that again in graph allocation.
const nativeImageMaxDecompressedSbomSize = 16 * intFile.MB
// nativeImageMaxExportDirectorySize bounds the export directory, which is symbol metadata and runs to a
// few KB. Bounding by the bytes remaining is not enough on its own: a mostly empty multi-gigabyte file
// is cheap to ship in a layer and would still authorize reading all of it.
const nativeImageMaxExportDirectorySize = 16 * intFile.MB
// NewNativeImageCataloger returns a new Native Image cataloger object.
func NewNativeImageCataloger() pkg.Cataloger {
return &nativeImageCataloger{}
@@ -94,35 +104,40 @@ func (c *nativeImageCataloger) Name() string {
}
// decompressSbom returns the packages given within a native image executable's SBOM.
func decompressSbom(dataBuf []byte, sbomStart uint64, lengthStart uint64) ([]pkg.Package, []artifact.Relationship, error) {
lengthEnd := lengthStart + 8
bufLen := len(dataBuf)
if lengthEnd > uint64(bufLen) {
//
// Offsets and the stored length all come from the binary, so the bounds below subtract from the buffer
// length rather than adding to a file-controlled value, which wraps and then panics on the slice.
func decompressSbom(dataBuf []byte, sbomStart, lengthStart uint64) ([]pkg.Package, []artifact.Relationship, error) {
bufLen := uint64(len(dataBuf))
if lengthStart > bufLen || bufLen-lengthStart < 8 {
return nil, nil, errors.New("the 'sbom_length' symbol overflows the binary")
}
length := dataBuf[lengthStart:lengthEnd]
p := bytes.NewBuffer(length)
var storedLength uint64
err := binary.Read(p, binary.LittleEndian, &storedLength)
if err != nil {
return nil, nil, fmt.Errorf("could not read from binary file: %w", err)
}
storedLength := binary.LittleEndian.Uint64(dataBuf[lengthStart : lengthStart+8])
log.WithFields("len", storedLength).Trace("found java native-image SBOM")
sbomEnd := sbomStart + storedLength
if sbomEnd > uint64(bufLen) {
if sbomStart > bufLen || storedLength > bufLen-sbomStart {
return nil, nil, errors.New("the sbom symbol overflows the binary")
}
sbomCompressed := dataBuf[sbomStart:sbomEnd]
p = bytes.NewBuffer(sbomCompressed)
gzreader, err := gzip.NewReader(p)
sbomCompressed := dataBuf[sbomStart : sbomStart+storedLength]
gzreader, err := gzip.NewReader(bytes.NewBuffer(sbomCompressed))
if err != nil {
return nil, nil, fmt.Errorf("could not decompress the java native-image SBOM: %w", err)
}
defer gzreader.Close()
sbom, _, _, err := cyclonedxjson.NewFormatDecoder().Decode(gzreader)
// the compressed bytes are bounded by the file, the decompressed stream is not. one byte past the
// ceiling, so a stream sitting exactly on it is still legal
raw, err := io.ReadAll(io.LimitReader(gzreader, nativeImageMaxDecompressedSbomSize+1))
if err != nil {
return nil, nil, fmt.Errorf("could not decompress the java native-image SBOM: %w", err)
}
if len(raw) > nativeImageMaxDecompressedSbomSize {
return nil, nil, fmt.Errorf("the java native-image SBOM decompresses past %d bytes", nativeImageMaxDecompressedSbomSize)
}
sbom, _, _, err := cyclonedxjson.NewFormatDecoder().Decode(bytes.NewReader(raw))
if err != nil {
return nil, nil, fmt.Errorf("could not unmarshal the java native-image SBOM: %w", err)
}
@@ -133,6 +148,15 @@ func decompressSbom(dataBuf []byte, sbomStart uint64, lengthStart uint64) ([]pkg
return pkgs, sbom.Relationships, nil
}
// symbolOffset converts an SBOM symbol address into an offset within the section that should hold it.
// The subtraction is unsigned, so an address below the section base underflows into a huge offset.
func symbolOffset(addr, sectionBase uint64) (uint64, error) {
if addr < sectionBase {
return 0, errors.New("an SBOM symbol precedes the section that should contain it")
}
return addr - sectionBase, nil
}
// fileError logs an error message when an executable cannot be read.
func fileError(filename string, err error) (nativeImage, error) {
// We could not read the file as a binary for the desired platform, but it may still be a native-image executable.
@@ -174,6 +198,8 @@ func newMachO(filename string, r io.ReaderAt) (nativeImage, error) {
log.WithFields("filename", filename, "error", err).Trace("not a MachO binary")
return nil, nil
}
// a real read failure, not a format mismatch; returning nil here would drop the file silently
return fileError(filename, err)
}
if bi == nil {
return nil, nil
@@ -183,6 +209,35 @@ func newMachO(filename string, r io.ReaderAt) (nativeImage, error) {
}, nil
}
// readExportDirectory weighs the size the file declares against both an absolute cap and the bytes
// really present before allocating.
//
// note: dir.VirtualAddress is an RVA used directly as a file offset, with no section-table translation.
// Long-standing behavior this preserves rather than fixes; see rvaToFileOffset in dotnet/pe.
func readExportDirectory(r io.ReaderAt, dir pe.DataDirectory) ([]byte, error) {
if dir.Size > nativeImageMaxExportDirectorySize {
return nil, fmt.Errorf("export directory declares %d bytes, over the %d byte limit", dir.Size, nativeImageMaxExportDirectorySize)
}
// the size has to come from the reader, not from what the file claims
end, ok := intFile.ReaderSize(r)
if !ok {
return nil, errors.New("unable to measure the binary")
}
if remaining := end - int64(dir.VirtualAddress); remaining < int64(dir.Size) {
return nil, fmt.Errorf("export directory declares %d bytes at RVA %d but only %d remain",
dir.Size, dir.VirtualAddress, max(remaining, 0))
}
// the count decides, not the error: a ReadAt may report a full read as io.EOF, or a short one with no
// error at all. io.ReadFull covers both but spins forever on a reader that keeps answering (0, nil)
exports := make([]byte, dir.Size)
if n, err := r.ReadAt(exports, int64(dir.VirtualAddress)); n < len(exports) {
return nil, fmt.Errorf("could not read the exported symbols data directory: read %d of %d bytes (%v)", n, len(exports), err)
}
return exports, nil
}
// newPE reads a Native Image from a Portable Executable file.
func newPE(filename string, r io.ReaderAt) (nativeImage, error) {
// First attempt to read an PE file.
@@ -211,15 +266,12 @@ func newPE(filename string, r io.ReaderAt) (nativeImage, error) {
if exportSymbolsDataDirectory.Size == 0 {
return fileError(filename, errors.New(nativeImageMissingExportedDataDirectoryError))
}
exportSymbolsOffset := uint64(exportSymbolsDataDirectory.VirtualAddress)
exports := make([]byte, exportSymbolsDataDirectory.Size)
_, err = r.ReadAt(exports, int64(exportSymbolsOffset))
exports, err := readExportDirectory(r, exportSymbolsDataDirectory)
if err != nil {
return fileError(filename, fmt.Errorf("could not read the exported symbols data directory: %w", err))
return fileError(filename, err)
}
return nativeImagePE{
file: bi,
reader: r,
exportSymbols: exportSymbolsDataDirectory,
exports: exports,
t: exportTypesPE{
@@ -275,15 +327,20 @@ func (ni nativeImageElf) fetchPkgs() (pkgs []pkg.Package, relationships []artifa
}
dataSection := bi.Section(".data")
if dataSection == nil {
return nil, nil, fmt.Errorf("no .data section found in binary: %w", err)
return nil, nil, errors.New("no .data section found in binary")
}
dataSectionBase := dataSection.Addr
data, err := dataSection.Data()
if err != nil {
return nil, nil, fmt.Errorf("cannot read the .data section: %w", err)
}
sbomLocation := sbom.Value - dataSectionBase
lengthLocation := sbomLength.Value - dataSectionBase
sbomLocation, err := symbolOffset(sbom.Value, dataSection.Addr)
if err != nil {
return nil, nil, err
}
lengthLocation, err := symbolOffset(sbomLength.Value, dataSection.Addr)
if err != nil {
return nil, nil, err
}
return decompressSbom(data, sbomLocation, lengthLocation)
}
@@ -354,48 +411,43 @@ func (ni nativeImageMachO) fetchPkgs() (pkgs []pkg.Package, relationships []arti
log.Tracef("cannot obtain buffer from data segment")
return nil, nil, nil
}
sbomLocation := sbom.Value - dataSegment.Addr
lengthLocation := sbomLength.Value - dataSegment.Addr
sbomLocation, err := symbolOffset(sbom.Value, dataSegment.Addr)
if err != nil {
return nil, nil, err
}
lengthLocation, err := symbolOffset(sbomLength.Value, dataSegment.Addr)
if err != nil {
return nil, nil, err
}
return decompressSbom(dataBuf, sbomLocation, lengthLocation)
}
// fetchExportAttribute obtains an attribute from the exported symbols directory entry.
func (ni nativeImagePE) fetchExportAttribute(i int) (uint32, error) {
var attribute uint32
n := len(ni.exports)
j := int(unsafe.Sizeof(ni.header)) + i*int(unsafe.Sizeof(ni.t.headerAttribute))
if j+4 >= n {
sz := int(unsafe.Sizeof(ni.t.headerAttribute))
// i is only ever 0-3, so this cannot overflow; > not >= because an attribute ending flush with the
// directory is still entirely present
j := int(unsafe.Sizeof(ni.header)) + i*sz
if j+sz > n {
log.Tracef("invalid index to export directory entry attribute: %v", j)
return uint32(0), errors.New(nativeImageInvalidIndexError)
}
p := bytes.NewBuffer(ni.exports[j : j+4])
err := binary.Read(p, binary.LittleEndian, &attribute)
if err != nil {
log.Tracef("error fetching export directory entry attribute: %v", err)
return uint32(0), err
}
return attribute, nil
return binary.LittleEndian.Uint32(ni.exports[j : j+sz]), nil
}
// fetchExportFunctionPointer obtains a function pointer from the exported symbols directory entry.
func (ni nativeImagePE) fetchExportFunctionPointer(functionsBase uint32, i uint32) (uint32, error) {
var pointer uint32
n := uint32(len(ni.exports))
sz := uint32(unsafe.Sizeof(ni.t.functionPointer))
j := functionsBase + i*sz
if j+sz >= n {
// functionsBase is a file-controlled RVA: in uint32 the sum wraps under the bound, then panics
n := uint64(len(ni.exports))
sz := uint64(unsafe.Sizeof(ni.t.functionPointer))
j := uint64(functionsBase) + uint64(i)*sz
if j > n || n-j < sz {
log.Tracef("invalid index to exported function: %v", j)
return uint32(0), errors.New(nativeImageInvalidIndexError)
}
p := bytes.NewBuffer(ni.exports[j : j+sz])
err := binary.Read(p, binary.LittleEndian, &pointer)
if err != nil {
log.Tracef("error fetching exported function: %v", err)
return uint32(0), err
}
return pointer, nil
return binary.LittleEndian.Uint32(ni.exports[j : j+sz]), nil
}
// fetchExportContent obtains the content of the export directory entry relevant to the SBOM.
@@ -427,27 +479,30 @@ func (ni nativeImagePE) fetchSbomSymbols(content *exportContentPE) {
sbomBytes := []byte(nativeImageSbomSymbol + "\x00")
sbomLengthBytes := []byte(nativeImageSbomLengthSymbol + "\x00")
svmVersionInfoBytes := []byte(nativeImageSbomVersionSymbol + "\x00")
n := uint32(len(ni.exports))
n := uint64(len(ni.exports))
sz := uint64(unsafe.Sizeof(ni.t.namePointer))
// an RVA below the directory would underflow into a huge offset that wraps back into range below
if content.addressOfNames < ni.exportSymbols.VirtualAddress {
log.Tracef("exported name array precedes the export directory: %v", content.addressOfNames)
return
}
addressBase := uint64(content.addressOfNames - ni.exportSymbols.VirtualAddress)
// Find SBOM, SBOM Length, and SVM Version Symbol
for i := uint32(0); i < content.numberOfNames; i++ {
j := i * uint32(unsafe.Sizeof(ni.t.namePointer))
addressBase := content.addressOfNames - ni.exportSymbols.VirtualAddress
k := addressBase + j
sz := uint32(unsafe.Sizeof(ni.t.namePointer))
if k+sz >= n {
k := addressBase + uint64(i)*sz
if k > n || n-k < sz {
log.Tracef("invalid index to exported function: %v", k)
// If we are at the end of exports, stop looking
return
}
var symbolAddress uint32
p := bytes.NewBuffer(ni.exports[k : k+sz])
err := binary.Read(p, binary.LittleEndian, &symbolAddress)
if err != nil {
log.Tracef("error fetching address of symbol %v", err)
symbolAddress := binary.LittleEndian.Uint32(ni.exports[k : k+sz])
if symbolAddress < ni.exportSymbols.VirtualAddress {
log.Tracef("exported symbol precedes the export directory: %v", symbolAddress)
return
}
symbolBase := symbolAddress - ni.exportSymbols.VirtualAddress
symbolBase := uint64(symbolAddress - ni.exportSymbols.VirtualAddress)
if symbolBase >= n {
log.Tracef("invalid index to exported symbol: %v", symbolBase)
return
@@ -482,6 +537,9 @@ func (ni nativeImagePE) fetchPkgs() (pkgs []pkg.Package, relationships []artifac
if content.addressOfSbom == uint32(0) || content.addressOfSbomLength == uint32(0) || content.addressOfSvmVersion == uint32(0) {
return nil, nil, errors.New(nativeImageMissingSymbolsError)
}
if content.addressOfFunctions < ni.exportSymbols.VirtualAddress {
return nil, nil, errors.New("exported function array precedes the export directory")
}
functionsBase := content.addressOfFunctions - ni.exportSymbols.VirtualAddress
sbomOffset := content.addressOfSbom
sbomAddress, err := ni.fetchExportFunctionPointer(functionsBase, sbomOffset)
@@ -503,10 +561,16 @@ func (ni nativeImagePE) fetchPkgs() (pkgs []pkg.Package, relationships []artifac
log.Tracef("cannot obtain buffer from the java native-image .data section")
return nil, nil, nil
}
sbomLocation := sbomAddress - dataSection.VirtualAddress
lengthLocation := sbomLengthAddress - dataSection.VirtualAddress
sbomLocation, err := symbolOffset(uint64(sbomAddress), uint64(dataSection.VirtualAddress))
if err != nil {
return nil, nil, err
}
lengthLocation, err := symbolOffset(uint64(sbomLengthAddress), uint64(dataSection.VirtualAddress))
if err != nil {
return nil, nil, err
}
return decompressSbom(dataBuf, uint64(sbomLocation), uint64(lengthLocation))
return decompressSbom(dataBuf, sbomLocation, lengthLocation)
}
// fetchPkgs provides the packages available in a UnionReader.
@@ -527,6 +591,9 @@ func fetchPkgs(reader unionreader.UnionReader, location file.Location) ([]pkg.Pa
for _, makeNativeImage := range imageFormats {
ni, err := makeNativeImage(filename, r)
if err != nil {
// both "not this format" and a real rejection of a file that is one, which drops every
// package the binary carries and is only visible at trace level
log.WithFields("file", filename, "error", err).Trace("unable to read possible java native-image")
continue
}
if ni == nil {
@@ -535,14 +602,17 @@ func fetchPkgs(reader unionreader.UnionReader, location file.Location) ([]pkg.Pa
newPkgs, newRelationships, err := ni.fetchPkgs()
if err != nil {
log.Tracef("unable to extract SBOM from possible java native-image %s: %v", filename, err)
continue
} else {
// Associate extracted packages with the native image location
for i := range newPkgs {
newPkgs[i].Locations.Add(location)
}
pkgs = append(pkgs, newPkgs...)
relationships = append(relationships, newRelationships...)
}
// Associate extracted packages with the native image location
for i := range newPkgs {
newPkgs[i].Locations.Add(location)
}
pkgs = append(pkgs, newPkgs...)
relationships = append(relationships, newRelationships...)
// nothing parses as two of these today; stopping makes that explicit rather than relying on it.
// note a format that parses but whose fetchPkgs fails is not retried as another format
break
}
}
return pkgs, relationships
@@ -4,15 +4,19 @@ import (
"bufio"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/binary"
"fmt"
"io"
"os"
"path"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
intFile "github.com/anchore/syft/internal/file"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/cpe"
"github.com/anchore/syft/syft/internal/unionreader"
@@ -265,3 +269,303 @@ func verifyRelationshipFields(t *testing.T, expected, actual []artifact.Relation
require.Equal(t, expected[i].Type, actual[i].Type)
}
}
// buildMinimalPE64 assembles the smallest PE64 debug/pe will parse, with DataDirectory[0] set to the
// given RVA and size. The point of the fixture is that size can claim far more than totalSize.
//
// newPE uses the directory's VirtualAddress directly as a file offset, so no sections are needed and
// exportRVA doubles as the offset the read starts from.
func buildMinimalPE64(exportRVA, exportSize uint32, totalSize int) []byte {
const peHdrOff = 0x40
buf := make([]byte, totalSize)
copy(buf, []byte{'M', 'Z'})
binary.LittleEndian.PutUint32(buf[0x3c:], peHdrOff)
p := buf[peHdrOff:]
copy(p, []byte{'P', 'E', 0, 0})
// COFF file header
binary.LittleEndian.PutUint16(p[4:], 0x8664) // Machine = AMD64
binary.LittleEndian.PutUint16(p[20:], 240) // SizeOfOptionalHeader
binary.LittleEndian.PutUint16(p[22:], 0x22) // Characteristics: executable image
// optional header (PE32+)
oh := p[24:]
binary.LittleEndian.PutUint16(oh[0:], 0x20b) // Magic = PE32+
binary.LittleEndian.PutUint32(oh[108:], 16) // NumberOfRvaAndSizes
// DataDirectory[0] = export table
binary.LittleEndian.PutUint32(oh[112:], exportRVA)
binary.LittleEndian.PutUint32(oh[116:], exportSize)
return buf
}
// measureAlloc reports the bytes allocated while fn runs. TotalAlloc is process-wide, so a test using
// this must not call t.Parallel.
func measureAlloc(t *testing.T, fn func()) uint64 {
t.Helper()
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
fn()
runtime.ReadMemStats(&after)
return after.TotalAlloc - before.TotalAlloc
}
// readSizeRecorder records the largest single read requested of it. A small allocation is not enough on
// its own; no single read may exceed the file size either, whatever the headers claim.
type readSizeRecorder struct {
*bytes.Reader
maxRead int
}
func (r *readSizeRecorder) Read(p []byte) (int, error) {
if len(p) > r.maxRead {
r.maxRead = len(p)
}
return r.Reader.Read(p)
}
func (r *readSizeRecorder) ReadAt(p []byte, off int64) (int, error) {
if len(p) > r.maxRead {
r.maxRead = len(p)
}
return r.Reader.ReadAt(p, off)
}
func TestNewPE_ExportDirectorySizeIsNotAllocatedUpFront(t *testing.T) {
// Size is a user-controlled uint32 that used to size a make([]byte, Size) before the read, so a small
// file claiming 4GB reserved 4GB.
const totalSize = 8192
data := buildMinimalPE64(0x1000, 0xFFFFFFFF, totalSize)
r := &readSizeRecorder{Reader: bytes.NewReader(data)}
var err error
allocated := measureAlloc(t, func() {
_, err = newPE("oversized-export-dir.exe", r)
})
require.Error(t, err, "an export directory the file cannot satisfy must not read as successful")
require.ErrorContains(t, err, "byte limit")
t.Logf("allocated %d bytes for a directory declaring %d in a %d byte file",
allocated, uint64(0xFFFFFFFF), totalSize)
require.Less(t, allocated, uint64(intFile.MB),
"the declared size must not be reserved before the read confirms the bytes exist")
assert.LessOrEqual(t, r.maxRead, totalSize,
"no single read may exceed the file size, regardless of what the headers claim")
}
func TestNewPE_ExportDirectoryWithinFileIsRead(t *testing.T) {
// exportRVA=0x1000 in an 8192 byte file leaves exactly 4096 bytes for the directory to claim
const exportRVA = 0x1000
const totalSize = 8192
const remaining = totalSize - exportRVA
tests := []struct {
name string
exportSize uint32
wantErr bool
}{
{name: "well within the file", exportSize: 64, wantErr: false},
// pins the boundary the not-enough-bytes check must not break: every byte remaining is available
{name: "exactly the bytes remaining", exportSize: remaining, wantErr: false},
{name: "one byte past what remains", exportSize: remaining + 1, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := buildMinimalPE64(exportRVA, tt.exportSize, totalSize)
ni, err := newPE("ok.exe", bytes.NewReader(data))
if tt.wantErr {
require.Error(t, err, "the bound must not allow reading past what the file has")
return
}
require.NoError(t, err, "the bound must not reject an export directory that really is present")
require.NotNil(t, ni)
})
}
}
func TestDecompressSbom_RejectsOutOfRangeOffsets(t *testing.T) {
// every offset and length here is read out of the binary, so each must be rejected rather than wrapped
// into a value that passes a bound and panics on the slice. wantErrMsg pins which of the three guards fires
const sbomLengthOverflowsMsg = "the 'sbom_length' symbol overflows the binary"
const sbomOverflowsMsg = "the sbom symbol overflows the binary"
tests := []struct {
name string
bufLen int
sbomStart uint64
lengthStart uint64
storedLength uint64
wantErrMsg string
}{
{
name: "length offset wraps past the end of the buffer",
bufLen: 64,
lengthStart: ^uint64(0) - 3, // lengthStart+8 wraps to 4, which compares as in range
wantErrMsg: sbomLengthOverflowsMsg,
},
{
name: "length offset starts beyond the buffer",
bufLen: 64,
lengthStart: 100,
wantErrMsg: sbomLengthOverflowsMsg,
},
{
name: "stored length wraps past the end of the buffer",
bufLen: 64,
sbomStart: 16,
lengthStart: 0,
storedLength: ^uint64(0) - 3, // sbomStart+storedLength wraps to 12
wantErrMsg: sbomOverflowsMsg,
},
{
name: "stored length runs past the end of the buffer",
bufLen: 64,
sbomStart: 16,
lengthStart: 0,
storedLength: 1000,
wantErrMsg: sbomOverflowsMsg,
},
{
name: "sbom offset starts beyond the buffer",
bufLen: 64,
sbomStart: ^uint64(0) - 3,
lengthStart: 0,
wantErrMsg: sbomOverflowsMsg,
},
{
name: "length field straddles the end of the buffer",
bufLen: 64,
lengthStart: 60,
wantErrMsg: sbomLengthOverflowsMsg,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dataBuf := make([]byte, tt.bufLen)
// subtract rather than add, for the same reason the code under test has to: lengthStart is deliberately
// large enough to wrap
if tt.lengthStart <= uint64(tt.bufLen) && uint64(tt.bufLen)-tt.lengthStart >= 8 {
binary.LittleEndian.PutUint64(dataBuf[tt.lengthStart:], tt.storedLength)
}
var err error
require.NotPanics(t, func() {
_, _, err = decompressSbom(dataBuf, tt.sbomStart, tt.lengthStart)
})
require.ErrorContains(t, err, tt.wantErrMsg)
})
}
}
func TestDecompressSbom_RejectsStreamExpandingPastTheLimit(t *testing.T) {
// zeros stand in for any uniform, highly compressible payload: they expand about 1000x, far past what
// real JSON does
tests := []struct {
name string
decompressedSize int
wantRejected bool
}{
{
name: "one byte past the limit is rejected",
decompressedSize: nativeImageMaxDecompressedSbomSize + 1,
wantRejected: true,
},
{
// pins the other side: tightening the LimitedReader by one byte would reject a legal payload
name: "exactly at the limit falls through to the decoder",
decompressedSize: nativeImageMaxDecompressedSbomSize,
wantRejected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var compressed bytes.Buffer
z := gzip.NewWriter(&compressed)
_, err := z.Write(make([]byte, tt.decompressedSize))
require.NoError(t, err)
require.NoError(t, z.Close())
t.Logf("%d compressed bytes expand to %d (%dx)", compressed.Len(), tt.decompressedSize,
tt.decompressedSize/compressed.Len())
dataBuf := append(compressed.Bytes(), make([]byte, 8)...)
lengthStart := uint64(compressed.Len())
binary.LittleEndian.PutUint64(dataBuf[lengthStart:], lengthStart)
var decompressErr error
allocated := measureAlloc(t, func() {
_, _, decompressErr = decompressSbom(dataBuf, 0, lengthStart)
})
err = decompressErr
require.Error(t, err, "zeros are never a valid SBOM, so this errors either way")
if tt.wantRejected {
require.Contains(t, err.Error(), "decompresses past",
"hitting the bound should say so, not report a parse failure")
t.Logf("allocated %d bytes rejecting a stream past the limit", allocated)
// generous on purpose: -race roughly doubles the allocation counted here, and the point is catching an
// unbounded blowup, not pinning a multiple
require.Less(t, allocated, uint64(8*nativeImageMaxDecompressedSbomSize),
"rejecting an oversized stream must not retain multiples of the limit in memory")
return
}
require.NotContains(t, err.Error(), "decompresses past",
"a stream sitting exactly on the limit must reach the decoder, not be capped away")
})
}
}
// the other tests compute their sizes FROM nativeImageMaxDecompressedSbomSize, so raising it leaves them
// green. This is the one assertion that does not.
func TestNativeImageMaxDecompressedSbomSize_Ceiling(t *testing.T) {
require.LessOrEqual(t, int64(nativeImageMaxDecompressedSbomSize), int64(64*intFile.MB),
"the ceiling is what stands between a gzip bomb and an OOM; raising it needs a new argument here")
}
func TestDecompressSbom_AcceptsLargeSbom(t *testing.T) {
// the size limit must not truncate a genuinely large SBOM. Distinct names, versions, purls and hashes
// keep this shaped like a real CycloneDX document rather than a uniform payload.
const components = 20000
var sb bytes.Buffer
sb.WriteString(`{"bomFormat":"CycloneDX","specVersion":"1.5","version":1,"components":[`)
for i := 0; i < components; i++ {
if i > 0 {
sb.WriteString(",")
}
// a sha256-shaped value keeps the entropy (and so the compression ratio) realistic
hash := sha256.Sum256([]byte(fmt.Sprintf("component-%d", i)))
fmt.Fprintf(&sb, `{"type":"library","name":"lib-%d","version":"%d.%d.%d",`+
`"purl":"pkg:maven/com.example.group%d/lib-%d@%d.%d.%d",`+
`"hashes":[{"alg":"SHA-256","content":"%x"}]}`,
i, i%20, i%7, i%13, i%50, i, i%20, i%7, i%13, hash)
}
sb.WriteString(`]}`)
raw := sb.Bytes()
var compressed bytes.Buffer
z := gzip.NewWriter(&compressed)
_, err := z.Write(raw)
require.NoError(t, err)
require.NoError(t, z.Close())
t.Logf("%d components: %d bytes of JSON, %d compressed (%dx), limit %d",
components, len(raw), compressed.Len(), len(raw)/compressed.Len(), nativeImageMaxDecompressedSbomSize)
require.Less(t, int64(len(raw)), int64(nativeImageMaxDecompressedSbomSize),
"a real SBOM of this size must sit well inside the limit, otherwise the limit is too low")
dataBuf := append(compressed.Bytes(), make([]byte, 8)...)
lengthStart := uint64(compressed.Len())
binary.LittleEndian.PutUint64(dataBuf[lengthStart:], lengthStart)
pkgs, _, err := decompressSbom(dataBuf, 0, lengthStart)
require.NoError(t, err, "a large SBOM at a realistic ratio must not be rejected")
require.Len(t, pkgs, components)
}
@@ -0,0 +1,145 @@
package java
import (
"bytes"
"io"
"math"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/internal/unionreader"
)
func TestSymbolOffset(t *testing.T) {
tests := []struct {
name string
addr uint64
sectionBase uint64
wantOffset uint64
wantErr bool
}{
{
name: "addr below section base underflows if unchecked, so it must error instead",
addr: 0x900,
sectionBase: 0x1000,
wantErr: true,
},
{
name: "addr equal to section base gives offset zero",
addr: 0x1000,
sectionBase: 0x1000,
wantOffset: 0,
},
{
name: "addr above section base gives the exact delta",
addr: 0x1010,
sectionBase: 0x1000,
wantOffset: 0x10,
},
{
name: "zero section base succeeds for any addr",
addr: 0x2a,
sectionBase: 0,
wantOffset: 0x2a,
},
{
name: "max addr and max section base are equal, so offset is zero",
addr: math.MaxUint64,
sectionBase: math.MaxUint64,
wantOffset: 0,
},
{
name: "max addr against a zero section base gives the max delta",
addr: math.MaxUint64,
sectionBase: 0,
wantOffset: math.MaxUint64,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := symbolOffset(tt.addr, tt.sectionBase)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantOffset, got)
})
}
}
// readerFromFixture opens a testdata fixture and wraps it the same way processLocation does before
// handing it to fetchPkgs.
func readerFromFixture(t *testing.T, path string) unionreader.UnionReader {
t.Helper()
f, err := os.Open(path)
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })
reader, err := unionreader.GetUnionReader(io.NopCloser(f))
require.NoError(t, err)
return reader
}
// neither fixture is a native image, so both exercise the path where a format matches and its fetchPkgs
// then fails, which the break at the end of the format loop makes final.
//
// the break itself cannot be distinguished from the old fall-through here: ELF, Mach-O and PE magics are
// mutually exclusive, so no fixture can validly parse as two formats. Nothing reaches the branch that
// attaches location to each package either, since no committed fixture is a real native image with an
// embedded SBOM.
func TestFetchPkgs(t *testing.T) {
tests := []struct {
name string
fixture string
}{
{
name: "real native image without SBOM symbols",
fixture: "testdata/java-builds/packages/example-java-app",
},
{
name: "real Mach-O that is not a native image",
fixture: "testdata/java-builds/packages/gcc-amd64-darwin-exec-debug",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := readerFromFixture(t, tt.fixture)
location := file.NewLocation(tt.fixture)
require.NotPanics(t, func() {
gotPkgs, gotRels := fetchPkgs(reader, location)
assert.Empty(t, gotPkgs)
assert.Empty(t, gotRels)
})
})
}
}
// covers macho.NewFile failing with something other than *macho.FormatError. Garbage bytes take the
// FormatError branch and return (nil, nil); a valid magic with nothing behind it does not.
func TestNewMachO_TruncatedFileIsARealFailure(t *testing.T) {
// a valid Mach-O magic with nothing behind it yields io.ErrUnexpectedEOF, not a *macho.FormatError
_, err := newMachO("truncated", bytes.NewReader([]byte{0xcf, 0xfa, 0xed, 0xfe}))
require.ErrorContains(t, err, "unable to read executable")
}
func TestFetchPkgs_GarbageBytesYieldsNothing(t *testing.T) {
// bytes that match none of ELF, Mach-O or PE magic numbers
garbage := bytes.Repeat([]byte{0xDE, 0xAD, 0xBE, 0xEF}, 256)
reader, err := unionreader.GetUnionReader(io.NopCloser(bytes.NewReader(garbage)))
require.NoError(t, err)
location := file.NewLocation("garbage.bin")
require.NotPanics(t, func() {
pkgs, rels := fetchPkgs(reader, location)
assert.Empty(t, pkgs)
assert.Empty(t, rels)
})
}
@@ -0,0 +1,398 @@
package java
import (
"bytes"
"debug/pe"
"encoding/binary"
"io"
"math"
"testing"
"time"
"unsafe"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
intFile "github.com/anchore/syft/internal/file"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/pkg"
)
// pins the bound at the byte where an attribute ends flush with the directory: `j+sz > n` (not `>=`) is
// what lets attribute 3 be read out of a 36-byte exports buffer, since bytes 32:36 are entirely present.
func TestFetchExportAttribute(t *testing.T) {
// the four attributes fetchExportContent reads start right after this header
require.EqualValues(t, 20, unsafe.Sizeof(exportPrefixPE{}))
tests := []struct {
name string
exports []byte
index int
wantValue uint32
wantErr bool
}{
{
name: "one byte short of attribute 3 is rejected",
exports: make([]byte, 35),
index: 3,
wantErr: true,
},
{
name: "attribute 3 flush with the end of exports is read",
exports: func() []byte {
b := make([]byte, 36)
binary.LittleEndian.PutUint32(b[32:36], 0xDEADBEEF)
return b
}(),
index: 3,
wantValue: 0xDEADBEEF,
},
{
name: "attribute 0 reads the first attribute slot",
exports: func() []byte {
b := make([]byte, 24)
binary.LittleEndian.PutUint32(b[20:24], 42)
return b
}(),
index: 0,
wantValue: 42,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ni := nativeImagePE{exports: tt.exports}
got, err := ni.fetchExportAttribute(tt.index)
if tt.wantErr {
require.ErrorContains(t, err, nativeImageInvalidIndexError)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantValue, got)
})
}
}
// pins the uint32->uint64 widening: computing `functionsBase + i*sz` in uint32 wraps a huge base back
// into range instead of erroring.
func TestFetchExportFunctionPointer(t *testing.T) {
tests := []struct {
name string
exports []byte
functionsBase uint32
index uint32
wantValue uint32
wantErr bool
}{
{
name: "functionsBase near the uint32 max must not wrap back into range",
exports: make([]byte, 64),
functionsBase: 0xFFFFFFFF,
index: 1,
wantErr: true,
},
{
name: "an in-range function pointer is read",
exports: func() []byte {
b := make([]byte, 64)
binary.LittleEndian.PutUint32(b[16:20], 0xCAFEBABE)
return b
}(),
functionsBase: 8,
index: 2,
wantValue: 0xCAFEBABE,
},
{
name: "a pointer flush with the end of exports is read",
exports: func() []byte {
b := make([]byte, 64)
binary.LittleEndian.PutUint32(b[60:64], 0x11223344)
return b
}(),
functionsBase: 60,
index: 0,
wantValue: 0x11223344,
},
{
name: "a pointer straddling the end of exports",
exports: make([]byte, 64),
functionsBase: 62,
index: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ni := nativeImagePE{exports: tt.exports}
var got uint32
var err error
require.NotPanics(t, func() {
got, err = ni.fetchExportFunctionPointer(tt.functionsBase, tt.index)
})
if tt.wantErr {
require.ErrorContains(t, err, nativeImageInvalidIndexError)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantValue, got)
})
}
}
// addressOfNames below the directory's VirtualAddress. VirtualAddress is deliberately huge so the
// underflow lands back in range; a small one would let an unrelated bound mask this guard's absence.
func TestFetchSbomSymbols_NameArrayPrecedingDirectoryGuard(t *testing.T) {
va := uint32(0xFFFFFFF0)
addressOfNames := uint32(5)
wrapped := addressOfNames - va // what addressBase would be if the guard were missing
require.Less(t, wrapped, uint32(32), "the fixture only proves the guard's necessity if the wrap lands in range")
exports := make([]byte, 32)
le := binary.LittleEndian
// a name pointer table here would only be reached without the guard
le.PutUint32(exports[wrapped:], va+10) // index 0: resolves to a non-matching name
le.PutUint32(exports[wrapped+4:], va+5) // index 1: resolves to "sbom"
copy(exports[10:], "zzzz\x00")
copy(exports[5:], nativeImageSbomSymbol+"\x00")
ni := nativeImagePE{exports: exports, exportSymbols: pe.DataDirectory{VirtualAddress: va}}
content := &exportContentPE{addressOfNames: addressOfNames, numberOfNames: 2}
require.NotPanics(t, func() { ni.fetchSbomSymbols(content) })
assert.Zero(t, content.addressOfSbom, "addressOfNames below the directory must not be read at all")
assert.Zero(t, content.addressOfSbomLength)
assert.Zero(t, content.addressOfSvmVersion)
}
// a name pointer entry resolving below the directory. Same reasoning as the addressOfNames guard above.
func TestFetchSbomSymbols_SymbolAddressPrecedingDirectoryGuard(t *testing.T) {
va := uint32(0xFFFFFFF0)
exports := make([]byte, 32)
le := binary.LittleEndian
le.PutUint32(exports[0:4], va+10) // index 0: resolves to a non-matching name
le.PutUint32(exports[4:8], 5) // index 1: a symbolAddress below the directory
wrapped := uint32(5) - va // what symbolBase would be for index 1 if the guard were missing
require.Less(t, wrapped, uint32(32), "the fixture only proves the guard's necessity if the wrap lands in range")
copy(exports[10:], "zzzz\x00")
copy(exports[wrapped:], nativeImageSbomSymbol+"\x00")
ni := nativeImagePE{exports: exports, exportSymbols: pe.DataDirectory{VirtualAddress: va}}
content := &exportContentPE{addressOfNames: va, numberOfNames: 2}
require.NotPanics(t, func() { ni.fetchSbomSymbols(content) })
assert.Zero(t, content.addressOfSbom, "a symbolAddress below the directory must not be read as a name")
assert.Zero(t, content.addressOfSbomLength)
assert.Zero(t, content.addressOfSvmVersion)
}
// a symbolBase past the end of exports. Needs no wraparound: without the guard the slice is simply out
// of range and panics.
func TestFetchSbomSymbols_SymbolBaseOutOfRangeGuard(t *testing.T) {
const va = uint32(0x2000)
exports := make([]byte, 8)
binary.LittleEndian.PutUint32(exports[0:4], va+13) // symbolBase would be 13, past an 8-byte buffer
ni := nativeImagePE{exports: exports, exportSymbols: pe.DataDirectory{VirtualAddress: va}}
content := &exportContentPE{addressOfNames: va, numberOfNames: 1}
require.NotPanics(t, func() { ni.fetchSbomSymbols(content) })
assert.Zero(t, content.addressOfSbom)
assert.Zero(t, content.addressOfSbomLength)
assert.Zero(t, content.addressOfSvmVersion)
}
// addressBase past the end of exports. Every other fixture has addressBase == 0, so this is the only case
// reaching the `k > n` half: without it `n-k` underflows, clears the size check, and the slice panics.
func TestFetchSbomSymbols_AddressBaseOutOfRangeGuard(t *testing.T) {
const va = uint32(0x2000)
exports := make([]byte, 16)
ni := nativeImagePE{exports: exports, exportSymbols: pe.DataDirectory{VirtualAddress: va}}
content := &exportContentPE{addressOfNames: 0x2064, numberOfNames: 1} // addressBase = 0x64 = 100, past exports
require.NotPanics(t, func() { ni.fetchSbomSymbols(content) })
assert.Zero(t, content.addressOfSbom)
assert.Zero(t, content.addressOfSbomLength)
assert.Zero(t, content.addressOfSvmVersion)
}
// TestFetchSbomSymbols_FindsAllThreeSymbols is the happy path. fetchSbomSymbols records the loop index
// of a match, not its address.
func TestFetchSbomSymbols_FindsAllThreeSymbols(t *testing.T) {
const va = 0x2000
exports := make([]byte, 96)
le := binary.LittleEndian
// name pointer table: one uint32 RVA per name, in loop order.
// name pointer table: one uint32 RVA per name, in loop order. note fetchPkgs treats a stored index of 0
// as "symbol not found", so no symbol may sit at index 0 or this would pass for the wrong reason
le.PutUint32(exports[0:4], va+52) // index 0: an unrelated name
le.PutUint32(exports[4:8], va+57) // index 1: "sbom"
le.PutUint32(exports[8:12], va+62) // index 2: "sbom_length"
le.PutUint32(exports[12:16], va+74) // index 3: "__svm_version_info"
copy(exports[52:], "xxxx\x00")
copy(exports[57:], nativeImageSbomSymbol+"\x00")
copy(exports[62:], nativeImageSbomLengthSymbol+"\x00")
copy(exports[74:], nativeImageSbomVersionSymbol+"\x00")
ni := nativeImagePE{exports: exports, exportSymbols: pe.DataDirectory{VirtualAddress: va}}
content := &exportContentPE{addressOfNames: va, numberOfNames: 4}
ni.fetchSbomSymbols(content)
assert.EqualValues(t, 1, content.addressOfSbom)
assert.EqualValues(t, 2, content.addressOfSbomLength)
assert.EqualValues(t, 3, content.addressOfSvmVersion)
}
// numberOfNames is read straight out of the file, so a hostile binary can claim ~4 billion names. The
// scan must stop when the name pointer table runs off the end of exports, not iterate that far.
func TestFetchSbomSymbols_HugeNameCountTerminatesQuickly(t *testing.T) {
exports := make([]byte, 16) // room for exactly 4 name pointer table entries
ni := nativeImagePE{exports: exports, exportSymbols: pe.DataDirectory{VirtualAddress: 0}}
content := &exportContentPE{addressOfNames: 0, numberOfNames: math.MaxUint32}
start := time.Now()
require.NotPanics(t, func() { ni.fetchSbomSymbols(content) })
assert.Less(t, time.Since(start), time.Second,
"the scan must stop at the end of exports, not loop numberOfNames times")
}
// the one guard in fetchPkgs reachable without a real *pe.File: addressOfFunctions below the export
// directory base must error rather than underflow into a huge functionsBase.
func TestFetchPkgs_AddressOfFunctionsPrecedingDirectoryErrors(t *testing.T) {
const va = 0x2000
exports := make([]byte, 96)
le := binary.LittleEndian
// the four attributes fetchExportContent reads
le.PutUint32(exports[20:24], 10) // numberOfFunctions (unused before the guard under test)
le.PutUint32(exports[24:28], 4) // numberOfNames
le.PutUint32(exports[28:32], va-1) // addressOfFunctions: precedes the export directory
le.PutUint32(exports[32:36], va+36)
// name pointer table at offset 36, resolving all three SBOM symbols so the guard under test is
// reached instead of the earlier "missing symbols" return
le.PutUint32(exports[36:40], va+52)
le.PutUint32(exports[40:44], va+57)
le.PutUint32(exports[44:48], va+62)
le.PutUint32(exports[48:52], va+74)
copy(exports[52:], "xxxx\x00")
copy(exports[57:], nativeImageSbomSymbol+"\x00")
copy(exports[62:], nativeImageSbomLengthSymbol+"\x00")
copy(exports[74:], nativeImageSbomVersionSymbol+"\x00")
ni := nativeImagePE{exports: exports, exportSymbols: pe.DataDirectory{VirtualAddress: va}}
var pkgs []pkg.Package
var rels []artifact.Relationship
var err error
require.NotPanics(t, func() {
pkgs, rels, err = ni.fetchPkgs()
})
require.Error(t, err)
assert.NotContains(t, err.Error(), "recovered from panic",
"the guard must reject this cleanly, not rely on the recover")
assert.Contains(t, err.Error(), "exported function array precedes the export directory")
assert.Empty(t, pkgs)
assert.Empty(t, rels)
}
// the three outcomes of weighing a declared size against both the cap and the bytes the file really has.
func TestReadExportDirectory(t *testing.T) {
t.Run("size over the cap is rejected", func(t *testing.T) {
dir := pe.DataDirectory{VirtualAddress: 0, Size: nativeImageMaxExportDirectorySize + 1}
_, err := readExportDirectory(bytes.NewReader(make([]byte, 1024)), dir)
require.Error(t, err)
assert.Contains(t, err.Error(), "byte limit")
})
t.Run("a size the file cannot satisfy is rejected", func(t *testing.T) {
dir := pe.DataDirectory{VirtualAddress: 0, Size: 600}
_, err := readExportDirectory(bytes.NewReader(make([]byte, 512)), dir)
require.Error(t, err)
assert.Contains(t, err.Error(), "remain")
})
t.Run("a size the file can exactly satisfy succeeds", func(t *testing.T) {
data := make([]byte, 200)
binary.LittleEndian.PutUint32(data[100:104], 0xABCDEF01)
dir := pe.DataDirectory{VirtualAddress: 100, Size: 100} // remaining bytes (200-100) exactly
got, err := readExportDirectory(bytes.NewReader(data), dir)
require.NoError(t, err)
require.Len(t, got, 100)
assert.Equal(t, uint32(0xABCDEF01), binary.LittleEndian.Uint32(got[0:4]))
})
t.Run("a reader that cannot report its size is rejected", func(t *testing.T) {
data := make([]byte, 64)
r := struct{ io.ReaderAt }{bytes.NewReader(data)} // promotes ReadAt only, no Size() or Seek
dir := pe.DataDirectory{VirtualAddress: 0, Size: 32}
_, err := readExportDirectory(r, dir)
require.ErrorContains(t, err, "unable to measure the binary")
})
}
// proves the remaining-bytes guard, not just the cap: a small, cheap file can still declare a size under
// the cap, and only the bytes really present can reject it before the read and its allocation.
func TestReadExportDirectory_DoesNotReadThePhantomBytes(t *testing.T) {
const realSize = 4096
rec := &readSizeRecorder{Reader: bytes.NewReader(make([]byte, realSize))}
dir := pe.DataDirectory{VirtualAddress: 0, Size: nativeImageMaxExportDirectorySize}
allocated := measureAlloc(t, func() {
_, err := readExportDirectory(rec, dir)
// a Size exactly at the cap must fall through to the remaining-bytes check, not be capped away;
// pins the `>` (not `>=`) in the cap comparison
require.ErrorContains(t, err, "remain")
})
t.Logf("allocated %d bytes for a directory declaring %d against a %d byte file", allocated, dir.Size, realSize)
assert.Less(t, allocated, uint64(intFile.MB),
"rejecting an oversized declaration must not first allocate against it")
assert.LessOrEqual(t, rec.maxRead, realSize,
"no read should ask for more than the file actually holds")
}
// shortReader has 64 addressable bytes but hands back fewer than asked with a nil error, the
// non-conforming shape syft/internal/unionreader documents for squashfs
type shortReader struct {
b []byte
perRead int
}
func (s shortReader) Size() int64 { return int64(len(s.b)) }
func (s shortReader) ReadAt(p []byte, off int64) (int, error) {
if len(p) == 1 { // intFile.ReaderSize probes the last byte, and that probe has to succeed
p[0] = s.b[off]
return 1, nil
}
return copy(p[:s.perRead], s.b[off:off+int64(s.perRead)]), nil
}
// regression for the real defect: a ReadAt handing back fewer bytes than asked with a nil error used to
// be accepted, and the zeroed tail of the buffer parsed as export directory data.
func TestReadExportDirectory_RejectsShortReadWithNilError(t *testing.T) {
tests := []struct {
name string
perRead int
}{
{name: "reader answers with zero bytes and a nil error", perRead: 0},
{name: "reader answers with a partial read and a nil error", perRead: 4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := shortReader{b: make([]byte, 64), perRead: tt.perRead}
dir := pe.DataDirectory{VirtualAddress: 0, Size: 32}
_, err := readExportDirectory(r, dir)
require.ErrorContains(t, err, "read")
})
}
}