From 96b259ce17f244b9519eb812c7be887988c2b6f7 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 3 Aug 2026 17:43:19 +0100 Subject: [PATCH] Add binary package for native object inspection Inspect(path) reports format, architecture, dynamic dependencies, producer strings, embedded Go build info, and heuristic static-link hints for ELF, Mach-O (thin and universal, including FAT_MAGIC_64), and PE files. Built on debug/elf, debug/macho, debug/pe, and debug/buildinfo with no new module dependencies. ELF: DT_NEEDED, DT_SONAME, .comment, arch derived from Machine plus Class and ByteOrder so riscv64/ppc64le/mips variants are distinguished. Mach-O: LC_LOAD_DYLIB via ImportedLibraries, plus raw load-command decode for LC_ID_DYLIB, LC_LOAD_WEAK_DYLIB, and LC_BUILD_VERSION. The fat header is parsed directly since debug/macho.NewFatFile rejects FAT_MAGIC_64; each slice is bounds-checked against the declared size and Go build info is read from the first slice. PE: DLL names read from IMAGE_IMPORT_DESCRIPTOR entries in the import directory since pe.ImportedLibraries is a stub and pe.ImportedSymbols omits ordinal-only imports. InspectReader confines all reads to an io.SectionReader over the declared size. Tests cross-compile a trivial program to nine GOOS/GOARCH targets at test time so all three formats are exercised without checking binaries into the repository. --- binary/binary.go | 178 ++++++++++++++++++++ binary/binary_test.go | 304 ++++++++++++++++++++++++++++++++++ binary/elf.go | 115 +++++++++++++ binary/macho.go | 270 ++++++++++++++++++++++++++++++ binary/pe.go | 162 ++++++++++++++++++ binary/static.go | 73 ++++++++ binary/testdata/hello/main.go | 6 + 7 files changed, 1108 insertions(+) create mode 100644 binary/binary.go create mode 100644 binary/binary_test.go create mode 100644 binary/elf.go create mode 100644 binary/macho.go create mode 100644 binary/pe.go create mode 100644 binary/static.go create mode 100644 binary/testdata/hello/main.go diff --git a/binary/binary.go b/binary/binary.go new file mode 100644 index 0000000..a1438b8 --- /dev/null +++ b/binary/binary.go @@ -0,0 +1,178 @@ +// Package binary inspects native executable and shared-object files and +// reports their format, architecture, dynamic dependencies, and toolchain +// producer strings. +package binary + +import ( + "debug/buildinfo" + "errors" + "fmt" + "io" + "os" +) + +// Object describes a single native object file. +type Object struct { + // Path is the filesystem path the object was read from. + Path string `json:"path"` + + // Format is the container format: "elf", "mach-o", "mach-o-universal", + // or "pe". + Format string `json:"format"` + + // Arch is the target architecture in GOARCH form where a mapping + // exists, otherwise the format's native name. For universal Mach-O + // binaries it lists each slice separated by "/". + Arch string `json:"arch,omitempty"` + + // SOName is the object's declared install name: DT_SONAME for ELF, + // LC_ID_DYLIB for Mach-O. + SOName string `json:"soname,omitempty"` + + // Needed lists dynamic dependencies: DT_NEEDED for ELF, LC_LOAD_DYLIB + // and LC_LOAD_WEAK_DYLIB for Mach-O, and the PE import directory. + Needed []string `json:"needed,omitempty"` + + // Producer lists toolchain identification strings recovered from the + // object: the ELF .comment section (GCC, clang, and rustc all write + // there), Mach-O LC_BUILD_VERSION, and the Go version when build info + // is present. + Producer []string `json:"producer,omitempty"` + + // Go is set when the object was produced by the Go toolchain and + // carries embedded build metadata. + Go *GoBuild `json:"go,omitempty"` + + // Static lists libraries that appear to be statically linked into the + // object, inferred from version banners in read-only data. These are + // heuristic matches and should be reported as low confidence. + Static []Hint `json:"static,omitempty"` +} + +// GoBuild is the subset of debug/buildinfo exposed in the report. +type GoBuild struct { + Version string `json:"version"` + Path string `json:"path,omitempty"` + Main string `json:"main,omitempty"` + Deps []string `json:"deps,omitempty"` +} + +// Hint is a heuristic match for a statically-linked library. +type Hint struct { + Library string `json:"library"` + Version string `json:"version,omitempty"` + Match string `json:"match"` +} + +// Arch values, in GOARCH form where a mapping exists. +const ( + archAMD64 = "amd64" + arch386 = "386" + archARM64 = "arm64" + archARM64E = "arm64e" + archARM = "arm" + archRISCV32 = "riscv32" + archRISCV64 = "riscv64" + archPPC = "ppc" + archPPC64 = "ppc64" + archPPC64LE = "ppc64le" + archS390X = "s390x" + archMIPS = "mips" + archMIPSLE = "mipsle" + archMIPS64 = "mips64" + archMIPS64LE = "mips64le" + archLoong64 = "loong64" +) + +// ErrUnrecognized is returned when the input is not a supported native +// object format. +var ErrUnrecognized = errors.New("unrecognized native object format") + +// Inspect opens the file at path and returns its native object metadata. +func Inspect(path string) (*Object, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + info, err := f.Stat() + if err != nil { + return nil, err + } + + obj, err := InspectReader(f, info.Size()) + if err != nil { + return nil, err + } + obj.Path = path + return obj, nil +} + +// InspectReader reads a native object from r. All reads are confined to the +// first size bytes. +func InspectReader(r io.ReaderAt, size int64) (*Object, error) { + const minHeader = 4 + if size < minHeader { + return nil, ErrUnrecognized + } + sr := io.NewSectionReader(r, 0, size) + + var head [minHeader]byte + if _, err := sr.ReadAt(head[:], 0); err != nil { + return nil, fmt.Errorf("reading header: %w", err) + } + + obj, rodata, err := dispatch(sr, size, head) + if err != nil { + return nil, err + } + + // The fat Mach-O path reads build info from its first slice because + // debug/buildinfo cannot open FAT_MAGIC_64 containers itself. + if obj.Go == nil { + if bi, err := buildinfo.Read(sr); err == nil { + obj.Go = goBuildFrom(bi) + obj.Producer = append(obj.Producer, bi.GoVersion) + } + } + + if len(rodata) > 0 { + obj.Static = scanStatic(rodata) + } + + return obj, nil +} + +func dispatch(r *io.SectionReader, size int64, head [4]byte) (*Object, []byte, error) { + switch { + case head == [4]byte{0x7f, 'E', 'L', 'F'}: + return inspectELF(r) + case isMachO(head): + return inspectMachO(r) + case isMachOFat(head): + return inspectMachOFat(r, size, head) + case head[0] == 'M' && head[1] == 'Z': + return inspectPE(r) + default: + return nil, nil, ErrUnrecognized + } +} + +func goBuildFrom(bi *buildinfo.BuildInfo) *GoBuild { + g := &GoBuild{ + Version: bi.GoVersion, + Path: bi.Path, + } + if bi.Main.Path != "" { + g.Main = bi.Main.Path + "@" + bi.Main.Version + } + for _, dep := range bi.Deps { + if dep.Replace != nil { + g.Deps = append(g.Deps, dep.Path+" => "+dep.Replace.Path+"@"+dep.Replace.Version) + } else { + g.Deps = append(g.Deps, dep.Path+"@"+dep.Version) + } + } + return g +} diff --git a/binary/binary_test.go b/binary/binary_test.go new file mode 100644 index 0000000..e988a5d --- /dev/null +++ b/binary/binary_test.go @@ -0,0 +1,304 @@ +package binary + +import ( + "bytes" + "debug/elf" + stdbin "encoding/binary" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" +) + +func TestInspectSelf(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + obj, err := Inspect(exe) + if err != nil { + t.Fatal(err) + } + + wantFormat := hostFormat() + if obj.Format != wantFormat { + t.Fatalf("Format = %q, want %q", obj.Format, wantFormat) + } + if obj.Arch != runtime.GOARCH { + t.Fatalf("Arch = %q, want %q", obj.Arch, runtime.GOARCH) + } + if obj.Go == nil { + t.Fatal("Go build info not detected on Go test binary") + } + if !strings.HasPrefix(obj.Go.Version, "go") && !strings.HasPrefix(obj.Go.Version, "devel") { + t.Fatalf("Go.Version = %q", obj.Go.Version) + } + if !slices.Contains(obj.Producer, obj.Go.Version) { + t.Fatalf("Producer = %v, want to include %q", obj.Producer, obj.Go.Version) + } +} + +func TestInspectCrossFormats(t *testing.T) { + if testing.Short() { + t.Skip("cross-compilation skipped in -short") + } + + tests := []struct { + goos, goarch, format string + }{ + {"linux", "amd64", "elf"}, + {"linux", "arm64", "elf"}, + {"linux", "ppc64le", "elf"}, + {"linux", "riscv64", "elf"}, + {"linux", "mips64le", "elf"}, + {"darwin", "arm64", "mach-o"}, + {"darwin", "amd64", "mach-o"}, + {"windows", "amd64", "pe"}, + {"windows", "arm64", "pe"}, + } + + for _, tt := range tests { + t.Run(tt.goos+"/"+tt.goarch, func(t *testing.T) { + t.Parallel() + bin := buildFixture(t, tt.goos, tt.goarch) + obj, err := Inspect(bin) + if err != nil { + t.Fatal(err) + } + if obj.Format != tt.format { + t.Fatalf("Format = %q, want %q", obj.Format, tt.format) + } + if obj.Arch != tt.goarch { + t.Fatalf("Arch = %q, want %q", obj.Arch, tt.goarch) + } + if obj.Go == nil || obj.Go.Path != "github.com/git-pkgs/brief/binary/testdata/hello" { + t.Fatalf("Go = %+v, want testdata/hello module path", obj.Go) + } + }) + } +} + +func TestInspectMachOUniversal(t *testing.T) { + if testing.Short() { + t.Skip("cross-compilation skipped in -short") + } + lipo, err := exec.LookPath("lipo") + if err != nil { + t.Skip("lipo not available") + } + + amd64 := buildFixture(t, "darwin", "amd64") + arm64 := buildFixture(t, "darwin", "arm64") + + for _, variant := range []struct{ name, flag string }{ + {"fat32", ""}, + {"fat64", "-fat64"}, + } { + t.Run(variant.name, func(t *testing.T) { + out := filepath.Join(t.TempDir(), "universal") + args := []string{"-create"} + if variant.flag != "" { + args = append(args, variant.flag) + } + args = append(args, "-output", out, amd64, arm64) + if b, err := exec.Command(lipo, args...).CombinedOutput(); err != nil { + t.Fatalf("lipo: %v\n%s", err, b) + } + + obj, err := Inspect(out) + if err != nil { + t.Fatal(err) + } + if obj.Format != "mach-o-universal" { + t.Fatalf("Format = %q, want mach-o-universal", obj.Format) + } + if obj.Arch != "amd64/arm64" { + t.Fatalf("Arch = %q, want amd64/arm64", obj.Arch) + } + if obj.Go == nil || obj.Go.Path != "github.com/git-pkgs/brief/binary/testdata/hello" { + t.Fatalf("Go = %+v, want testdata/hello module path from first slice", obj.Go) + } + }) + } +} + +func TestInspectMachOFatRejectsMalformed(t *testing.T) { + // Zero-arch fat header. + empty := []byte("\xca\xfe\xba\xbe\x00\x00\x00\x00") + if _, err := InspectReader(bytes.NewReader(empty), int64(len(empty))); !errors.Is(err, errEmptyFat) { + t.Fatalf("zero-arch fat: err = %v, want errEmptyFat", err) + } + + // One arch pointing at garbage inside the file. + buf := make([]byte, 128) + copy(buf, "\xca\xfe\xba\xbe\x00\x00\x00\x01") + stdbin.BigEndian.PutUint32(buf[16:], 32) // offset + stdbin.BigEndian.PutUint32(buf[20:], 64) // size + if _, err := InspectReader(bytes.NewReader(buf), int64(len(buf))); err == nil { + t.Fatal("fat header pointing at non-Mach-O slice was accepted") + } + + // Slice extends past declared size. + stdbin.BigEndian.PutUint32(buf[20:], 4096) + if _, err := InspectReader(bytes.NewReader(buf), int64(len(buf))); err == nil { + t.Fatal("out-of-bounds fat slice was accepted") + } +} + +func TestDylibNameRejectsOverflowOffset(t *testing.T) { + raw := make([]byte, 24) + stdbin.LittleEndian.PutUint32(raw[8:], 0xffffffff) + if name, ok := dylibName(raw, stdbin.LittleEndian); ok { + t.Fatalf("dylibName accepted out-of-range offset: %q", name) + } +} + +func TestELFArch(t *testing.T) { + tests := []struct { + m elf.Machine + class elf.Class + order stdbin.ByteOrder + want string + }{ + {elf.EM_RISCV, elf.ELFCLASS64, stdbin.LittleEndian, "riscv64"}, + {elf.EM_RISCV, elf.ELFCLASS32, stdbin.LittleEndian, "riscv32"}, + {elf.EM_PPC64, elf.ELFCLASS64, stdbin.LittleEndian, "ppc64le"}, + {elf.EM_PPC64, elf.ELFCLASS64, stdbin.BigEndian, "ppc64"}, + {elf.EM_PPC, elf.ELFCLASS32, stdbin.BigEndian, "ppc"}, + {elf.EM_MIPS, elf.ELFCLASS32, stdbin.BigEndian, "mips"}, + {elf.EM_MIPS, elf.ELFCLASS32, stdbin.LittleEndian, "mipsle"}, + {elf.EM_MIPS, elf.ELFCLASS64, stdbin.BigEndian, "mips64"}, + {elf.EM_MIPS, elf.ELFCLASS64, stdbin.LittleEndian, "mips64le"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := elfArch(tt.m, tt.class, tt.order); got != tt.want { + t.Fatalf("elfArch(%v, %v, %v) = %q, want %q", tt.m, tt.class, tt.order, got, tt.want) + } + }) + } +} + +func TestInspectReaderBoundsSize(t *testing.T) { + // Use a real, valid executable so an unbounded implementation would + // succeed: with the section-reader bound in place a size of 4 must + // fail even though the underlying ReaderAt has the full file. + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + buf, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + r := bytes.NewReader(buf) + + if _, err := InspectReader(r, int64(len(buf))); err != nil { + t.Fatalf("full-size control failed: %v", err) + } + if _, err := InspectReader(r, 4); err == nil { + t.Fatal("InspectReader read past declared size") + } + if _, err := InspectReader(r, 0); !errors.Is(err, ErrUnrecognized) { + t.Fatalf("size=0 error = %v, want ErrUnrecognized", err) + } + if _, err := InspectReader(r, -1); !errors.Is(err, ErrUnrecognized) { + t.Fatalf("size<0 error = %v, want ErrUnrecognized", err) + } +} + +func TestInspectUnrecognized(t *testing.T) { + path := filepath.Join(t.TempDir(), "text") + if err := os.WriteFile(path, []byte("not a binary"), 0o644); err != nil { + t.Fatal(err) + } + _, err := Inspect(path) + if !errors.Is(err, ErrUnrecognized) { + t.Fatalf("Inspect(text) error = %v, want ErrUnrecognized", err) + } +} + +func TestInspectShortFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "short") + if err := os.WriteFile(path, []byte{0x7f}, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Inspect(path); err == nil { + t.Fatal("Inspect on 1-byte file succeeded") + } +} + +func TestScanStatic(t *testing.T) { + tests := []struct { + name string + rodata string + library string + version string + }{ + {"zlib deflate", "xx\x00deflate 1.3.1 Copyright 1995-2024 Jean-loup Gailly\x00", "zlib", "1.3.1"}, + {"zlib inflate", "inflate 1.2.13 Copyright 1995-2022 Mark Adler", "zlib", "1.2.13"}, + {"openssl", "\x00OpenSSL 3.0.13 30 Jan 2024\x00", "openssl", "3.0.13"}, + {"openssl 1.1", "OpenSSL 1.1.1w 11 Sep 2023", "openssl", "1.1.1w"}, + {"sqlite", "\x002026-07-24 19:02:57 bf7c7f30031888f4e796e429ab3978879485813aaca6f641c7b33e4e09459bcc\x00", "sqlite", ""}, + {"libcurl", "libcurl/8.6.0", "libcurl", "8.6.0"}, + {"pcre2", "PCRE2 10.42 2022-12-11", "pcre2", "10.42"}, + {"libpng", "libpng version 1.6.43", "libpng", "1.6.43"}, + {"boringssl", "BoringSSL", "boringssl", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hints := scanStatic([]byte(tt.rodata)) + if len(hints) != 1 { + t.Fatalf("scanStatic(%q) = %v, want one hit", tt.rodata, hints) + } + if hints[0].Library != tt.library || hints[0].Version != tt.version { + t.Fatalf("hit = %+v, want %s@%s", hints[0], tt.library, tt.version) + } + }) + } +} + +func TestScanStaticDedupes(t *testing.T) { + rodata := []byte("deflate 1.3.1 Copyright x\x00inflate 1.3.1 Copyright y") + hints := scanStatic(rodata) + if len(hints) != 1 { + t.Fatalf("got %d hits, want 1 deduped", len(hints)) + } +} + +func TestScanStaticNoMatch(t *testing.T) { + if hints := scanStatic([]byte("hello world 3.14.159")); hints != nil { + t.Fatalf("got %v, want nil", hints) + } +} + +func buildFixture(t *testing.T, goos, goarch string) string { + t.Helper() + + out := filepath.Join(t.TempDir(), "hello") + if goos == "windows" { + out += ".exe" + } + + cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", out, "./testdata/hello") + cmd.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+goarch, "CGO_ENABLED=0") + if b, err := cmd.CombinedOutput(); err != nil { + t.Skipf("cross-compile %s/%s: %v\n%s", goos, goarch, err, b) + } + return out +} + +func hostFormat() string { + switch runtime.GOOS { + case "darwin", "ios": + return "mach-o" + case "windows": + return "pe" + default: + return "elf" + } +} diff --git a/binary/elf.go b/binary/elf.go new file mode 100644 index 0000000..f506265 --- /dev/null +++ b/binary/elf.go @@ -0,0 +1,115 @@ +package binary + +import ( + "bytes" + "debug/elf" + stdbin "encoding/binary" + "io" +) + +func inspectELF(r io.ReaderAt) (*Object, []byte, error) { + f, err := elf.NewFile(r) + if err != nil { + return nil, nil, err + } + defer func() { _ = f.Close() }() + + obj := &Object{ + Format: "elf", + Arch: elfArch(f.Machine, f.Class, f.ByteOrder), + } + + if needed, err := f.DynString(elf.DT_NEEDED); err == nil { + obj.Needed = needed + } + if soname, err := f.DynString(elf.DT_SONAME); err == nil && len(soname) > 0 { + obj.SOName = soname[0] + } + + obj.Producer = elfComment(f) + + return obj, elfROData(f), nil +} + +// elfComment returns the NUL-separated toolchain strings from the .comment +// section. GCC and clang both write entries of the form +// "GCC: (Debian 12.2.0-14) 12.2.0" or "clang version 17.0.6". +func elfComment(f *elf.File) []string { + sec := f.Section(".comment") + if sec == nil { + return nil + } + data, err := sec.Data() + if err != nil { + return nil + } + var out []string + seen := make(map[string]bool) + for raw := range bytes.SplitSeq(data, []byte{0}) { + s := string(bytes.TrimSpace(raw)) + if s == "" || seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out +} + +// elfROData returns the concatenation of sections likely to hold string +// literals for the static-link banner scan. +func elfROData(f *elf.File) []byte { + var buf bytes.Buffer + for _, name := range []string{".rodata", ".rdata", ".data.rel.ro"} { + if sec := f.Section(name); sec != nil { + if data, err := sec.Data(); err == nil { + buf.Write(data) + } + } + } + return buf.Bytes() +} + +func elfArch(m elf.Machine, class elf.Class, order stdbin.ByteOrder) string { + is64 := class == elf.ELFCLASS64 + le := order == stdbin.LittleEndian + switch m { + case elf.EM_X86_64: + return archAMD64 + case elf.EM_386: + return arch386 + case elf.EM_AARCH64: + return archARM64 + case elf.EM_ARM: + return archARM + case elf.EM_RISCV: + if is64 { + return archRISCV64 + } + return archRISCV32 + case elf.EM_PPC64: + if le { + return archPPC64LE + } + return archPPC64 + case elf.EM_PPC: + return archPPC + case elf.EM_S390: + return archS390X + case elf.EM_MIPS: + switch { + case is64 && le: + return archMIPS64LE + case is64: + return archMIPS64 + case le: + return archMIPSLE + default: + return archMIPS + } + case elf.EM_LOONGARCH: + return archLoong64 + default: + return m.String() + } +} diff --git a/binary/macho.go b/binary/macho.go new file mode 100644 index 0000000..7bc1d1b --- /dev/null +++ b/binary/macho.go @@ -0,0 +1,270 @@ +package binary + +import ( + "bytes" + "debug/buildinfo" + "debug/macho" + stdbin "encoding/binary" + "errors" + "fmt" + "io" + "strings" +) + +// Mach-O load commands not covered by debug/macho's typed Loads. +const ( + lcIDDylib = 0xd + lcLoadWeakDylib = 0x80000018 + lcBuildVersion = 0x32 + + loadCommandHeaderLen = 8 // cmd uint32 + cmdsize uint32 + cpuSubtypeMask = 0x00ffffff + cpuSubtypeARM64E = 2 + + // Packed version encoding used by minos/sdk fields: X.Y.Z stored as + // XXXX.YY.ZZ in a uint32. + versionMajorShift = 16 + versionMinorShift = 8 + versionByteMask = 0xff +) + +func isMachO(head [4]byte) bool { + switch head { + case [4]byte{0xcf, 0xfa, 0xed, 0xfe}, + [4]byte{0xce, 0xfa, 0xed, 0xfe}, + [4]byte{0xfe, 0xed, 0xfa, 0xcf}, + [4]byte{0xfe, 0xed, 0xfa, 0xce}: + return true + } + return false +} + +const ( + fatMagic32 = 0xcafebabe + fatMagic64 = 0xcafebabf + fatHeaderLen = 8 + fatArch32Len = 20 + fatArch64Len = 32 + fatMaxArchSane = 64 +) + +func isMachOFat(head [4]byte) bool { + m := stdbin.BigEndian.Uint32(head[:]) + return m == fatMagic32 || m == fatMagic64 +} + +func inspectMachO(r io.ReaderAt) (*Object, []byte, error) { + f, err := macho.NewFile(r) + if err != nil { + return nil, nil, err + } + defer func() { _ = f.Close() }() + + obj, rodata := machOFromFile(f, "mach-o") + return obj, rodata, nil +} + +// inspectMachOFat parses the fat header directly rather than delegating to +// debug/macho.NewFatFile, which only accepts FAT_MAGIC and rejects +// FAT_MAGIC_64. +func inspectMachOFat(r io.ReaderAt, size int64, head [4]byte) (*Object, []byte, error) { + slices, err := readFatArches(r, size, head) + if err != nil { + return nil, nil, err + } + + first, err := macho.NewFile(slices[0]) + if err != nil { + return nil, nil, fmt.Errorf("mach-o fat slice 0: %w", err) + } + defer func() { _ = first.Close() }() + + // Report the first slice's dependencies and producer; list all + // architectures in Arch. + obj, rodata := machOFromFile(first, "mach-o-universal") + arches := make([]string, 0, len(slices)) + arches = append(arches, obj.Arch) + for i, sr := range slices[1:] { + f, err := macho.NewFile(sr) + if err != nil { + return nil, nil, fmt.Errorf("mach-o fat slice %d: %w", i+1, err) + } + arches = append(arches, machOArch(f.Cpu, f.SubCpu)) + _ = f.Close() + } + obj.Arch = strings.Join(arches, "/") + + // debug/buildinfo cannot open FAT_MAGIC_64 containers, so read build + // metadata from the first slice directly. + if bi, err := buildinfo.Read(slices[0]); err == nil { + obj.Go = goBuildFrom(bi) + obj.Producer = append(obj.Producer, bi.GoVersion) + } + + return obj, rodata, nil +} + +var errEmptyFat = errors.New("mach-o fat header has zero architectures") + +func readFatArches(r io.ReaderAt, size int64, head [4]byte) ([]*io.SectionReader, error) { + is64 := stdbin.BigEndian.Uint32(head[:]) == fatMagic64 + + var hdr [fatHeaderLen]byte + if _, err := r.ReadAt(hdr[:], 0); err != nil { + return nil, err + } + narch := stdbin.BigEndian.Uint32(hdr[4:]) + if narch == 0 { + return nil, errEmptyFat + } + if narch > fatMaxArchSane { + return nil, fmt.Errorf("mach-o fat header claims %d architectures", narch) + } + + entryLen := fatArch32Len + if is64 { + entryLen = fatArch64Len + } + table := make([]byte, int(narch)*entryLen) + if _, err := r.ReadAt(table, fatHeaderLen); err != nil { + return nil, err + } + + out := make([]*io.SectionReader, 0, narch) + for i := range int(narch) { + entry := table[i*entryLen:] + var off, sz int64 + if is64 { + off = int64(stdbin.BigEndian.Uint64(entry[8:])) + sz = int64(stdbin.BigEndian.Uint64(entry[16:])) + } else { + off = int64(stdbin.BigEndian.Uint32(entry[8:])) + sz = int64(stdbin.BigEndian.Uint32(entry[12:])) + } + if off < 0 || sz <= 0 || off+sz < off || off+sz > size { + return nil, fmt.Errorf("mach-o fat arch %d out of bounds", i) + } + out = append(out, io.NewSectionReader(r, off, sz)) + } + return out, nil +} + +func machOFromFile(f *macho.File, format string) (*Object, []byte) { + obj := &Object{ + Format: format, + Arch: machOArch(f.Cpu, f.SubCpu), + } + + if libs, err := f.ImportedLibraries(); err == nil { + obj.Needed = libs + } + + for _, load := range f.Loads { + raw := load.Raw() + if len(raw) < loadCommandHeaderLen { + continue + } + cmd := f.ByteOrder.Uint32(raw[0:4]) + switch cmd { + case lcIDDylib: + if name, ok := dylibName(raw, f.ByteOrder); ok { + obj.SOName = name + } + case lcLoadWeakDylib: + if name, ok := dylibName(raw, f.ByteOrder); ok { + obj.Needed = append(obj.Needed, name) + } + case lcBuildVersion: + if s := buildVersion(raw, f.ByteOrder); s != "" { + obj.Producer = append(obj.Producer, s) + } + } + } + + return obj, machOROData(f) +} + +// dylibName decodes the path string from an LC_*_DYLIB command payload. +func dylibName(raw []byte, bo stdbin.ByteOrder) (string, bool) { + // struct dylib_command { cmd; cmdsize; struct dylib { name.offset; ... } } + const nameOffsetAt = 8 + if len(raw) < nameOffsetAt+4 { + return "", false + } + off := bo.Uint32(raw[nameOffsetAt:]) + if int64(off) >= int64(len(raw)) { + return "", false + } + name := raw[off:] + if i := bytes.IndexByte(name, 0); i >= 0 { + name = name[:i] + } + return string(name), len(name) > 0 +} + +// buildVersion decodes LC_BUILD_VERSION into a string like +// "macos 14.0 (sdk 14.2)". +func buildVersion(raw []byte, bo stdbin.ByteOrder) string { + // struct build_version_command { cmd; cmdsize; platform; minos; sdk; ntools; } + const headerLen = 24 + if len(raw) < headerLen { + return "" + } + platform := bo.Uint32(raw[8:]) + minos := bo.Uint32(raw[12:]) + sdk := bo.Uint32(raw[16:]) + return fmt.Sprintf("%s %s (sdk %s)", machOPlatform(platform), + machOVersion(minos), machOVersion(sdk)) +} + +func machOVersion(v uint32) string { + return fmt.Sprintf("%d.%d.%d", + v>>versionMajorShift, + (v>>versionMinorShift)&versionByteMask, + v&versionByteMask) +} + +func machOPlatform(p uint32) string { + names := map[uint32]string{ + 1: "macos", 2: "ios", 3: "tvos", 4: "watchos", 5: "bridgeos", + 6: "maccatalyst", 7: "ios-simulator", 8: "tvos-simulator", + 9: "watchos-simulator", 10: "driverkit", 11: "visionos", + 12: "visionos-simulator", + } + if name, ok := names[p]; ok { + return name + } + return fmt.Sprintf("platform(%d)", p) +} + +func machOROData(f *macho.File) []byte { + var buf bytes.Buffer + for _, name := range []string{"__cstring", "__const", "__rodata"} { + if sec := f.Section(name); sec != nil { + if data, err := sec.Data(); err == nil { + buf.Write(data) + } + } + } + return buf.Bytes() +} + +func machOArch(cpu macho.Cpu, sub uint32) string { + switch cpu { + case macho.CpuAmd64: + return archAMD64 + case macho.Cpu386: + return arch386 + case macho.CpuArm64: + if sub&cpuSubtypeMask == cpuSubtypeARM64E { + return archARM64E + } + return archARM64 + case macho.CpuArm: + return archARM + case macho.CpuPpc64: + return archPPC64 + default: + return cpu.String() + } +} diff --git a/binary/pe.go b/binary/pe.go new file mode 100644 index 0000000..8925b03 --- /dev/null +++ b/binary/pe.go @@ -0,0 +1,162 @@ +package binary + +import ( + "bytes" + "debug/pe" + stdbin "encoding/binary" + "fmt" + "io" +) + +const ( + // IMAGE_IMPORT_DESCRIPTOR is 20 bytes; the DLL name RVA sits at + // offset 12. The table is terminated by an all-zero descriptor. + peImportDescriptorLen = 20 + peImportDescriptorName = 12 + peDirectoryEntryImport = 1 + peMaxDLLNameLen = 256 +) + +func inspectPE(r io.ReaderAt) (*Object, []byte, error) { + f, err := pe.NewFile(r) + if err != nil { + return nil, nil, err + } + defer func() { _ = f.Close() }() + + obj := &Object{ + Format: "pe", + Arch: peArch(f.Machine), + Needed: peImportedLibraries(f), + } + + return obj, peROData(f), nil +} + +// peImportedLibraries reads DLL names from the import directory. +// pe.File.ImportedLibraries is an unimplemented stub in the standard library, +// and pe.File.ImportedSymbols omits libraries whose imports are all by +// ordinal, so read IMAGE_IMPORT_DESCRIPTOR entries directly. +func peImportedLibraries(f *pe.File) []string { + dirs := peDataDirectories(f) + if len(dirs) <= peDirectoryEntryImport { + return nil + } + dir := dirs[peDirectoryEntryImport] + if dir.VirtualAddress == 0 || dir.Size == 0 { + return nil + } + + sec := peSectionForRVA(f, dir.VirtualAddress) + if sec == nil { + return nil + } + data, err := sec.Data() + if err != nil { + return nil + } + off := int64(dir.VirtualAddress) - int64(sec.VirtualAddress) + if off < 0 || off >= int64(len(data)) { + return nil + } + + var libs []string + seen := make(map[string]bool) + for p := data[off:]; len(p) >= peImportDescriptorLen; p = p[peImportDescriptorLen:] { + desc := p[:peImportDescriptorLen] + if isZero(desc) { + break + } + nameRVA := stdbin.LittleEndian.Uint32(desc[peImportDescriptorName:]) + name := peStringAtRVA(f, nameRVA) + if name == "" || seen[name] { + continue + } + seen[name] = true + libs = append(libs, name) + } + return libs +} + +func peDataDirectories(f *pe.File) []pe.DataDirectory { + switch h := f.OptionalHeader.(type) { + case *pe.OptionalHeader32: + return h.DataDirectory[:] + case *pe.OptionalHeader64: + return h.DataDirectory[:] + default: + return nil + } +} + +func peSectionForRVA(f *pe.File, rva uint32) *pe.Section { + for _, s := range f.Sections { + if rva >= s.VirtualAddress && rva < s.VirtualAddress+s.VirtualSize { + return s + } + } + return nil +} + +func peStringAtRVA(f *pe.File, rva uint32) string { + sec := peSectionForRVA(f, rva) + if sec == nil { + return "" + } + data, err := sec.Data() + if err != nil { + return "" + } + off := int64(rva) - int64(sec.VirtualAddress) + if off < 0 || off >= int64(len(data)) { + return "" + } + s := data[off:] + if i := bytes.IndexByte(s, 0); i >= 0 { + s = s[:i] + } + if len(s) > peMaxDLLNameLen { + s = s[:peMaxDLLNameLen] + } + return string(s) +} + +func isZero(b []byte) bool { + for _, c := range b { + if c != 0 { + return false + } + } + return true +} + +func peROData(f *pe.File) []byte { + var buf bytes.Buffer + for _, name := range []string{".rdata", ".rodata"} { + if sec := f.Section(name); sec != nil { + if data, err := sec.Data(); err == nil { + buf.Write(data) + } + } + } + return buf.Bytes() +} + +func peArch(m uint16) string { + switch m { + case pe.IMAGE_FILE_MACHINE_AMD64: + return archAMD64 + case pe.IMAGE_FILE_MACHINE_I386: + return arch386 + case pe.IMAGE_FILE_MACHINE_ARM64: + return archARM64 + case pe.IMAGE_FILE_MACHINE_ARMNT: + return archARM + case pe.IMAGE_FILE_MACHINE_RISCV64: + return archRISCV64 + case pe.IMAGE_FILE_MACHINE_LOONGARCH64: + return archLoong64 + default: + return fmt.Sprintf("machine(0x%x)", m) + } +} diff --git a/binary/static.go b/binary/static.go new file mode 100644 index 0000000..60931e2 --- /dev/null +++ b/binary/static.go @@ -0,0 +1,73 @@ +package binary + +import "regexp" + +// staticPatterns matches version-banner strings that libraries commonly embed +// in read-only data. A hit means the library was probably compiled into the +// object rather than dynamically linked, since dynamic dependencies would +// show up in Needed instead. Matches are heuristic; callers should treat them +// as low confidence. +// +// Each pattern's first submatch, if present, is the version. +var staticPatterns = []struct { + library string + re *regexp.Regexp +}{ + {"zlib", regexp.MustCompile(`(?:deflate|inflate) (1\.[0-9]+(?:\.[0-9]+)?) Copyright`)}, + {"openssl", regexp.MustCompile(`OpenSSL ([0-9]+\.[0-9]+\.[0-9]+[a-z]?)`)}, + {"boringssl", regexp.MustCompile(`BoringSSL`)}, + // SQLITE_SOURCE_ID: "YYYY-MM-DD HH:MM:SS " is a distinctive + // fingerprint; the version string itself is a bare "3.x.y" that is too + // generic to match safely. + {"sqlite", regexp.MustCompile(`20[0-9]{2}-[01][0-9]-[0-3][0-9] [0-2][0-9]:[0-5][0-9]:[0-5][0-9] [0-9a-f]{40,}`)}, + {"libcurl", regexp.MustCompile(`libcurl/([0-9]+\.[0-9]+\.[0-9]+)`)}, + {"pcre2", regexp.MustCompile(`PCRE2 (10\.[0-9]+)`)}, + {"libpng", regexp.MustCompile(`libpng version ([0-9]+\.[0-9]+\.[0-9]+)`)}, + {"libjpeg-turbo", regexp.MustCompile(`libjpeg-turbo version ([0-9]+\.[0-9]+\.[0-9]+)`)}, + {"libwebp", regexp.MustCompile(`libwebp ([0-9]+\.[0-9]+\.[0-9]+)`)}, + {"libxml2", regexp.MustCompile(`libxml2 (2\.[0-9]+\.[0-9]+)`)}, + {"libxml2", regexp.MustCompile(`xmlParseDoc : `)}, + {"brotli", regexp.MustCompile(`[Bb]rotli/([0-9]+\.[0-9]+\.[0-9]+)`)}, + {"lz4", regexp.MustCompile(`LZ4 v([0-9]+\.[0-9]+\.[0-9]+)`)}, + {"zstd", regexp.MustCompile(`Zstandard v([0-9]+\.[0-9]+\.[0-9]+)`)}, + {"libffi", regexp.MustCompile(`libffi ([0-9]+\.[0-9]+(?:\.[0-9]+)?)`)}, + {"mbedtls", regexp.MustCompile(`[Mm]bed ?TLS ([0-9]+\.[0-9]+\.[0-9]+)`)}, + {"expat", regexp.MustCompile(`expat_([0-9]+\.[0-9]+\.[0-9]+)`)}, + {"cares", regexp.MustCompile(`c-ares ([0-9]+\.[0-9]+\.[0-9]+)`)}, +} + +const maxMatchLen = 128 + +func scanStatic(rodata []byte) []Hint { + var hints []Hint + seen := make(map[string]bool) + for _, p := range staticPatterns { + m := p.re.FindSubmatchIndex(rodata) + if m == nil { + continue + } + match := clip(rodata[m[0]:m[1]]) + var version string + if len(m) >= 4 && m[2] >= 0 { + version = string(rodata[m[2]:m[3]]) + } + key := p.library + "@" + version + if seen[key] { + continue + } + seen[key] = true + hints = append(hints, Hint{ + Library: p.library, + Version: version, + Match: match, + }) + } + return hints +} + +func clip(b []byte) string { + if len(b) > maxMatchLen { + b = b[:maxMatchLen] + } + return string(b) +} diff --git a/binary/testdata/hello/main.go b/binary/testdata/hello/main.go new file mode 100644 index 0000000..662bf01 --- /dev/null +++ b/binary/testdata/hello/main.go @@ -0,0 +1,6 @@ +// hello is a trivial program cross-compiled by the binary package tests to +// produce ELF, Mach-O, and PE fixtures without checking binaries into the +// repository. +package main + +func main() {}