mirror of
https://github.com/anchore/syft.git
synced 2026-08-20 00:58:31 +02:00
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>
This commit is contained in:
parent
2293641e3b
commit
def9bc2e16
@ -82,6 +82,17 @@ const nativeImageMissingSymbolsError = "one or more symbols are missing from the
|
|||||||
const nativeImageInvalidIndexError = "parsing the executable file generated an invalid index"
|
const nativeImageInvalidIndexError = "parsing the executable file generated an invalid index"
|
||||||
const nativeImageMissingExportedDataDirectoryError = "exported data directory is missing"
|
const nativeImageMissingExportedDataDirectoryError = "exported data directory is missing"
|
||||||
|
|
||||||
|
// nativeImageMaxDecompressedSbomSize bounds how far the embedded SBOM may decompress.
|
||||||
|
//
|
||||||
|
// A native-image SBOM enumerating thousands of Java dependencies comes to single-digit megabytes (20k
|
||||||
|
// components is ~4MB, see TestDecompressSbom_AcceptsLargeSbom), so this is a ~20x margin over anything
|
||||||
|
// real and should never truncate a genuine SBOM. Truncating one silently drops packages, so the margin
|
||||||
|
// is deliberate, but the ceiling is not free either: the decoder buffers the whole stream before it can
|
||||||
|
// identify it, and io.ReadAll's growth makes the real peak roughly double this. It has to bound the
|
||||||
|
// decompressed stream rather than the compressed bytes, since gzip will happily turn a few kilobytes
|
||||||
|
// into gigabytes. Hitting it is logged, because the packages are dropped either way.
|
||||||
|
const nativeImageMaxDecompressedSbomSize = 100 * 1024 * 1024
|
||||||
|
|
||||||
// NewNativeImageCataloger returns a new Native Image cataloger object.
|
// NewNativeImageCataloger returns a new Native Image cataloger object.
|
||||||
func NewNativeImageCataloger() pkg.Cataloger {
|
func NewNativeImageCataloger() pkg.Cataloger {
|
||||||
return &nativeImageCataloger{}
|
return &nativeImageCataloger{}
|
||||||
@ -94,34 +105,48 @@ func (c *nativeImageCataloger) Name() string {
|
|||||||
|
|
||||||
// decompressSbom returns the packages given within a native image executable's SBOM.
|
// 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) {
|
func decompressSbom(dataBuf []byte, sbomStart uint64, lengthStart uint64) ([]pkg.Package, []artifact.Relationship, error) {
|
||||||
lengthEnd := lengthStart + 8
|
return decompressSbomWithLimit(dataBuf, sbomStart, lengthStart, nativeImageMaxDecompressedSbomSize)
|
||||||
bufLen := len(dataBuf)
|
}
|
||||||
if lengthEnd > uint64(bufLen) {
|
|
||||||
|
// decompressSbomWithLimit is decompressSbom with the decompressed-size bound passed in, so that a test
|
||||||
|
// can exercise the bound without first having to produce the hundreds of megabytes it takes to trip the
|
||||||
|
// real one. Production always goes through decompressSbom.
|
||||||
|
//
|
||||||
|
// Both offsets and the stored length come from the binary being parsed, so every bound below subtracts
|
||||||
|
// from the known-good buffer length rather than adding to a user-controlled value: `start+n` wraps, and a
|
||||||
|
// wrapped sum compares as in-range while the slice that follows it panics.
|
||||||
|
func decompressSbomWithLimit(dataBuf []byte, sbomStart, lengthStart uint64, maxDecompressed int64) ([]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")
|
return nil, nil, errors.New("the 'sbom_length' symbol overflows the binary")
|
||||||
}
|
}
|
||||||
|
|
||||||
length := dataBuf[lengthStart:lengthEnd]
|
storedLength := binary.LittleEndian.Uint64(dataBuf[lengthStart : lengthStart+8])
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.WithFields("len", storedLength).Trace("found java native-image SBOM")
|
log.WithFields("len", storedLength).Trace("found java native-image SBOM")
|
||||||
sbomEnd := sbomStart + storedLength
|
if sbomStart > bufLen || storedLength > bufLen-sbomStart {
|
||||||
if sbomEnd > uint64(bufLen) {
|
|
||||||
return nil, nil, errors.New("the sbom symbol overflows the binary")
|
return nil, nil, errors.New("the sbom symbol overflows the binary")
|
||||||
}
|
}
|
||||||
|
|
||||||
sbomCompressed := dataBuf[sbomStart:sbomEnd]
|
sbomCompressed := dataBuf[sbomStart : sbomStart+storedLength]
|
||||||
p = bytes.NewBuffer(sbomCompressed)
|
gzreader, err := gzip.NewReader(bytes.NewBuffer(sbomCompressed))
|
||||||
gzreader, err := gzip.NewReader(p)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("could not decompress the java native-image SBOM: %w", err)
|
return nil, nil, fmt.Errorf("could not decompress the java native-image SBOM: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sbom, _, _, err := cyclonedxjson.NewFormatDecoder().Decode(gzreader)
|
// sbomCompressed is bounded by the file, but the decompressed stream is not, so bound it too. The
|
||||||
|
// decoder buffers everything it is handed before it can even tell whether the payload is CycloneDX,
|
||||||
|
// so a payload that is not an SBOM at all still costs whatever we allow here.
|
||||||
|
limited := &io.LimitedReader{R: gzreader, N: maxDecompressed + 1}
|
||||||
|
|
||||||
|
sbom, _, _, err := cyclonedxjson.NewFormatDecoder().Decode(limited)
|
||||||
|
// checked before err, since hitting the bound surfaces as an unhelpful "not a cyclonedx json
|
||||||
|
// document" from the decoder rather than as anything about size
|
||||||
|
if limited.N == 0 {
|
||||||
|
log.WithFields("limit", maxDecompressed, "compressed", len(sbomCompressed)).
|
||||||
|
Debug("java native-image SBOM decompresses past the size limit; skipping it")
|
||||||
|
return nil, nil, fmt.Errorf("the java native-image SBOM decompresses past %d bytes", maxDecompressed)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("could not unmarshal the java native-image SBOM: %w", err)
|
return nil, nil, fmt.Errorf("could not unmarshal the java native-image SBOM: %w", err)
|
||||||
}
|
}
|
||||||
@ -132,6 +157,16 @@ func decompressSbom(dataBuf []byte, sbomStart uint64, lengthStart uint64) ([]pkg
|
|||||||
return pkgs, sbom.Relationships, nil
|
return pkgs, sbom.Relationships, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// symbolOffsets converts the SBOM symbol addresses into offsets within the section data that should hold
|
||||||
|
// them. Both addresses come from the binary, and the subtraction is unsigned, so an address below the
|
||||||
|
// section base underflows into a huge offset instead of a negative one.
|
||||||
|
func symbolOffsets(sbomAddr, lengthAddr, sectionBase uint64) (sbomOffset uint64, lengthOffset uint64, err error) {
|
||||||
|
if sbomAddr < sectionBase || lengthAddr < sectionBase {
|
||||||
|
return 0, 0, errors.New("an SBOM symbol precedes the section that should contain it")
|
||||||
|
}
|
||||||
|
return sbomAddr - sectionBase, lengthAddr - sectionBase, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fileError logs an error message when an executable cannot be read.
|
// fileError logs an error message when an executable cannot be read.
|
||||||
func fileError(filename string, err error) (nativeImage, error) {
|
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.
|
// We could not read the file as a binary for the desired platform, but it may still be a native-image executable.
|
||||||
@ -210,11 +245,22 @@ func newPE(filename string, r io.ReaderAt) (nativeImage, error) {
|
|||||||
return fileError(filename, errors.New(nativeImageMissingExportedDataDirectoryError))
|
return fileError(filename, errors.New(nativeImageMissingExportedDataDirectoryError))
|
||||||
}
|
}
|
||||||
exportSymbolsOffset := uint64(exportSymbolsDataDirectory.VirtualAddress)
|
exportSymbolsOffset := uint64(exportSymbolsDataDirectory.VirtualAddress)
|
||||||
exports := make([]byte, exportSymbolsDataDirectory.Size)
|
// Size is a user-controlled uint32 from the PE optional header, so sizing the buffer from it up
|
||||||
_, err = r.ReadAt(exports, int64(exportSymbolsOffset))
|
// front lets a small file reserve up to 4GB. Reading grows the buffer to what the file actually holds
|
||||||
|
// instead, bounded by the declared size, and the length check below still requires the whole directory
|
||||||
|
// to be present rather than accepting a truncated read. The io.LimitReader is redundant with the
|
||||||
|
// SectionReader, which already stops at exportSize, but the ruleguard rule in test/rules/rules.go
|
||||||
|
// matches on the text of the io.ReadAll argument and cannot see that; removing it fails lint.
|
||||||
|
exportSize := int64(exportSymbolsDataDirectory.Size)
|
||||||
|
sectionReader := io.NewSectionReader(r, int64(exportSymbolsOffset), exportSize)
|
||||||
|
exports, err := io.ReadAll(io.LimitReader(sectionReader, exportSize))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fileError(filename, fmt.Errorf("could not read the exported symbols data directory: %w", err))
|
return fileError(filename, fmt.Errorf("could not read the exported symbols data directory: %w", err))
|
||||||
}
|
}
|
||||||
|
if int64(len(exports)) != exportSize {
|
||||||
|
return fileError(filename, fmt.Errorf("exported symbols data directory is truncated: got %d of %d bytes",
|
||||||
|
len(exports), exportSize))
|
||||||
|
}
|
||||||
return nativeImagePE{
|
return nativeImagePE{
|
||||||
file: bi,
|
file: bi,
|
||||||
reader: r,
|
reader: r,
|
||||||
@ -273,15 +319,16 @@ func (ni nativeImageElf) fetchPkgs() (pkgs []pkg.Package, relationships []artifa
|
|||||||
}
|
}
|
||||||
dataSection := bi.Section(".data")
|
dataSection := bi.Section(".data")
|
||||||
if dataSection == nil {
|
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()
|
data, err := dataSection.Data()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot read the .data section: %w", err)
|
return nil, nil, fmt.Errorf("cannot read the .data section: %w", err)
|
||||||
}
|
}
|
||||||
sbomLocation := sbom.Value - dataSectionBase
|
sbomLocation, lengthLocation, err := symbolOffsets(sbom.Value, sbomLength.Value, dataSection.Addr)
|
||||||
lengthLocation := sbomLength.Value - dataSectionBase
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return decompressSbom(data, sbomLocation, lengthLocation)
|
return decompressSbom(data, sbomLocation, lengthLocation)
|
||||||
}
|
}
|
||||||
@ -352,48 +399,39 @@ func (ni nativeImageMachO) fetchPkgs() (pkgs []pkg.Package, relationships []arti
|
|||||||
log.Tracef("cannot obtain buffer from data segment")
|
log.Tracef("cannot obtain buffer from data segment")
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
sbomLocation := sbom.Value - dataSegment.Addr
|
sbomLocation, lengthLocation, err := symbolOffsets(sbom.Value, sbomLength.Value, dataSegment.Addr)
|
||||||
lengthLocation := sbomLength.Value - dataSegment.Addr
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return decompressSbom(dataBuf, sbomLocation, lengthLocation)
|
return decompressSbom(dataBuf, sbomLocation, lengthLocation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchExportAttribute obtains an attribute from the exported symbols directory entry.
|
// fetchExportAttribute obtains an attribute from the exported symbols directory entry.
|
||||||
func (ni nativeImagePE) fetchExportAttribute(i int) (uint32, error) {
|
func (ni nativeImagePE) fetchExportAttribute(i int) (uint32, error) {
|
||||||
var attribute uint32
|
|
||||||
n := len(ni.exports)
|
n := len(ni.exports)
|
||||||
|
// i is only ever 0-3, so this arithmetic cannot overflow; the bound is > rather than >= because an
|
||||||
|
// attribute ending flush with the directory is still entirely present
|
||||||
j := int(unsafe.Sizeof(ni.header)) + i*int(unsafe.Sizeof(ni.t.headerAttribute))
|
j := int(unsafe.Sizeof(ni.header)) + i*int(unsafe.Sizeof(ni.t.headerAttribute))
|
||||||
if j+4 >= n {
|
if j+4 > n {
|
||||||
log.Tracef("invalid index to export directory entry attribute: %v", j)
|
log.Tracef("invalid index to export directory entry attribute: %v", j)
|
||||||
return uint32(0), errors.New(nativeImageInvalidIndexError)
|
return uint32(0), errors.New(nativeImageInvalidIndexError)
|
||||||
}
|
}
|
||||||
p := bytes.NewBuffer(ni.exports[j : j+4])
|
return binary.LittleEndian.Uint32(ni.exports[j : j+4]), nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchExportFunctionPointer obtains a function pointer from the exported symbols directory entry.
|
// fetchExportFunctionPointer obtains a function pointer from the exported symbols directory entry.
|
||||||
func (ni nativeImagePE) fetchExportFunctionPointer(functionsBase uint32, i uint32) (uint32, error) {
|
func (ni nativeImagePE) fetchExportFunctionPointer(functionsBase uint32, i uint32) (uint32, error) {
|
||||||
var pointer uint32
|
// functionsBase derives from a file-controlled RVA, so widen to uint64 before indexing: in uint32 the
|
||||||
|
// sum wraps to a small value that passes the bound and then panics on the slice
|
||||||
n := uint32(len(ni.exports))
|
n := uint64(len(ni.exports))
|
||||||
sz := uint32(unsafe.Sizeof(ni.t.functionPointer))
|
sz := uint64(unsafe.Sizeof(ni.t.functionPointer))
|
||||||
j := functionsBase + i*sz
|
j := uint64(functionsBase) + uint64(i)*sz
|
||||||
if j+sz >= n {
|
if j > n || n-j < sz {
|
||||||
log.Tracef("invalid index to exported function: %v", j)
|
log.Tracef("invalid index to exported function: %v", j)
|
||||||
return uint32(0), errors.New(nativeImageInvalidIndexError)
|
return uint32(0), errors.New(nativeImageInvalidIndexError)
|
||||||
}
|
}
|
||||||
p := bytes.NewBuffer(ni.exports[j : j+sz])
|
return binary.LittleEndian.Uint32(ni.exports[j : j+sz]), nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchExportContent obtains the content of the export directory entry relevant to the SBOM.
|
// fetchExportContent obtains the content of the export directory entry relevant to the SBOM.
|
||||||
@ -425,27 +463,31 @@ func (ni nativeImagePE) fetchSbomSymbols(content *exportContentPE) {
|
|||||||
sbomBytes := []byte(nativeImageSbomSymbol + "\x00")
|
sbomBytes := []byte(nativeImageSbomSymbol + "\x00")
|
||||||
sbomLengthBytes := []byte(nativeImageSbomLengthSymbol + "\x00")
|
sbomLengthBytes := []byte(nativeImageSbomLengthSymbol + "\x00")
|
||||||
svmVersionInfoBytes := []byte(nativeImageSbomVersionSymbol + "\x00")
|
svmVersionInfoBytes := []byte(nativeImageSbomVersionSymbol + "\x00")
|
||||||
n := uint32(len(ni.exports))
|
n := uint64(len(ni.exports))
|
||||||
|
sz := uint64(unsafe.Sizeof(ni.t.namePointer))
|
||||||
|
|
||||||
|
// the name array must start inside the directory we read; an RVA below it would underflow the
|
||||||
|
// subtraction into a huge offset that then wraps back into range on the bound 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
|
// Find SBOM, SBOM Length, and SVM Version Symbol
|
||||||
for i := uint32(0); i < content.numberOfNames; i++ {
|
for i := uint32(0); i < content.numberOfNames; i++ {
|
||||||
j := i * uint32(unsafe.Sizeof(ni.t.namePointer))
|
k := addressBase + uint64(i)*sz
|
||||||
addressBase := content.addressOfNames - ni.exportSymbols.VirtualAddress
|
if k > n || n-k < sz {
|
||||||
k := addressBase + j
|
|
||||||
sz := uint32(unsafe.Sizeof(ni.t.namePointer))
|
|
||||||
if k+sz >= n {
|
|
||||||
log.Tracef("invalid index to exported function: %v", k)
|
log.Tracef("invalid index to exported function: %v", k)
|
||||||
// If we are at the end of exports, stop looking
|
// If we are at the end of exports, stop looking
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var symbolAddress uint32
|
symbolAddress := binary.LittleEndian.Uint32(ni.exports[k : k+sz])
|
||||||
p := bytes.NewBuffer(ni.exports[k : k+sz])
|
if symbolAddress < ni.exportSymbols.VirtualAddress {
|
||||||
err := binary.Read(p, binary.LittleEndian, &symbolAddress)
|
log.Tracef("exported symbol precedes the export directory: %v", symbolAddress)
|
||||||
if err != nil {
|
|
||||||
log.Tracef("error fetching address of symbol %v", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
symbolBase := symbolAddress - ni.exportSymbols.VirtualAddress
|
symbolBase := uint64(symbolAddress - ni.exportSymbols.VirtualAddress)
|
||||||
if symbolBase >= n {
|
if symbolBase >= n {
|
||||||
log.Tracef("invalid index to exported symbol: %v", symbolBase)
|
log.Tracef("invalid index to exported symbol: %v", symbolBase)
|
||||||
return
|
return
|
||||||
@ -480,6 +522,9 @@ func (ni nativeImagePE) fetchPkgs() (pkgs []pkg.Package, relationships []artifac
|
|||||||
if content.addressOfSbom == uint32(0) || content.addressOfSbomLength == uint32(0) || content.addressOfSvmVersion == uint32(0) {
|
if content.addressOfSbom == uint32(0) || content.addressOfSbomLength == uint32(0) || content.addressOfSvmVersion == uint32(0) {
|
||||||
return nil, nil, errors.New(nativeImageMissingSymbolsError)
|
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
|
functionsBase := content.addressOfFunctions - ni.exportSymbols.VirtualAddress
|
||||||
sbomOffset := content.addressOfSbom
|
sbomOffset := content.addressOfSbom
|
||||||
sbomAddress, err := ni.fetchExportFunctionPointer(functionsBase, sbomOffset)
|
sbomAddress, err := ni.fetchExportFunctionPointer(functionsBase, sbomOffset)
|
||||||
@ -501,10 +546,13 @@ func (ni nativeImagePE) fetchPkgs() (pkgs []pkg.Package, relationships []artifac
|
|||||||
log.Tracef("cannot obtain buffer from the java native-image .data section")
|
log.Tracef("cannot obtain buffer from the java native-image .data section")
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
sbomLocation := sbomAddress - dataSection.VirtualAddress
|
sbomLocation, lengthLocation, err := symbolOffsets(uint64(sbomAddress), uint64(sbomLengthAddress),
|
||||||
lengthLocation := sbomLengthAddress - dataSection.VirtualAddress
|
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.
|
// fetchPkgs provides the packages available in a UnionReader.
|
||||||
@ -525,6 +573,9 @@ func fetchPkgs(reader unionreader.UnionReader, location file.Location) ([]pkg.Pa
|
|||||||
for _, makeNativeImage := range imageFormats {
|
for _, makeNativeImage := range imageFormats {
|
||||||
ni, err := makeNativeImage(filename, r)
|
ni, err := makeNativeImage(filename, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// this covers both "not this format" and a real rejection of a file that is one, and the
|
||||||
|
// latter drops every package the binary carries, so it cannot go unrecorded
|
||||||
|
log.WithFields("file", filename, "error", err).Trace("unable to read possible java native-image")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ni == nil {
|
if ni == nil {
|
||||||
|
|||||||
@ -4,11 +4,13 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@ -265,3 +267,190 @@ func verifyRelationshipFields(t *testing.T, expected, actual []artifact.Relation
|
|||||||
require.Equal(t, expected[i].Type, actual[i].Type)
|
require.Equal(t, expected[i].Type, actual[i].Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildMinimalPE64 assembles the smallest PE64 that debug/pe will parse, with DataDirectory[0] (the
|
||||||
|
// export directory) set to the given RVA and size. totalSize is how large the resulting file is; the
|
||||||
|
// point of the fixture is that size can claim far more than that.
|
||||||
|
//
|
||||||
|
// newPE uses the directory's VirtualAddress directly as a file offset rather than translating it, so the
|
||||||
|
// fixture needs no sections 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPE_ExportDirectorySizeIsNotAllocatedUpFront(t *testing.T) {
|
||||||
|
// exportSymbolsDataDirectory.Size is a user-controlled uint32 from the PE optional header, and it used
|
||||||
|
// to size a make([]byte, Size) before the read, so an 8KB file claiming 4GB reserved 4GB.
|
||||||
|
data := buildMinimalPE64(0x1000, 0xFFFFFFFF, 8192)
|
||||||
|
|
||||||
|
var before, after runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&before)
|
||||||
|
_, err := newPE("oversized-export-dir.exe", bytes.NewReader(data))
|
||||||
|
runtime.ReadMemStats(&after)
|
||||||
|
|
||||||
|
require.Error(t, err, "an export directory the file cannot satisfy must not read as successful")
|
||||||
|
require.Contains(t, err.Error(), "truncated")
|
||||||
|
|
||||||
|
allocated := after.TotalAlloc - before.TotalAlloc
|
||||||
|
t.Logf("allocated %d bytes for a directory declaring %d", allocated, uint64(0xFFFFFFFF))
|
||||||
|
require.Less(t, allocated, uint64(8*1024*1024),
|
||||||
|
"the declared size must not be reserved before the read confirms the bytes exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPE_ExportDirectoryWithinFileIsRead(t *testing.T) {
|
||||||
|
// the bound must not reject an export directory that really is present
|
||||||
|
data := buildMinimalPE64(0x1000, 64, 8192)
|
||||||
|
|
||||||
|
ni, err := newPE("ok.exe", bytes.NewReader(data))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ni)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecompressSbom_RejectsOutOfRangeOffsets(t *testing.T) {
|
||||||
|
// every offset and length below is read out of the binary, so each has to be rejected rather than
|
||||||
|
// wrapped into a value that passes a bound and then panics on the slice that follows
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bufLen int
|
||||||
|
sbomStart uint64
|
||||||
|
lengthStart uint64
|
||||||
|
storedLength uint64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "length offset starts beyond the buffer",
|
||||||
|
bufLen: 64,
|
||||||
|
lengthStart: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stored length runs past the end of the buffer",
|
||||||
|
bufLen: 64,
|
||||||
|
sbomStart: 16,
|
||||||
|
lengthStart: 0,
|
||||||
|
storedLength: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sbom offset starts beyond the buffer",
|
||||||
|
bufLen: 64,
|
||||||
|
sbomStart: ^uint64(0) - 3,
|
||||||
|
lengthStart: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dataBuf := make([]byte, tt.bufLen)
|
||||||
|
// subtract from the length rather than adding to the offset, for the same reason the code
|
||||||
|
// under test has to: tt.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotPanics(t, func() {
|
||||||
|
_, _, err := decompressSbom(dataBuf, tt.sbomStart, tt.lengthStart)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecompressSbom_RejectsStreamExpandingPastTheLimit(t *testing.T) {
|
||||||
|
// the compressed bytes are bounded by the file, but what they expand to is not, and the decoder
|
||||||
|
// buffers the whole stream before it can tell whether the payload is even CycloneDX. Zeros stand in
|
||||||
|
// for any uniform, highly compressible payload: they expand about 1000x, far past what real JSON does.
|
||||||
|
// the bound is passed in rather than taken from nativeImageMaxDecompressedSbomSize, so that tripping
|
||||||
|
// it costs a megabyte instead of the several hundred the real limit would demand
|
||||||
|
const limit = 1024 * 1024
|
||||||
|
const decompressedSize = limit + 1
|
||||||
|
|
||||||
|
var compressed bytes.Buffer
|
||||||
|
z := gzip.NewWriter(&compressed)
|
||||||
|
_, err := z.Write(make([]byte, decompressedSize))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, z.Close())
|
||||||
|
|
||||||
|
t.Logf("%d compressed bytes expand to %d (%dx)", compressed.Len(), decompressedSize,
|
||||||
|
decompressedSize/compressed.Len())
|
||||||
|
|
||||||
|
dataBuf := append(compressed.Bytes(), make([]byte, 8)...)
|
||||||
|
lengthStart := uint64(compressed.Len())
|
||||||
|
binary.LittleEndian.PutUint64(dataBuf[lengthStart:], lengthStart)
|
||||||
|
|
||||||
|
_, _, err = decompressSbomWithLimit(dataBuf, 0, lengthStart, limit)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "decompresses past",
|
||||||
|
"hitting the bound should say so, not report a parse failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecompressSbom_AcceptsLargeSbom(t *testing.T) {
|
||||||
|
// guards the risk the size limit introduces: a genuinely large SBOM must still be cataloged.
|
||||||
|
// Components carry distinct names, versions, purls and hashes, so this is 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)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user