Repository navigation
v0.20.0 — the answers a dlopen call was withholding
The answers a dlopen call was withholding
vexscan's strongest conclusion is the one it almost never got to make: the function this advisory names is exported by nothing this package installs. It is read straight out of the package's own symbol tables, and until now any blocking taint anywhere in the image withheld it — including a dlopen call in some unrelated library.
That was never sound in the direction that mattered. dlopen chooses which of the image's objects to load; it cannot add a symbol to an object that does not define one. Read a package's tables, find the advisory's function in none of them, and no amount of runtime loading makes that answer wrong.
So the two questions are now gated separately:
- Would the dynamic linker load this library? Reachability. Every taint undermines it, and every taint still withholds it.
- Does any object this package ships define the vulnerable function? Presence. Only the taints that can hide a copy of the code withhold this one.
The cost of conflating them was almost everything. A Debian base layer ships fifteen dlopen callers before the application is added — bash, perl, libpam, libselinux, useradd — and one survivor was enough. Eight stock images, --all --mine-advisories, same miner on both sides:
| image | ruled out before | after |
|---|---|---|
| nginx:1.27 | 9 | 52 |
| postgres:16 | 134 | 157 |
| redis:7 | 7 | 27 |
| debian:12 | 7 | 26 |
| node:22-slim | 7 | 26 |
| python:3.12-slim | 6 | 23 |
| haproxy:3.0 | 6 | 23 |
| ubi9 | 66 | 66 |
| total | 242 | 400 |
2258 affected down to 2100. ubi9 not moving is the conservative half working, not a bug: its advisories name few functions, so there is nothing mined to be absent.
A static binary is the case that really does threaten presence — it carries its libraries inside itself, so the package's tables are not a complete account of what is in the image — and that one still withholds the answer, as do the two kinds that leave the workload unidentified. The classification is written as the exemptions rather than the members, so a taint kind added later blocks both questions until somebody makes the argument that it only blocks one.
None of it is quietly dropped. The taints that no longer gate the answer stay in the report as notes, under a line saying why they do not bear on it:
CVE-2023-39616 aom not_present vulnerable_code_not_present
[llm-mined] av1_common_int named by the advisory, exported by nothing libaom3 installs
[elf-needed-closure] 22 observations stop the closure being a complete account of what loads,
which withholds any answer about reachability but not this one: choosing
to load an object cannot add a symbol the object does not define
[elf-needed-closure] /usr/bin/bash calls dlopen, so what it loads is decided at runtime
...
An image with twenty-two runtime loaders in it and an image with none must not produce the same evidence, even when they reach the same verdict.
Static entrypoints stop being a dead end
A statically linked entrypoint is the one thing that genuinely defeats the closure: it carries its libraries inside itself, so an unreferenced .so on disk proves nothing. Two ways out of it now.
A pure-Go binary built with CGO_ENABLED=0 links no C library at all, so it cannot be hiding a copy of one — the unreferenced .so really is the answer, and the taint is recorded rather than allowed to block.
A cgo binary might have linked it in, so it stays blocking — but if it is unstripped, its own .symtab can settle this specific advisory. For each blocking static entrypoint the symbol table is read, and the taint clears only when the binary defines none of the validated vulnerable symbols and does define the namespace each belongs to. That second condition is what keeps absence meaningful: a binary that never links openssl at all is invisible, not clean. All bindings are read, including locals, so a statically linked function that was never exported still counts as present.
It clears one binary for one advisory. Every other blocking taint, and every static root the proof did not cover, still blocks.
A plugin is only a root if something can load it
NSS modules, PAM modules, gconv converters and OpenSSL engines are opened by name rather than linked, so the closure has always rooted them unconditionally — which means an image that ships fifty PAM modules and no libpam walked all fifty. They now wait until the closure actually reaches the library that opens them: NSS and gconv on libc, PAM on libpam, engines and providers on libcrypto. A .node addon or a Python extension module has no nameable loader and is still rooted unconditionally.
A gated plugin is reported, not silently skipped, and the row says which library to go looking for if you think the image really does run PAM.
What an entrypoint can start
The closure models one process image, not a process tree. A Go entrypoint that can exec another program is now detected by scanning for the two Unix chokepoints — syscall.forkExec and syscall.Exec — and the answer is three-valued: it can, it cannot, or nobody could tell. An entrypoint proved unable to start anything records a discharged taint rather than nothing at all, because "somebody checked" and "nobody looked" are different claims. --exec-policy=assume-none is there for the cases you have already accounted for with --roots.
Also
--format text no longer loses the report when there is no pager on the box.