Skip to content

Commit aca2048

Browse files
magerstamarodage
andauthored
Improved bootloader parsing (#356) (#358)
* Improved bootloader parsing (#356) * Add unit tests for inheritBootloaderKindBySHA function --------- Co-authored-by: Rodage, Alpesh Ramesh <alpesh.ramesh.rodage@intel.com>
1 parent fd70d1b commit aca2048

4 files changed

Lines changed: 179 additions & 39 deletions

File tree

internal/image/imageinspect/bootloader_pe.go

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,15 @@ import (
88
"strings"
99
)
1010

11-
// ParsePEFromBytes parses a PE (Portable Executable) binary from the given byte slice
1211
func ParsePEFromBytes(p string, blob []byte) (EFIBinaryEvidence, error) {
1312
ev := EFIBinaryEvidence{
1413
Path: p,
1514
Size: int64(len(blob)),
1615
SectionSHA256: map[string]string{},
1716
OSReleaseSorted: []KeyValue{},
18-
Kind: classifyBootloaderKind(p, nil), // refine after we parse sections
17+
Kind: BootloaderUnknown, // set after we have more evidence
1918
}
2019

21-
// whole-file hash
2220
ev.SHA256 = sha256Hex(blob)
2321

2422
r := bytes.NewReader(blob)
@@ -30,31 +28,26 @@ func ParsePEFromBytes(p string, blob []byte) (EFIBinaryEvidence, error) {
3028

3129
ev.Arch = peMachineToArch(f.FileHeader.Machine)
3230

33-
// Sections
3431
for _, s := range f.Sections {
3532
name := strings.TrimRight(s.Name, "\x00")
3633
ev.Sections = append(ev.Sections, name)
3734
}
3835

39-
// Signed evidence: presence of Authenticode blob
4036
signed, sigSize, sigNote := peSignatureInfo(f)
4137
ev.Signed = signed
4238
ev.SignatureSize = sigSize
4339
if sigNote != "" {
4440
ev.Notes = append(ev.Notes, sigNote)
4541
}
4642

47-
// SBAT section presence
4843
ev.HasSBAT = hasSection(ev.Sections, ".sbat")
4944

50-
// UKI detection: these sections are highly indicative
5145
isUKI := hasSection(ev.Sections, ".linux") &&
5246
(hasSection(ev.Sections, ".cmdline") || hasSection(ev.Sections, ".osrel") || hasSection(ev.Sections, ".uname"))
5347
ev.IsUKI = isUKI
5448
if isUKI {
5549
ev.Kind = BootloaderUKI
5650
} else {
57-
// reclassify based on name/path/sections
5851
ev.Kind = classifyBootloaderKind(p, ev.Sections)
5952
}
6053

@@ -121,18 +114,21 @@ func peSignatureInfo(f *pe.File) (signed bool, sigSize int, note string) {
121114
return false, 0, ""
122115
}
123116

124-
// classifyBootloaderKind classifies the bootloader kind based on path and sections
117+
// classifyBootloaderKind classifies the bootloader kind based on path and sections.
118+
// It intentionally avoids content-string heuristics for stability.
119+
// For BOOTX64.EFI copies/aliases, rely on SHA-inheritance post-pass.
125120
func classifyBootloaderKind(p string, sections []string) BootloaderKind {
126121
lp := strings.ToLower(p)
127122

128-
// Most deterministic first:
123+
// Deterministic first:
129124
if sections != nil && hasSection(sections, ".linux") {
130-
// likely UKI; caller can override with stricter check
131125
return BootloaderUKI
132126
}
133127

134128
// Path / filename heuristics:
135-
// shim often includes "shim" and/or has .sbat too
129+
if strings.Contains(lp, "mmx64.efi") || strings.Contains(lp, "mmia32.efi") {
130+
return BootloaderMokManager
131+
}
136132
if strings.Contains(lp, "shim") {
137133
return BootloaderShim
138134
}
@@ -142,10 +138,7 @@ func classifyBootloaderKind(p string, sections []string) BootloaderKind {
142138
if strings.Contains(lp, "grub") {
143139
return BootloaderGrub
144140
}
145-
if strings.Contains(lp, "mmx64.efi") || strings.Contains(lp, "mmia32.efi") {
146-
return BootloaderMokManager
147-
}
148-
// fallback
141+
149142
return BootloaderUnknown
150143
}
151144

@@ -160,6 +153,34 @@ func hasSection(secs []string, want string) bool {
160153
return false
161154
}
162155

156+
// inheritBootloaderKindBySHA assigns a kind to "unknown" EFI binaries when they
157+
// are byte-identical to another EFI binary already classified as a known kind.
158+
// This reliably handles fallback paths like EFI/BOOT/BOOTX64.EFI.
159+
func inheritBootloaderKindBySHA(evs []EFIBinaryEvidence) {
160+
known := make(map[string]BootloaderKind) // sha256 -> kind
161+
162+
// First pass: record known kinds by hash.
163+
for _, ev := range evs {
164+
if ev.SHA256 == "" || ev.Kind == BootloaderUnknown {
165+
continue
166+
}
167+
if _, ok := known[ev.SHA256]; !ok {
168+
known[ev.SHA256] = ev.Kind
169+
}
170+
}
171+
172+
// Second pass: upgrade unknowns when a known hash exists.
173+
for i := range evs {
174+
if evs[i].Kind != BootloaderUnknown || evs[i].SHA256 == "" {
175+
continue
176+
}
177+
if k, ok := known[evs[i].SHA256]; ok {
178+
evs[i].Kind = k
179+
evs[i].Notes = append(evs[i].Notes, "bootloader kind inherited from identical EFI binary (sha256 match)")
180+
}
181+
}
182+
}
183+
163184
// peMachineToArch maps PE machine types to architecture strings
164185
func peMachineToArch(m uint16) string {
165186
switch m {

internal/image/imageinspect/fs_inspect.go

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,6 @@ func readExtSuperblock(r io.ReaderAt, partOff int64, out *FilesystemSummary) err
181181
return nil
182182
}
183183

184-
// readFATBootSector reads the FAT boot sector and fills in details.
185184
func readFATBootSector(r io.ReaderAt, partOff int64, out *FilesystemSummary) error {
186185
bs := make([]byte, 512)
187186
if _, err := r.ReadAt(bs, partOff); err != nil && err != io.EOF {
@@ -200,73 +199,87 @@ func readFATBootSector(r io.ReaderAt, partOff int64, out *FilesystemSummary) err
200199
totSec16 := binary.LittleEndian.Uint16(bs[19:21])
201200
fatSz16 := binary.LittleEndian.Uint16(bs[22:24])
202201
totSec32 := binary.LittleEndian.Uint32(bs[32:36])
202+
fatSz32 := binary.LittleEndian.Uint32(bs[36:40]) // BPB_FATSz32 (FAT32)
203203

204204
out.Type = "vfat"
205205
out.BytesPerSector = bytesPerSec
206206
out.SectorsPerCluster = secPerClus
207207

208+
// Basic sanity checks to avoid bogus classification
209+
switch bytesPerSec {
210+
case 512, 1024, 2048, 4096:
211+
// ok
212+
default:
213+
return fmt.Errorf("invalid BPB: bytesPerSec=%d", bytesPerSec)
214+
}
215+
if secPerClus == 0 {
216+
return fmt.Errorf("invalid BPB: sectorsPerCluster=0")
217+
}
218+
if numFATs == 0 {
219+
return fmt.Errorf("invalid BPB: numFATs=0")
220+
}
221+
208222
// Total sectors is either TotSec16 or TotSec32
209223
totalSectors := uint32(totSec16)
210224
if totalSectors == 0 {
211225
totalSectors = totSec32
212226
}
227+
if totalSectors == 0 {
228+
return fmt.Errorf("invalid BPB: totalSectors=0")
229+
}
213230

214-
// FAT32 detection (canonical)
215-
fatSz32 := binary.LittleEndian.Uint32(bs[36:40]) // only meaningful if FAT32
231+
// Canonical FAT32 detection:
232+
// - RootEntCnt must be 0 for FAT32
233+
// - FATSz16 must be 0 for FAT32
234+
// - FATSz32 should be non-zero for FAT32 (but we won't *require* it to avoid false negatives on odd images)
216235
isFAT32 := (rootEntCnt == 0) && (fatSz16 == 0) && (fatSz32 != 0)
217236

218237
if isFAT32 {
219238
out.FATType = "FAT32"
220-
221239
out.UUID = fmt.Sprintf("%08x", binary.LittleEndian.Uint32(bs[67:71]))
222240
out.Label = strings.TrimRight(string(bs[71:82]), " \x00")
223241

224-
// cluster count for FAT32
242+
// cluster count for FAT32 (root dir is in data area)
225243
rootDirSectors := uint32(0)
226244
fatSectors := fatSz32
227245
dataSectors := totalSectors - (uint32(rsvdSecCnt) + (numFATs * fatSectors) + rootDirSectors)
228-
if secPerClus == 0 {
229-
return fmt.Errorf("invalid BPB: sectorsPerCluster=0")
230-
}
231246
out.ClusterCount = dataSectors / uint32(secPerClus)
232-
233247
return nil
234248
}
235249

236250
// FAT12/16-style: classify via cluster count
237-
// Root dir sectors:
238251
rootDirSectors := ((uint32(rootEntCnt) * 32) + (uint32(bytesPerSec) - 1)) / uint32(bytesPerSec)
239-
240-
// FAT size sectors (FAT12/16 uses fatSz16)
241252
fatSectors := uint32(fatSz16)
242253

243-
// Data sectors:
244-
dataSectors := totalSectors - (uint32(rsvdSecCnt) + (numFATs * fatSectors) + rootDirSectors)
245-
246-
// Count of clusters:
247-
if secPerClus == 0 {
248-
return fmt.Errorf("invalid BPB: sectorsPerCluster=0")
254+
// If FAT16 fields suggest nonsense, note it (helps debug wrong offsets/sector)
255+
if fatSectors == 0 {
256+
out.Notes = append(out.Notes, fmt.Sprintf("BPB_FATSz16=0 but not detected as FAT32 (RootEntCnt=%d FATSz32=%d)", rootEntCnt, fatSz32))
249257
}
258+
259+
dataSectors := totalSectors - (uint32(rsvdSecCnt) + (numFATs * fatSectors) + rootDirSectors)
250260
clusterCount := dataSectors / uint32(secPerClus)
251261

252-
// Standard FAT thresholds
262+
out.ClusterCount = clusterCount
263+
253264
switch {
254265
case clusterCount < 4085:
255266
out.FATType = "FAT12"
256-
out.ClusterCount = clusterCount
257267
case clusterCount < 65525:
258268
out.FATType = "FAT16"
259-
out.ClusterCount = clusterCount
260269
default:
261-
// It’s possible to encounter FAT32-like cluster counts without the FAT32 BPB layout,
262-
// but for an ESP this is unlikely. Still, classify as FAT32 if huge.
270+
// If cluster count is huge, it's overwhelmingly likely FAT32.
263271
out.FATType = "FAT32"
264272
}
265273

266274
// FAT12/16 Extended BPB: VolID @ 39..43, Label @ 43..54
267275
out.UUID = fmt.Sprintf("%08x", binary.LittleEndian.Uint32(bs[39:43]))
268276
out.Label = strings.TrimRight(string(bs[43:54]), " \x00")
269277

278+
out.Notes = append(out.Notes, fmt.Sprintf(
279+
"FAT BPB: BytsPerSec=%d SecPerClus=%d Rsvd=%d NumFATs=%d RootEntCnt=%d TotSec16=%d TotSec32=%d FATSz16=%d FATSz32=%d",
280+
bytesPerSec, secPerClus, rsvdSecCnt, numFATs, rootEntCnt, totSec16, totSec32, fatSz16, fatSz32,
281+
))
282+
270283
return nil
271284
}
272285

internal/image/imageinspect/fs_raw.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ func scanAndHashEFIFromRawFAT(r io.ReaderAt, partOff int64, out *FilesystemSumma
138138
}
139139
}
140140

141+
inheritBootloaderKindBySHA(out.EFIBinaries)
141142
sort.Slice(out.EFIBinaries, func(i, j int) bool { return out.EFIBinaries[i].Path < out.EFIBinaries[j].Path })
142143

143144
out.HasShim = out.HasShim || hasShim

internal/image/imageinspect/imageinspect_core_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,3 +807,108 @@ func TestInspectCore_PropagatesFilesystemError_WhenCalled(t *testing.T) {
807807
t.Fatalf("expected GetFilesystem to be called at least once")
808808
}
809809
}
810+
811+
func TestInheritBootloaderKindBySHA_InheritsFromKnown(t *testing.T) {
812+
evs := []EFIBinaryEvidence{
813+
{Path: "/EFI/ubuntu/shimx64.efi", SHA256: "abc123def456", Kind: BootloaderShim},
814+
{Path: "/EFI/BOOT/BOOTX64.EFI", SHA256: "abc123def456", Kind: BootloaderUnknown},
815+
}
816+
817+
inheritBootloaderKindBySHA(evs)
818+
819+
if evs[1].Kind != BootloaderShim {
820+
t.Errorf("expected BOOTX64.EFI to inherit kind=shim, got %q", evs[1].Kind)
821+
}
822+
if len(evs[1].Notes) == 0 || !strings.Contains(evs[1].Notes[0], "sha256 match") {
823+
t.Errorf("expected note about sha256 inheritance, got %v", evs[1].Notes)
824+
}
825+
// Original should remain unchanged
826+
if evs[0].Kind != BootloaderShim {
827+
t.Errorf("original should stay shim, got %q", evs[0].Kind)
828+
}
829+
}
830+
831+
func TestInheritBootloaderKindBySHA_NoMatchLeavesUnknown(t *testing.T) {
832+
evs := []EFIBinaryEvidence{
833+
{Path: "/EFI/ubuntu/shimx64.efi", SHA256: "abc123", Kind: BootloaderShim},
834+
{Path: "/EFI/BOOT/BOOTX64.EFI", SHA256: "different456", Kind: BootloaderUnknown},
835+
}
836+
837+
inheritBootloaderKindBySHA(evs)
838+
839+
if evs[1].Kind != BootloaderUnknown {
840+
t.Errorf("expected BOOTX64.EFI to remain unknown when hash differs, got %q", evs[1].Kind)
841+
}
842+
if len(evs[1].Notes) != 0 {
843+
t.Errorf("expected no notes when no match, got %v", evs[1].Notes)
844+
}
845+
}
846+
847+
func TestInheritBootloaderKindBySHA_EmptySHA256Ignored(t *testing.T) {
848+
evs := []EFIBinaryEvidence{
849+
{Path: "/EFI/ubuntu/shimx64.efi", SHA256: "", Kind: BootloaderShim},
850+
{Path: "/EFI/BOOT/BOOTX64.EFI", SHA256: "", Kind: BootloaderUnknown},
851+
}
852+
853+
inheritBootloaderKindBySHA(evs)
854+
855+
// Both should remain unchanged - empty SHA256 entries are skipped
856+
if evs[0].Kind != BootloaderShim {
857+
t.Errorf("expected shim to remain shim, got %q", evs[0].Kind)
858+
}
859+
if evs[1].Kind != BootloaderUnknown {
860+
t.Errorf("expected unknown to remain unknown when SHA256 empty, got %q", evs[1].Kind)
861+
}
862+
}
863+
864+
func TestInheritBootloaderKindBySHA_MultipleInheritances(t *testing.T) {
865+
evs := []EFIBinaryEvidence{
866+
{Path: "/EFI/ubuntu/shimx64.efi", SHA256: "shimhash", Kind: BootloaderShim},
867+
{Path: "/EFI/fedora/grubx64.efi", SHA256: "grubhash", Kind: BootloaderGrub},
868+
{Path: "/EFI/BOOT/BOOTX64.EFI", SHA256: "shimhash", Kind: BootloaderUnknown},
869+
{Path: "/EFI/BOOT/grubx64.efi", SHA256: "grubhash", Kind: BootloaderUnknown},
870+
{Path: "/EFI/unknown/mystery.efi", SHA256: "otherhash", Kind: BootloaderUnknown},
871+
}
872+
873+
inheritBootloaderKindBySHA(evs)
874+
875+
if evs[2].Kind != BootloaderShim {
876+
t.Errorf("BOOTX64.EFI should inherit shim, got %q", evs[2].Kind)
877+
}
878+
if evs[3].Kind != BootloaderGrub {
879+
t.Errorf("grubx64.efi copy should inherit grub, got %q", evs[3].Kind)
880+
}
881+
if evs[4].Kind != BootloaderUnknown {
882+
t.Errorf("mystery.efi should remain unknown, got %q", evs[4].Kind)
883+
}
884+
}
885+
886+
func TestInheritBootloaderKindBySHA_FirstKnownWins(t *testing.T) {
887+
// If the same hash appears with different kinds, first one wins
888+
evs := []EFIBinaryEvidence{
889+
{Path: "/EFI/first/shimx64.efi", SHA256: "samehash", Kind: BootloaderShim},
890+
{Path: "/EFI/second/grubx64.efi", SHA256: "samehash", Kind: BootloaderGrub},
891+
{Path: "/EFI/BOOT/BOOTX64.EFI", SHA256: "samehash", Kind: BootloaderUnknown},
892+
}
893+
894+
inheritBootloaderKindBySHA(evs)
895+
896+
// The unknown should inherit from the first known (shim)
897+
if evs[2].Kind != BootloaderShim {
898+
t.Errorf("expected first known kind (shim) to win, got %q", evs[2].Kind)
899+
}
900+
}
901+
902+
func TestInheritBootloaderKindBySHA_AlreadyClassifiedNotOverwritten(t *testing.T) {
903+
evs := []EFIBinaryEvidence{
904+
{Path: "/EFI/ubuntu/shimx64.efi", SHA256: "abc123", Kind: BootloaderShim},
905+
{Path: "/EFI/BOOT/BOOTX64.EFI", SHA256: "abc123", Kind: BootloaderGrub}, // Already classified differently
906+
}
907+
908+
inheritBootloaderKindBySHA(evs)
909+
910+
// Should NOT overwrite an already-classified binary
911+
if evs[1].Kind != BootloaderGrub {
912+
t.Errorf("already classified binary should not be overwritten, got %q", evs[1].Kind)
913+
}
914+
}

0 commit comments

Comments
 (0)