Unbounded memory allocation in Quill via unvalidated size fields in Mach-O binary parsing
Description
Quill provides simple mac binary signing and notarization from any platform. Quill before version v0.7.1 contains an unbounded memory allocation vulnerability when parsing Mach-O binaries. Exploitation requires that Quill processes an attacker-supplied Mach-O binary, which is most likely in environments such as CI/CD pipelines, shared signing services, or any workflow where externally-submitted binaries are accepted for signing. When parsing a Mach-O binary, Quill reads several size and count fields from the LC_CODE_SIGNATURE load command and embedded code signing structures (SuperBlob, BlobIndex) and uses them to allocate memory buffers without validating that the values are reasonable or consistent with the actual file size. Affected fields include DataSize, DataOffset, and Size from the load command, Count from the SuperBlob header, and Length from individual blob headers. An attacker can craft a minimal (~4KB) malicious Mach-O binary with extremely large values in these fields, causing Quill to attempt to allocate excessive memory. This leads to memory exhaustion and denial of service, potentially crashing the host process. Both the Quill CLI and Go library are affected when used to parse untrusted Mach-O files. This vulnerability is fixed in 0.7.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Quill before v0.7.1 is vulnerable to unbounded memory allocation when parsing malicious Mach-O binaries, leading to denial of service in CI/CD pipelines and signing services.
A denial-of-service vulnerability (CVE-2026-31961) exists in Quill, a tool for signing and notarizing macOS binaries from any platform. Versions prior to 0.7.1 are affected. The root cause is an unbounded memory allocation flaw in the Mach-O binary parser. O parsing logic. When processing the LC_CODE_SIGNATURE load command and related embedded code signing structures (SuperBlob, BlobIndex), Quill reads several size and count fields (such as DataSize, DataOffset, Size, Count, and Length) and uses them directly to allocate memory buffers without validating that these values are reasonable or consistent with the actual file size [1][2]. This lack of validation lets an attacker cause excessive memory consumption.
Exploitation requires that an attacker to supply a crafted, minimal (~4KB) malicious Mach-O binary containing extremely large values in the affected fields. Quill then attempts to allocate memory beyond available resources, leading to memory exhaustion and a denial-of-service condition. The attack surface is most relevant in environments where Quill processes externally submitted binaries, such as CI/CD pipelines, shared signing services, or automated workflows that accept untrusted binaries for signing. Both the Quill CLI and the underlying Go library are impacted when used to parse such files [2][3].
Impact An attacker achieving this Denial-of-Service condition can crash the host process processing a binary, disrupting signing operations. This can block software releases or signing tasks in automated pipelines. The vulnerability does not lead to arbitrary code execution or privilege escalation; its impact is confined to availability.
The vulnerability is fixed in Quill version 0.7.1 [4]. The patch introduces explicit security limits on fields parsed from Mach-O binaries, including caps on the superblob size (50 MB), blob count (25), individual blob length (16 MB), and loader command size (128 bytes), preventing the unbounded allocations that caused the issue [1]. Users are strongly advised to update to the latest version, especially if running signing services that accept untrusted inputs.
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/anchore/quillGo | < 0.7.1 | 0.7.1 |
Affected products
2- anchore/quillv5Range: < 0.7.1
Patches
180cf3fe08267account for excessive read limits in macho parsing code (#682)
2 files changed · +513 −81
quill/macho/file.go+177 −81 modified@@ -1,3 +1,4 @@ +// Package macho provides functionality for reading and modifying Mach-O binaries. package macho import ( @@ -24,6 +25,45 @@ const ( PageSizeBits = 12 PageSize = 1 << PageSizeBits + + // The below constants control security limits for parsing untrusted Mach-O binaries. + // + // A malicious binary can claim to have huge data sections (e.g., 4GB) to trick us into + // allocating massive amounts of memory. These limits cap how much memory we'll allocate + // based on values read from the binary. We also verify that claimed data ranges actually + // fit within the file. + // + // How code signing data is structured: + // + // - The signature lives in a container called a "superblob" + // - The superblob holds multiple smaller pieces called "blobs" + // - Most blobs are small (under 100KB): entitlements, requirements, the signature itself + // - One blob is large: the "code directory" which stores a hash of every 4KB page + // + // The code directory grows with binary size (roughly: binary_size / 4KB × 32 bytes): + // + // 83MB binary → ~630KB code directory + // 500MB binary → ~4MB code directory + // 2GB binary → ~16MB code directory + // + // The number of blobs does NOT grow with binary size. Real binaries tend to have 4-8 blobs + // regardless of size. These limits support binaries up to ~2GB with plenty of headroom. + + // maxSuperBlobSize caps the total signature container size. + // Real-world superblobs are under 1MB; 50MB allows for very large binaries. + maxSuperBlobSize = 50 * 1024 * 1024 // 50 MB + + // maxBlobCount caps how many blobs can be in the container. + // Real binaries have 4-8 blobs; 25 is generous while blocking absurd values. + maxBlobCount = 25 + + // maxBlobLength caps the size of any single blob. + // The code directory is the largest blob; 16MB supports binaries up to ~2GB. + maxBlobLength = 16 * 1024 * 1024 // 16 MB + + // maxLoaderCmdSize caps the size of loader command structures. + // The code signature command is 16 bytes; 128 bytes is plenty of headroom. + maxLoaderCmdSize = 128 ) type File struct { @@ -32,19 +72,22 @@ type File struct { io.ReaderAt io.WriterAt *macho.File + fileSize int64 // cached file size, -1 if not determined } func NewFile(path string) (*File, error) { m := &File{ - path: path, + path: path, + fileSize: -1, } return m, m.refresh(true) } func NewReadOnlyFile(path string) (*File, error) { m := &File{ - path: path, + path: path, + fileSize: -1, } return m, m.refresh(false) @@ -97,6 +140,7 @@ func (m *File) refresh(withWrite bool) error { m.WriterAt = f } m.File = o + m.fileSize = -1 // invalidate cached file size return nil } @@ -108,6 +152,47 @@ func (m *File) Close() error { return m.File.Close() } +// getFileSize returns the size of the underlying file (cached). +func (m *File) getFileSize() (int64, error) { + if m.fileSize >= 0 { + return m.fileSize, nil + } + + currentPos, err := m.Seek(0, io.SeekCurrent) + if err != nil { + return 0, fmt.Errorf("unable to get current file position: %w", err) + } + + size, err := m.Seek(0, io.SeekEnd) + if err != nil { + return 0, fmt.Errorf("unable to seek to end of file: %w", err) + } + + if _, err := m.Seek(currentPos, io.SeekStart); err != nil { + return 0, fmt.Errorf("unable to restore file position: %w", err) + } + + m.fileSize = size + return size, nil +} + +// validateDataRange checks offset + size doesn't overflow and fits in file. +func (m *File) validateDataRange(offset, size uint32, description string) error { + end := uint64(offset) + uint64(size) + + fileSize, err := m.getFileSize() + if err != nil { + return fmt.Errorf("%s: unable to determine file size: %w", description, err) + } + + if int64(end) > fileSize { + return fmt.Errorf("%s: data extends beyond file (offset=%d, size=%d, file_size=%d)", + description, offset, size, fileSize) + } + + return nil +} + func (m *File) Patch(content []byte, size int, offset uint64) (err error) { if m.WriterAt == nil { return fmt.Errorf("writes not allowed") @@ -116,6 +201,7 @@ func (m *File) Patch(content []byte, size int, offset uint64) (err error) { if err != nil { return fmt.Errorf("unable to patch macho binary: %w", err) } + m.fileSize = -1 // invalidate cached file size before refresh return m.refresh(true) } @@ -244,6 +330,17 @@ func (m *File) RemoveSigningContent() error { return fmt.Errorf("unable to extract existing code signing cmd: %w", err) } + // validate command sizes before allocation + if cmd.Size > maxLoaderCmdSize { + return fmt.Errorf("loader command size exceeds maximum (%d > %d)", cmd.Size, maxLoaderCmdSize) + } + if cmd.DataSize > maxSuperBlobSize { + return fmt.Errorf("superblob size exceeds maximum (%d > %d)", cmd.DataSize, maxSuperBlobSize) + } + if err := m.validateDataRange(cmd.DataOffset, cmd.DataSize, "code signing data"); err != nil { + return err + } + if !m.isSigningCommandLastLoader() { return fmt.Errorf("code signing command is not the last loader command, so cannot remove it (easily) without corrupting the binary") } @@ -319,6 +416,11 @@ func (m *File) HashPages(hasher hash.Hash) (hashes [][]byte, err error) { return nil, fmt.Errorf("LcCodeSignature is not present, any generated page hashes will be wrong. Bailing") } + // validate DataOffset is within file bounds before allocating memory via io.ReadAll + if err := m.validateDataRange(0, cmd.DataOffset, "code signing data offset"); err != nil { + return nil, err + } + if _, err = m.Seek(0, io.SeekStart); err != nil { return nil, fmt.Errorf("unable to seek within macho binary: %w", err) } @@ -337,122 +439,116 @@ func (m *File) HashPages(hasher hash.Hash) (hashes [][]byte, err error) { } func (m *File) CDBytes(order binary.ByteOrder, ith int) (cd []byte, err error) { - cmd, _, err := m.CodeSigningCmd() + csBlob, superBlobReader, err := m.readSuperBlob() if err != nil { - return nil, fmt.Errorf("unable to extract code signing cmd: %w", err) - } - - superBlobBytes := make([]byte, cmd.DataSize) - if _, err := m.ReadAt(superBlobBytes, int64(cmd.DataOffset)); err != nil { - return nil, fmt.Errorf("unable to extract code signing block from macho binary: %w", err) - } - - superBlobReader := bytes.NewReader(superBlobBytes) - - csBlob := SuperBlob{} - if err := binary.Read(superBlobReader, SigningOrder, &csBlob.SuperBlobHeader); err != nil { - return nil, fmt.Errorf("unable to extract superblob header from macho binary: %w", err) - } - - csBlob.Index = make([]BlobIndex, csBlob.Count) - if err := binary.Read(superBlobReader, SigningOrder, &csBlob.Index); err != nil { return nil, err } var found int -blobIndex: for _, index := range csBlob.Index { - if _, err := superBlobReader.Seek(int64(index.Offset), io.SeekStart); err != nil { - return nil, fmt.Errorf("unable to seek to code signing blob index=%d: %w", index.Offset, err) + if index.Type != CsSlotCodedirectory && index.Type != CsSlotAlternateCodedirectories { + continue } - switch index.Type { - case CsSlotCodedirectory, CsSlotAlternateCodedirectories: - found++ - if found <= ith { - continue blobIndex - } - - var cdBlobHeader BlobHeader - // read the header - if err := binary.Read(superBlobReader, SigningOrder, &cdBlobHeader); err != nil { - return nil, err - } - - var cdHeader CodeDirectoryHeader - if err := binary.Read(superBlobReader, SigningOrder, &cdHeader); err != nil { - return nil, err - } - - // head back to the beginning of the CD - if _, err := superBlobReader.Seek(int64(index.Offset), io.SeekStart); err != nil { - return nil, fmt.Errorf("unable to seek to code directory: %w", err) - } - - cdBytes := make([]byte, cdBlobHeader.Length) - // note: though the binary may be LE or BE, for hashing we always use LE - // note: the entire blob is encoded, not just the code directory (which is only the blob payload) - if err := binary.Read(superBlobReader, order, &cdBytes); err != nil { - return nil, err - } - - return cdBytes, nil + found++ + if found <= ith { + continue } + + return m.readBlobBytes(superBlobReader, index, order, "code directory") } return nil, ErrNoCodeDirectory } -var ErrNoCodeDirectory = fmt.Errorf("unable to find code directory") - -func (m *File) CMSBlobBytes(order binary.ByteOrder) (cd []byte, err error) { +// readSuperBlob reads and validates the code signing superblob, returning the parsed blob and a reader. +func (m *File) readSuperBlob() (*SuperBlob, *bytes.Reader, error) { cmd, _, err := m.CodeSigningCmd() if err != nil { - return nil, fmt.Errorf("unable to extract code signing cmd: %w", err) + return nil, nil, fmt.Errorf("unable to extract code signing cmd: %w", err) + } + + if cmd == nil { + return nil, nil, fmt.Errorf("no code signing command found") + } + + if cmd.DataSize > maxSuperBlobSize { + return nil, nil, fmt.Errorf("superblob size exceeds maximum (%d > %d)", cmd.DataSize, maxSuperBlobSize) + } + if err := m.validateDataRange(cmd.DataOffset, cmd.DataSize, "code signing superblob"); err != nil { + return nil, nil, err } superBlobBytes := make([]byte, cmd.DataSize) if _, err := m.ReadAt(superBlobBytes, int64(cmd.DataOffset)); err != nil { - return nil, fmt.Errorf("unable to extract code signing block from macho binary: %w", err) + return nil, nil, fmt.Errorf("unable to extract code signing block from macho binary: %w", err) } superBlobReader := bytes.NewReader(superBlobBytes) - csBlob := SuperBlob{} + csBlob := &SuperBlob{} if err := binary.Read(superBlobReader, SigningOrder, &csBlob.SuperBlobHeader); err != nil { - return nil, fmt.Errorf("unable to extract superblob header from macho binary: %w", err) + return nil, nil, fmt.Errorf("unable to extract superblob header from macho binary: %w", err) + } + + if csBlob.Count > maxBlobCount { + return nil, nil, fmt.Errorf("blob count exceeds maximum (%d > %d)", csBlob.Count, maxBlobCount) } csBlob.Index = make([]BlobIndex, csBlob.Count) if err := binary.Read(superBlobReader, SigningOrder, &csBlob.Index); err != nil { + return nil, nil, err + } + + return csBlob, superBlobReader, nil +} + +// readBlobBytes reads and returns the raw bytes of a blob at the given index. +func (m *File) readBlobBytes(reader *bytes.Reader, index BlobIndex, order binary.ByteOrder, blobName string) ([]byte, error) { + if _, err := reader.Seek(int64(index.Offset), io.SeekStart); err != nil { + return nil, fmt.Errorf("unable to seek to %s blob: %w", blobName, err) + } + + var blobHeader BlobHeader + if err := binary.Read(reader, SigningOrder, &blobHeader); err != nil { return nil, err } - for _, index := range csBlob.Index { - if _, err := superBlobReader.Seek(int64(index.Offset), io.SeekStart); err != nil { - return nil, fmt.Errorf("unable to seek to code signing blob index=%d: %w", index.Offset, err) - } + if blobHeader.Length > maxBlobLength { + return nil, fmt.Errorf("%s blob size exceeds maximum (%d > %d)", blobName, blobHeader.Length, maxBlobLength) + } - switch index.Type { //nolint:gocritic - case CsSlotCmsSignature: + // validate that the blob fits within the superblob buffer (defense in depth against malicious length values) + superBlobSize := reader.Size() + if int64(index.Offset)+int64(blobHeader.Length) > superBlobSize { + return nil, fmt.Errorf("%s blob extends beyond superblob (offset=%d + length=%d > %d)", blobName, index.Offset, blobHeader.Length, superBlobSize) + } - var blobHeader BlobHeader - // read the header - if err := binary.Read(superBlobReader, SigningOrder, &blobHeader); err != nil { - return nil, err - } + // seek back to the beginning of the blob to read the full content + if _, err := reader.Seek(int64(index.Offset), io.SeekStart); err != nil { + return nil, fmt.Errorf("unable to seek to %s: %w", blobName, err) + } - // head back to the beginning of the CD - if _, err := superBlobReader.Seek(int64(index.Offset), io.SeekStart); err != nil { - return nil, fmt.Errorf("unable to seek to CMS bob: %w", err) - } + blobBytes := make([]byte, blobHeader.Length) + if err := binary.Read(reader, order, &blobBytes); err != nil { + return nil, err + } - b := make([]byte, blobHeader.Length) - if err := binary.Read(superBlobReader, order, &b); err != nil { - return nil, err - } + return blobBytes, nil +} - return b, nil +var ErrNoCodeDirectory = fmt.Errorf("unable to find code directory") + +func (m *File) CMSBlobBytes(order binary.ByteOrder) (cd []byte, err error) { + csBlob, superBlobReader, err := m.readSuperBlob() + if err != nil { + return nil, err + } + + for _, index := range csBlob.Index { + if index.Type != CsSlotCmsSignature { + continue } + return m.readBlobBytes(superBlobReader, index, order, "CMS") } return nil, fmt.Errorf("unable to find CMS blob") }
quill/macho/file_test.go+336 −0 modified@@ -2,7 +2,10 @@ package macho import ( "crypto/sha256" + "encoding/binary" "fmt" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -356,3 +359,336 @@ func TestFile_HashCD(t *testing.T) { }) } } + +// createMaliciousMachO creates a minimal Mach-O file with a malicious code signing command. +// The dataSize and dataOffset parameters control the values in the LC_CODE_SIGNATURE command. +func createMaliciousMachO(t *testing.T, dataSize, dataOffset uint32) string { + t.Helper() + + // create a minimal 64-bit Mach-O header + dir := t.TempDir() + path := filepath.Join(dir, "malicious") + + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + + // Mach-O 64-bit magic + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0xFEEDFACF))) // MH_MAGIC_64 + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0x01000007))) // CPU_TYPE_X86_64 + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0x80000003))) // CPU_SUBTYPE_X86_64_ALL + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0x00000002))) // MH_EXECUTE + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(1))) // ncmds = 1 + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(16))) // sizeofcmds = 16 + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0))) // flags + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0))) // reserved (64-bit padding) + + // LC_CODE_SIGNATURE command (cmd = 0x1D = 29) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0x1D))) // cmd + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(16))) // cmdsize + require.NoError(t, binary.Write(f, binary.LittleEndian, dataOffset)) // dataoff + require.NoError(t, binary.Write(f, binary.LittleEndian, dataSize)) // datasize + + return path +} + +// createMaliciousSuperBlob creates a Mach-O file with a valid superblob but malicious count value. +func createMaliciousSuperBlob(t *testing.T, blobCount uint32) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "malicious_superblob") + + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + + superBlobOffset := uint32(48) // after header + load command + superBlobSize := uint32(12) // just the header + + // Mach-O 64-bit header + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0xFEEDFACF))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0x01000007))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0x80000003))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0x00000002))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(1))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(16))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0))) + + // LC_CODE_SIGNATURE + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0x1D))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(16))) + require.NoError(t, binary.Write(f, binary.LittleEndian, superBlobOffset)) + require.NoError(t, binary.Write(f, binary.LittleEndian, superBlobSize)) + + // SuperBlob header (big endian as per code signing format) + require.NoError(t, binary.Write(f, binary.BigEndian, uint32(0xFADE0CC0))) // magic + require.NoError(t, binary.Write(f, binary.BigEndian, superBlobSize)) // length + require.NoError(t, binary.Write(f, binary.BigEndian, blobCount)) // count + + return path +} + +// createMaliciousBlobLength creates a Mach-O file with a superblob containing a blob that claims +// an oversized length value. This tests the maxBlobLength validation. +func createMaliciousBlobLength(t *testing.T, blobLength uint32, slotType SlotType) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "malicious_blob_length") + + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + + superBlobOffset := uint32(48) // after header + load command + // superblob header (12) + 1 blob index (8) + blob header (8) = 28 bytes minimum + superBlobSize := uint32(28) + + // Mach-O 64-bit header + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0xFEEDFACF))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0x01000007))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0x80000003))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0x00000002))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(1))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(16))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0))) + + // LC_CODE_SIGNATURE + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(0x1D))) + require.NoError(t, binary.Write(f, binary.LittleEndian, uint32(16))) + require.NoError(t, binary.Write(f, binary.LittleEndian, superBlobOffset)) + require.NoError(t, binary.Write(f, binary.LittleEndian, superBlobSize)) + + // SuperBlob header (big endian as per code signing format) + require.NoError(t, binary.Write(f, binary.BigEndian, uint32(0xFADE0CC0))) // magic + require.NoError(t, binary.Write(f, binary.BigEndian, superBlobSize)) // length + require.NoError(t, binary.Write(f, binary.BigEndian, uint32(1))) // count = 1 blob + + // BlobIndex entry: type + offset + require.NoError(t, binary.Write(f, binary.BigEndian, uint32(slotType))) // slot type + require.NoError(t, binary.Write(f, binary.BigEndian, uint32(20))) // offset within superblob (after header + index) + + // Blob header with malicious length + require.NoError(t, binary.Write(f, binary.BigEndian, uint32(0xFADE0C02))) // magic (code directory) + require.NoError(t, binary.Write(f, binary.BigEndian, blobLength)) // malicious length + + return path +} + +func TestFile_CDBytes_ValidationOversizedBlobLength(t *testing.T) { + // create a malicious binary with a blob claiming length > maxBlobLength + path := createMaliciousBlobLength(t, maxBlobLength+1, CsSlotCodedirectory) + + m, err := NewReadOnlyFile(path) + require.NoError(t, err) + defer m.Close() + + _, err = m.CDBytes(binary.LittleEndian, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "blob size exceeds maximum") +} + +func TestFile_CDBytes_ValidationBlobExtendsBeyondSuperBlob(t *testing.T) { + // create a malicious binary with a blob length that exceeds superblob bounds + // (but is under maxBlobLength) + path := createMaliciousBlobLength(t, 1000, CsSlotCodedirectory) // 1000 > 28 byte superblob + + m, err := NewReadOnlyFile(path) + require.NoError(t, err) + defer m.Close() + + _, err = m.CDBytes(binary.LittleEndian, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "extends beyond superblob") +} + +func TestFile_CMSBlobBytes_ValidationOversizedBlobLength(t *testing.T) { + // create a malicious binary with a CMS blob claiming length > maxBlobLength + path := createMaliciousBlobLength(t, maxBlobLength+1, CsSlotCmsSignature) + + m, err := NewReadOnlyFile(path) + require.NoError(t, err) + defer m.Close() + + _, err = m.CMSBlobBytes(binary.LittleEndian) + require.Error(t, err) + assert.Contains(t, err.Error(), "blob size exceeds maximum") +} + +func TestFile_CMSBlobBytes_ValidationBlobExtendsBeyondSuperBlob(t *testing.T) { + // create a malicious binary with a CMS blob length that exceeds superblob bounds + path := createMaliciousBlobLength(t, 1000, CsSlotCmsSignature) // 1000 > 28 byte superblob + + m, err := NewReadOnlyFile(path) + require.NoError(t, err) + defer m.Close() + + _, err = m.CMSBlobBytes(binary.LittleEndian) + require.Error(t, err) + assert.Contains(t, err.Error(), "extends beyond superblob") +} + +func TestFile_CDBytes_ValidationOversizedSuperBlob(t *testing.T) { + // create a malicious binary with a DataSize larger than maxSuperBlobSize + path := createMaliciousMachO(t, maxSuperBlobSize+1, 48) + + m, err := NewReadOnlyFile(path) + require.NoError(t, err) + defer m.Close() + + _, err = m.CDBytes(binary.LittleEndian, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "superblob size exceeds maximum") +} + +func TestFile_CDBytes_ValidationDataBeyondFile(t *testing.T) { + // create a malicious binary where offset + size extends beyond file + path := createMaliciousMachO(t, 1000, 48) + + m, err := NewReadOnlyFile(path) + require.NoError(t, err) + defer m.Close() + + _, err = m.CDBytes(binary.LittleEndian, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "data extends beyond file") +} + +func TestFile_CDBytes_ValidationOversizedBlobCount(t *testing.T) { + // create a malicious binary with too many blob indices + path := createMaliciousSuperBlob(t, maxBlobCount+1) + + m, err := NewReadOnlyFile(path) + require.NoError(t, err) + defer m.Close() + + _, err = m.CDBytes(binary.LittleEndian, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "blob count exceeds maximum") +} + +func TestFile_CMSBlobBytes_ValidationOversizedSuperBlob(t *testing.T) { + path := createMaliciousMachO(t, maxSuperBlobSize+1, 48) + + m, err := NewReadOnlyFile(path) + require.NoError(t, err) + defer m.Close() + + _, err = m.CMSBlobBytes(binary.LittleEndian) + require.Error(t, err) + assert.Contains(t, err.Error(), "superblob size exceeds maximum") +} + +func TestFile_CMSBlobBytes_ValidationDataBeyondFile(t *testing.T) { + path := createMaliciousMachO(t, 1000, 48) + + m, err := NewReadOnlyFile(path) + require.NoError(t, err) + defer m.Close() + + _, err = m.CMSBlobBytes(binary.LittleEndian) + require.Error(t, err) + assert.Contains(t, err.Error(), "data extends beyond file") +} + +func TestFile_CMSBlobBytes_ValidationOversizedBlobCount(t *testing.T) { + path := createMaliciousSuperBlob(t, maxBlobCount+1) + + m, err := NewReadOnlyFile(path) + require.NoError(t, err) + defer m.Close() + + _, err = m.CMSBlobBytes(binary.LittleEndian) + require.Error(t, err) + assert.Contains(t, err.Error(), "blob count exceeds maximum") +} + +// Note: Testing oversized loader command size (cmd.Size) is not possible because the Go +// standard library's macho.NewFile() validates command block sizes during parsing and +// rejects malformed binaries before our validation runs. This provides defense-in-depth. + +func TestFile_RemoveSigningContent_ValidationOversizedDataSize(t *testing.T) { + // use a copy of a real signed binary and patch it with oversized DataSize + originalPath := test.AssetCopy(t, "hello_adhoc_signed") + + m, err := NewFile(originalPath) + require.NoError(t, err) + + // patch the code signing command to have oversized DataSize + cmd, offset, err := m.CodeSigningCmd() + require.NoError(t, err) + require.NotNil(t, cmd) + + cmd.DataSize = maxSuperBlobSize + 1 + + // write patched command back + var buf [16]byte + binary.LittleEndian.PutUint32(buf[0:4], uint32(cmd.Cmd)) + binary.LittleEndian.PutUint32(buf[4:8], cmd.Size) + binary.LittleEndian.PutUint32(buf[8:12], cmd.DataOffset) + binary.LittleEndian.PutUint32(buf[12:16], cmd.DataSize) + + _, err = m.WriteAt(buf[:], int64(offset)) + require.NoError(t, err) + m.Close() + + // reopen and test + m2, err := NewFile(originalPath) + require.NoError(t, err) + defer m2.Close() + + err = m2.RemoveSigningContent() + require.Error(t, err) + assert.Contains(t, err.Error(), "superblob size exceeds maximum") +} + +func TestFile_HashPages_ValidationDataBeyondFile(t *testing.T) { + // create a malicious binary where DataOffset extends beyond file size + // DataOffset is used by HashPages to read everything up to that point + path := createMaliciousMachO(t, 100, 0xFFFFFFFF) // huge offset beyond file + + m, err := NewReadOnlyFile(path) + require.NoError(t, err) + defer m.Close() + + _, err = m.HashPages(sha256.New()) + require.Error(t, err) + assert.Contains(t, err.Error(), "data extends beyond file") +} + +func TestFile_ValidDataRangePassing(t *testing.T) { + // test that legitimate signed binaries still work + tests := []struct { + name string + binaryPath string + }{ + { + name: "adhoc signed binary", + binaryPath: test.Asset(t, "hello_adhoc_signed"), + }, + { + name: "signed binary", + binaryPath: test.Asset(t, "syft_signed"), + }, + { + name: "signed binary extracted from universal binary", + binaryPath: test.Asset(t, "ls_x86_64_signed"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, err := NewReadOnlyFile(tt.binaryPath) + require.NoError(t, err) + defer m.Close() + + // CDBytes should work + cdBytes, err := m.CDBytes(binary.LittleEndian, 0) + require.NoError(t, err) + assert.NotEmpty(t, cdBytes) + }) + } +}
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-xj69-m9qq-8m94ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-31961ghsaADVISORY
- developer.apple.com/documentation/technotes/tn3126-inside-code-signing-hashesghsaWEB
- github.com/anchore/quill/commit/80cf3fe082678af0ec4f9f8dd93f39189d2dc1feghsaWEB
- github.com/anchore/quill/releases/tag/v0.7.1ghsaWEB
- github.com/anchore/quill/security/advisories/GHSA-xj69-m9qq-8m94ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.