← Back to Feed CACHED · 2026-05-17 09:42:19 · cache_key CVE-2025-29912
CVE-2026-28794 · CWE-1321 · Disclosed 2026-03-06

oRPC is an tool that helps build APIs that are end-to-end type-safe and adhere to OpenAPI standards

ASSESSED — NOISGATE V0.5
Vendor
Reassessed
Verdict:
01 · The Real Story

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.

"Pre-auth prototype pollution is real, but this is a niche Node library flaw, not a turnkey internet-wide RCE."
02 · The Attack Path

4 steps from start to impact.

STEP 01

Find an exposed oRPC RPC endpoint

The attacker first needs a live application endpoint speaking oRPC's RPC protocol, typically on a Node.js web service. This is not a generic Node runtime bug; it only matters where the application actually exposes the vulnerable request path and accepts RPC-formatted input. Weaponized tool: plain curl or any HTTP client, as shown in the vendor advisory PoC.
Conditions required:
  • Internet or internal network reachability to an oRPC RPC endpoint
  • Application uses oRPC with affected @orpc/client deserialization logic
  • Version is earlier than 1.13.6
Where this breaks in practice:
  • 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
Detection/coverage: External attack-surface tools may find the app, but dependency scanners/SCA are the reliable way to identify affected versions.
STEP 02

Send crafted meta or maps payload

The attacker sends RPC input containing path segments like __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.
Conditions required:
  • Endpoint accepts attacker-controlled RPC request bodies
  • Deserializer processes meta and/or maps arrays
  • No upstream filter blocks malformed RPC payloads
Where this breaks in practice:
  • 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
Detection/coverage: Look for requests containing __proto__ or constructor in RPC metadata fields; generic web scanners may miss this because the payload format is framework-specific.
STEP 03

Pollute global Object.prototype

If the request reaches the vulnerable code, the process-wide prototype becomes polluted for the life of the Node.js process. That changes behavior across unrelated objects and requests, creating a durable foothold inside the app runtime until restart. Weaponized behavior: write gadgets via Object.prototype.role, toString, or other application-relevant properties.
Conditions required:
  • Deserializer follows untrusted path segments without hasOwn checks
  • Application process remains running after the malicious request
Where this breaks in practice:
  • 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
Detection/coverage: EDR rarely sees this as malware-like behavior. App telemetry, unhandled exceptions, auth anomalies, and crash loops are more realistic indicators.
STEP 04

Trigger an application gadget

The attacker then relies on application logic or a downstream library to consume the polluted property in a meaningful way. The advisory gives a concrete auth-bypass example with 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.
Conditions required:
  • Application contains a useful gadget or flawed property trust pattern
  • Polluted key is referenced by auth, business logic, serialization, or command-building code
Where this breaks in practice:
  • 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
Detection/coverage: Authorization anomalies, sudden role inflation, or crashes after suspicious RPC requests are more likely than signature-based exploit detection.
03 · Intelligence Metadata

The supporting signals.

In-the-wild statusNo confirmed active exploitation found in the sources reviewed; this CVE is not in CISA KEV.
Proof-of-concept availabilityYes — vendor PoC exists. GitHub advisory includes a curl proof of concept using the maps vector; reporter credited as mnixry.
EPSS0.00871 from the prompt/upstream intel, which is low in absolute terms and argues against emergency fleet-wide treatment by itself.
KEV statusNot KEV-listed as of the CISA KEV catalog page reviewed; no due date pressure from KEV.
CVSS vector reality checkAV: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 versionUpgrade to 1.13.6 or later. Commit adds Object.hasOwn(...) checks during deserialization to stop traversal into non-own properties.
Exposure/scanning realityThere 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 popularitynpm 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 reporterGHSA published 2026-03-02; CVE/NVD published 2026-03-06. Reporter credited as mnixry in the GitHub advisory.
04 · The Call

noisgate verdict.

Final Verdict
DOWNGRADED to HIGH (8.4/10)

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.

HIGH Affected version and patch boundary
HIGH Pre-auth exploitability of the vulnerable deserializer path
MEDIUM Real-world blast radius across typical enterprise deployments
MEDIUM Likelihood of RCE versus auth bypass/DoS

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.

05 · Compensating Control

What to do — in priority order.

  1. Block suspicious RPC metadata keys — At reverse proxy, API gateway, or app middleware, reject requests containing __proto__, constructor, or similar dangerous path segments in RPC meta/maps fields. For a HIGH verdict, deploy this compensating control within 30 days if patching cannot finish first.
  2. 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.
  3. Add request-body detections — Create WAF, proxy, or SIEM detections for request bodies containing __proto__, constructor, and anomalous RPC maps arrays. This will not prevent every exploit, but it gives you triage value and should be in place within 30 days for internet-facing instances.
  4. 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.
What doesn't work
  • Zod or 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.
06 · Verification

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.

noisgate-verify.py
PYTHONREAD-ONLYSAFE
#!/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()
07 · Bottom Line

If you remember one thing.

TL;DR
Monday morning, have your app owners query source repos, build artifacts, and deployed 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

  1. NVD CVE-2026-28794
  2. GitHub Security Advisory GHSA-m272-9rp6-32mc
  3. Patch commit
  4. OSV record
  5. npm package page for @orpc/client
  6. CISA Known Exploited Vulnerabilities Catalog
  7. FIRST EPSS API documentation
Peer Review

What defenders are saying.

Submit a review attribution: handle + country only
0 flags selected · stored anonymously
Validation Results

Crowdsourced verification outputs.

Results submitted by users who ran the verification payload against their environment.