Cross-package deserialization chain in a popular Node.js structured-logging library reaches the plugin loader via prototype pollution
The chain begins at the JSON transformer's type-tag fast-path and lands inside the plugin loader's deferred-require resolver, traversing nine call-graph hops across two packages. The transformer trusts a __sg_type field on incoming records, looks it up in a constructor registry initialised at module load, and applies the resulting class without verifying that the registry itself is intact. Because the registry is a plain object reachable via prototype, a prior pollution gadget anywhere in the same process — including the library's own merge helper, lib/util/merge.js:44 — silently grafts an attacker-chosen entry on top of the prototype chain.
The exploit is reachable from any service that logs an attacker-controlled request body before sanitisation, which is the common pattern across the affected dependents. Once the constructor lookup resolves to the polluted entry, the plugin loader's lazy-require path is invoked with the attacker-supplied module identifier; the loader then calls require() against the user-controlled string and immediately executes the module's top-level statements. The end-to-end primitive is unauthenticated remote code execution on the logging process, which on most affected deployments is the same process serving HTTP traffic.
The proposed mitigation is a one-line guard at lib/transformers/json.js:122 that rejects type-tag values whose prototype is not Object.prototype, plus a hard registry freeze applied at module load. We attached a patch to the disclosure that takes the worst-case decode time from microseconds to within the same order of magnitude — measurable but not detectable in practice. The maintainer accepted the patch with minor renaming and shipped it in 4.2.1 alongside an advisory.
Disclosure opened on D0+2 to logger-core's maintainer; acknowledgement landed inside 18 hours. The maintainer requested a 30-day extension to coordinate with two large downstream packagers that vendored the transformer; we granted it without objection. Patch shipped on D0+45, advisory cleared CVE assignment on D0+88, this dossier published on D0+92. No exploitation observed in our customer telemetry across the disclosure window.
HYPOTHESIS:
Untrusted __sg_type field in logger-core's JSON transformer
resolves to attacker-grafted constructor via prototype pollution,
reaching the plugin loader's lazy require() call.
CITED PATH (9 hops):
[1] http-server/middleware/body.js:71 -> req.body propagates raw JSON
[2] app/logger.js:34 -> logger.info(req.body) call site
[3] logger-core/index.js:118 -> entry into structured pipeline
[4] logger-core/lib/transformers/json.js:118 -> readTypeTag(record)
[5] logger-core/lib/transformers/json.js:154 -> registry[tag] lookup
[6] (prototype lookup) -> resolves polluted entry
[7] logger-core/lib/loader/plugin.js:62 -> resolvePluginById(entry.id)
[8] logger-core/lib/loader/plugin.js:78 -> require(idStr)
[9] <attacker module top-level> -> code execution
DISPROOF:
Attempt 1: sanitiser model claimed the registry is frozen at
module load. REFUTED at lib/registry.js:41 — Object.freeze is
called on the wrong object (the wrapper, not the table).
Attempt 2: claim that pollution gadget is not reachable.
REFUTED — lib/util/merge.js:44 accepts __proto__ keys when the
parser option deepMerge=true, which is the default for the
library's own config loader (lib/config/load.js:21).
Attempt 3: claim that require() rejects relative paths.
REFUTED — line 78 calls require.resolve() with paths option
pointing at process.cwd(), accepting any module name resolvable
from the consumer's node_modules.
PROPOSED PATCH:
--- a/lib/transformers/json.js
+++ b/lib/transformers/json.js
@@ -120,6 +120,9 @@ function readTypeTag(record) {
const tag = record.__sg_type;
if (typeof tag !== 'string') return null;
+ if (!Object.prototype.hasOwnProperty.call(registry, tag)) {
+ return null;
+ }
return registry[tag];
}
CONFIDENCE: 0.94 (survived 3-attempt disproof pass).