TL;DR: Node.js can use the OpenSSL and zlib copies built into its executable while a native addon in the same process loads different copies installed by Debian. Before fixing a vulnerability, find out which copy the affected code actually uses.
We have an app called Webhook Archive. Imagine a customer has paid, the payment provider says the charge succeeded, but the order still looks unpaid. To investigate, the team needs the exact message the payment provider sent. Webhook Archive keeps those original webhooks, including events such as payment.completed and order.shipped.
When an event arrives, OpenSSL creates a SHA-256 fingerprint from the message. That fingerprint becomes the event ID, zlib makes the message smaller, and the app saves it. If the provider sends the same message again, the app gets the same ID and recognizes the retry.
The JavaScript request handler passes the message to a native addon, a small C++ part of the app that JavaScript can call. The addon uses the OpenSSL and zlib files installed by Debian.
At first, the setup looks simple: one Node.js process, one OpenSSL, and one zlib. The version endpoint shows where that picture breaks.
One Process, Four Library Versions
The complete example is in node-library-security-research. Its docker/minimal-runtime.Dockerfile builds the addon on Debian 12 Bookworm and runs it on a smaller image from the same Debian release. The compiler, Python, libssl-dev, and zlib1g-dev stay in the build stage. The finished image keeps only the files needed to run the app.
Clone the example and build the image:
git clone https://github.com/topcug/node-library-security-research.git
cd node-library-security-research
make build
Start the app and ask which library versions it uses:
docker run --rm -d --name webhook-archive \
-p 3000:3000 webhook-archive:minimal >/dev/null
until versions=$(curl -fsS http://127.0.0.1:3000/versions 2>/dev/null); do sleep 1; done
jq . <<<"${versions}"
docker stop webhook-archive >/dev/null
{
"node": "v22.23.4",
"nodeOpenSSL": "3.5.9",
"nodeZlib": "1.3.1-e00f703",
"nativeOpenSSL": "OpenSSL 3.0.22 25 Aug 2026",
"nativeZlib": "1.2.13"
}
Here’s the weird part: one Node.js process returned four library versions. node:crypto was using the OpenSSL 3.5.9 built into Node.js, while the C++ addon in that same process was using Debian’s OpenSSL 3.0.22. Node’s zlib module reported the built-in 1.3.1-e00f703 copy, and the addon reported Debian’s zlib 1.2.13.
That difference changes what an update can fix. apt-get upgrade can replace Debian’s libcrypto.so.3 and libz.so.1. It can finish successfully while the OpenSSL and zlib copies inside /usr/local/bin/node stay exactly as they were. Those built-in copies change only when the Node.js image changes.
Trace the Addon to Its Shared Libraries
dpkg-query tells us which Debian package owns the zlib file:
docker run --rm --entrypoint /usr/bin/dpkg-query \
webhook-archive:minimal \
-S /lib/x86_64-linux-gnu/libz.so.1.2.13
zlib1g:amd64: /lib/x86_64-linux-gnu/libz.so.1.2.13
ldd shows which shared libraries Linux selects for the addon:
docker run --rm --entrypoint /usr/bin/ldd \
webhook-archive:minimal \
/app/build/Release/native_versions.node
libcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1
Finally, /proc/self/maps shows the files loaded into the running Node.js process. This command loads the addon, calls both libraries, and prints their paths:
docker run --rm --entrypoint /usr/local/bin/node \
webhook-archive:minimal -e '
const fs = require("node:fs");
const addon = require("/app/build/Release/native_versions.node");
addon.opensslVersion();
addon.zlibVersion();
const paths = fs.readFileSync("/proc/self/maps", "utf8")
.split("\n")
.map((line) => line.trim().split(/\s+/).at(-1))
.filter((path) => /lib(crypto|z)\.so/.test(path));
process.stdout.write(`${[...new Set(paths)].join("\n")}\n`);
'
/usr/lib/x86_64-linux-gnu/libz.so.1.2.13
/usr/lib/x86_64-linux-gnu/libcrypto.so.3
These commands answer different questions. dpkg-query names the package that owns a file, ldd shows what the addon needs, and /proc/self/maps shows what the running process loaded.
The OpenSSL and zlib copies built into Node.js are part of the Node.js executable. We use process.versions to identify them.
A Successful Build Can Still Fail at Runtime
The build and runtime images must also provide compatible library files. At first glance, this Dockerfile looks reasonable: it compiles the addon on Debian 12 Bookworm and copies the result into a smaller Debian 11 Bullseye image.
FROM node:22-bookworm AS builder
RUN npm run build
FROM node:22-bullseye-slim
COPY --from=builder \
/app/build/Release/native_versions.node \
./build/Release/native_versions.node
Bookworm provides libcrypto.so.3, but the Bullseye runtime does not:
docker run --rm --entrypoint sh node:22-bullseye-slim \
-c 'ls -l /usr/lib/x86_64-linux-gnu/libcrypto.so.3'
ls: cannot access '/usr/lib/x86_64-linux-gnu/libcrypto.so.3': No such file or directory
The odd part is that Docker accepted this setup. It copied the addon and completed the build, and everything looked fine until Node.js tried to load the addon. Then Linux looked for libcrypto.so.3 in the Bullseye image and couldn’t find it:
docker build \
--file docker/abi-mismatch.Dockerfile \
--tag webhook-archive:abi-mismatch \
.
docker run --rm webhook-archive:abi-mismatch
Error: libcrypto.so.3: cannot open shared object file: No such file or directory
at Object..node (node:internal/modules/cjs/loader:1939:18)
at Object.<anonymous> (/app/app/routes.js:6:16)
{
code: 'ERR_DLOPEN_FAILED'
}
This is why changing a base image is not enough. We need to test the addon inside the finished image.
Check Whether the App Uses the Vulnerable Code
At first, a Trivy finding looks simple: the image contains a package version linked to a known vulnerability. But Trivy gets there by matching the package name and version. Whether Webhook Archive can reach the vulnerable code is still an open question.
Scan the finished image, not the source folder:
trivy image \
--scanners vuln \
--severity HIGH,CRITICAL \
--format json \
--output trivy.json \
webhook-archive:minimal
The report contains one critical result:
jq -r '.Results[].Vulnerabilities[]
| select(.Severity == "CRITICAL")
| "Package: \(.PkgName)\nCVE: \(.VulnerabilityID)\nInstalled version: \(.InstalledVersion)\nSeverity: \(.Severity)\nStatus: \(.Status)"' \
trivy.json
Package: zlib1g
CVE: CVE-2023-45853
Installed version: 1:1.2.13.dfsg-1
Severity: CRITICAL
Status: will_not_fix
CVE-2023-45853 affects zipOpenNewFileInZip4_64 in MiniZip, optional ZIP-file code under zlib’s contrib directory. Here’s the catch: the addon uses normal zlib compression and never creates or reads ZIP files. That leaves one concrete question: is the vulnerable MiniZip function available in the library the addon actually loads?
We still need to inspect the exact Debian library. Copy that file from the image and search its exported function names:
container_id=$(docker create webhook-archive:minimal)
docker cp "${container_id}:/lib/x86_64-linux-gnu/libz.so.1.2.13" \
/tmp/libz.so.1.2.13
docker rm "${container_id}" >/dev/null
if nm -D --defined-only /tmp/libz.so.1.2.13 \
| grep -q 'zipOpenNewFileInZip4_64'; then
printf 'found\n'
else
printf 'not found\n'
fi
not found
not found answered that question. The addon loaded libz.so.1.2.13, and that file did not make the vulnerable MiniZip function available. Trivy correctly matched the package version. The runtime check added the missing piece: this addon could not reach the vulnerable function through the library it loaded.
Update the Right Copy
If a finding affects libssl3 or zlib1g, updating the Debian package changes the copy used by the addon. It does not change the copy inside Node.js. If a finding affects the OpenSSL or zlib version reported by process.versions, we must change the Node.js image.
For CVE-2023-45853, the package matches but the affected MiniZip function is not in the library the addon loads.
After any Node.js or Debian package update, build and run the addon on the same supported Debian release. Confirm that the addon still loads, the webhook can be saved and restored, and the reported library versions are the ones you intended to update. Then scan the finished image again.
Summary
One Node.js process can use two OpenSSL versions and two zlib versions at the same time. Node.js uses the copies built into its executable, while a native addon can load different copies from Debian.
That is why a package name or upgrade command is not enough. Start with the running process, identify the file behind the finding, check which code uses it, and test the finished image.