Skip to content

Commit 7acb5f6

Browse files
authored
feat(go): detect version from ELF symbol table for binaries built with -trimpath (#10197)
1 parent 5b54388 commit 7acb5f6

5 files changed

Lines changed: 218 additions & 19 deletions

File tree

docs/guide/coverage/language/golang.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ $ trivy rootfs ./your_binary
104104
Go binaries installed using the `go install` command contains correct (semver) version for the main module and therefore are detected by Trivy.
105105
In other cases, Go uses the `(devel)` version[^2].
106106
In this case, Trivy will attempt to parse any `-ldflags` as it's a common practice to pass versions this way.
107+
If the `-ldflags` are not available (e.g., due to the Go `-trimpath` flag), Trivy will attempt to extract the version from the ELF symbol table.
107108
If unsuccessful, the version will be empty[^3].
108109

109110
### Standard Library { #go-binary-stdlib }
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package binary
2+
3+
import (
4+
"debug/elf"
5+
"io"
6+
"strings"
7+
8+
"github.com/aquasecurity/trivy/pkg/log"
9+
)
10+
11+
// elfSymbolVersion attempts to extract the version from the ELF symbol table.
12+
//
13+
// When Go builds with `-ldflags "-X main.version=1.0.0"`, the linker creates `.str`-suffixed
14+
// symbols (e.g. `main.version.str`) containing the string value. Trivy normally extracts
15+
// these values by parsing the `-ldflags` recorded in the binary's buildinfo. However, when
16+
// `-trimpath` is used, Go does not record `-ldflags` in the buildinfo due to a known bug
17+
// (https://go.dev/issue/63432), making the existing ldflags parsing ineffective.
18+
// The `.str` symbols in the ELF symbol table remain intact regardless of `-trimpath`,
19+
// so reading them directly serves as a fallback.
20+
//
21+
// This only works for unstripped ELF binaries where `.symtab` is present.
22+
// Binaries built with `-ldflags "-s -w"` strip the symbol table and cannot be handled here.
23+
func (p *Parser) elfSymbolVersion(r io.ReaderAt, moduleName string) string {
24+
f, err := elf.NewFile(r)
25+
if err != nil {
26+
p.logger.Debug("Not an ELF binary, skipping symbol table lookup", log.Err(err))
27+
return ""
28+
}
29+
defer f.Close()
30+
31+
syms, err := f.Symbols()
32+
if err != nil {
33+
p.logger.Debug("No symbol table found (binary may be stripped)", log.Err(err))
34+
return ""
35+
}
36+
37+
// foundVersions uses the same 3-tier priority as ParseLDFlags:
38+
// [0]: <module_path>/cmd/**/*.version.str
39+
// [1]: defaultVersionPrefixes (main, common, version, cmd)
40+
// [2]: other
41+
var foundVersions = make([][]string, 3)
42+
for i := range syms {
43+
sym := &syms[i]
44+
if !strings.HasSuffix(sym.Name, ".str") || sym.Size == 0 {
45+
continue
46+
}
47+
48+
// Strip ".str" suffix to get the original key (e.g. "main.version")
49+
key := strings.TrimSuffix(sym.Name, ".str")
50+
if !isVersionXKey(key) {
51+
continue
52+
}
53+
54+
val := readELFSymbolString(f, sym)
55+
if val == "" || !isValidSemVer(val) {
56+
continue
57+
}
58+
59+
classifyVersion(foundVersions, key, moduleName, val)
60+
}
61+
62+
return p.chooseVersion(moduleName, foundVersions)
63+
}
64+
65+
// readELFSymbolString reads the string value of an ELF symbol.
66+
// The offset is computed as sym.Value (virtual address) minus the section's base address,
67+
// following the same pattern as Go's cmd/internal/objfile/elf.go (symbolData function).
68+
// cf. https://go.dev/src/cmd/internal/objfile/elf.go
69+
func readELFSymbolString(f *elf.File, sym *elf.Symbol) string {
70+
if int(sym.Section) >= len(f.Sections) {
71+
return ""
72+
}
73+
74+
sec := f.Sections[sym.Section]
75+
offset := sym.Value - sec.Addr
76+
buf := make([]byte, sym.Size)
77+
if _, err := sec.ReadAt(buf, int64(offset)); err != nil {
78+
return ""
79+
}
80+
81+
return strings.TrimRight(string(buf), "\x00")
82+
}
Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
package binary
22

3+
import "io"
4+
35
// Bridge to expose binary parser internals to tests in the binary_test package.
46

57
// ChooseMainVersion exports chooseMainVersion for testing.
6-
func (p *Parser) ChooseMainVersion(version, ldflagsVersion string) string {
7-
return p.chooseMainVersion(version, ldflagsVersion)
8+
func (p *Parser) ChooseMainVersion(version, ldflagsVersion, elfVersion string) string {
9+
return p.chooseMainVersion(version, ldflagsVersion, elfVersion)
10+
}
11+
12+
// ELFSymbolVersion exports elfSymbolVersion for testing.
13+
func (p *Parser) ELFSymbolVersion(r io.ReaderAt, name string) string {
14+
return p.elfSymbolVersion(r, name)
815
}

pkg/dependency/parser/golang/binary/parse.go

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ func (p *Parser) Parse(_ context.Context, r xio.ReadSeekerAt) ([]ftypes.Package,
110110
// See https://github.com/aquasecurity/trivy/issues/1837#issuecomment-1832523477.
111111
version := p.checkVersion(info.Main.Path, info.Main.Version)
112112
ldflagsVersion := p.ParseLDFlags(info.Main.Path, ldflags)
113-
version = p.chooseMainVersion(version, ldflagsVersion)
113+
elfVersion := p.elfSymbolVersion(r, info.Main.Path)
114+
version = p.chooseMainVersion(version, ldflagsVersion, elfVersion)
114115

115116
root := ftypes.Package{
116117
ID: dependency.ID(ftypes.GoBinary, info.Main.Path, version),
@@ -148,15 +149,29 @@ func (p *Parser) checkVersion(name, version string) string {
148149
}
149150

150151
// chooseMainVersion determines which version to use for the main module.
151-
// It prefers the ldflags version when:
152-
// - The build info version is empty, OR
153-
// - The build info version is a pseudo-version AND ldflags version is available
154-
// This handles cases where actual release versions are injected via -ldflags.
155-
func (p *Parser) chooseMainVersion(version, ldflagsVersion string) string {
156-
if version == "" || (module.IsPseudoVersion(version) && ldflagsVersion != "") {
152+
// The priority order is:
153+
// 1. Build info version (if it is a real semver, e.g. "v1.2.3" from `go install`)
154+
// 2. ldflags version (e.g. `-ldflags "-X main.version=v1.0.0"`)
155+
// 3. ELF symbol table version (fallback when `-trimpath` hides `-ldflags`)
156+
// 4. Original version as-is (may be empty or a pseudo-version)
157+
//
158+
// Examples:
159+
//
160+
// chooseMainVersion("v1.2.3", "v1.0.0", "v1.0.0") => "v1.2.3" (real semver wins)
161+
// chooseMainVersion("v0.0.0-2024...", "v1.0.0", "") => "v1.0.0" (ldflags over pseudo)
162+
// chooseMainVersion("v0.0.0-2024...", "", "v2.0.0") => "v2.0.0" (ELF over pseudo)
163+
// chooseMainVersion("", "", "") => "" (nothing available)
164+
func (p *Parser) chooseMainVersion(version, ldflagsVersion, elfVersion string) string {
165+
switch {
166+
case version != "" && !module.IsPseudoVersion(version):
167+
return version
168+
case ldflagsVersion != "":
157169
return ldflagsVersion
170+
case elfVersion != "":
171+
return elfVersion
172+
default:
173+
return version
158174
}
159-
return version
160175
}
161176

162177
func (p *Parser) ldFlags(settings []debug.BuildSetting) []string {
@@ -216,14 +231,7 @@ func (p *Parser) ParseLDFlags(name string, flags []string) string {
216231
key = strings.TrimLeft(key, `'`)
217232
val = strings.TrimRight(val, `'`)
218233
if isVersionXKey(key) && isValidSemVer(val) {
219-
switch {
220-
case strings.HasPrefix(key, name+"/cmd/"):
221-
foundVersions[0] = append(foundVersions[0], val)
222-
case defaultVersionPrefixes.Contains(versionPrefix(key)):
223-
foundVersions[1] = append(foundVersions[1], val)
224-
default:
225-
foundVersions[2] = append(foundVersions[2], val)
226-
}
234+
classifyVersion(foundVersions, key, name, val)
227235
}
228236
}
229237

@@ -267,6 +275,23 @@ func isValidSemVer(ver string) bool {
267275
return semver.IsValid(ver) || semver.IsValid("v"+ver)
268276
}
269277

278+
// classifyVersion categorizes a version value into one of three priority tiers
279+
// based on its key:
280+
//
281+
// [0]: <module_path>/cmd/**/*.version
282+
// [1]: defaultVersionPrefixes (main, common, version, cmd)
283+
// [2]: other
284+
func classifyVersion(foundVersions [][]string, key, moduleName, val string) {
285+
switch {
286+
case strings.HasPrefix(key, moduleName+"/cmd/"):
287+
foundVersions[0] = append(foundVersions[0], val)
288+
case defaultVersionPrefixes.Contains(versionPrefix(key)):
289+
foundVersions[1] = append(foundVersions[1], val)
290+
default:
291+
foundVersions[2] = append(foundVersions[2], val)
292+
}
293+
}
294+
270295
// versionPrefix returns version prefix from `-ldflags` flag key
271296
// e.g.
272297
// - `github.com/aquasecurity/trivy/pkg/version/app.ver` => `version`

pkg/dependency/parser/golang/binary/parse_test.go

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ func TestParser_ChooseMainVersion(t *testing.T) {
240240
name string
241241
version string
242242
ldflagsVersion string
243+
elfVersion string
243244
want string
244245
}{
245246
{
@@ -278,11 +279,94 @@ func TestParser_ChooseMainVersion(t *testing.T) {
278279
ldflagsVersion: "",
279280
want: "v2.0.0-20251210145848-560ea94fc7d6",
280281
},
282+
{
283+
name: "pseudo-version with ELF version",
284+
version: "v0.0.0-20210121094942-22b2f8951d46",
285+
elfVersion: "v1.0.0",
286+
want: "v1.0.0",
287+
},
288+
{
289+
name: "pseudo-version with both ldflags and ELF prefers ldflags",
290+
version: "v0.0.0-20210121094942-22b2f8951d46",
291+
ldflagsVersion: "v1.2.3",
292+
elfVersion: "v1.0.0",
293+
want: "v1.2.3",
294+
},
295+
{
296+
name: "empty version with ELF version",
297+
version: "",
298+
elfVersion: "v2.0.0",
299+
want: "v2.0.0",
300+
},
301+
{
302+
name: "empty version with both ldflags and ELF prefers ldflags",
303+
version: "",
304+
ldflagsVersion: "v1.2.3",
305+
elfVersion: "v2.0.0",
306+
want: "v1.2.3",
307+
},
308+
{
309+
name: "regular semver version with ELF version keeps original",
310+
version: "v1.2.3",
311+
elfVersion: "v1.2.4",
312+
want: "v1.2.3",
313+
},
281314
}
282315
for _, tt := range tests {
283316
t.Run(tt.name, func(t *testing.T) {
284317
p := binary.NewParser()
285-
got := p.ChooseMainVersion(tt.version, tt.ldflagsVersion)
318+
got := p.ChooseMainVersion(tt.version, tt.ldflagsVersion, tt.elfVersion)
319+
assert.Equal(t, tt.want, got)
320+
})
321+
}
322+
}
323+
324+
func TestParser_ELFSymbolVersion(t *testing.T) {
325+
tests := []struct {
326+
name string
327+
inputFile string
328+
moduleName string
329+
want string
330+
}{
331+
{
332+
name: "ELF with version symbol",
333+
inputFile: "testdata/main-version-via-ldflags.elf",
334+
moduleName: "github.com/aquasecurity/test",
335+
want: "v1.0.0",
336+
},
337+
{
338+
name: "ELF without version symbols",
339+
inputFile: "testdata/test.elf",
340+
moduleName: "github.com/aquasecurity/test",
341+
want: "",
342+
},
343+
{
344+
name: "PE binary (not ELF)",
345+
inputFile: "testdata/test.exe",
346+
moduleName: "github.com/aquasecurity/test",
347+
want: "",
348+
},
349+
{
350+
name: "Mach-O binary (not ELF)",
351+
inputFile: "testdata/test.macho",
352+
moduleName: "github.com/aquasecurity/test",
353+
want: "",
354+
},
355+
{
356+
name: "dummy file",
357+
inputFile: "testdata/dummy",
358+
moduleName: "test",
359+
want: "",
360+
},
361+
}
362+
for _, tt := range tests {
363+
t.Run(tt.name, func(t *testing.T) {
364+
f, err := os.Open(tt.inputFile)
365+
require.NoError(t, err)
366+
defer f.Close()
367+
368+
p := binary.NewParser()
369+
got := p.ELFSymbolVersion(f, tt.moduleName)
286370
assert.Equal(t, tt.want, got)
287371
})
288372
}

0 commit comments

Comments
 (0)