From 2838a1fd2dbe82c87604e7a1d28bb648b1c5add5 Mon Sep 17 00:00:00 2001 From: mayankpande88 Date: Tue, 6 Oct 2026 02:39:27 +0530 Subject: [PATCH 1/2] fix(tls): read a stripped binary's function table through the open file A stripped Go binary's symbols come only from .gopclntab, and openGoFuncTable read it by reopening the path, a /proc//exe link. When the process exited between the ELF open and that reopen, its crypto/tls functions were "not found". LookupSymbols caches that per binary and AttachGoTlsUprobes caches no_symbols, so every later process of the binary went unprobed until the entries were evicted. Short-lived processes of a stripped binary, the first of which is often gone before the lookup finishes, hit this. In a local run where attaching was slower, 450 attaches of the short-lived test client returned no_symbols. ELFFile now keeps the opened file and the table is mapped from it. A symbol is also no longer recorded as missing when the table failed to load for another reason. --- ebpftracer/elf.go | 20 ++++++++++++----- ebpftracer/gopclntab.go | 8 ++----- ebpftracer/gopclntab_test.go | 43 ++++++++++++++++++++++++++++++++++++ ebpftracer/symbol_cache.go | 7 ++++++ 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/ebpftracer/elf.go b/ebpftracer/elf.go index 7d1875f..6040e4d 100644 --- a/ebpftracer/elf.go +++ b/ebpftracer/elf.go @@ -4,6 +4,7 @@ import ( "debug/elf" "fmt" "io" + "os" "github.com/cilium/ebpf" "github.com/cilium/ebpf/link" @@ -116,7 +117,10 @@ func (s *Symbol) AttachUretprobes(exe *link.Executable, prog *ebpf.Program, pid } type ELFFile struct { - path string + path string + // file is the open binary. Everything is read through it: reopening path, + // a /proc//exe link, fails once that process has exited. + file *os.File elf *elf.File symbols []elf.Symbol textSection *elf.Section @@ -126,11 +130,16 @@ type ELFFile struct { } func OpenELFFile(path string) (*ELFFile, error) { - file, err := elf.Open(path) + file, err := os.Open(path) if err != nil { return nil, err } - return &ELFFile{path: path, elf: file}, nil + ef, err := elf.NewFile(file) + if err != nil { + file.Close() + return nil, err + } + return &ELFFile{path: path, file: file, elf: ef}, nil } func (f *ELFFile) readSymbols() error { @@ -176,7 +185,7 @@ func (f *ELFFile) GetSymbol(name string) (*Symbol, error) { // cannot be read. func (f *ELFFile) goFuncTable() *goFuncTable { if f.goFuncs == nil && f.goFuncsErr == nil { - f.goFuncs, f.goFuncsErr = openGoFuncTable(f.path, f.elf) + f.goFuncs, f.goFuncsErr = openGoFuncTable(f.file, f.elf) } return f.goFuncs } @@ -196,7 +205,8 @@ func (f *ELFFile) Close() error { if f.goFuncs != nil { f.goFuncs.close() } - return f.elf.Close() + // elf.File.Close does nothing for a file made with elf.NewFile. + return f.file.Close() } // stackCheckWindow bounds the prologue scanned for the stack check: at most diff --git a/ebpftracer/gopclntab.go b/ebpftracer/gopclntab.go index 72982cd..cc32467 100644 --- a/ebpftracer/gopclntab.go +++ b/ebpftracer/gopclntab.go @@ -39,7 +39,7 @@ type goFuncTable struct { textEnd uint64 // end of .text; a function must lie in [textStart, textEnd) } -func openGoFuncTable(path string, ef *elf.File) (*goFuncTable, error) { +func openGoFuncTable(file *os.File, ef *elf.File) (*goFuncTable, error) { sec := ef.Section(".gopclntab") if sec == nil { // PIE binaries place it in the relocated read-only data. @@ -49,11 +49,7 @@ func openGoFuncTable(path string, ef *elf.File) (*goFuncTable, error) { return nil, errNoGoFuncTable } - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() + // The mapping outlives file: closing the descriptor does not unmap it. info, err := file.Stat() if err != nil { return nil, err diff --git a/ebpftracer/gopclntab_test.go b/ebpftracer/gopclntab_test.go index 225ad50..cb0df4a 100644 --- a/ebpftracer/gopclntab_test.go +++ b/ebpftracer/gopclntab_test.go @@ -241,3 +241,46 @@ func TestReturnOffsets_RejectsSymbolOutsideText(t *testing.T) { } } } + +// A stripped Go binary's symbols come only from .gopclntab. It used to be read +// by reopening the path, a /proc//exe link for a process: when the +// process exited between the ELF open and that reopen, its TLS functions were +// "not found", the binary was cached as having none, and every later process +// of it went unprobed. The table is now read through the file already open. +func TestGetSymbol_StrippedGoBinaryAfterPathIsGone(t *testing.T) { + if testing.Short() { + t.Skip("builds Go binaries") + } + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("go toolchain not found") + } + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "go.mod"), []byte("module tlsprobe\n\ngo 1.21\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "main.go"), []byte(gopclntabTestProgram), 0o644); err != nil { + t.Fatal(err) + } + bin := filepath.Join(t.TempDir(), "stripped") + cmd := exec.Command(goBin, "build", "-ldflags=-s -w", "-o", bin, ".") + cmd.Dir = src + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go build: %v\n%s", err, out) + } + + ef, err := OpenELFFile(bin) + if err != nil { + t.Fatal(err) + } + defer ef.Close() + if err := os.Remove(bin); err != nil { // the process exits + t.Fatal(err) + } + for _, name := range tlsFuncs { + if _, err := ef.GetSymbol(name); err != nil { + t.Errorf("%s: %v", name, err) + } + } +} diff --git a/ebpftracer/symbol_cache.go b/ebpftracer/symbol_cache.go index a6f7aa4..2fba487 100644 --- a/ebpftracer/symbol_cache.go +++ b/ebpftracer/symbol_cache.go @@ -1,6 +1,7 @@ package ebpftracer import ( + "errors" "fmt" "os" "sync" @@ -150,6 +151,12 @@ func readProbeTargets(path string, names []string) (map[string]ProbeTarget, erro for _, name := range names { s, err := ef.GetSymbol(name) if err != nil { + // "Not found" is cached for the binary, so it must mean the + // binary lacks the symbol, not that its function table (the only + // symbol source of a stripped Go binary) could not be read. + if ef.goFuncsErr != nil && !errors.Is(ef.goFuncsErr, errNoGoFuncTable) { + return nil, ef.goFuncsErr + } targets[name] = ProbeTarget{} continue } From 89979b433c7944bdaf73cbbb2a0c1d26daaf7509 Mon Sep 17 00:00:00 2001 From: mayankpande88 Date: Tue, 6 Oct 2026 03:40:00 +0530 Subject: [PATCH 2/2] test(tls): build the stripped test binary for linux whatever the host --- ebpftracer/gopclntab_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ebpftracer/gopclntab_test.go b/ebpftracer/gopclntab_test.go index cb0df4a..15031be 100644 --- a/ebpftracer/gopclntab_test.go +++ b/ebpftracer/gopclntab_test.go @@ -265,7 +265,8 @@ func TestGetSymbol_StrippedGoBinaryAfterPathIsGone(t *testing.T) { bin := filepath.Join(t.TempDir(), "stripped") cmd := exec.Command(goBin, "build", "-ldflags=-s -w", "-o", bin, ".") cmd.Dir = src - cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + // An ELF binary whatever the host builds natively. + cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("go build: %v\n%s", err, out) }