pkg/proc: enable basic debug functionality for stripped ELF binaries (#3408)

We used to parse the .gopclntab section but removed support in favor of
simply using DWARF debug information, due to lack of C symbols among
other reasons. This makes it impossible to debug stripped binaries,
which some distrubutions ship by default.

Add back in basic support for .gopclntab which survives if the binary
is stripped, allowing for rudimentary debugging such as basic
program navigation, tracing, etc...
This commit is contained in:
Derek Parker 2023-06-14 04:23:46 -07:00 committed by GitHub
parent 7d8f47674b
commit ccf17a6f42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 184 additions and 186 deletions

@ -26,7 +26,6 @@ import (
"github.com/go-delve/delve/pkg/terminal"
"github.com/go-delve/delve/service/dap"
"github.com/go-delve/delve/service/dap/daptest"
"github.com/go-delve/delve/service/debugger"
"github.com/go-delve/delve/service/rpc2"
godap "github.com/google/go-dap"
"golang.org/x/tools/go/packages"
@ -282,57 +281,6 @@ func TestContinue(t *testing.T) {
cmd.Wait()
}
// TestChildProcessExitWhenNoDebugInfo verifies that the child process exits when dlv launch the binary without debug info
func TestChildProcessExitWhenNoDebugInfo(t *testing.T) {
noDebugFlags := protest.LinkStrip
// -s doesn't strip symbols on Mac, use -w instead
if runtime.GOOS == "darwin" {
noDebugFlags = protest.LinkDisableDWARF
}
if _, err := exec.LookPath("ps"); err != nil {
t.Skip("test skipped, `ps` not found")
}
dlvbin := getDlvBin(t)
fix := protest.BuildFixture("http_server", noDebugFlags)
// dlv exec the binary file and expect error.
out, err := exec.Command(dlvbin, "exec", "--headless", "--log", fix.Path).CombinedOutput()
t.Log(string(out))
if err == nil {
t.Fatalf("Expected err when launching the binary without debug info, but got nil")
}
// Test only for dlv's prefix of the error like "could not launch process: could not open debug info"
if !strings.Contains(string(out), "could not launch process") || !strings.Contains(string(out), debugger.NoDebugWarning) {
t.Fatalf("Expected logged error 'could not launch process: ... - %s'", debugger.NoDebugWarning)
}
// search the running process named fix.Name
cmd := exec.Command("ps", "-aux")
stdout, err := cmd.StdoutPipe()
assertNoError(err, t, "stdout pipe")
defer stdout.Close()
assertNoError(cmd.Start(), t, "start `ps -aux`")
var foundFlag bool
scan := bufio.NewScanner(stdout)
for scan.Scan() {
t.Log(scan.Text())
if strings.Contains(scan.Text(), fix.Name) {
foundFlag = true
break
}
}
cmd.Wait()
if foundFlag {
t.Fatalf("Expected child process exited, but found it running")
}
}
// TestRedirect verifies that redirecting stdin works
func TestRedirect(t *testing.T) {
const listenAddr = "127.0.0.1:40573"
@ -711,57 +659,6 @@ func TestDAPCmd(t *testing.T) {
cmd.Wait()
}
func TestDAPCmdWithNoDebugBinary(t *testing.T) {
const listenAddr = "127.0.0.1:40579"
dlvbin := getDlvBin(t)
cmd := exec.Command(dlvbin, "dap", "--log", "--listen", listenAddr)
stdout, err := cmd.StdoutPipe()
assertNoError(err, t, "stdout pipe")
defer stdout.Close()
stderr, err := cmd.StderrPipe()
assertNoError(err, t, "stderr pipe")
defer stderr.Close()
assertNoError(cmd.Start(), t, "start dap instance")
scanOut := bufio.NewScanner(stdout)
scanErr := bufio.NewScanner(stderr)
// Wait for the debug server to start
scanOut.Scan()
listening := "DAP server listening at: " + listenAddr
if scanOut.Text() != listening {
cmd.Process.Kill() // release the port
t.Fatalf("Unexpected stdout:\ngot %q\nwant %q", scanOut.Text(), listening)
}
go func() { // Capture logging
for scanErr.Scan() {
t.Log(scanErr.Text())
}
}()
// Exec the stripped debuggee and expect things to fail
noDebugFlags := protest.LinkStrip
// -s doesn't strip symbols on Mac, use -w instead
if runtime.GOOS == "darwin" {
noDebugFlags = protest.LinkDisableDWARF
}
fixture := protest.BuildFixture("increment", noDebugFlags)
go func() {
for scanOut.Scan() {
t.Errorf("Unexpected stdout: %s", scanOut.Text())
}
}()
client := daptest.NewClient(listenAddr)
client.LaunchRequest("exec", fixture.Path, false)
client.ExpectErrorResponse(t)
client.DisconnectRequest()
client.ExpectDisconnectResponse(t)
client.ExpectTerminatedEvent(t)
client.Close()
cmd.Wait()
}
func newDAPRemoteClient(t *testing.T, addr string, isDlvAttach bool, isMulti bool) *daptest.Client {
c := daptest.NewClient(addr)
c.AttachRequest(map[string]interface{}{"mode": "remote", "stopOnEntry": true})

@ -4,6 +4,7 @@ import (
"bytes"
"debug/dwarf"
"debug/elf"
"debug/gosym"
"debug/macho"
"debug/pe"
"encoding/binary"
@ -332,7 +333,7 @@ func FindFunctionLocation(p Process, funcName string, lineOffset int) ([]uint64,
if lineOffset > 0 {
fn := origfns[0]
filename, lineno := fn.cu.lineInfo.PCToLine(fn.Entry, fn.Entry)
filename, lineno := bi.EntryLineForFunc(fn)
return FindFileLocation(p, filename, lineno+lineOffset)
}
@ -364,14 +365,16 @@ func FindFunctionLocation(p Process, funcName string, lineOffset int) ([]uint64,
// If sameline is set FirstPCAfterPrologue will always return an
// address associated with the same line as fn.Entry.
func FirstPCAfterPrologue(p Process, fn *Function, sameline bool) (uint64, error) {
pc, _, line, ok := fn.cu.lineInfo.PrologueEndPC(fn.Entry, fn.End)
if ok {
if !sameline {
return pc, nil
}
_, entryLine := fn.cu.lineInfo.PCToLine(fn.Entry, fn.Entry)
if entryLine == line {
return pc, nil
if fn.cu.lineInfo != nil {
pc, _, line, ok := fn.cu.lineInfo.PrologueEndPC(fn.Entry, fn.End)
if ok {
if !sameline {
return pc, nil
}
_, entryLine := p.BinInfo().EntryLineForFunc(fn)
if entryLine == line {
return pc, nil
}
}
}
@ -380,7 +383,7 @@ func FirstPCAfterPrologue(p Process, fn *Function, sameline bool) (uint64, error
return fn.Entry, err
}
if pc == fn.Entry {
if pc == fn.Entry && fn.cu.lineInfo != nil {
// Look for the first instruction with the stmt flag set, so that setting a
// breakpoint with file:line and with the function name always result on
// the same instruction being selected.
@ -601,6 +604,25 @@ func (fn *Function) PrologueEndPC() uint64 {
return pc
}
func (fn *Function) AllPCs(excludeFile string, excludeLine int) ([]uint64, error) {
if !fn.cu.image.Stripped() {
return fn.cu.lineInfo.AllPCsBetween(fn.Entry, fn.End-1, excludeFile, excludeLine)
}
var pcs []uint64
fnFile, lastLine, _ := fn.cu.image.symTable.PCToLine(fn.Entry)
for pc := fn.Entry; pc < fn.End; pc++ {
f, line, pcfn := fn.cu.image.symTable.PCToLine(pc)
if pcfn == nil {
continue
}
if f == fnFile && line > lastLine {
lastLine = line
pcs = append(pcs, pc)
}
}
return pcs, nil
}
// From $GOROOT/src/runtime/traceback.go:597
// exportedRuntime reports whether the function is an exported runtime function.
// It is only for runtime functions, so ASCII A-Z is fine.
@ -719,6 +741,9 @@ func (bi *BinaryInfo) LastModified() time.Time {
// DwarfReader returns a reader for the dwarf data
func (so *Image) DwarfReader() *reader.Reader {
if so.dwarf == nil {
return nil
}
return reader.New(so.dwarf)
}
@ -731,13 +756,26 @@ func (bi *BinaryInfo) Types() ([]string, error) {
return types, nil
}
func (bi *BinaryInfo) EntryLineForFunc(fn *Function) (string, int) {
return bi.pcToLine(fn, fn.Entry)
}
func (bi *BinaryInfo) pcToLine(fn *Function, pc uint64) (string, int) {
if fn.cu.lineInfo == nil {
f, l, _ := fn.cu.image.symTable.PCToLine(pc)
return f, l
}
f, l := fn.cu.lineInfo.PCToLine(fn.Entry, pc)
return f, l
}
// PCToLine converts an instruction address to a file/line/function.
func (bi *BinaryInfo) PCToLine(pc uint64) (string, int, *Function) {
fn := bi.PCToFunc(pc)
if fn == nil {
return "", 0, nil
}
f, ln := fn.cu.lineInfo.PCToLine(fn.Entry, pc)
f, ln := bi.pcToLine(fn, pc)
return f, ln, fn
}
@ -810,6 +848,8 @@ type Image struct {
debugAddr *godwarf.DebugAddrSection
debugLineStr []byte
symTable *gosym.Table
typeCache map[dwarf.Offset]godwarf.Type
compileUnits []*compileUnit // compileUnits is sorted by increasing DWARF offset
@ -835,6 +875,10 @@ func (image *Image) registerRuntimeTypeToDIE(entry *dwarf.Entry, ardr *reader.Re
}
}
func (image *Image) Stripped() bool {
return image.dwarf == nil
}
// AddImage adds the specified image to bi, loading data asynchronously.
// Addr is the relocated entry point for the executable and staticBase (i.e.
// the relocation offset) for all other images.
@ -1405,7 +1449,22 @@ func loadBinaryInfoElf(bi *BinaryInfo, image *Image, path string, addr uint64, w
var serr error
sepFile, dwarfFile, serr = bi.openSeparateDebugInfo(image, elfFile, bi.DebugInfoDirectories)
if serr != nil {
return serr
fmt.Fprintln(os.Stderr, "Warning: no debug info found, some functionality will be missing such as stack traces and variable evaluation.")
symTable, err := readPcLnTableElf(elfFile, path)
if err != nil {
return fmt.Errorf("could not create symbol table from %s ", path)
}
image.symTable = symTable
for _, f := range image.symTable.Funcs {
cu := &compileUnit{}
cu.image = image
fn := Function{Name: f.Name, Entry: f.Entry, End: f.End, cu: cu}
bi.Functions = append(bi.Functions, fn)
}
for f := range image.symTable.Files {
bi.Sources = append(bi.Sources, f)
}
return nil
}
image.sepDebugCloser = sepFile
image.dwarf, err = dwarfFile.DWARF()

@ -554,7 +554,7 @@ func (t *Target) setEBPFTracepointOnFunc(fn *Function, goidOffset int64) error {
if t.BinInfo().Producer() != "" && goversion.ProducerAfterOrEqual(t.BinInfo().Producer(), 1, 15) {
variablesFlags |= reader.VariablesTrustDeclLine
}
_, l, _ := t.BinInfo().PCToLine(fn.Entry)
_, l := t.BinInfo().EntryLineForFunc(fn)
var args []ebpf.UProbeArgMap
varEntries := reader.Variables(dwarfTree, fn.Entry, l, variablesFlags)

@ -1053,7 +1053,7 @@ func readStackVariable(t *Target, thread Thread, regs Registers, off uint64, typ
func fakeFunctionEntryScope(scope *EvalScope, fn *Function, cfa int64, sp uint64) error {
scope.PC = fn.Entry
scope.Fn = fn
scope.File, scope.Line, _ = scope.BinInfo.PCToLine(fn.Entry)
scope.File, scope.Line = scope.BinInfo.EntryLineForFunc(fn)
scope.Regs.CFA = cfa
scope.Regs.Reg(scope.Regs.SPRegNum).Uint64Val = sp

@ -12,6 +12,9 @@ func (gcache *goroutineCache) init(bi *BinaryInfo) {
exeimage := bi.Images[0]
rdr := exeimage.DwarfReader()
if rdr == nil {
return
}
gcache.allglenAddr, _ = rdr.AddrFor("runtime.allglen", exeimage.StaticBase, bi.Arch.PtrSize())

75
pkg/proc/pclntab.go Normal file

@ -0,0 +1,75 @@
package proc
import (
"bytes"
"debug/buildinfo"
"debug/elf"
"debug/gosym"
"encoding/binary"
"fmt"
"strings"
)
// From go/src/debug/gosym/pclntab.go
const (
go12magic = 0xfffffffb
go116magic = 0xfffffffa
go118magic = 0xfffffff0
go120magic = 0xfffffff1
)
// Select the magic number based on the Go version
func magicNumber(goVersion string) []byte {
bs := make([]byte, 4)
var magic uint32
if strings.Compare(goVersion, "go1.20") >= 0 {
magic = go120magic
} else if strings.Compare(goVersion, "go1.18") >= 0 {
magic = go118magic
} else if strings.Compare(goVersion, "go1.16") >= 0 {
magic = go116magic
} else {
magic = go12magic
}
binary.LittleEndian.PutUint32(bs, magic)
return bs
}
func readPcLnTableElf(exe *elf.File, path string) (*gosym.Table, error) {
// Default section label is .gopclntab
sectionLabel := ".gopclntab"
section := exe.Section(sectionLabel)
if section == nil {
// binary may be built with -pie
sectionLabel = ".data.rel.ro"
section = exe.Section(sectionLabel)
if section == nil {
return nil, fmt.Errorf("could not read section .gopclntab")
}
}
tableData, err := section.Data()
if err != nil {
return nil, fmt.Errorf("found section but could not read .gopclntab")
}
bi, err := buildinfo.ReadFile(path)
if err != nil {
return nil, err
}
// Find .gopclntab by magic number even if there is no section label
magic := magicNumber(bi.GoVersion)
pclntabIndex := bytes.Index(tableData, magic)
if pclntabIndex < 0 {
return nil, fmt.Errorf("could not find magic number in %s ", path)
}
tableData = tableData[pclntabIndex:]
addr := exe.Section(".text").Addr
lineTable := gosym.NewLineTable(tableData, addr)
symTable, err := gosym.NewTable([]byte{}, lineTable)
if err != nil {
return nil, fmt.Errorf("could not create symbol table from %s ", path)
}
return symTable, nil
}

@ -3165,69 +3165,18 @@ func TestShadowedFlag(t *testing.T) {
})
}
func TestAttachStripped(t *testing.T) {
if testBackend == "lldb" && runtime.GOOS == "linux" {
bs, _ := ioutil.ReadFile("/proc/sys/kernel/yama/ptrace_scope")
if bs == nil || strings.TrimSpace(string(bs)) != "0" {
t.Logf("can not run TestAttachStripped: %v\n", bs)
return
}
}
if testBackend == "rr" {
return
}
if runtime.GOOS == "darwin" {
t.Log("-s does not produce stripped executables on macOS")
return
}
if buildMode != "" {
t.Skip("not enabled with buildmode=PIE")
}
fixture := protest.BuildFixture("testnextnethttp", protest.LinkStrip)
cmd := exec.Command(fixture.Path)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
assertNoError(cmd.Start(), t, "starting fixture")
// wait for testnextnethttp to start listening
t0 := time.Now()
for {
conn, err := net.Dial("tcp", "127.0.0.1:9191")
if err == nil {
conn.Close()
break
}
time.Sleep(50 * time.Millisecond)
if time.Since(t0) > 10*time.Second {
t.Fatal("fixture did not start")
}
}
var p *proc.TargetGroup
var err error
switch testBackend {
case "native":
p, err = native.Attach(cmd.Process.Pid, []string{})
case "lldb":
path := ""
if runtime.GOOS == "darwin" {
path = fixture.Path
}
p, err = gdbserial.LLDBAttach(cmd.Process.Pid, path, []string{})
default:
t.Fatalf("unknown backend %q", testBackend)
}
t.Logf("error is %v", err)
if err == nil {
p.Detach(true)
t.Fatalf("expected error after attach, got nothing")
} else {
cmd.Process.Kill()
}
os.Remove(fixture.Path)
func TestDebugStripped(t *testing.T) {
// Currently only implemented for Linux ELF executables.
// TODO(derekparker): Add support for Mach-O and PE.
skipUnlessOn(t, "linux only", "linux")
withTestProcessArgs("testnextprog", t, "", []string{}, protest.LinkStrip, func(p *proc.Target, grp *proc.TargetGroup, f protest.Fixture) {
setFunctionBreakpoint(p, t, "main.main")
assertNoError(grp.Continue(), t, "Continue")
assertCurrentLocationFunction(p, t, "main.main")
assertLineNumber(p, t, 37, "first continue")
assertNoError(grp.Next(), t, "Next")
assertLineNumber(p, t, 38, "after next")
})
}
func TestIssue844(t *testing.T) {

@ -253,6 +253,9 @@ func (it *stackIterator) Err() error {
// frameBase calculates the frame base pseudo-register for DWARF for fn and
// the current frame.
func (it *stackIterator) frameBase(fn *Function) int64 {
if fn.cu.image.Stripped() {
return 0
}
dwarfTree, err := fn.cu.image.getDwarfTree(fn.offset)
if err != nil {
return 0
@ -695,6 +698,6 @@ func (d *Defer) DeferredFunc(p *Target) (file string, line int, fn *Function) {
if fn == nil {
return "", 0, nil
}
file, line = fn.cu.lineInfo.PCToLine(fn.Entry, fn.Entry)
file, line = bi.EntryLineForFunc(fn)
return file, line, fn
}

@ -392,7 +392,7 @@ func stepInstructionOut(dbp *Target, curthread Thread, fnname1, fnname2 string)
}
loc, err := curthread.Location()
var locFnName string
if loc.Fn != nil {
if loc.Fn != nil && !loc.Fn.cu.image.Stripped() {
locFnName = loc.Fn.Name
// Calls to runtime.Breakpoint are inlined in some versions of Go when
// inlining is enabled. Here we attempt to resolve any inlining.
@ -677,7 +677,7 @@ func next(dbp *Target, stepInto, inlinedStepOut bool) error {
}
}
if !backward {
if !backward && !topframe.Current.Fn.cu.image.Stripped() {
_, err = setDeferBreakpoint(dbp, text, topframe, sameGCond, stepInto)
if err != nil {
return err
@ -685,7 +685,7 @@ func next(dbp *Target, stepInto, inlinedStepOut bool) error {
}
// Add breakpoints on all the lines in the current function
pcs, err := topframe.Current.Fn.cu.lineInfo.AllPCsBetween(topframe.Current.Fn.Entry, topframe.Current.Fn.End-1, topframe.Current.File, topframe.Current.Line)
pcs, err := topframe.Current.Fn.AllPCs(topframe.Current.File, topframe.Current.Line)
if err != nil {
return err
}
@ -865,6 +865,11 @@ func FindDeferReturnCalls(text []AsmInstruction) []uint64 {
// If includeCurrentFn is true it will also remove all instructions
// belonging to the current function.
func removeInlinedCalls(pcs []uint64, topframe Stackframe) ([]uint64, error) {
// TODO(derekparker) it should be possible to still use some internal
// runtime information to do this.
if topframe.Call.Fn == nil || topframe.Call.Fn.cu.image.Stripped() {
return pcs, nil
}
dwarfTree, err := topframe.Call.Fn.cu.image.getDwarfTree(topframe.Call.Fn.offset)
if err != nil {
return pcs, err
@ -1061,7 +1066,7 @@ func skipAutogeneratedWrappersOut(g *G, thread Thread, startTopframe, startRetfr
if frame.Current.Fn == nil {
return
}
file, line := frame.Current.Fn.cu.lineInfo.PCToLine(frame.Current.Fn.Entry, frame.Current.Fn.Entry)
file, line := g.Thread.BinInfo().EntryLineForFunc(frame.Current.Fn)
if !isAutogeneratedOrDeferReturn(Location{File: file, Line: line, Fn: frame.Current.Fn}) {
return &frames[i-1], &frames[i]
}

@ -535,7 +535,7 @@ func (g *G) StartLoc(tgt *Target) Location {
if fn == nil {
return Location{PC: g.StartPC}
}
f, l := fn.cu.lineInfo.PCToLine(fn.Entry, fn.Entry)
f, l := tgt.BinInfo().EntryLineForFunc(fn)
return Location{PC: fn.Entry, File: f, Line: l, Fn: fn}
}

@ -1315,6 +1315,13 @@ func (d *Debugger) collectBreakpointInformation(apiThread *api.Thread, thread pr
tgt := d.target.TargetForThread(thread.ThreadID())
// If we're dealing with a stripped binary don't attempt to load more
// information, we won't be able to.
img := tgt.BinInfo().PCToImage(bp.Addr)
if img != nil && img.Stripped() {
return nil
}
if bp.Goroutine {
g, err := proc.GetG(thread)
if err != nil {