Özgür Kesim
d800683dce
1. symbolyze.Scanner now implements the RunEvery(time.Duration) method that will call scanner.Run() periodically. It should be called to run in its own goroutine. The loop can be stopped by calling scanner.Stop(). 2. The scanner now collects all errors from all observers in a private error type `errors []error`. It's Error() returns a cumulated list of errors, seperated by a newline.
425 lines
11 KiB
Go
425 lines
11 KiB
Go
package symbolyze
|
|
|
|
import (
|
|
"bufio"
|
|
"debug/elf"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Scanner represents an engine for scanning for a specific symbol in all
|
|
// ELF-files matching a certain pattern. The pattern is described in
|
|
// fileapth.Match().
|
|
//
|
|
// Once a Scanner is created with NewScanner(), it should be populated with
|
|
// Observer functions using OnFound(). Optionally, the scanner can be put into
|
|
// debugging mode by a call to DebugOn() prior to a call to Run().
|
|
//
|
|
// A call to Scanner.Run() then starts the engine and it will scan all pids in
|
|
// /proc. Whenever a match is found, all observers will be called with the
|
|
// (pid, offset), concurrently.
|
|
type Scanner struct {
|
|
rwmutex
|
|
|
|
symbol string
|
|
pathglob string
|
|
cache map[string]uint64 // Contains (pathname, offset)
|
|
observers []Observer // Callbacks
|
|
|
|
// Instead of using a boolean to indicate debugging, we use function
|
|
// members. This way we can populate them with noop-functions in the
|
|
// non-debug case and not polute the code with if-statements.
|
|
debugf func(format string, v ...interface{})
|
|
debugln func(v ...interface{})
|
|
logger // Embedded logger
|
|
|
|
errors errors
|
|
|
|
ticker *time.Ticker // Used to run the scanner repeatedly
|
|
}
|
|
|
|
type errors []error
|
|
|
|
func (e errors) Error() string {
|
|
switch len(e) {
|
|
case 0:
|
|
return "nil"
|
|
case 1:
|
|
return e[0].Error()
|
|
default:
|
|
list := make([]string, len(e))
|
|
for i := range e {
|
|
list[i] = e[i].Error()
|
|
}
|
|
return fmt.Sprintf("multiple errors:\n%s", strings.Join(list, "\n"))
|
|
}
|
|
}
|
|
|
|
// We use a lowercase type alias for *log.Logger so that we can embedd it in
|
|
// Scanner without exporting it.
|
|
type logger = *log.Logger
|
|
type rwmutex = sync.RWMutex
|
|
|
|
var (
|
|
nodebugf = func(format string, v ...interface{}) {}
|
|
nodebugln = func(v ...interface{}) {}
|
|
)
|
|
|
|
// An Observer is a callback that can be registered with Scanner.OnFound. It
|
|
// will be called with a pid and an offset. Observers are called concurrently.
|
|
// They have to be thread-safe.
|
|
type Observer func(pid int, offset uint64) error
|
|
|
|
// NewScanner returns a new Scanner that scans all running processes for the
|
|
// given symbol name in all memory-mapped files matching the given pathglob.
|
|
// To be useful, one or more Observer functions should be registered with
|
|
// Scanner.OnFound(). The scanning starts with a call of Scanner.Run().
|
|
func NewScanner(symbol, pathglob string) *Scanner {
|
|
return &Scanner{
|
|
symbol: symbol,
|
|
pathglob: pathglob,
|
|
cache: map[string]uint64{},
|
|
|
|
logger: log.New(os.Stderr, "[symbolyze] ", log.Ltime|log.Lmicroseconds),
|
|
|
|
// debugging is off per default.
|
|
debugf: nodebugf,
|
|
debugln: nodebugln,
|
|
}
|
|
}
|
|
|
|
// Debug sets the scanner into debugging mode. It must be called only once
|
|
// before a call to Scanner.Run().
|
|
func (S *Scanner) Debug(on bool) {
|
|
S.Lock()
|
|
defer S.Unlock()
|
|
|
|
if on {
|
|
// Use the embedded *log.Logger for debugging.
|
|
S.debugf = S.Printf
|
|
S.debugln = S.Println
|
|
S.debugln("starting in debug-mode")
|
|
} else {
|
|
S.debugf = nodebugf
|
|
S.debugln = nodebugln
|
|
}
|
|
}
|
|
|
|
// setErrorf puts the Scanner into an error state with the given error
|
|
// statement. It also logs the error. setErrorf is not thread-safe.
|
|
func (S *Scanner) setErrorf(format string, a ...interface{}) {
|
|
S.Lock()
|
|
S.errors = append(S.errors, fmt.Errorf(format, a...))
|
|
S.Unlock()
|
|
S.Printf(format, a...)
|
|
}
|
|
|
|
func (S *Scanner) HasErrors() bool {
|
|
S.RLock()
|
|
defer S.RUnlock()
|
|
return len(S.errors) > 0
|
|
}
|
|
|
|
func (S *Scanner) Errors() error {
|
|
S.RLock()
|
|
defer S.RUnlock()
|
|
if len(S.errors) == 0 {
|
|
return nil
|
|
} else {
|
|
e2 := make(errors, len(S.errors))
|
|
copy(e2, S.errors)
|
|
return e2
|
|
}
|
|
}
|
|
|
|
func (S *Scanner) addError(err error) {
|
|
S.Lock()
|
|
defer S.Unlock()
|
|
S.errors = append(S.errors, err)
|
|
}
|
|
|
|
// OnFound puts Observer functions at the end of the internal queue. All
|
|
// Observer functions are called in sequence in their own goroutine whenever
|
|
// the scanner finds the symbol in a running program. That implies that an
|
|
// Observer has to be thread-safe. Errors from the observers will be logged.
|
|
func (S *Scanner) OnFound(fun ...Observer) {
|
|
S.Lock()
|
|
defer S.Unlock()
|
|
|
|
S.observers = append(S.observers, fun...)
|
|
return
|
|
}
|
|
|
|
// Run starts the scanning process. It scans the entries of all /proc/NNN/maps
|
|
// files for pathnames that match the provided path-glob and are executables or
|
|
// shared libraries in ELF format. It searches for the provided symbol in
|
|
// those files and calls the registered Observer functions, concurrently, with
|
|
// the pid and offset of the symbol.
|
|
//
|
|
// Run will return an error if it couldn't read the proc filesystem. Otherwise
|
|
// it will try to continue to loop over all pids, writing potential errors to
|
|
// the console. Errors from the observer functions are logged.
|
|
func (S *Scanner) Run() error {
|
|
if S.HasErrors() {
|
|
return S.Errors()
|
|
}
|
|
|
|
proc, err := os.Open("/proc")
|
|
if err != nil {
|
|
S.setErrorf("Failed to open /proc: %v\n", err)
|
|
return S.Errors()
|
|
}
|
|
|
|
infos, err := proc.Readdir(-1)
|
|
if err != nil {
|
|
S.setErrorf("Failed to read /proc: %v\n", err)
|
|
return S.Errors()
|
|
}
|
|
|
|
proc.Close()
|
|
|
|
var wg sync.WaitGroup // To be able to wait for all the observers to finish
|
|
|
|
for _, pinfo := range infos {
|
|
var pid_s = pinfo.Name()
|
|
|
|
// The entry /proc/NNN/ must be a directory with integer name
|
|
if !pinfo.IsDir() {
|
|
continue
|
|
} else if pid, err := strconv.Atoi(pid_s); err != nil {
|
|
continue
|
|
} else if offset, found := S.searchSymbolInPid(pid); !found {
|
|
continue
|
|
} else {
|
|
// Call the observers with (pid, offset), in the
|
|
// background.
|
|
wg.Add(len(S.observers)) // Wait for this many goroutines
|
|
go func() {
|
|
for n, observer := range S.observers {
|
|
go func() {
|
|
err = observer(pid, offset)
|
|
if err != nil {
|
|
S.addError(fmt.Errorf("S.observer[%d](%d, %d) error: %v", n, pid, offset, err))
|
|
}
|
|
wg.Done()
|
|
}()
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
wg.Wait() // Wait for all observers to finish
|
|
return S.Errors()
|
|
}
|
|
|
|
// RunEvery() starts a scanning process and repeats at the given time step.
|
|
func (S *Scanner) RunEvery(step time.Duration) {
|
|
S.ticker = time.NewTicker(step)
|
|
for {
|
|
select {
|
|
case <-S.ticker.C:
|
|
err := S.Run()
|
|
if err != nil {
|
|
S.Println(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (S *Scanner) Stop() {
|
|
if S.ticker != nil {
|
|
S.debugln("Stopping ticker")
|
|
S.ticker.Stop()
|
|
}
|
|
}
|
|
|
|
// searchSymbolInPid loops over the entries in /proc/<pid>/maps and searches for
|
|
// the symbol in the mapped files.
|
|
//
|
|
// The current implementation makes the following assumptions:
|
|
// 1. The pathname in an entry does not contain spaces.
|
|
// 2. The pathname starts with /.
|
|
// 3. The symbol must be in a region that has permission "rw-p".
|
|
// 4. The symbol is present at most in one mapped file at the same time.
|
|
//
|
|
// It returns the offsets in memory of the running program, if found.
|
|
func (S *Scanner) searchSymbolInPid(pid int) (offset uint64, found bool) {
|
|
|
|
path := filepath.Join("/proc", strconv.Itoa(pid), "maps")
|
|
maps, err := os.Open(path)
|
|
if err != nil {
|
|
S.debugf("%v\n", err)
|
|
return 0, false
|
|
}
|
|
|
|
defer maps.Close()
|
|
|
|
// Read the entries by line
|
|
scanner := bufio.NewScanner(maps)
|
|
for scanner.Scan() {
|
|
|
|
// A line of our interest in the maps file has the following
|
|
// structure, see man proc(5).
|
|
//
|
|
// 0 1 2 3 4 5
|
|
// address perms offset dev inode pathname
|
|
// 7fdd8fece000-7fdd8ff74000 rw-p 00423000 fd:01 14156759 /usr/lib/x86_64-linux-gnu/libpython3.7m.so.1.0
|
|
|
|
// We assume that the pathname contains no spaces so
|
|
// bytes.Fields splits the line excactly into six fields
|
|
fields := strings.Fields(scanner.Text())
|
|
if len(fields) != 6 {
|
|
continue
|
|
}
|
|
|
|
pathname := fields[5]
|
|
if !strings.HasPrefix(pathname, "/") { // Not a pathname
|
|
continue
|
|
}
|
|
|
|
// The filename must match the given pattern
|
|
filename := filepath.Base(pathname)
|
|
ok, err := filepath.Match(S.pathglob, filename)
|
|
if err != nil || !ok {
|
|
continue
|
|
}
|
|
|
|
// The symbol needs to be writable
|
|
if fields[1] != "rw-p" {
|
|
continue
|
|
}
|
|
|
|
// Get the start address of the mapped region in memory
|
|
startAddress, _, err := parseRange(fields[0])
|
|
if err != nil {
|
|
S.Printf("%v\n", err)
|
|
continue
|
|
}
|
|
|
|
// Read the offset in the file that this region is mapping
|
|
fileOffset, err := strconv.ParseUint(fields[2], 16, 64)
|
|
if err != nil {
|
|
S.Printf("fields[2] %#q: %v\n", fields[2], err)
|
|
continue
|
|
}
|
|
|
|
// Finally, find the symbol in the binary. If found,
|
|
// findSymbol returns the offset of the symbol in memory,
|
|
// taking alignment into account.
|
|
memOffset, found := S.findSymbolInELF(pathname)
|
|
if !found {
|
|
continue
|
|
}
|
|
|
|
// Hurray, we've found an entry!
|
|
return startAddress + memOffset - fileOffset, true
|
|
}
|
|
|
|
return 0, false
|
|
}
|
|
|
|
// findSymbolInELF searches for the provided symbol in the given pathname to an
|
|
// ELF-file. If found, it returns the offset of the symbol in the virtual
|
|
// memory according to the fomula:
|
|
//
|
|
// vmOffset = alignedOffset(section) + offsetInSection(symbol)
|
|
//
|
|
// The result will be cached so that subsequent calls to findSymbolInELF with
|
|
// the same pathname can quickly return.
|
|
func (S *Scanner) findSymbolInELF(pathname string) (offset uint64, found bool) {
|
|
|
|
// 0. Return the value from the cache, if found.
|
|
if offset, found = S.cache[pathname]; found {
|
|
return offset, found
|
|
}
|
|
|
|
// 1. Open the file with the ELF-parser
|
|
file, err := elf.Open(pathname)
|
|
if err != nil {
|
|
S.Printf("%v", err)
|
|
return 0, false
|
|
}
|
|
|
|
defer file.Close()
|
|
|
|
// 2. Find the symbol
|
|
symbols, err := file.DynamicSymbols()
|
|
if err != nil {
|
|
S.Printf("%v", err)
|
|
return 0, false
|
|
}
|
|
|
|
var sym *elf.Symbol
|
|
for _, s := range symbols {
|
|
if s.Name == S.symbol {
|
|
S.debugf("Found symbol %#v in %s: %#v\n", sym, pathname, s)
|
|
sym = &s
|
|
break
|
|
}
|
|
}
|
|
if sym == nil {
|
|
S.debugf("symbol %q not found in %s\n", sym, pathname)
|
|
return 0, false
|
|
}
|
|
|
|
// 3. Extract the information about the section
|
|
if len(file.Sections) < int(sym.Section) {
|
|
S.debugf("len(file.Section) < int(sym.Section) for symbol %q in %s\n", sym, pathname)
|
|
return 0, false
|
|
}
|
|
|
|
section := file.Sections[sym.Section]
|
|
if section == nil {
|
|
S.debugf("Section %v not found for ELF-Header %q in %s\n", sym.Section, pathname)
|
|
return 0, false
|
|
}
|
|
|
|
// 4. Calculate the offset of the given section, aligned according to
|
|
// the SectionHeader.Addralign entry.
|
|
mask := section.SectionHeader.Addralign - 1
|
|
alignedSectOff := (section.SectionHeader.Offset + mask) & (^mask)
|
|
|
|
// 5. The location of the symbol in virtual memory is finally:
|
|
vmOffset := alignedSectOff + (sym.Value - section.SectionHeader.Addr)
|
|
|
|
// 6. Store this calculation in our cache so that we don't to touch
|
|
// this file again.
|
|
S.cache[pathname] = vmOffset
|
|
|
|
return vmOffset, true
|
|
}
|
|
|
|
// parseRange is a helper function that parses the first field in a line in
|
|
// /proc/<pid>/maps:
|
|
// 7fdd8fece000-7fdd8ff74000 rw-p ...
|
|
// It returns the start and end addresses of the range and a potential error.
|
|
func parseRange(input string) (start, end uint64, e error) {
|
|
// 7fdd8fece000-7fdd8ff74000
|
|
parts := strings.Split(input, "-")
|
|
|
|
if len(parts) != 2 {
|
|
e = fmt.Errorf("[parseRange] unrecognized format for region: %#q", input)
|
|
return 0, 0, e
|
|
}
|
|
|
|
start, e = strconv.ParseUint(parts[0], 16, 64)
|
|
if e != nil {
|
|
e = fmt.Errorf("[parseRange] couldn't parse start-address %#q in %#q: %w", parts[0], input, e)
|
|
return 0, 0, e
|
|
}
|
|
|
|
end, e = strconv.ParseUint(parts[1], 16, 64)
|
|
if e != nil {
|
|
e = fmt.Errorf("[parseRange] couldn't parse end-address %#q in %#q: %w", parts[1], input, e)
|
|
return 0, 0, e
|
|
}
|
|
|
|
return start, end, e
|
|
}
|