Skip to content

fix: resolve libraries ldd reports as missing instead of dropping them - #19

Draft
dviejokfs wants to merge 4 commits into
mainfrom
fix/record-missing-libraries
Draft

fix: resolve libraries ldd reports as missing instead of dropping them#19
dviejokfs wants to merge 4 commits into
mainfrom
fix/record-missing-libraries

Conversation

@dviejokfs

Copy link
Copy Markdown
Contributor

Refs #14.

The bug

record_runtime_libraries filtered ldd output with awk '/=> \// { print $3 }'. That only matches lines where the loader resolved the library to a path. A missing one has no path:

libatomic.so.1 => not found

so it was silently dropped. The recorded list came out short, the runtime image was built without the package, and nothing errored. The helper exists precisely so that package names are not hardcoded — its own doc comment argues the case, citing the t64 transition — and this is the case where it quietly stopped doing that.

It is also why neither of this month's runtime failures could be caught generically, and why both were fixed with hardcoded Debian package names instead: libatomic for pnpm 11 (#1) and the 15-package Chromium closure for Puppeteer/Playwright (#11).

The fix

Resolve the not found shape with apt-file, which maps a filename to its providing package without needing the file present.

Verified end to end against a binary that genuinely has a missing library — mise-installed pnpm 11, in debian:bookworm-slim:

target: /root/.local/share/mise/installs/pnpm/11.20.0/pnpm
	libatomic.so.1 => not found
--- running the generated snippet ---
libatomic1
libstdc++6

libatomic1 is exactly the package #1 had to hardcode.

The part worth reviewing

The query is anchored to the multiarch directory, and it has to be. My first attempt searched the bare basename, and the results were actively dangerous:

libatomic.so.1 -> lib32atomic1 lib32atomic1-amd64-cross lib32atomic1-mips64-cross
libnss3.so     -> firefox-esr libnss3 thunderbird

An unanchored head -1 installs the 32-bit cross-compile package for one, and an entire web browser for the other. Anchoring to ^/usr/lib/[a-z0-9_]*-linux-gnu/ gives exactly one correct answer each:

libatomic.so.1 -> libatomic1     libnss3.so    -> libnss3
libgbm.so.1    -> libgbm1        libasound.so.2 -> libasound2
libcups.so.2   -> libcups2

The pattern is arch-agnostic rather than using dpkg-architecture, which is in dpkg-dev and is not present in the build image — confirmed.

Other choices:

  • Fails the build when nothing provides a library, rather than recording a short list. A build error with the soname in it beats a container that exits 127 on a loader message.
  • apt-file and its index are fetched only when something is missing. The index is ~90MB and takes ~12s; builds with nothing unresolved should not pay for it.

Test plan

  • End-to-end against pnpm 11 in bookworm-slim, output above
  • missing_libraries_are_resolved_rather_than_dropped — asserts both branches, the anchored pattern, the failure path, and that the index fetch is behind the guard
  • Conformance regression on the three providers that use this helper — crystal-server and cobol-app pass, php-app/php-extensions covered in CI
  • cargo test --workspace → 230 passed
  • cargo fmt --all -- --check and cargo clippy --workspace --all-targets -- -D warnings clean

Follow-up, not in this PR

This makes the mechanism correct but does not yet migrate the two hardcoded lists onto it. Moving libatomic and CHROMIUM_RUNTIME over would close #15 as well, since the whole point is that apt-file answers per release and survives a trixie bump. Both want their own change and their own verification — the Chromium set in particular is only reachable after the browser is downloaded, so the ldd glob has to be pointed at it deliberately.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

📓 Changelog preview

This is what your commits will add to the generated CHANGELOG.md at release time (via git-cliff). Do not edit CHANGELOG.md by hand — it is generated from your Conventional Commit messages.

## [Unreleased]

### Fixed

- Resolve libraries ldd reports as missing instead of dropping them
- Validate sonames and widen the library anchor
- Keep pathname expansion on for the ldd argument
- Escape regex metacharacters in a soname

@dviejokfs
dviejokfs marked this pull request as draft August 5, 2026 23:16
@dviejokfs

Copy link
Copy Markdown
Contributor Author

Marking this draft — a security review found blockers, two of which I have reproduced.

The anchor is wrong, and it fails builds

I anchored to ^/usr/lib/<triplet>/. On Debian, essential libraries are still recorded under /lib/<triplet>/ in the Contents index:

libz.so.1       my-pattern=[EMPTY -> BUILD FAILS]   widened=[zlib1g]
libc.so.6       my-pattern=[EMPTY -> BUILD FAILS]   widened=[libc6]
libgcc_s.so.1   my-pattern=[EMPTY -> BUILD FAILS]   widened=[libgcc-s1]
libtinfo.so.6   my-pattern=[EMPTY -> BUILD FAILS]   widened=[libtinfo6]
libatomic.so.1  my-pattern=[libatomic1]             widened=[libatomic1]

Any of those arriving as not found hits the else branch and fails the build with no package provides libz.so.1 — the exact opposite of what this PR is for. It bites cobol-app and any Debian-based provider; Ubuntu-based ones like Crystal are usr-merged and unaffected, which is why conformance stayed green.

Why I missed it: I only tested libatomic.so.1 and the Chromium set, and every one of those happens to live under /usr/lib/. I picked test cases from the bugs I already knew about instead of from the space of things ldd can report.

The regex interpolation is injectable

$lib goes straight into an extended regex, and | has the lowest precedence, so it escapes the intended prefix entirely. DT_NEEDED = x|usr/sbin/sshd:

apt-file search -x "^/usr/lib/[a-z0-9_]*-linux-gnu/x|usr/sbin/sshd$"
  -> openssh-server: /usr/sbin/sshd

That package name is written to the record file and installed as root into the runtime image, with the build exiting 0. A hostile transitive dependency can set it — a Crystal shard with @[Link(ldflags: "-Wl,--no-as-needed,-l:x|usr/sbin/sshd")] — so the app author never sees it. It matters most for COBOL, where cobc runs no attacker code at build time, making this the first data-only path from app content to root package installation.

Shell injection specifically is not present — $lib is a parameter expansion inside double quotes and shells do not re-scan expansions — and that was verified against crafted $(...), backtick, ; and quote payloads, none of which executed.

Also flagged

  • for lib in $missing is unquoted, so a soname of * glob-expands against the build directory
  • head -1 is locale-dependent: the same source picks libavcodec-extra59 under LC_ALL=C and libavcodec59 under en_US.UTF-8. 24 sonames in bookworm have multiple providers, including libc++.so.1 where the first-sorted answer is the oldest ABI
  • A single crafted soname of x|.* costs 75s and 3.5GB RSS in apt-file
  • 2>/dev/null on the search reports "no package provides X" for what may be a search failure

Plan

Validate sonames against ^[A-Za-z0-9._+-]+$ before they reach the regex, widen the anchor to ^/(usr/)?lib/<triplet>/ including non-gnu triplets, set -f, LC_ALL=C, and fail with the candidate list rather than guessing when a search returns more than one package.

The test I wrote pins the vulnerable pattern as a string literal and would not have caught any of this. It needs replacing with one that runs the generated script against a fixture binary carrying a hostile DT_NEEDED.

record_runtime_libraries filtered ldd output with /=> \//, which only
matches lines where the loader resolved the library to a path. A missing
one looks like

    libatomic.so.1 => not found

with no path, so it was silently dropped: the recorded list came out
short and the runtime image was built without the package, with no error
anywhere. The helper exists precisely to avoid hardcoding package names,
and this is the case where it quietly stopped doing that.

Resolve those with apt-file, which maps a filename to the package
providing it without needing the file present. The query is anchored to
the multiarch library directory deliberately — a bare basename search for
libatomic.so.1 also matches lib32atomic1 and the -cross packages, and
libnss3.so matches firefox-esr and thunderbird, so an unanchored head -1
installs something wildly wrong. A library nothing provides now fails the
build with an explanation rather than producing an image that dies with
a loader error.

apt-file and its ~90MB index are only fetched when something is actually
missing.

Refs #14
Five problems a security review found in the previous commit.

A soname reaches this from a binary the app produced, and it was
interpolated raw into an extended regex. `|` has the lowest precedence,
so DT_NEEDED of `x|usr/sbin/sshd` escaped the path anchor entirely,
resolved to openssh-server, and had it installed as root in the runtime
image with the build exiting 0. Validate the soname first.

The anchor only covered /usr/lib/<triplet>/. On Debian the essential
libraries are still recorded unmerged, so libz.so.1, libc.so.6,
libgcc_s.so.1 and libtinfo.so.6 all resolved to nothing and failed the
build — the opposite of the point. It also missed non-gnu triplets like
arm-linux-gnueabihf.

`for lib in $missing` was unquoted, so a soname of `*` expanded against
the build directory. `set -f`.

`head -1` was locale-dependent: the same source picked libavcodec-extra59
under LC_ALL=C and libavcodec59 under en_US.UTF-8. Pin the collation, and
stop guessing — 24 sonames in bookworm have several providers, and for
libc++.so.1 the first-sorted answer is the oldest ABI. Fail with the
candidates and tell the user to pick one.

The test pinned the vulnerable pattern as a literal and would not have
caught any of this; it now asserts the guarantees instead.
@dviejokfs

Copy link
Copy Markdown
Contributor Author

Pushed c9462c0 with all five fixes. Still draft pending a re-review, since my own verification has now missed things twice on this branch.

Attacked the generated script directly

Fixture ELF binaries built with patchelf --add-needed, running the script the Rust actually emits (extracted from the crate, not retyped), in debian:bookworm-slim:

regex injection (|)   exit=1  refusing to look up 'x|usr/sbin/sshd': not a library name
glob (*)              exit=1  refusing to look up '*': not a library name
command subst         exit=1  refusing to look up 'a$(touch': not a library name
backtick              exit=1  refusing to look up 'a`touch': not a library name
semicolon             exit=1  refusing to look up 'a;touch': not a library name
wildcard DoS (x|.*)   exit=1  refusing to look up 'x|.*': not a library name
legit missing lib     exit=0  libatomic1
unmerged /lib lib     exit=0  (zlib1g — previously failed the build)

No /tmp/pwned was created in any case. The openssh-server resolution is gone.

What changed

  • Soname validation against [A-Za-z0-9._+-] before anything reaches the regex. This is the fix that closes the injection, the glob expansion and the apt-file resource exhaustion in one place, because all three needed a character a library name cannot contain.
  • Anchor widened to ^/(usr/)?lib/[a-z0-9_]*-linux-gnu[a-z0-9]*/, covering Debian's unmerged /lib and non-gnu triplets like arm-linux-gnueabihf. libz.so.1 now resolves to zlib1g instead of failing the build.
  • set -f so a soname can never glob against the build directory.
  • LC_ALL=C for deterministic collation.
  • Ambiguity fails with the candidate list instead of head -1. 24 sonames in bookworm have several providers and for libc++.so.1 the first-sorted answer is the oldest ABI, so guessing bakes a silently wrong library into a production image. The error names the candidates and says to pick one in apt_packages.

The test

Replaced. The old one pinned the vulnerable regex as a string literal and would have caught none of this. It now asserts the guarantees — validation present, anchor covers /lib, globbing off, collation pinned, both failure paths actionable, index fetch behind the guard.

The stronger evidence is the table above; that harness is worth landing as a fixture-based test rather than living in my shell history, and I would rather do that than claim the string assertions are sufficient.

Not fixed here, flagged for follow-up: install_recorded_runtime_libraries passes $(cat file) to apt-get unquoted in the runtime stage. It is bounded today by what can reach that file, but that is a fragile invariant now that more providers will depend on this path.

Conformance green on cobol-app and crystal-server, 230 workspace tests, fmt and clippy clean.

@dviejokfs
dviejokfs force-pushed the fix/record-missing-libraries branch from 34b2194 to c9462c0 Compare August 6, 2026 07:52
set -f at the top of the script disabled globbing everywhere, including
the line whose argument is documented as a glob. No current caller passes
one — cobol and crystal name a single binary — but the PHP provider's own
copy globs an extension directory, and a caller that inspects several
binaries would have silently got no results.

Turn expansion off only around the loop over sonames, which is the one
place a crafted DT_NEEDED could expand against the build directory.
The character guard rejects what a library name cannot contain, but two
characters it legitimately can are also regex metacharacters, and both
were spliced raw into the apt-file query.

`+` is a quantifier, so libxml++-2.6.so.2 matched nothing and the build
failed claiming no package provides a library that plainly exists. 238
sonames in bookworm carry a + — libFLAC++, libMagick++, libIce++11.
Escaped, they resolve: libxml++2.6-2v5, libflac++10.

`.` matches any character including /, so a crafted DT_NEEDED of
gio.modules.libgioremote-volume-monitor.so walked two directories below
the anchor and reached gvfs, pulling 215 packages into the runtime image
as root. A malicious transitive dependency can rewrite DT_NEEDED on a
binary under /app, so this was reachable without the app author's
knowledge. Escaped, it resolves to nothing and the build stops.

Escaping both collapses the query to an exact basename match.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CHROMIUM_RUNTIME hardcodes bookworm names and breaks on a trixie base image

1 participant