We found a critical remote code execution vulnerability in protobuf.js, a core building block for anything that speaks Protocol Buffers from JavaScript, directly or through gRPC, Firebase, and most cloud SDKs. Here's how it works, the risk, and how to fix it.
We found a critical remote code execution vulnerability in protobuf.js, a core building block for anything that speaks Protocol Buffers from JavaScript, directly or through gRPC, Firebase, and most cloud SDKs. Here's how it works, the risk, and how to fix it.
We found a critical remote code execution vulnerability in protobuf.js, a core building block for anything that speaks Protocol Buffers from JavaScript, directly or through gRPC, Firebase, and most cloud SDKs. Here's how it works, the risk, and how to fix it.
We found a critical remote code execution vulnerability in protobuf.js, a core building block for anything that speaks Protocol Buffers from JavaScript, directly or through gRPC, Firebase, and most cloud SDKs. Here's how it works, the risk, and how to fix it.
We found a critical remote code execution vulnerability in protobuf.js, a core building block for anything that speaks Protocol Buffers from JavaScript, directly or through gRPC, Firebase, and most cloud SDKs. Here's how it works, the risk, and how to fix it.
Endor Labs researchers discovered a critical vulnerability in protobuf.js, the most widely used JavaScript runtime for Protocol Buffers, a data format used by millions of applications to exchange information, including services built on Google Cloud, Firebase, and most modern cloud platforms. The protobuf.js package is downloaded roughly 52 million times per week and is often installed as a hidden dependency of other popular libraries, meaning many development teams ship it without realizing it.Exploitation is straightforward. It requires an attacker to supply a malicious configuration file (protobuf schema) to the target application — a precondition that sounds narrow but is common in practice. Applications routinely load these files from shared registries, partner integrations, or third-party servers. Once a poisoned file is in memory, exploitation is trivial: the first message the application processes triggers the payload, with no authentication or user interaction required.
Upgrade with npm install protobufjs@^8.0.1 or npm install protobufjs@^7.5.5 depending on your major line. npm ls protobufjs will surface transitive pulls.
Protocol Buffers are the default serialization format across large parts of modern infrastructure, gRPC services, mobile APIs, telemetry pipelines, and internal RPC. protobuf.js is the JavaScript runtime most of those workloads rely on when they need to speak protobuf from Node.js or the browser. The package has around 52 million weekly downloads on npm and 10k GitHub stars. It is pulled in, directly or transitively, by @grpc/proto-loader, Firebase SDKs, Google Cloud client libraries, and a long tail of observability and data-plane tooling. If you have a node_modules tree with anything cloud‑adjacent, you almost certainly ship protobufjs.
The library's value proposition is runtime dynamism: give it a .proto file or a JSON descriptor and it will synthesize constructors, encoders, and decoders on the fly. That dynamism is also exactly the problem.
This vulnerability is not a supply-chain attack against protobuf.js itself, the package is legitimate and maintained by developers affiliated either now or in the past with Google. It is a vulnerability in how protobuf.js processes the data developers feed it. And as we'll argue below, this class of bugs, dev-tool-as-code-execution-primitive, has an emerging threat model that the ecosystem has been slow to internalize or accept.
protobuf.js does not interpret protobuf schemas; it compiles them. For every message type, the library assembles a small string of JavaScript source and hands it to the Function constructor, which parses and executes it as fresh code. This is a common performance trick: generated code is faster than a generic interpreter walking a schema at runtime.
The helper that turns schema fragments into executable source lives in lib/codegen/index.js . Its assembly step is a single concatenation:
There is no AST, no escaping pass, and no identifier validator. functionName is interpolated verbatim between " function " and "("; whatever string lives in that slot becomes the identifier of a live JavaScript function declaration. The resulting source is then compiled and invoked:
Function (source) is essentially eval () by another name: the argument string is parsed and executed as fresh JavaScript. It is exactly the pattern ESLint's no-new-func rule is designed to flag: the rule's own documentation calls the Function constructor "similar to eval()" and warns that it "introduces potential security risks." Both Function(...) call sites in codegen/index.js carry `// eslint-disable-line no-new-func`, and we suspect those disables are a structural reason the bug went unnoticed for so long: subsequent readers saw the opt-out and trusted the earlier review, instead of tracing every string that flows into the call. Eslint documentation recommends treating eslint-disable on security-sensitive rules as important discussion points during code review or audit.
The untrusted input reaches this machinery through src/type.js. Type.generateConstructor calls the codegen helper with the message type's *name* as the function identifier, i.e., the functionName that lands in the " function " + functionName + "(" slot above. Before the fix, that name came straight from reflection metadata, which for schemas loaded from JSON traces back to the attacker-supplied descriptor: no escaping, no validation. If a type is named User, the generated source is unremarkable. If a type is named User){process.mainModule.require("child_process").execSync("id"); function x(, the "name" closes the synthetic signature, inlines the payload, and starts a throwaway function to balance the trailing brace. Function happily parses and runs the whole thing, running the attacker-controlled command id under the hood.
Crucially, the constructor is generated lazily . Loading the malicious descriptor with Root.fromJSON does not, on its own, fire the payload; the Function call happens the first time the application needs the type's constructor, which in practice is the first time it decodes (or encodes, or instantiates) a message of that type. This is why the vulnerability is a threat to running applications rather than to build pipelines. The advisory's minimal proof of concept makes this explicit: load the descriptor, look up the outer type, pass a short, completely benign,buffer to .decode, and a child process running id appears. In a real deployment the triggering message is ordinary user traffic; the schema is the poisoned component.
The advisory's precondition sounds reassuring: you're only at risk if an attacker can influence the protobuf schema your application loads. In practice, the habit of reusing protobuf definitions makes that precondition easy to meet. Teams are actively encouraged to pull schemas from third parties: the Buf Schema Registry hosts versioned, dependency-managed . proto modules for consumption across organizations; googleapis/googleapis and the well-known types in protocolbuffers/protobuf are vendored into countless repositories; envoyproxy/envoy/api and cncf/xds are the de-facto control-plane schemas for service meshes. Reuse is the explicit design point. The attack surface opens the moment any of those sources, or the pipeline by which they reach your service, can be influenced by someone outside your trust boundary.
If your service only loads strictly trusted, version-controlled .proto files from your own repository, and which your development team wrote, you are outside the attack model. If any path loads schemas at runtime from somewhere humans can influence, which is the entire reason Root.fromJSON exists, you are inside it.
The cultural assumption around schemas, config files, OpenAPI specs, and similar artifacts is that they are inert descriptions. Developers vet JavaScript dependencies with some skepticism; almost no one runs SCA against a .proto file or peer-reviews the x-enumDescriptions block in a vendor's OpenAPI document. The moment a dev tool compiles, generates code from, or interprets that data in a non-trivial way, the trust model needs to be the same as for executable code. protobuf.js is an unusually pure example because the compilation step is literal Function-constructor source assembly, but the pattern data input → dynamic code generation → execution is the rule, not the exception, in modern developer tooling.
Attackers have been aggressively targeting and monetizing developer tooling for years. Two categories are worth separating:
Different package, different field, same shape as protobuf.js: an untrusted string concatenated into a template that will later be executed as code . The dev tool ecosystem is full of these sinks because so much of it lives in the space where "data" and "code" overlap: template engines, codegen, schema compilers, linters with AST rewriters, notebook kernels, LSP servers, build plugins.
What connects these cases is less "where the code runs" and more "who controls the input that ends up as code." Orval and eslint-scope compromise happens at build time, on developer machines and CI runners, i.e., hosts loaded with SSH keys, cloud credentials, signing material, and package publishing tokens. protobuf.js compromise happens at runtime, inside services that speak to customers and partners, hosts loaded with user data, service tokens, and access to internal systems. Both surfaces matter, and both are reached through the exact same design pattern: a trusted library composing code from untrusted text.
The upstream patch is intentionally small. Pull request #2127 (Fix commit: 535df44 ) adds a single line in the Type constructor in src/type.js :
Non-word characters, i.e., everything outside [A-Za-z0-9_] , are stripped before the name is used anywhere in the codegen path. Parentheses, braces, semicolons, quotes, whitespace: all the tokens an attacker needs to close the synthetic function signature are simply filtered out. The commit message notes, correctly, that there is no legitimate reason for a protobuf type name to contain characters outside the alphanumeric set.
A safer fix would be to stop round-tripping attacker-reachable identifiers through Function at all, emit code that uses safe lookups in a table keyed by sanitized identifiers, rather than splicing names into source. That is a larger change and the one-line filter is correct as a conservative remediation.
The impact is what you would expect from arbitrary code execution in a library this deep in the dependency graph, triggered on the production decode path:
protobuf.js is an excellent library with a subtle trap: the same dynamic-compilation machinery that makes it fast is a hairline from executing attacker-supplied text. GHSA-xq3m-2v4x-88gg is fixed by restricting the names in protobuf definition files, but the lesson is broader. Every developer tool that compiles, generates, or evaluates "data" is a code-execution surface. eslint-scope showed us what compromised developer tools can do; Orval and now `protobuf.js` show us what benign developer tools can do when fed hostile input. Both sides of that threat model need to be part of how teams evaluate the tooling in their build pipelines and in their node_modules.
If you ship protobuf schemas dynamically, patch today, move toward precompiled static artifacts, and start treating your .proto files, and every other "just a config" file in your stack, with the same care you give the JavaScript you run.
When you're ready to take the step in securing your software supply chain, here are 3 ways Endor Labs can help:
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
