One Node.js Process Can Use Two OpenSSL Versions at Once

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.

References