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 ¶
- Variables
- func Symbols(fsys target.RootFS, name string) (defined, undefined []string, err error)
- type DlopenPolicy
- type FileSet
- type Graph
- func (g *Graph) BlockingTaints() []Taint
- func (g *Graph) Canon(name string) string
- func (g *Graph) Classify(files []string) FileSet
- func (g *Graph) CountReachable() int
- func (g *Graph) Node(name string) (*Node, bool)
- func (g *Graph) Nodes() []*Node
- func (g *Graph) Reachable(name string) bool
- func (g *Graph) Roots() []string
- func (g *Graph) Taints() []Taint
- type Info
- type Node
- type Options
- type Reader
- type RootKind
- type SymbolReader
- type Taint
- type TaintKind
Constants ¶
This section is empty.
Variables ¶
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 ¶
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 ¶
Build indexes every ELF object in the tree and resolves the closure rooted at what the image runs.
func (*Graph) BlockingTaints ¶
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 ¶
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 ¶
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 ¶
CountReachable is the size of the closure.
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 (*Info) IsProgram ¶
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 ¶
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 ¶
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 ¶
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.
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" )