This is a universal skeleton key that only fits buildings wired with a very specific lock
CVE-2026-28794 is a prototype pollution bug in the StandardRPCJsonSerializer used by @orpc/client before 1.13.6. The vulnerable deserializer trusts attacker-controlled path segments in the meta and maps arrays, so requests can traverse into dangerous keys like __proto__ or constructor and write into Object.prototype. The vendor advisory shows this can happen before schema validation, and it impacts oRPC deployments using the RPC protocol path, not just toy demos.
The vendor's CRITICAL 9.8 baseline captures the best-case exploit chain for the attacker, but it overstates the average enterprise risk. The reachable population is much narrower than a perimeter appliance bug: an attacker needs an internet-reachable oRPC RPC endpoint, the vulnerable serializer in use, and an application where polluted properties meaningfully change auth or execution flow. That keeps this in HIGH, because direct pre-auth global state corruption is serious, but it is not an automatic one-packet RCE across the fleet.
4 steps from start to impact.
Find an exposed oRPC RPC endpoint
curl or any HTTP client, as shown in the vendor advisory PoC.- Internet or internal network reachability to an oRPC RPC endpoint
- Application uses oRPC with affected
@orpc/clientdeserialization logic - Version is earlier than
1.13.6
- oRPC is a developer library, not a mass-deployed appliance
- Many enterprises will have zero exposed oRPC services
- Internet scanners cannot reliably fingerprint a vulnerable npm dependency from the outside
Send crafted meta or maps payload
__proto__ or constructor so the deserializer walks into prototype space. The advisory PoC uses multipart form data and the maps vector to assign arbitrary values before Zod validation runs. Weaponized tool: curl with crafted FormData fields from the GitHub advisory.- Endpoint accepts attacker-controlled RPC request bodies
- Deserializer processes
metaand/ormapsarrays - No upstream filter blocks malformed RPC payloads
- This is protocol-specific input, not generic JSON to any route
- Reverse proxies or WAFs may block obviously malformed multipart requests, though coverage will be inconsistent
- Apps not using the relevant serializer path are unaffected even if they import oRPC elsewhere
__proto__ or constructor in RPC metadata fields; generic web scanners may miss this because the payload format is framework-specific.Pollute global Object.prototype
Object.prototype.role, toString, or other application-relevant properties.- Deserializer follows untrusted path segments without
hasOwnchecks - Application process remains running after the malicious request
- A restart clears the polluted prototype
- Not every polluted property yields a useful security outcome
- Some apps will crash noisily instead of granting stealthy access
Trigger an application gadget
user.role === "admin"; RCE requires a separate gadget chain that interprets polluted properties in an execution-sensitive context. Weaponized tool: follow-on HTTP requests that exercise auth checks or gadget-bearing code paths.- Application contains a useful gadget or flawed property trust pattern
- Polluted key is referenced by auth, business logic, serialization, or command-building code
- RCE is conditional, not guaranteed
- Well-written apps using explicit own-property checks may blunt impact
- Many real apps will cap out at DoS or logic corruption rather than full takeover
The supporting signals.
| In-the-wild status | No confirmed active exploitation found in the sources reviewed; this CVE is not in CISA KEV. |
|---|---|
| Proof-of-concept availability | Yes — vendor PoC exists. GitHub advisory includes a curl proof of concept using the maps vector; reporter credited as mnixry. |
| EPSS | 0.00871 from the prompt/upstream intel, which is low in absolute terms and argues against emergency fleet-wide treatment by itself. |
| KEV status | Not KEV-listed as of the CISA KEV catalog page reviewed; no due date pressure from KEV. |
| CVSS vector reality check | AV:N/AC:L/PR:N/UI:N is fair for exposed endpoints, but C:H/I:H/A:H assumes favorable gadgets and broad exposure that many real deployments will not have. |
| Affected versions | @orpc/client <= 1.13.5 per GitHub advisory; NVD describes versions prior to 1.13.6. |
| Fixed version | Upgrade to 1.13.6 or later. Commit adds Object.hasOwn(...) checks during deserialization to stop traversal into non-own properties. |
| Exposure/scanning reality | There is no meaningful Shodan/Censys-style version telemetry for this issue because it is a library embedded inside applications. Use SCA/SBOM/package-lock discovery, not internet census data. |
| Deployment popularity | npm shows @orpc/client at roughly 116k weekly downloads and only 26 dependents on the package page snapshot — notable, but far from ubiquitous enterprise middleware. |
| Disclosure and reporter | GHSA published 2026-03-02; CVE/NVD published 2026-03-06. Reporter credited as mnixry in the GitHub advisory. |
noisgate verdict.
The decisive factor is population reachability: this is pre-auth and serious, but only for applications exposing a vulnerable oRPC RPC path, which is a much smaller target set than the vendor's CRITICAL framing implies. The second brake is that RCE is conditional on downstream gadgets, so the consistent real-world outcome is process-wide corruption, auth bypass, or DoS — not guaranteed full code execution.
Why this verdict
- Start at 9.8/CRITICAL because the vulnerable path is network-reachable, pre-auth, and process-wide once hit.
- Down one notch for exposure population: this is a developer library inside select Node apps, not a broadly fingerprintable server product with internet-scale exposure.
- Down again for exploit-chain dependence: direct prototype pollution is reliable, but the worst-case RCE outcome needs app-specific gadgets that many deployments will not have.
- Not KEV and low EPSS: there is no authoritative evidence here of active exploitation pressure forcing immediate-hours handling.
- Still HIGH because the first successful request can corrupt the entire Node process before validation, enabling auth bypass and unstable service behavior.
Why not higher?
This is not a universally exposed edge service bug, and there is no evidence in the reviewed sources of active exploitation, KEV listing, or mass scanning pressure. Most importantly, the vendor's most dramatic outcome — RCE — is contingent on a second-stage gadget chain inside the app or its dependencies.
Why not lower?
The bug is pre-auth, remote, and can poison global process state before validation, which is materially worse than a garden-variety input-validation bug. Even without RCE, auth bypass and application-wide DoS are plausible enough that this should not be treated as routine backlog hygiene.
What to do — in priority order.
- Block suspicious RPC metadata keys — At reverse proxy, API gateway, or app middleware, reject requests containing
__proto__,constructor, or similar dangerous path segments in RPCmeta/mapsfields. For a HIGH verdict, deploy this compensating control within 30 days if patching cannot finish first. - Constrain RPC endpoint exposure — Move oRPC endpoints behind identity-aware access, VPN, service mesh policy, or internal-only routing wherever possible. This reduces the unauthenticated remote attacker population; for a HIGH verdict, implement on exposed high-value services within 30 days.
- Add request-body detections — Create WAF, proxy, or SIEM detections for request bodies containing
__proto__,constructor, and anomalous RPCmapsarrays. This will not prevent every exploit, but it gives you triage value and should be in place within 30 days for internet-facing instances. - Restart polluted services after suspected exploitation — Prototype pollution persists for the life of the Node.js process, so incident response should include process recycle after containment and log capture. Use this when suspicious requests are observed because simply blocking follow-on traffic does not unpollute memory.
Zodor downstream schema validation alone does not help, because the advisory states pollution occurs before validation.- Network scanners looking for a banner or version string do not reliably find this, because the vulnerable component is an embedded npm dependency.
- Assuming a process survives means it is safe does not work; successful pollution can persist quietly without an immediate crash.
Crowdsourced verification payload.
Run this on a developer workstation, CI runner, or directly on the target host where the Node.js application source or deployed artifact is available. Invoke it as python3 verify_orpc_cve_2026_28794.py /path/to/app with read access only; no admin privileges are required unless your deployment directories are restricted.
#!/usr/bin/env python3
# verify_orpc_cve_2026_28794.py
# Detect vulnerable @orpc/client versions for CVE-2026-28794.
# Usage: python3 verify_orpc_cve_2026_28794.py /path/to/app
# Exit codes: 0 PATCHED, 1 VULNERABLE, 2 UNKNOWN, 3 usage/error
import json
import os
import re
import sys
from typing import Optional, Tuple, List
PKG = '@orpc/client'
FIXED = (1, 13, 6)
SEMVER_RE = re.compile(r'^(\d+)\.(\d+)\.(\d+)')
def parse_ver(v: str) -> Optional[Tuple[int, int, int]]:
if not v:
return None
v = v.strip()
if v.startswith('v'):
v = v[1:]
m = SEMVER_RE.match(v)
if not m:
return None
return tuple(int(x) for x in m.groups())
def cmp_ver(a: Tuple[int, int, int], b: Tuple[int, int, int]) -> int:
return (a > b) - (a < b)
def classify(v: str) -> str:
pv = parse_ver(v)
if pv is None:
return 'UNKNOWN'
return 'PATCHED' if cmp_ver(pv, FIXED) >= 0 else 'VULNERABLE'
def load_json(path: str):
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return None
def check_package_lock(root: str, findings: List[str]) -> Optional[str]:
path = os.path.join(root, 'package-lock.json')
data = load_json(path)
if not data:
return None
# npm v7+ package-lock structure
packages = data.get('packages', {})
node = packages.get(f'node_modules/{PKG}')
if isinstance(node, dict) and 'version' in node:
version = str(node['version'])
findings.append(f'package-lock.json: {PKG}={version}')
return classify(version)
# npm v6 structure
def walk_deps(dep_obj):
if not isinstance(dep_obj, dict):
return None
for name, meta in dep_obj.items():
if name == PKG and isinstance(meta, dict) and 'version' in meta:
return str(meta['version'])
if isinstance(meta, dict):
child = walk_deps(meta.get('dependencies', {}))
if child:
return child
return None
version = walk_deps(data.get('dependencies', {}))
if version:
findings.append(f'package-lock.json: {PKG}={version}')
return classify(version)
return None
def check_pnpm_lock(root: str, findings: List[str]) -> Optional[str]:
path = os.path.join(root, 'pnpm-lock.yaml')
if not os.path.isfile(path):
return None
try:
with open(path, 'r', encoding='utf-8') as f:
text = f.read()
except Exception:
return None
patterns = [
re.compile(rf'^[\t ]*/{re.escape(PKG)}@(\d+\.\d+\.\d+):', re.M),
re.compile(rf'^[\t ]*{re.escape(PKG)}@(\d+\.\d+\.\d+):', re.M),
]
for pat in patterns:
m = pat.search(text)
if m:
version = m.group(1)
findings.append(f'pnpm-lock.yaml: {PKG}={version}')
return classify(version)
return None
def check_yarn_lock(root: str, findings: List[str]) -> Optional[str]:
path = os.path.join(root, 'yarn.lock')
if not os.path.isfile(path):
return None
try:
with open(path, 'r', encoding='utf-8') as f:
text = f.read()
except Exception:
return None
pat = re.compile(rf'"?{re.escape(PKG)}@[^\n]+:\n(?: .*\n)*? version "(\d+\.\d+\.\d+)"', re.M)
m = pat.search(text)
if m:
version = m.group(1)
findings.append(f'yarn.lock: {PKG}={version}')
return classify(version)
return None
def check_node_modules(root: str, findings: List[str]) -> Optional[str]:
path = os.path.join(root, 'node_modules', '@orpc', 'client', 'package.json')
data = load_json(path)
if isinstance(data, dict) and 'version' in data:
version = str(data['version'])
findings.append(f'node_modules: {PKG}={version}')
return classify(version)
return None
def aggregate(results: List[Optional[str]]) -> str:
present = [r for r in results if r is not None]
if not present:
return 'UNKNOWN'
if 'VULNERABLE' in present:
return 'VULNERABLE'
if 'PATCHED' in present and all(r == 'PATCHED' for r in present):
return 'PATCHED'
return 'UNKNOWN'
def main():
if len(sys.argv) != 2:
print('UNKNOWN - usage: python3 verify_orpc_cve_2026_28794.py /path/to/app')
sys.exit(3)
root = sys.argv[1]
if not os.path.isdir(root):
print(f'UNKNOWN - path not found: {root}')
sys.exit(3)
findings: List[str] = []
results = [
check_package_lock(root, findings),
check_pnpm_lock(root, findings),
check_yarn_lock(root, findings),
check_node_modules(root, findings),
]
verdict = aggregate(results)
if findings:
print('; '.join(findings))
if verdict == 'VULNERABLE':
print(f'VULNERABLE - {PKG} version earlier than 1.13.6 detected')
sys.exit(1)
elif verdict == 'PATCHED':
print(f'PATCHED - {PKG} version 1.13.6 or later detected')
sys.exit(0)
else:
print(f'UNKNOWN - could not confirm installed {PKG} version from lockfiles or node_modules')
sys.exit(2)
if __name__ == '__main__':
main()
If you remember one thing.
node_modules for @orpc/client < 1.13.6, then prioritize only the services that actually expose oRPC RPC endpoints. For this HIGH verdict, use the noisgate mitigation SLA to put compensating controls on exposed high-value services within 30 days, and use the noisgate remediation SLA to complete the actual package upgrade to 1.13.6+ within 180 days**; if you see suspicious __proto__` payloads in logs, treat that specific service as urgent and patch/restart it immediately rather than waiting for the broader window.Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.