elfgraph

package
v0.8.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package elfgraph answers one question about a container image: which shared libraries would the dynamic linker actually load?

It is the OS-package analog of the pclntab test the Go plugin uses, and a weaker one. pclntab is ground truth about what the linker removed from a shipped artifact; a DT_NEEDED closure is ground truth only for an image that is fully dynamic, does not dlopen, and has a known entrypoint. Everything this package does that looks like conservatism -- the taints, the always-rooted plugin directories, the entrypoint escalation -- exists to keep the gap between those two situations visible instead of silently answering "not reachable" for an image the closure cannot actually reason about.

Index

Constants

This section is empty.

Variables

View Source
var ErrNotELF = errors.New("not an ELF file")

ErrNotELF reports that a file is not an ELF object. It is not a failure: most files in an image are not ELF, and callers walking a tree skip it.

Functions

func Symbols

func Symbols(fsys target.RootFS, name string) (defined, undefined []string, err error)

Symbols reports the dynamic symbols an object defines and the ones it expects someone else to define.

Only global and weak symbols are returned. A local symbol cannot satisfy another object's reference, so its presence says nothing about whether a vulnerable function is callable from outside the library that holds it.

This is separate from ReadELF because it is only needed for the mined-symbol validation path, on the handful of libraries one package installs, rather than for every object in an image.

Types

type DlopenPolicy

type DlopenPolicy string

DlopenPolicy decides what a reachable dlopen call does to the closure.

const (
	// DlopenTaint is the default: record it and block not_affected.
	DlopenTaint DlopenPolicy = "taint"

	// DlopenAssumeNone takes the user's word that nothing meaningful is
	// dlopen'd, recording the observation without letting it block.
	DlopenAssumeNone DlopenPolicy = "assume-none"
)

func ParseDlopenPolicy

func ParseDlopenPolicy(s string) (DlopenPolicy, error)

ParseDlopenPolicy validates a --dlopen-policy value.

type FileSet

type FileSet struct {
	// ELF are the object files the package installs.
	ELF []string
	// Reachable are the ones the closure loads.
	Reachable []string
}

FileSet is what Classify found about one package's files.

type Graph

type Graph struct {
	// contains filtered or unexported fields
}

Graph is the resolved shared-library closure of an image.

func Build

func Build(fsys target.RootFS, opts Options) (*Graph, error)

Build indexes every ELF object in the tree and resolves the closure rooted at what the image runs.

func (*Graph) BlockingTaints

func (g *Graph) BlockingTaints() []Taint

BlockingTaints returns the global taints that stop a not_affected conclusion for any package. Scoped ones -- an unresolved soname -- are left out; a caller judging one package asks about the sonames that package installs.

func (*Graph) Canon

func (g *Graph) Canon(name string) string

Canon resolves a tree path through symlinks and returns the tree-absolute path a node would be keyed by.

Callers must use it on any path that came from outside this package. A dpkg file list records /lib/x86_64-linux-gnu/libc.so.6 on a system where /lib is a symlink into /usr; comparing that string against a walked tree finds nothing, and finding nothing reads as "this package ships no code".

func (*Graph) Classify

func (g *Graph) Classify(files []string) FileSet

Classify sorts a package's file list into the ELF objects it owns and the subset of those the closure reaches. Paths that are not ELF -- man pages, configuration, scripts -- are simply absent from both.

func (*Graph) CountReachable

func (g *Graph) CountReachable() int

CountReachable is the size of the closure.

func (*Graph) Node

func (g *Graph) Node(name string) (*Node, bool)

Node returns the node at a path, canonicalizing it first.

func (*Graph) Nodes

func (g *Graph) Nodes() []*Node

Nodes returns every indexed object, in path order.

func (*Graph) Reachable

func (g *Graph) Reachable(name string) bool

Reachable reports whether the dynamic linker would load this file.

func (*Graph) Roots

func (g *Graph) Roots() []string

Roots returns the paths the closure started from, in path order.

func (*Graph) Taints

func (g *Graph) Taints() []Taint

Taints returns everything the closure could not account for.

type Info

type Info struct {
	Class   elf.Class   `json:"class"`
	Machine elf.Machine `json:"machine"`
	Type    elf.Type    `json:"type"`

	// Interp is the PT_INTERP path -- the program interpreter. Empty means the
	// kernel would load this file directly, with no dynamic linker involved.
	Interp string `json:"interp,omitempty"`

	// Dynamic reports whether the file has a PT_DYNAMIC segment.
	Dynamic bool `json:"dynamic"`

	Soname  string   `json:"soname,omitempty"`
	Needed  []string `json:"needed,omitempty"`
	RPath   []string `json:"rpath,omitempty"`
	RunPath []string `json:"runpath,omitempty"`

	// Dlopen reports that this object imports dlopen or dlmopen, so its real
	// dependency set is decided at runtime by strings this package cannot read.
	Dlopen bool `json:"dlopen,omitempty"`
}

Info is what the dynamic linker reads out of one ELF file.

Symbol tables are deliberately absent. An image can hold thousands of ELF objects and glibc alone exports a couple of thousand symbols; holding all of that for a question most scans never ask would cost more memory than the rest of a scan combined. Dlopen is precomputed because the closure needs it for every object, and everything else goes through Symbols on demand.

func ReadELF

func ReadELF(fsys target.RootFS, name string) (*Info, error)

ReadELF is the real Reader, backed by debug/elf.

func (*Info) IsProgram

func (i *Info) IsProgram() bool

IsProgram reports whether this object is something the kernel executes rather than something the loader maps.

The test is PT_INTERP, which a shared library never has and a dynamic executable always does, plus ET_EXEC for the classic static case. It exists because container images keep programs well outside the PATH -- apt's transport methods in /usr/lib/apt/methods, git's helpers in /usr/lib/git-core, anything under /usr/libexec -- and those are exactly the programs that pull in the libraries a PATH-only scan would call dead code.

Static-pie executables are missed: they are ET_DYN with no interpreter, and nothing structural separates them from a plugin module. Including them would mean classifying every dependency-free .so as a program, which is the more expensive mistake -- it would mark a plugin as statically linked and taint every image that ships one.

func (*Info) Static

func (i *Info) Static() bool

Static reports that nothing would be dynamically loaded on this object's behalf.

Only ask this of something you already know is an executable. A shared library also has no PT_INTERP, and there is no reliable way to tell a static-pie executable from a library by inspection alone -- both are ET_DYN with no interpreter. The closure only calls it on roots, which are files it has already concluded would be executed.

type Node

type Node struct {
	// Path is the tree-absolute path with every symlink already resolved, so
	// two names for one file are one node. Callers holding a path from
	// somewhere else -- a dpkg file list, say -- must put it through Canon
	// before looking it up, because dpkg records the pre-usrmerge /lib name for
	// files that now live under /usr/lib.
	Path string `json:"path"`

	Info *Info `json:"info"`

	// Root marks an object the closure starts from, with Why saying which rule
	// put it there and Kind saying how much that rule is worth.
	Root bool     `json:"root,omitempty"`
	Why  string   `json:"why,omitempty"`
	Kind RootKind `json:"root_kind,omitempty"`

	// Reachable is the answer this package exists to produce.
	Reachable bool `json:"reachable"`

	// Needed maps each DT_NEEDED soname to the node that satisfied it, or ""
	// when nothing did.
	Needed map[string]string `json:"needed,omitempty"`

	// NeededBy lists the objects that pulled this one in, which is the
	// explanation a reader wants when a finding says "reachable".
	NeededBy []string `json:"needed_by,omitempty"`
}

Node is one ELF object in the image.

type Options

type Options struct {
	// Config is the image configuration. Entrypoint, Cmd, Env and WorkingDir
	// are all load-bearing: they decide the roots, the LD_LIBRARY_PATH, and how
	// a relative argv[0] resolves.
	Config target.ImageConfig

	// Roots are extra tree-absolute paths to treat as executed, from --roots.
	// This is the escape hatch for an image whose real entrypoint comes from
	// outside the config -- a Kubernetes command override, an init system.
	Roots []string

	// DlopenPolicy decides whether a reachable dlopen blocks conclusions.
	DlopenPolicy DlopenPolicy

	// ReadELF loads ELF metadata. Defaults to the debug/elf-backed reader.
	ReadELF Reader

	Logf func(string, ...any)
}

Options configures a closure build.

type Reader

type Reader func(fsys target.RootFS, name string) (*Info, error)

Reader loads ELF metadata for a tree-absolute path.

It is a function type so the graph algorithm can be tested against a fake filesystem with no ELF files in it at all. Resolution order, class matching, rpath inheritance and taint propagation are the parts that get details wrong; none of them need a real object file to exercise.

type RootKind

type RootKind int

RootKind ranks the reasons an object can be a root, because two of the conclusions this package draws depend on which reason applied.

const (
	// RootPlugin is loaded by name at runtime -- an NSS module, an OpenSSL
	// provider. It is not a program, and asking whether it is "statically
	// linked" is a category error: it has no PT_INTERP because no shared
	// object does.
	RootPlugin RootKind = iota

	// RootEscalated is a program rooted only because the image does not say
	// what it runs.
	RootEscalated

	// RootExplicit is the image's own entrypoint, or a path the user named
	// with --roots. This is the image's actual purpose.
	RootExplicit
)

type SymbolReader

type SymbolReader func(fsys target.RootFS, name string) (defined, undefined []string, err error)

SymbolReader loads an object's dynamic symbol table. Like Reader it is a function type, so that a caller's validation logic can be exercised without real ELF objects to hold the symbols.

type Taint

type Taint struct {
	Kind TaintKind `json:"kind"`

	// Detail is the human-readable statement of what was observed.
	Detail string `json:"detail"`

	// Path is the object that caused it, when there is one.
	Path string `json:"path,omitempty"`

	// Soname scopes an unresolved-needed taint to the library that went
	// missing. Conclusions about every other library are unaffected by it.
	Soname string `json:"soname,omitempty"`

	// Blocking says whether this taint stops a not_affected conclusion.
	// Blocking is a field rather than a property of Kind because
	// --dlopen-policy=assume-none demotes a dlopen taint to a note: the user
	// asserted the risk away, and the record should still show it was there.
	Blocking bool `json:"blocking"`

	// Global says the taint applies to every package rather than to the
	// scope named by Path or Soname.
	Global bool `json:"global,omitempty"`
}

Taint is one recorded reason a not_affected conclusion is unavailable.

func (Taint) String

func (t Taint) String() string

type TaintKind

type TaintKind string

TaintKind names a reason the closure cannot be trusted to be complete.

A taint never sets a status. It blocks the analysis from concluding that something is unaffected, and says in the output why the conclusion was not available. That direction is the whole point: the failure mode worth engineering against is a tool that reports "not reachable" about an image whose reachability it could not actually compute.

const (
	// TaintUnresolvedNeeded is a DT_NEEDED entry that matched no file. The
	// library it names might be the one that holds the vulnerable code, so
	// conclusions about that soname are blocked -- but only that soname.
	TaintUnresolvedNeeded TaintKind = "unresolved-needed"

	// TaintDlopen is a reachable object that imports dlopen or dlmopen. Its
	// real dependency set is chosen at runtime from strings this package
	// cannot read, so the closure is a lower bound on what gets loaded.
	TaintDlopen TaintKind = "dlopen"

	// TaintStaticELF is a reachable executable with no program interpreter.
	// It carries its libraries inside itself, where DT_NEEDED cannot see
	// them, so an unreferenced .so on disk proves nothing about whether the
	// same code is running.
	TaintStaticELF TaintKind = "static-elf"

	// TaintShellEntrypoint is an entrypoint that is a shell or a supervisor
	// rather than the program itself. What it goes on to execute is not
	// knowable from the image, so every executable in the PATH directories is
	// treated as a root.
	TaintShellEntrypoint TaintKind = "shell-entrypoint"

	// TaintNoEntrypoint is an image config with neither Entrypoint nor Cmd,
	// or one naming a file that is not in the image -- or, in rootfs mode, the
	// absence of any config at all. There is nothing to root the closure at,
	// so the same escalation applies.
	TaintNoEntrypoint TaintKind = "no-entrypoint"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL