From 822e444a608952a991e92f7f1fee85b06a4e167f Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sun, 2 Aug 2026 02:54:36 +0000 Subject: [PATCH] fix: detect arm64 Linux kernel images in the kernel cataloger The linux-kernel cataloger only handled x86 bzImage layouts, so arm64 kernel images (raw Image, whole-file gzip, and EFI zboot PE wrappers with embedded gzip/zstd/xz streams) produced no linux-kernel component. Detect the arm64 header magic, search through embedded compressed streams with vendored decoders under bounded reads, and extract the version from the Linux version banner, keeping the x86 path first. Refs #5126 Signed-off-by: Alex Chen --- .../kernel/parse_arm64_linux_kernel_file.go | 176 ++++++++++++++ .../parse_arm64_linux_kernel_file_test.go | 223 ++++++++++++++++++ .../kernel/parse_linux_kernel_file.go | 40 ++-- 3 files changed, 425 insertions(+), 14 deletions(-) create mode 100644 syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file.go create mode 100644 syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file_test.go diff --git a/syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file.go b/syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file.go new file mode 100644 index 00000000000..d75bc64b1d9 --- /dev/null +++ b/syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file.go @@ -0,0 +1,176 @@ +package kernel + +import ( + "bytes" + "compress/bzip2" + "compress/gzip" + "io" + "strings" + + "github.com/klauspost/compress/zstd" + "github.com/ulikunitz/xz" + + "github.com/anchore/syft/syft/internal/unionreader" + "github.com/anchore/syft/syft/pkg" +) + +const ( + // arm64ImageMagicOffset is the offset of the arm64 Linux kernel image magic + // (0x644d5241, "ARM\x64") in the image header; see + // Documentation/arch/arm64/booting.rst in the kernel source tree. + arm64ImageMagicOffset = 0x38 + + // maxARM64KernelFileSize bounds how much of a candidate kernel file is read + // while looking for a wrapped arm64 image (distro vmlinuz files are far smaller). + maxARM64KernelFileSize = 64 << 20 + + // maxARM64KernelDecompressedSize bounds decompression of a candidate payload + // as a safeguard against decompression bombs. + maxARM64KernelDecompressedSize = 256 << 20 +) + +var ( + arm64ImageMagic = []byte{'A', 'R', 'M', 0x64} + linuxVersionMarker = []byte("Linux version ") +) + +// kernelCompressionFormat pairs a compression stream magic with a decoder, used +// when searching for an arm64 kernel image wrapped inside another binary (e.g. +// EFI zboot), mirroring the approach of scripts/extract-vmlinux from the kernel +// source tree. Only formats with decoders already vendored by syft are covered. +type kernelCompressionFormat struct { + magic []byte + decompress func([]byte) []byte +} + +var kernelCompressionFormats = []kernelCompressionFormat{ + {[]byte{0x1f, 0x8b, 0x08}, gunzipBytes}, // gzip + {[]byte{0x28, 0xb5, 0x2f, 0xfd}, unzstdBytes}, // zstd + {[]byte{0xfd, '7', 'z', 'X', 'Z', 0x00}, unxzBytes}, // xz + {[]byte{'B', 'Z', 'h'}, bunzip2Bytes}, // bzip2 +} + +// parseARM64LinuxKernelImage detects arm64 kernel images, which deitch/magic +// does not recognize (its "Linux kernel" test keys off x86-specific bytes). +// arm64 distro kernels are shipped as a raw Image, a whole-file compressed +// Image (e.g. Ubuntu), or an EFI zboot PE binary wrapping a compressed Image +// (Amazon Linux, RHEL). +func parseARM64LinuxKernelImage(ur unionreader.UnionReader) (p pkg.LinuxKernel, ok bool) { + if _, err := ur.Seek(0, io.SeekStart); err != nil { + return p, false + } + data, err := io.ReadAll(io.LimitReader(ur, maxARM64KernelFileSize)) + if err != nil { + return p, false + } + payload := findARM64ImagePayload(data) + if payload == nil { + return p, false + } + version, extendedVersion, found := parseLinuxVersionBanner(payload) + if !found { + return p, false + } + return pkg.LinuxKernel{ + Architecture: "arm64", + Version: version, + ExtendedVersion: extendedVersion, + Format: "Image", + }, true +} + +// findARM64ImagePayload returns the arm64 kernel image contained in data: data +// itself when it is a raw image, or the first embedded compression stream that +// decompresses to an arm64 image. +func findARM64ImagePayload(data []byte) []byte { + if isARM64Image(data) { + return data + } + for _, format := range kernelCompressionFormats { + if payload := findCompressedARM64Image(data, format); payload != nil { + return payload + } + } + return nil +} + +func findCompressedARM64Image(data []byte, format kernelCompressionFormat) []byte { + offset := 0 + for offset < len(data) { + idx := bytes.Index(data[offset:], format.magic) + if idx < 0 { + return nil + } + offset += idx + if payload := format.decompress(data[offset:]); isARM64Image(payload) { + return payload + } + offset++ + } + return nil +} + +// isARM64Image reports whether b starts with an arm64 kernel image header. +func isARM64Image(b []byte) bool { + return len(b) > arm64ImageMagicOffset+len(arm64ImageMagic) && + bytes.Equal(b[arm64ImageMagicOffset:arm64ImageMagicOffset+len(arm64ImageMagic)], arm64ImageMagic) +} + +// parseLinuxVersionBanner extracts the version from the "Linux version ..." +// banner string embedded in every kernel image (linux_banner in init/version.c). +func parseLinuxVersionBanner(payload []byte) (version, extendedVersion string, ok bool) { + idx := bytes.Index(payload, linuxVersionMarker) + if idx < 0 { + return "", "", false + } + rest := payload[idx+len(linuxVersionMarker):] + if end := bytes.IndexAny(rest, "\x00\n\r"); end >= 0 { + rest = rest[:end] + } + extendedVersion = strings.TrimSpace(string(rest)) + fields := strings.Fields(extendedVersion) + if len(fields) == 0 { + return "", "", false + } + return fields[0], extendedVersion, true +} + +func gunzipBytes(data []byte) []byte { + r, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil + } + return drainBounded(r) +} + +func unzstdBytes(data []byte) []byte { + r, err := zstd.NewReader(bytes.NewReader(data)) + if err != nil { + return nil + } + defer r.Close() + return drainBounded(r) +} + +func unxzBytes(data []byte) []byte { + r, err := xz.NewReader(bytes.NewReader(data)) + if err != nil { + return nil + } + return drainBounded(r) +} + +func bunzip2Bytes(data []byte) []byte { + return drainBounded(bzip2.NewReader(bytes.NewReader(data))) +} + +// drainBounded reads up to maxARM64KernelDecompressedSize bytes, tolerating a +// trailing error after a valid stream prefix (wrapped payloads are commonly +// followed by unrelated bytes). +func drainBounded(r io.Reader) []byte { + data, _ := io.ReadAll(io.LimitReader(r, maxARM64KernelDecompressedSize)) + if len(data) == 0 { + return nil + } + return data +} diff --git a/syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file_test.go b/syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file_test.go new file mode 100644 index 00000000000..d99576d1163 --- /dev/null +++ b/syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file_test.go @@ -0,0 +1,223 @@ +package kernel + +import ( + "bytes" + "encoding/binary" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/cpe" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/generic" +) + +// spec-level constants, encoded here independently of the implementation: +// the arm64 kernel image header carries the u32 magic 0x644d5241 ("ARM\x64") +// at offset 0x38 (see Documentation/arch/arm64/booting.rst in the kernel tree). +const arm64SpecMagicOffset = 0x38 + +var arm64SpecMagic = []byte{'A', 'R', 'M', 0x64} + +// escapeCPEVersion mirrors the escaping createLinuxKernelCPEs applies when +// binding attributes to a formatted string (backslash before CPE punctuation). +func escapeCPEVersion(version string) string { + const cpePunctuation = "-!\"#$%&'()+,./:;<=>@[]^`{|}~" + var sb strings.Builder + for _, c := range version { + if strings.ContainsRune(cpePunctuation, c) { + sb.WriteRune('\\') + } + sb.WriteRune(c) + } + return sb.String() +} + +// arm64ImageBytes constructs a minimal synthetic arm64 kernel image: a header +// carrying the "ARM\x64" magic at offset 0x38 followed by a linux_banner-style +// version string embedded in the payload. +func arm64ImageBytes(banner string) []byte { + b := make([]byte, 0x400) + copy(b[arm64SpecMagicOffset:], arm64SpecMagic) + b = append(b, "Linux version "+banner...) + b = append(b, 0) + return b +} + +// zbootWrap mimics an EFI zboot binary: a PE32+-looking prefix (MZ magic, +// e_lfanew, PE signature) with the compressed kernel image embedded at a +// non-zero offset, like Amazon Linux / RHEL arm64 vmlinuz files. +func zbootWrap(compressed []byte) []byte { + prefix := make([]byte, 0x400) + copy(prefix, []byte{'M', 'Z'}) + binary.LittleEndian.PutUint32(prefix[0x3c:], 0x80) + copy(prefix[0x80:], []byte{'P', 'E', 0, 0}) + out := make([]byte, 0, len(prefix)+len(compressed)+4) + out = append(out, prefix...) + out = append(out, compressed...) + // trailing junk after the stream must be tolerated (PE overlay data) + return append(out, 0xde, 0xad, 0xbe, 0xef) +} + +// x86BzImageBytes constructs a minimal synthetic x86 bzImage that deitch/magic +// recognizes as a Linux kernel, guarding that the x86 path still takes +// priority over the arm64 fallback. +func x86BzImageBytes(version string) []byte { + b := make([]byte, 0x300) + binary.LittleEndian.PutUint16(b[510:], 0xAA55) // boot_flag + copy(b[514:], []byte("HdrS")) // header magic + binary.LittleEndian.PutUint16(b[518:], 0x0202) // header version > 0x1ff + binary.LittleEndian.PutUint16(b[526:], 0x40) // version string offset (0x40 + 0x200 = 0x240) + b[529] = 1 // loadflags: LOADED_HIGH => bzImage (not zImage) + copy(b[0x240:], version) + return b +} + +func TestParseLinuxKernelFile_arm64(t *testing.T) { + const amznBanner = "6.18.38-76.139.amzn2023.aarch64 (mockbuild@ip-10-0-48-196.ec2.internal) (gcc (GCC) 11.5.0 20240719 (Red Hat 11.5.0-5)) #1 SMP PREEMPT_DYNAMIC Thu Jan 15 11:03:06 UTC 2026" + const ubuntuBanner = "7.0.0-1006-aws (buildd@bos03-arm64-012) (aarch64-linux-gnu-gcc-13 (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0) #6-Ubuntu SMP Fri Jan 30 11:21:07 UTC 2026" + + tests := []struct { + name string + data []byte + wantVer string + wantExt string + }{ + { + name: "raw arm64 image", + data: arm64ImageBytes(amznBanner), + wantVer: "6.18.38-76.139.amzn2023.aarch64", + wantExt: amznBanner, + }, + { + name: "whole-file gzip stream (ubuntu arm64 vmlinuz)", + data: gzCompress(arm64ImageBytes(ubuntuBanner)), + wantVer: "7.0.0-1006-aws", + wantExt: ubuntuBanner, + }, + { + name: "gzip payload inside efi zboot (amazon/rhel arm64 vmlinuz)", + data: zbootWrap(gzCompress(arm64ImageBytes(amznBanner))), + wantVer: "6.18.38-76.139.amzn2023.aarch64", + wantExt: amznBanner, + }, + { + name: "zstd payload inside efi zboot", + data: zbootWrap(zstCompress(arm64ImageBytes(amznBanner))), + wantVer: "6.18.38-76.139.amzn2023.aarch64", + wantExt: amznBanner, + }, + { + name: "xz payload inside efi zboot", + data: zbootWrap(xzCompress(arm64ImageBytes(amznBanner))), + wantVer: "6.18.38-76.139.amzn2023.aarch64", + wantExt: amznBanner, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := makeLocationReadCloser("/boot/vmlinuz-test.aarch64", tt.data) + pkgs, rels, err := parseLinuxKernelFile(testContext(t), nil, &generic.Environment{}, reader) + require.NoError(t, err) + require.Len(t, pkgs, 1) + assert.Empty(t, rels) + + p := pkgs[0] + assert.Equal(t, "linux-kernel", p.Name) + assert.Equal(t, tt.wantVer, p.Version) + assert.Equal(t, pkg.LinuxKernelPkg, p.Type) + assert.Equal(t, "pkg:generic/linux-kernel@"+tt.wantVer, p.PURL) + // createLinuxKernelCPEs escapes CPE punctuation in the version (e.g. + // "-" -> "\-"), so the expected CPE must use the escaped form — the raw + // version with hyphens is not parseable as a formatted string. + require.Len(t, p.CPEs, 1) + assert.Equal(t, cpe.Must("cpe:2.3:o:linux:linux_kernel:"+escapeCPEVersion(tt.wantVer)+":*:*:*:*:*:*:*", cpe.NVDDictionaryLookupSource), p.CPEs[0]) + + meta, ok := p.Metadata.(pkg.LinuxKernel) + require.True(t, ok) + assert.Equal(t, "arm64", meta.Architecture) + assert.Equal(t, "Image", meta.Format) + assert.Equal(t, tt.wantVer, meta.Version) + assert.Equal(t, tt.wantExt, meta.ExtendedVersion) + }) + } +} + +func TestParseLinuxKernelFile_arm64_Negative(t *testing.T) { + t.Run("garbage file", func(t *testing.T) { + reader := makeLocationReadCloser("/boot/vmlinuz-test.aarch64", bytes.Repeat([]byte{0x5a}, 0x1000)) + pkgs, rels, err := parseLinuxKernelFile(testContext(t), nil, &generic.Environment{}, reader) + require.NoError(t, err) + assert.Empty(t, pkgs) + assert.Empty(t, rels) + }) + + t.Run("arm64 image without version banner", func(t *testing.T) { + b := make([]byte, 0x400) + copy(b[arm64SpecMagicOffset:], arm64SpecMagic) + reader := makeLocationReadCloser("/boot/vmlinuz-test.aarch64", b) + pkgs, rels, err := parseLinuxKernelFile(testContext(t), nil, &generic.Environment{}, reader) + require.NoError(t, err) + assert.Empty(t, pkgs, "no version => no package (parity with the x86 empty-version rule)") + assert.Empty(t, rels) + }) + + // files that deitch/magic cannot read at all surface the magic error — + // this is pre-existing behavior that must be preserved + magicErrorCases := []struct { + name string + data []byte + }{ + {"too small to identify", []byte{'A', 'R', 'M', 0x64}}, + {"gzip payload without arm64 magic", gzCompress(bytes.Repeat([]byte{0x42}, 0x1000))}, + } + for _, tt := range magicErrorCases { + t.Run(tt.name, func(t *testing.T) { + reader := makeLocationReadCloser("/boot/vmlinuz-test.aarch64", tt.data) + pkgs, _, err := parseLinuxKernelFile(testContext(t), nil, &generic.Environment{}, reader) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to get magic type") + assert.Empty(t, pkgs) + }) + } +} + +func TestParseLinuxKernelFile_x86PathUnchanged(t *testing.T) { + const version = "5.10.121-linuxkit (root@buildkitsandbox) #1 SMP Fri Dec 2 10:35:42 UTC 2022" + reader := makeLocationReadCloser("/boot/vmlinuz-test.x86_64", x86BzImageBytes(version)) + + pkgs, _, err := parseLinuxKernelFile(testContext(t), nil, &generic.Environment{}, reader) + require.NoError(t, err) + require.Len(t, pkgs, 1, "x86 bzImage must still be detected through the deitch/magic path") + + meta, ok := pkgs[0].Metadata.(pkg.LinuxKernel) + require.True(t, ok) + assert.Equal(t, "x86", meta.Architecture) + assert.Equal(t, "bzImage", meta.Format) + assert.Equal(t, "5.10.121-linuxkit", meta.Version) + assert.Equal(t, version, meta.ExtendedVersion) +} + +func TestIsARM64Image(t *testing.T) { + img := arm64ImageBytes("1.2.3-test") + assert.True(t, isARM64Image(img)) + assert.False(t, isARM64Image(nil)) + assert.False(t, isARM64Image(make([]byte, arm64ImageMagicOffset+4))) + bad := bytes.Clone(img) + bad[arm64ImageMagicOffset] = 'X' + assert.False(t, isARM64Image(bad)) +} + +func TestParseLinuxVersionBanner(t *testing.T) { + payload := append([]byte{1, 2, 3}, []byte("Linux version 6.1.21-1.25.amzn2023.aarch64 (mockbuild@host) #1 SMP Tue May 1 00:00:00 UTC 2023\x00trailing")...) + version, extended, ok := parseLinuxVersionBanner(payload) + require.True(t, ok) + assert.Equal(t, "6.1.21-1.25.amzn2023.aarch64", version) + assert.Equal(t, "6.1.21-1.25.amzn2023.aarch64 (mockbuild@host) #1 SMP Tue May 1 00:00:00 UTC 2023", extended) + + _, _, ok = parseLinuxVersionBanner([]byte("no banner here")) + assert.False(t, ok) +} diff --git a/syft/pkg/cataloger/kernel/parse_linux_kernel_file.go b/syft/pkg/cataloger/kernel/parse_linux_kernel_file.go index d09601026b5..d0616148b6d 100644 --- a/syft/pkg/cataloger/kernel/parse_linux_kernel_file.go +++ b/syft/pkg/cataloger/kernel/parse_linux_kernel_file.go @@ -24,23 +24,35 @@ func parseLinuxKernelFile(_ context.Context, _ file.Resolver, _ *generic.Environ return nil, nil, fmt.Errorf("unable to get union reader for file: %w", err) } magicType, err := magic.GetType(unionReader) - if err != nil { - return nil, nil, fmt.Errorf("unable to get magic type for file: %w", err) - } - if len(magicType) < 1 || magicType[0] != linuxKernelMagicName { - return nil, nil, nil + if err == nil && len(magicType) > 0 && magicType[0] == linuxKernelMagicName { + metadata := parseLinuxKernelMetadata(magicType) + if metadata.Version == "" { + return nil, nil, nil + } + + return []pkg.Package{ + newLinuxKernelPackage( + metadata, + reader.Location, + ), + }, nil, nil } - metadata := parseLinuxKernelMetadata(magicType) - if metadata.Version == "" { - return nil, nil, nil + + // deitch/magic only recognizes x86 kernel images; fall back to detecting + // arm64 images (raw, compressed, or EFI-zboot-wrapped) ourselves. + if metadata, ok := parseARM64LinuxKernelImage(unionReader); ok { + return []pkg.Package{ + newLinuxKernelPackage( + metadata, + reader.Location, + ), + }, nil, nil } - return []pkg.Package{ - newLinuxKernelPackage( - metadata, - reader.Location, - ), - }, nil, nil + if err != nil { + return nil, nil, fmt.Errorf("unable to get magic type for file: %w", err) + } + return nil, nil, nil } func parseLinuxKernelMetadata(magicType []string) (p pkg.LinuxKernel) {