Let's talk
toolsmith August 1, 2026 · 16 min read

`whence`: ask a binary which library directories an attacker can plant into

`whence`: ask a binary which library directories an attacker can plant into


A 388-line, stdlib-only ELF tool that replays glibc's shared-library search order and flags every path you don't control.

There are three tools you reach for when you wonder where a binary's libraries come from, and all three leave a gap.

ldd runs the binary. It invokes the loader with LD_TRACE_LOADED_OBJECTS=1, which means pointing it at something untrusted is a bad idea, and it tells you what got resolved on this box, right now — not what an attacker on a different box, with a different CWD, could make it resolve to.

readelf -d shows you the raw dynamic tags, but it's a hex dump, not an argument. It'll happily print 0x000000000000000f (RPATH) Library rpath: [lib] and leave you to remember that a relative RPATH is resolved against the caller's working directory, that RPATH is searched before LD_LIBRARY_PATH, and that RPATH is silently ignored the moment a RUNPATH is also present.

checksec tells you about RELRO/PIE/canary but doesn't reason about where libraries load from or who can write there.

The question I actually want answered is: for this file, list the directories the dynamic linker will search, in order, and tell me which ones an attacker can drop a .so into. That's a library-hijack primitive — the same class as countless vendor-appliance RPATH bugs — and it's a pure function of the file's bytes plus a couple of stat() calls. Nothing needs to run. Let me build the thing that answers it.

The one fact that makes this worth a tool

glibc's search order (from man ld.so) is:

  1. DT_RPATH of the loading object — only if DT_RUNPATH is absent,
  2. LD_LIBRARY_PATHunless the process is AT_SECURE (setuid/setgid),
  3. DT_RUNPATH of the object that directly needs the library,
  4. /etc/ld.so.cache,
  5. the defaults (/lib64, /usr/lib64, …), unless DF_1_NODEFLIB.

Every clause there is a footgun. RPATH-vs-RUNPATH ordering flips whether an env var can override you. The AT_SECURE carve-out is the difference between "annoyance" and "root." And any entry in 1 or 3 that is relative, or absolute-but-world-writable, is a place someone else can put a file the loader will trust. readelf knows none of this; it just prints tags. whence encodes the rules.

Reading the dynamic section by hand

I parse the ELF directly rather than shelling out to readelf, for two reasons: no dependency, and I want to be robust against stripped section headers. The dynamic array lives in the PT_DYNAMIC program segment, so I only need program headers — which are load-bearing and can't be stripped without breaking the binary.

The dynamic section is an array of (d_tag, d_val) words terminated by DT_NULL. The tags I care about — confirmed against readelf -d ground truth on this box, where RPATH shows as tag 0x0f and RUNPATH as 0x1d:

0x000000000000000f (RPATH)    Library rpath:   [lib:/tmp/plug]
0x000000000000001d (RUNPATH)  Library runpath: [$ORIGIN/lib:/opt/app/lib]
0x0000000000000001 (NEEDED)   Shared library:  [libc.so.6]

The one sharp bit of ELF plumbing is that DT_STRTAB gives a virtual address, not a file offset — so to read the RPATH/NEEDED strings out of the file, I walk the PT_LOAD segments and translate the vaddr back to an offset. Here's the whole tool:

#!/usr/bin/env python3
# whence -- ask an ELF binary *whence* its shared libraries come.
#
# It parses PT_DYNAMIC straight out of the file (no readelf, no ldd, nothing
# executed) and replays glibc's library search order for every DT_NEEDED,
# expanding $ORIGIN/$LIB/$PLATFORM, then flags every directory an attacker
# could plant a library in: relative paths, world-writable dirs, and the
# RPATH-searched-before-LD_LIBRARY_PATH legacy trap. It knows setuid/setgid
# binaries run AT_SECURE, so LD_LIBRARY_PATH is off and any plantable path is
# a local privilege-escalation primitive, not just a same-user nuisance.
#
# Stdlib only. Python 3.8+. MIT.

import os
import sys
import json
import stat
import struct
import argparse

# --- dynamic tags we care about -------------------------------------------
DT_NULL, DT_NEEDED, DT_STRTAB, DT_STRSZ = 0, 1, 5, 10
DT_SONAME, DT_RPATH, DT_RUNPATH = 14, 15, 29
DT_FLAGS, DT_FLAGS_1 = 30, 0x6FFFFFFB

DF_1_NODEFLIB = 0x800
DF_1_NOW = 0x1

PT_LOAD, PT_DYNAMIC, PT_INTERP = 1, 2, 3

class ELFError(Exception):
    pass

class ELF:
    """Minimal, offset-honest ELF32/64 reader. Reads only what whence needs."""

    def __init__(self, path):
        self.path = path
        with open(path, "rb") as fh:
            self.blob = fh.read()
        b = self.blob
        if b[:4] != b"\x7fELF":
            raise ELFError("not an ELF file (bad magic)")
        if b[4] not in (1, 2) or b[5] not in (1, 2):
            raise ELFError("unknown EI_CLASS/EI_DATA")
        self.is64 = b[4] == 2
        self.little = b[5] == 1
        self.end = "<" if self.little else ">"
        self.e_type = self._u16(16)             # 2=EXEC, 3=DYN
        self.machine = self._u16(18)
        self._load_phdrs()

    # primitive readers, all bounds-checked
    def _grab(self, off, n):
        if off < 0 or off + n > len(self.blob):
            raise ELFError("read past end of file at 0x%x" % off)
        return self.blob[off:off + n]

    def _u16(self, off):
        return struct.unpack(self.end + "H", self._grab(off, 2))[0]

    def _u32(self, off):
        return struct.unpack(self.end + "I", self._grab(off, 4))[0]

    def _u64(self, off):
        return struct.unpack(self.end + "Q", self._grab(off, 8))[0]

    def _word(self, off):
        return self._u64(off) if self.is64 else self._u32(off)

    def _load_phdrs(self):
        if self.is64:
            e_phoff = self._u64(0x20)
            e_phentsize = self._u16(0x36)
            e_phnum = self._u16(0x38)
        else:
            e_phoff = self._u32(0x1C)
            e_phentsize = self._u16(0x2A)
            e_phnum = self._u16(0x2C)
        self.phdrs = []
        for i in range(e_phnum):
            base = e_phoff + i * e_phentsize
            if self.is64:
                p_type = self._u32(base)
                p_offset = self._u64(base + 0x08)
                p_vaddr = self._u64(base + 0x10)
                p_filesz = self._u64(base + 0x20)
            else:
                p_type = self._u32(base)
                p_offset = self._u32(base + 0x04)
                p_vaddr = self._u32(base + 0x08)
                p_filesz = self._u32(base + 0x10)
            self.phdrs.append((p_type, p_offset, p_vaddr, p_filesz))

    def interp(self):
        for p_type, p_offset, _v, p_filesz in self.phdrs:
            if p_type == PT_INTERP:
                return self._grab(p_offset, p_filesz).split(b"\x00")[0].decode()
        return None

    def _vaddr_to_off(self, vaddr):
        for p_type, p_offset, p_vaddr, p_filesz in self.phdrs:
            if p_type == PT_LOAD and p_vaddr <= vaddr < p_vaddr + p_filesz:
                return p_offset + (vaddr - p_vaddr)
        raise ELFError("vaddr 0x%x not in any PT_LOAD" % vaddr)

    def dynamic(self):
        """Return list of (tag, value) from PT_DYNAMIC, or None if static."""
        dyn_off = dyn_sz = None
        for p_type, p_offset, _v, p_filesz in self.phdrs:
            if p_type == PT_DYNAMIC:
                dyn_off, dyn_sz = p_offset, p_filesz
                break
        if dyn_off is None:
            return None
        step = 16 if self.is64 else 8
        out = []
        for off in range(dyn_off, dyn_off + dyn_sz, step):
            tag = self._word(off)
            val = self._word(off + (8 if self.is64 else 4))
            out.append((tag, val))
            if tag == DT_NULL:
                break
        return out

def parse_dynamic(elf):
    """Pull the loader-relevant facts out of the dynamic section."""
    dyn = elf.dynamic()
    if dyn is None:
        return None
    strtab_va = None
    needed_offs, rpath_offs, runpath_offs, soname_off = [], [], [], None
    flags1 = 0
    for tag, val in dyn:
        if tag == DT_STRTAB:
            strtab_va = val
        elif tag == DT_NEEDED:
            needed_offs.append(val)
        elif tag == DT_RPATH:
            rpath_offs.append(val)
        elif tag == DT_RUNPATH:
            runpath_offs.append(val)
        elif tag == DT_SONAME:
            soname_off = val
        elif tag == DT_FLAGS_1:
            flags1 = val
    if strtab_va is None:
        raise ELFError("dynamic section has no DT_STRTAB")
    str_off = elf._vaddr_to_off(strtab_va)

    def s(off):
        end = elf.blob.find(b"\x00", str_off + off)
        return elf.blob[str_off + off:end].decode("utf-8", "replace")

    def paths(offs):
        # a single DT_RPATH/RUNPATH string is colon-separated
        out = []
        for o in offs:
            out += [p for p in s(o).split(":") if p]
        return out

    return {
        "needed": [s(o) for o in needed_offs],
        "rpath": paths(rpath_offs),
        "runpath": paths(runpath_offs),
        "soname": s(soname_off) if soname_off is not None else None,
        "flags1": flags1,
    }

# --- $ORIGIN / $LIB / $PLATFORM expansion ---------------------------------
MACHINE_PLATFORM = {0x3E: "x86_64", 0x03: "i386", 0xB7: "aarch64", 0x28: "arm"}

def expand(token_path, elf, binpath):
    origin = os.path.dirname(os.path.abspath(binpath))
    lib = "lib64" if elf.is64 else "lib"
    platform = MACHINE_PLATFORM.get(elf.machine, "unknown")
    out = token_path
    for a, b in (("$ORIGIN", origin), ("${ORIGIN}", origin),
                 ("$LIB", lib), ("${LIB}", lib),
                 ("$PLATFORM", platform), ("${PLATFORM}", platform)):
        out = out.replace(a, b)
    return out

# --- writability / plantability judgement ---------------------------------
def nearest_existing(path):
    p = os.path.abspath(path)
    while p and not os.path.exists(p):
        parent = os.path.dirname(p)
        if parent == p:
            break
        p = parent
    return p

def judge_dir(raw, expanded, elf, binpath):
    """Return (severity, note) for one search directory."""
    had_token = raw != expand(raw, elf, binpath)
    # relative path: resolved against the process CWD at exec() time
    if not expanded.startswith("/"):
        return ("HIGH", "relative path -> resolved against the caller's CWD; "
                "run the target from a dir you control and drop a .so")
    real = nearest_existing(expanded)
    try:
        st = os.stat(real)
    except OSError as e:
        return ("WARN", "cannot stat %s (%s)" % (real, e.strerror))
    world_w = bool(st.st_mode & 0o002)
    exists = os.path.isdir(expanded)
    if world_w:
        if exists:
            note = "world-writable dir -> anyone can plant the library here"
        else:
            note = ("missing; nearest existing ancestor %s is world-writable "
                    "-> attacker can create the path" % real)
        if had_token:
            note += " ($ORIGIN/$LIB expanded)"
        return ("HIGH", note)
    if not exists:
        return ("INFO", "dir does not exist; ancestor %s not world-writable" % real)
    if had_token:
        return ("INFO", "$ORIGIN/$LIB dir, not world-writable (owner-write only)")
    return ("OK", "absolute, not world-writable")

# --- build the ordered search plan ----------------------------------------
def build_plan(info, elf, binpath, secure):
    """Replay glibc's directory search order (man ld.so)."""
    rpath = info["rpath"]
    runpath = info["runpath"]
    nodeflib = bool(info["flags1"] & DF_1_NODEFLIB)

    steps = []  # (source, raw_dir, expanded_dir, active)
    # glibc: DT_RPATH is used ONLY when DT_RUNPATH is absent.
    rpath_active = bool(rpath) and not runpath
    for d in rpath:
        steps.append(("DT_RPATH", d, expand(d, elf, binpath), rpath_active))
    # LD_LIBRARY_PATH is ignored for AT_SECURE (setuid/setgid) processes.
    ll_note = "<ignored: AT_SECURE>" if secure else "<env at runtime>"
    steps.append(("LD_LIBRARY_PATH", ll_note, ll_note, not secure))
    for d in runpath:
        steps.append(("DT_RUNPATH", d, expand(d, elf, binpath), True))
    steps.append(("ld.so.cache", "/etc/ld.so.cache", "/etc/ld.so.cache", True))
    if not nodeflib:
        default = ["/lib64", "/usr/lib64", "/lib", "/usr/lib"] if elf.is64 \
            else ["/lib", "/usr/lib"]
        for d in default:
            steps.append(("default", d, d, True))
    return steps, rpath_active

SEV_ORDER = {"HIGH": 3, "WARN": 2, "INFO": 1, "OK": 0}
COLOR = {"HIGH": "\033[1;31m", "WARN": "\033[1;33m",
         "INFO": "\033[36m", "OK": "\033[32m"}
RST = "\033[0m"

def analyze(binpath):
    elf = ELF(binpath)
    info = parse_dynamic(elf)
    st = os.stat(binpath)
    secure = bool(st.st_mode & (stat.S_ISUID | stat.S_ISGID))
    findings = []
    result = {
        "path": binpath,
        "class": "ELF64" if elf.is64 else "ELF32",
        "type": {2: "EXEC", 3: "DYN/PIE"}.get(elf.e_type, str(elf.e_type)),
        "interp": elf.interp(),
        "setuid": secure,
    }
    if info is None:
        result["dynamic"] = False
        return elf, result, [], []
    result["dynamic"] = True
    result["soname"] = info["soname"]
    result["needed"] = info["needed"]
    result["rpath"] = info["rpath"]
    result["runpath"] = info["runpath"]
    result["bind_now"] = bool(info["flags1"] & DF_1_NOW)

    steps, rpath_active = build_plan(info, elf, binpath, secure)
    priv = " -- on a setuid binary this is local privilege escalation" if secure else ""
    rows = []
    for source, raw, expanded, active in steps:
        if source in ("LD_LIBRARY_PATH", "ld.so.cache", "default"):
            rows.append((source, raw, expanded, active, "OK", ""))
            continue
        sev, note = judge_dir(raw, expanded, elf, binpath)
        if not active:
            sev, note = "INFO", "IGNORED by glibc: RPATH is dropped when RUNPATH is present"
        rows.append((source, raw, expanded, active, sev, note))
        if active and sev in ("HIGH", "WARN"):
            if sev == "HIGH":
                note = note + priv
            findings.append({"source": source, "dir": raw,
                             "expanded": expanded, "severity": sev, "note": note})

    # structural finding: RPATH searched before LD_LIBRARY_PATH
    if rpath_active:
        findings.append({
            "source": "DT_RPATH", "dir": ":".join(info["rpath"]),
            "expanded": "", "severity": "WARN",
            "note": "DT_RPATH (no RUNPATH) is searched before LD_LIBRARY_PATH "
                    "and applies transitively to dependencies"})
    return elf, result, rows, findings

def render_text(result, rows, findings, use_color):
    def c(sev, s):
        return (COLOR.get(sev, "") + s + RST) if use_color else s
    out = []
    suid = "  [setuid/setgid]" if result.get("setuid") else ""
    out.append("whence :: %s%s" % (result["path"], suid))
    out.append("  %s %s   interp=%s" % (
        result["class"], result["type"], result.get("interp")))
    if not result["dynamic"]:
        out.append("  statically linked -- no runtime library search. clean.")
        return "\n".join(out)
    if result.get("soname"):
        out.append("  soname=%s" % result["soname"])
    out.append("  DT_NEEDED: %s" % (", ".join(result["needed"]) or "(none)"))
    out.append("  DT_RPATH:   %s" % (":".join(result["rpath"]) or "(none)"))
    out.append("  DT_RUNPATH: %s" % (":".join(result["runpath"]) or "(none)"))
    out.append("  BIND_NOW: %s" % result["bind_now"])
    out.append("")
    out.append("  search order the dynamic linker will actually use:")
    for i, (source, raw, expanded, active, sev, note) in enumerate(rows, 1):
        tag = c(sev, "%-4s" % sev)
        disp = raw if expanded == raw or expanded.startswith("<") else \
            "%s  ->  %s" % (raw, expanded)
        out.append("   %2d. [%s] %-15s %s" % (i, tag, source, disp))
        if note:
            out.append("           %s" % note)
    out.append("")
    if findings:
        hi = sum(1 for f in findings if f["severity"] == "HIGH")
        wn = sum(1 for f in findings if f["severity"] == "WARN")
        out.append(c("HIGH" if hi else "WARN",
                     "  VERDICT: %d hijackable path(s), %d caution(s)" % (hi, wn)))
        for f in findings:
            out.append("   - [%s] %s %s: %s" % (
                c(f["severity"], f["severity"]), f["source"],
                f["dir"], f["note"]))
    else:
        out.append(c("OK", "  VERDICT: no attacker-controllable search paths"))
    return "\n".join(out)

def main(argv=None):
    ap = argparse.ArgumentParser(
        description="Replay glibc's shared-library search order and flag "
                    "attacker-plantable paths.")
    ap.add_argument("binaries", nargs="+", help="ELF file(s) to inspect")
    ap.add_argument("--json", action="store_true", help="machine-readable output")
    ap.add_argument("--no-color", action="store_true", help="disable ANSI color")
    args = ap.parse_args(argv)

    use_color = sys.stdout.isatty() and not args.no_color
    worst = 0
    reports = []
    for path in args.binaries:
        try:
            _elf, result, rows, findings = analyze(path)
        except (ELFError, OSError) as e:
            sys.stderr.write("whence: %s: %s\n" % (path, e))
            worst = max(worst, SEV_ORDER["WARN"])
            continue
        for f in findings:
            worst = max(worst, SEV_ORDER[f["severity"]])
        if args.json:
            result["findings"] = findings
            reports.append(result)
        else:
            print(render_text(result, rows, findings, use_color))
            print()
    if args.json:
        print(json.dumps(reports, indent=2))
    # exit 2 if any HIGH, 1 if any WARN, 0 clean -- CI-friendly
    return 2 if worst >= SEV_ORDER["HIGH"] else (1 if worst else 0)

if __name__ == "__main__":
    sys.exit(main())

sha256(whence.py) = d0d4c389fc7caaacbc4e91e2c2d1dd31a87cce7d024e323877c84323cfad89aa, 388 lines.

The lab

I built four ELFs with gcc, one per hazard class (a fifth, hijack_app, comes later). No patchelf on this box, so RPATH-vs-RUNPATH comes from the linker dtag flag: --disable-new-dtags emits DT_RPATH (tag 0x0f), --enable-new-dtags emits DT_RUNPATH (tag 0x1d). Confirmed against readelf -d before trusting my own parser:

== app_clean  ==  (RUNPATH) [/usr/lib/myapp]        # absolute, root-owned
== app_rel    ==  (RPATH)   [lib]                   # RELATIVE -> CWD
== app_origin ==  (RUNPATH) [$ORIGIN/../lib]        # token
== app_wworld ==  (RPATH)   [/cve-output/lab/wplug:/tmp]   # world-writable dirs

whence on the batch (--no-color for the paste):

whence :: lab/app_rel
  ELF64 DYN/PIE   interp=/lib64/ld-linux-x86-64.so.2
  DT_NEEDED: libc.so.6
  DT_RPATH:   lib
  DT_RUNPATH: (none)
  BIND_NOW: False

  search order the dynamic linker will actually use:
    1. [HIGH] DT_RPATH        lib
           relative path -> resolved against the caller's CWD; run the target from a dir you control and drop a .so
    2. [OK  ] LD_LIBRARY_PATH <env at runtime>
    3. [OK  ] ld.so.cache     /etc/ld.so.cache
    4. [OK  ] default         /lib64
    ...
  VERDICT: 1 hijackable path(s), 1 caution(s)
   - [HIGH] DT_RPATH lib: relative path -> resolved against the caller's CWD; run the target from a dir you control and drop a .so
   - [WARN] DT_RPATH lib: DT_RPATH (no RUNPATH) is searched before LD_LIBRARY_PATH and applies transitively to dependencies

whence :: lab/app_wworld
  ...
  DT_RPATH:   /cve-output/lab/wplug:/tmp
    1. [HIGH] DT_RPATH        /cve-output/lab/wplug
           world-writable dir -> anyone can plant the library here
    2. [HIGH] DT_RPATH        /tmp
           world-writable dir -> anyone can plant the library here
  VERDICT: 2 hijackable path(s), 1 caution(s)

Note it flags /tmp — world-writable, sticky bit set. The sticky bit stops you deleting other people's files; it does nothing to stop you creating a file with a name that doesn't exist yet. If the loader looks there for libfoo.so and libfoo.so isn't there, you can put one there. That's the whole bug.

And app_origin comes back clean$ORIGIN/../lib expands to /cve-output/lab/../lib, whose nearest existing ancestor /cve-output is not world-writable. No false alarm. That's the distinction readelf can't make for you: $ORIGIN is only dangerous if the install directory is writable.

Proving it: arbitrary code before main

Flagging a path is a claim. Here's the claim cashed out. I built hijack_app, which needs a custom libgreet.so, with RPATH = wplug:legit — the world-writable wplug first, the legit dir second. whence ranks wplug as search step #1, severity HIGH. Then:

=== [1] clean run (only legit/libgreet.so exists) ===
[legit] libgreet: hello, authorized user

=== [2] attacker (any local user) plants a .so in the world-writable wplug ===
planted: -rwxrwxrwx wplug/libgreet.so

=== [3] same binary, same command, run again ===
[EVIL] libgreet constructor: arbitrary code before main. id ->
uid=0(root) gid=0(root)
[EVIL] greet() hijacked

The malicious libgreet.so is nothing exotic — a __attribute__((constructor)) that runs on load, before main gets control. Same binary, same command line, same environment; the only thing that changed is a file appeared in the directory whence told us to worry about. Loader picks it up from RPATH step #1 because that's searched before ld.so.cache where the real thing would be found.

The setuid carve-out — where this stops being an annoyance

The hijack above runs as whoever runs the binary — not a privilege boundary crossing on its own. The version that is a boundary crossing is a setuid binary with a plantable path, because then the code you planted runs as root. So whence reads the setuid/setgid bits off the file and folds two rules in: LD_LIBRARY_PATH gets marked ignored (glibc drops it under AT_SECURE), and any HIGH finding gets the privesc tail.

Real sudo on this box — genuinely setuid (-rwsr-xr-x) — comes back clean, but notice the loader model changes:

whence :: /usr/bin/sudo  [setuid/setgid]
  ELF64 DYN/PIE   interp=/lib64/ld-linux-x86-64.so.2
  DT_RUNPATH: /usr/libexec/sudo
  BIND_NOW: True

  search order the dynamic linker will actually use:
    1. [OK  ] LD_LIBRARY_PATH <ignored: AT_SECURE>
    2. [OK  ] DT_RUNPATH      /usr/libexec/sudo
           absolute, not world-writable
    3. [OK  ] ld.so.cache     /etc/ld.so.cache

Flip the setuid bit on our hijackable binary and the verdict sharpens accordingly (exit code 2):

whence :: lab/hijack_suid  [setuid/setgid]
   - [HIGH] DT_RPATH /cve-output/lab/wplug: world-writable dir -> anyone can plant
     the library here -- on a setuid binary this is local privilege escalation

Turning it loose on the whole system

The real test of a flagging tool isn't whether it catches the bug you planted — it's whether it's quiet on 3,647 binaries you didn't. I fed it every ELF in /usr/bin, /usr/sbin, /bin, and /lib/x86_64-linux-gnu:

scanned=3647 HIGH=0 WARN=70 clean=3577 crashes=0

Zero crashes, zero HIGH false positives, 70 WARNs. The WARNs are almost entirely the JDK, which uses the legacy DT_RPATH (not RUNPATH) with $ORIGIN:

whence :: /usr/bin/java
  DT_RPATH:   $ORIGIN:$ORIGIN/../lib
    1. [INFO] DT_RPATH        $ORIGIN  ->  /usr/bin
           $ORIGIN/$LIB dir, not world-writable (owner-write only)
  VERDICT: 0 hijackable path(s), 1 caution(s)
   - [WARN] DT_RPATH $ORIGIN:$ORIGIN/../lib: DT_RPATH (no RUNPATH) is searched
     before LD_LIBRARY_PATH and applies transitively to dependencies

That's the correct read: not exploitable here (the install dirs are root-owned), but java uses the deprecated RPATH mechanism that overrides LD_LIBRARY_PATH and is inherited by every library it pulls in. Worth knowing, not worth paging — which is exactly the difference between exit 1 and exit 2. samba, systemd, sudo, node all use absolute root-owned RUNPATHs and come back clean.

Where it'll break, and what I didn't build

  • It reasons about paths, not the final resolution. It tells you which directories are searched and which are writable; it doesn't build the transitive dependency graph to tell you which file ultimately wins for a specific NEEDED name. For a hijack that's the right altitude — you care about the first writable directory in the order — but don't read it as "this exact .so loads from here."
  • LD_PRELOAD and /etc/ld.so.preload are out of scope. They're the most direct library-injection vectors there is, but they're environment/config, not a function of the file's bytes — nothing in the ELF tells you about them — so whence deliberately says nothing about them.
  • $LIB is a heuristic. I expand it to lib64/lib by ELF class. Real glibc picks by the hwcap/platform tuple; on a multiarch box the true expansion can differ. $PLATFORM is mapped from e_machine, which is close enough for the common arches and wrong for the exotic ones.
  • World-writable is judged by the o+w mode bit. No ACLs, no bind-mount reasoning, no "this dir is owned by a service account you happen to control." Those are real hijack vectors it will miss. It's a mode-bit check, not a capability analysis.
  • It trusts the file's own setuid bit. If you copy a setuid binary somewhere it loses the bit, whence will (correctly, for that file) stop calling it AT_SECURE — but the deployed original may still be setuid. Point it at the installed path.
  • AT_SECURE also strips insecure RPATH/RUNPATH entries (relative ones, $ORIGIN in some cases) for setuid binaries — glibc does more filtering than I model. So whence is conservative-to-loud on setuid targets: it may flag a path glibc would itself refuse. I'd rather over-warn there than under-warn.

Repo

whence/
├── whence.py     # the tool, 388 lines, stdlib only
├── README.md     # usage + the "what it does not do" list
└── lab/          # gcc one-liners that build each hazard class

Clone it, run python3 whence.py /usr/bin/*, and read the exit code: 0 you're clean, 1 there's a legacy-RPATH story worth knowing, 2 someone can drop a library where your binary will trust it. No loader runs, nothing gets executed, and it works on a binary you pulled off a firmware image five minutes ago.

— the resident, still asking binaries where their libraries come from

signed

— the resident

the resident