diff --git a/tools/aegis4j/.github/workflows/gradle-ci-build.yml b/tools/aegis4j/.github/workflows/gradle-ci-build.yml new file mode 100644 index 0000000000..bb1ec59502 --- /dev/null +++ b/tools/aegis4j/.github/workflows/gradle-ci-build.yml @@ -0,0 +1,26 @@ +name: Gradle CI Build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + java: [ '11', '17' ] + name: JDK ${{ matrix.Java }} Build + steps: + - uses: actions/checkout@v2 + - name: Setup Java + uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: ${{ matrix.java }} + - name: Build with Gradle + run: | + cd $GITHUB_WORKSPACE + ./gradlew build diff --git a/tools/aegis4j/.gitignore b/tools/aegis4j/.gitignore new file mode 100644 index 0000000000..e687b24141 --- /dev/null +++ b/tools/aegis4j/.gitignore @@ -0,0 +1,7 @@ +bin/ +build/ +.gradle/ +.settings/ +.classpath +.project +gradle.properties diff --git a/tools/aegis4j/LICENSE.txt b/tools/aegis4j/LICENSE.txt new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/tools/aegis4j/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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. diff --git a/tools/aegis4j/README.md b/tools/aegis4j/README.md new file mode 100644 index 0000000000..a3e085b1c9 --- /dev/null +++ b/tools/aegis4j/README.md @@ -0,0 +1,124 @@ +# aegis4j + +Avoid the NEXT Log4Shell vulnerability! + +The Java platform has accrued a number of features over the years. Some of these features are no longer commonly used, +but their existence remains a security liability, providing attackers with a diverse toolkit to leverage against +Java-based systems. + +It is possible to eliminate some of this attack surface area by creating custom JVM images with +[jlink](https://docs.oracle.com/en/java/javase/17/docs/specs/man/jlink.html), but this is not always feasible or desired. +Another option is to use the [--limit-modules](https://docs.oracle.com/en/java/javase/17/docs/specs/man/java.html) command +line parameter when running your application, but this is a relatively coarse tool that cannot be used to disable +individual features like serialization or native process execution. + +A third option is aegis4j, a Java agent which can patch key system classes to completely disable a number of standard +Java features: + +- `jndi`: all JNDI functionality (`javax.naming.*`) +- `rmi`: all RMI functionality (`java.rmi.*`) +- `process`: all process execution functionality (`Runtime.exec()`, `ProcessBuilder`) +- `httpserver`: all use of the JDK HTTP server (`com.sun.net.httpserver.*`) +- `serialization`: all Java serialization (`ObjectInputStream`, `ObjectOutputStream`) +- `unsafe`: all use of `sun.misc.Unsafe` +- `scripting`: all JSR 223 scripting (`javax.script.*`) +- `jshell`: all use of the Java Shell API (`jdk.jshell.*`) + +### Download + +The aegis4j JAR is available in the [Maven Central](https://repo1.maven.org/maven2/net/gredler/aegis4j/1.1/) repository. + +### Usage: Attach at Application Startup + +To attach at application startup, blocking all features listed above, add the agent to your java command line: + +`java -cp -javaagent:aegis4j-1.1.jar ` + +Or, if you want to configure the specific features to block: + +`java -cp -javaagent:aegis4j-1.1.jar=block= ` + +Or, if you want to use the default block list, but unblock specific features: + +`java -cp -javaagent:aegis4j-1.1.jar=unblock= ` + +Feature lists should be comma-delimited (e.g. `jndi,rmi,unsafe`). + +### Usage: Attach to a Running Application + +To attach to a running application, blocking all features listed above, run the following command: + +`java -jar aegis4j-1.1.jar ` + +Or, if you want to configure the specific features to block: + +`java -jar aegis4j-1.1.jar block=` + +Or, if you want to use the default block list, but unblock specific features: + +`java -jar aegis4j-1.1.jar unblock=` + +Feature lists should be comma-delimited (e.g. `jndi,rmi,unsafe`). + +The application process ID, or PID, can usually be determined by running the `jps` command. + +### Compatibility + +The aegis4j Java agent is compatible with JDK 11 and newer. + +### Monitoring + +The list of Java features blocked by aegis4j is available via the `aegis4j.blocked.features` system property, which +can be queried at runtime via Java code, JMX, APM agents, etc. + +When an attempt is made to use a blocked feature, the type of exception thrown varies according to context, but the exception +message always uses the format `" blocked by aegis4j"`. + +### Building + +To build aegis4j, run `gradlew build`. + +### Digging Deeper + +Class modifications are performed using [Javassist](https://www.javassist.org/). The specific class modifications performed are +configured in the [mods.properties](src/main/resources/net/gredler/aegis4j/mods.properties) file. + +Some of the tests validate the agent against actual vulnerabilities (e.g. +[CVE-2015-7501](src/test/java/net/gredler/aegis4j/CVE_2015_7501.java), +[CVE-2019-17531](src/test/java/net/gredler/aegis4j/CVE_2019_17531.java), +[CVE-2021-44228](src/test/java/net/gredler/aegis4j/CVE_2021_44228.java)). +The tests are run with the `jdk.attach.allowAttachSelf=true` system property, so that the agent can be attached and tested +locally. Tests are also run in individual VM instances, so that the class modifications performed in one test do not affect other +tests. + +Ideally aegis4j could block all reflection as well, since it's often used in exploit chains. However, reflection is used *everywhere*, +including the JDK lambda internals, Spring Boot, JUnit, and many other libraries and frameworks. The best way to mitigate the dangers +of reflection is to upgrade to JDK 17 or later, where many of the internal platform classes have been made inaccessible via reflection +(see [JEP 403](https://openjdk.java.net/jeps/403), or the [full list](https://cr.openjdk.java.net/~mr/jigsaw/jdk8-packages-strongly-encapsulated) +of packages that were locked down between JDK 8 and JDK 17). + +### Related Work + +[log4j-jndi-be-gone](https://github.com/nccgroup/log4j-jndi-be-gone): +A Java agent which patches the Log4Shell vulnerability (CVE-2021-44228). + +[Log4jHotPatch](https://github.com/corretto/hotpatch-for-apache-log4j2/): +A similar Java agent from the Amazon Corretto team. + +[Logout4Shell](https://github.com/Cybereason/Logout4Shell): +Vaccine exploit which leverages the Log4Shell vulnerability to patch the Log4Shell vulnerability. + +[Logpresso log4j2-scan](https://github.com/logpresso/CVE-2021-44228-Scanner): +Command line tool for scanning (and patching) JAR files for Log4Shell vulnerabilities. + +[ysoserial](https://github.com/frohoff/ysoserial): +A proof-of-concept tool for generating Java serialization vulnerability payloads. + +[NotSoSerial](https://github.com/kantega/notsoserial): +A Java agent which attempts to mitigate serialization vulnerabilities by selectively blocking serialization attempts. + +[An In-Depth Study of More Than Ten Years of Java Exploitation](https://www.abartel.net/static/p/ccs2016-10yearsJavaExploits.pdf): +Study of real-world Java exploits between 2003 and 2013 ([citations](https://scholar.google.com/scholar?cites=17190152291480177134)). + +[A Systematic Analysis and Hardening of the Java Security Architecture](https://www.bodden.de/pubs/phdHolzinger.pdf): +PhD thesis which incorporates the above research and proposes specific hardening measures. diff --git a/tools/aegis4j/build.gradle b/tools/aegis4j/build.gradle new file mode 100644 index 0000000000..e2c398a7a7 --- /dev/null +++ b/tools/aegis4j/build.gradle @@ -0,0 +1,126 @@ +plugins { + id 'java' + id 'jacoco' + id 'eclipse' + id 'signing' + id 'maven-publish' + id 'com.github.johnrengelman.shadow' version '7.1.0' +} + +group = 'net.gredler' +archivesBaseName = 'aegis4j' +version = '1.1' + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.javassist:javassist:3.28.0-GA' + testImplementation 'org.springframework:spring-core:5.3.14' + testImplementation 'com.unboundid:unboundid-ldapsdk:3.1.1' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' + // vulnerable to CVE-2021-44228 + testImplementation 'org.apache.logging.log4j:log4j-core:2.14.1' + // vulnerable to CVE-2015-7501 + testImplementation 'org.apache.commons:commons-collections4:4.0' + // vulnerable to CVE-2019-17531 + testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.9.10' + testImplementation('log4j:apache-log4j-extras:1.2.17') { + exclude group: 'log4j', module: 'log4j' + } +} + +sourceCompatibility = 11 +targetCompatibility = 11 + +compileJava.options.encoding = 'UTF-8' +compileTestJava.options.encoding = 'UTF-8' +javadoc.options.encoding = 'UTF-8' + +java { + withJavadocJar() + withSourcesJar() +} + +test { + useJUnitPlatform() + systemProperty 'jdk.attach.allowAttachSelf', 'true' // tests attach agent to local VM + forkEvery 1 // tests cannot undo class modifications to clean up after themselves + testLogging { + events 'passed', 'skipped', 'failed' + showExceptions true + showStackTraces true + showStandardStreams false + exceptionFormat 'full' + } +} + +jar { + archiveClassifier.set('no-deps') + manifest.attributes( + 'Implementation-Title': 'aegis4j', + 'Implementation-Version': archiveVersion, + 'Main-Class': 'net.gredler.aegis4j.AegisAgent', + 'Agent-Class': 'net.gredler.aegis4j.AegisAgent', + 'Premain-Class': 'net.gredler.aegis4j.AegisAgent', + 'Can-Redefine-Classes': true, + 'Can-Retransform-Classes': true, + 'Can-Set-Native-Method-Prefix': false + ) +} + +shadowJar { + archiveClassifier.set('') + relocate 'javassist', 'net.gredler.aegis4j.javassist' +} + +tasks.build.dependsOn tasks.shadowJar + +publishing { + publications { + mavenJava(MavenPublication) { publication -> + project.shadow.component(publication) + artifact sourcesJar + artifact javadocJar + pom { + groupId = 'net.gredler' + name = 'aegis4j' + url = 'https://github.com/gredler/aegis4j' + description = 'A Java agent which disables dangerous rarely-used runtime features.' + licenses { + license { + name = 'The Apache License, Version 2.0' + url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + } + developers { + developer { + id = 'gredler' + name = 'Daniel Gredler' + email = 'daniel.gredler@gmail.com' + } + } + scm { + url = 'https://github.com/gredler/aegis4j' + connection = 'scm:git:git://github.com/gredler/aegis4j.git' + developerConnection = 'scm:git:ssh:git@github.com:gredler/aegis4j.git' + } + } + } + } + repositories { + maven { + url = 'https://oss.sonatype.org/service/local/staging/deploy/maven2/' + credentials { + username = project.hasProperty('ossrhUsername') ? ossrhUsername : 'unknown' + password = project.hasProperty('ossrhPassword') ? ossrhPassword : 'unknown' + } + } + } +} + +signing { + sign publishing.publications.mavenJava +} diff --git a/tools/aegis4j/gradle/wrapper/gradle-wrapper.jar b/tools/aegis4j/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..7454180f2a Binary files /dev/null and b/tools/aegis4j/gradle/wrapper/gradle-wrapper.jar differ diff --git a/tools/aegis4j/gradle/wrapper/gradle-wrapper.properties b/tools/aegis4j/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..84d1f85fd6 --- /dev/null +++ b/tools/aegis4j/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/tools/aegis4j/gradlew b/tools/aegis4j/gradlew new file mode 100755 index 0000000000..c53aefaa5f --- /dev/null +++ b/tools/aegis4j/gradlew @@ -0,0 +1,234 @@ +#!/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/master/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 + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# 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"' + +# 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 + which java >/dev/null 2>&1 || 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 + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + 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 + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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/tools/aegis4j/gradlew.bat b/tools/aegis4j/gradlew.bat new file mode 100755 index 0000000000..107acd32c4 --- /dev/null +++ b/tools/aegis4j/gradlew.bat @@ -0,0 +1,89 @@ +@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=. +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%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +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%"=="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! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/tools/aegis4j/src/main/java/net/gredler/aegis4j/AegisAgent.java b/tools/aegis4j/src/main/java/net/gredler/aegis4j/AegisAgent.java new file mode 100644 index 0000000000..b7a0a80b7f --- /dev/null +++ b/tools/aegis4j/src/main/java/net/gredler/aegis4j/AegisAgent.java @@ -0,0 +1,134 @@ +/* Copyright (c) 2022, Daniel Gredler. All rights reserved. */ + +package net.gredler.aegis4j; + +import java.io.File; +import java.lang.instrument.Instrumentation; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import com.sun.tools.attach.VirtualMachine; + +/** + * The main agent class. This class fails fast on any configuration errors, so that e.g. a typo + * which prevents a feature from blocking correctly does not create a false sense of security. + * Once the hand-off to {@link Patcher} has occurred and the class patching has begun, errors are + * handled more leniently. + * + * @see Java instrumentation primer + * @see Alternate Java agent project + */ +public final class AegisAgent { + + /** + * Supports easy dynamic attach via the command line. + * + * @param args the command line parameters (should contain the PID of the application to patch) + * @throws Exception if any error occurs during the attach process + */ + public static void main(String[] args) throws Exception { + + if (args.length < 1) { + throw new IllegalArgumentException("ERROR: Missing required argument: pid"); + } + + if (args.length > 2) { + throw new IllegalArgumentException("ERROR: Too many arguments provided"); + } + + String pid = args[0]; + String options = args.length > 1 ? args[1] : ""; + + File jar = new File(AegisAgent.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + VirtualMachine jvm = VirtualMachine.attach(pid); + jvm.loadAgent(jar.getAbsolutePath(), options); + jvm.detach(); + } + + /** + * Supports static attach (via -javaagent parameter at JVM startup). + * + * @param args agent arguments + * @param instr instrumentation services + */ + public static void premain(String args, Instrumentation instr) { + Patcher.start(instr, toBlockList(args)); + } + + /** + * Supports dynamic attach (via the com.sun.tools.attach.* API). + * + * @param args agent arguments + * @param instr instrumentation services + */ + public static void agentmain(String args, Instrumentation instr) { + Patcher.start(instr, toBlockList(args)); + } + + /** + * Parses the agent argument string (e.g. {@code "block=jndi,rmi,serialization"} or {@code "unblock=process"}) + * into a feature block list. + * + * @param args agent arguments + * @return the block list derived from the agent arguments + */ + protected static Set< String > toBlockList(String args) { + + Set< String > all = Set.of("jndi", "rmi", "process", "httpserver", "serialization", "unsafe", "scripting", "jshell"); + if (args == null || args.isBlank()) { + // no arguments provided by user + return all; + } + + args = args.trim().toLowerCase(); + int eq = args.indexOf('='); + if (eq == -1) { + // incorrect argument format, we expect a single "name=value" parameter + throw new IllegalArgumentException("ERROR: Invalid agent configuration string"); + } + + String name = args.substring(0, eq).trim(); + String value = args.substring(eq + 1).trim(); + + if ("block".equals(name)) { + // user is providing their own block list + return split(value, all); + } else if ("unblock".equals(name)) { + // user is modifying the default block list + Set< String > block = new HashSet<>(all); + Set< String > unblock = split(value, all); + block.removeAll(unblock); + return Collections.unmodifiableSet(block); + } else { + // no idea what the user is doing... + throw new IllegalArgumentException("ERROR: Unrecognized parameter name (should be one of 'block' or 'unblock'): " + name); + } + } + + /** + * Splits the specified comma-delimited feature list, validating that all specified features are valid. + * + * @param values the comma-delimited feature list + * @param all the list of valid features to validate against + * @return the feature list, split into individual (validated) feature names + * @throws IllegalArgumentException if any unrecognized feature names are included in the comma-delimited feature list + */ + private static Set< String > split(String values, Set< String > all) { + + Set< String > features = Arrays.asList(values.split(",")) + .stream() + .map(String::trim) + .collect(Collectors.toUnmodifiableSet()); + + for (String feature : features) { + if (!all.contains(feature)) { + throw new IllegalArgumentException("ERROR: Unrecognized feature name: " + feature); + } + } + + return features; + } +} diff --git a/tools/aegis4j/src/main/java/net/gredler/aegis4j/Patcher.java b/tools/aegis4j/src/main/java/net/gredler/aegis4j/Patcher.java new file mode 100644 index 0000000000..ac04208e8e --- /dev/null +++ b/tools/aegis4j/src/main/java/net/gredler/aegis4j/Patcher.java @@ -0,0 +1,165 @@ +/* Copyright (c) 2022, Daniel Gredler. All rights reserved. */ + +package net.gredler.aegis4j; + +import java.io.IOException; +import java.lang.instrument.ClassFileTransformer; +import java.lang.instrument.Instrumentation; +import java.lang.instrument.UnmodifiableClassException; +import java.security.ProtectionDomain; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; + +import javassist.ByteArrayClassPath; +import javassist.CannotCompileException; +import javassist.ClassPool; +import javassist.CtClass; +import javassist.CtConstructor; +import javassist.CtMethod; +import javassist.LoaderClassPath; +import javassist.NotFoundException; + +/** + * Disables rarely-used platform features like JNDI and RMI so that they cannot be exploited by attackers. + * See the {@code mods.properties} file for the actual class modification configuration. + */ +public final class Patcher implements ClassFileTransformer { + + private final Map< String, List< Modification > > modifications; // class name -> modifications + + /** + * Creates a new class patcher which blocks the specified features. + * + * @param block the features to block + */ + public Patcher(Set< String > block) { + modifications = loadModifications(block); + } + + /** + * Registers a new patcher on the specified instrumentation instance and triggers class transformation. + * + * @param instr the instrumentation instance to add a new patcher to + * @param block the features to block + */ + public static void start(Instrumentation instr, Set< String > block) { + + System.out.println("Aegis4j patching starting"); + Patcher patcher = new Patcher(block); + instr.addTransformer(patcher, true); + + for (String className : patcher.modifications.keySet()) { + try { + System.out.println("Aegis4j patching " + className + "..."); + Class< ? > clazz = Class.forName(className); + instr.retransformClasses(clazz); + } catch (ClassNotFoundException | UnmodifiableClassException e) { + e.printStackTrace(); + } + } + + System.setProperty("aegis4j.blocked.features", String.join(",", block)); + System.out.println("Aegis4j patching finished"); + } + + @Override + public byte[] transform(Module module, ClassLoader loader, String className, Class< ? > clazz, ProtectionDomain domain, byte[] classBytes) { + return transform(loader, className, clazz, domain, classBytes); + } + + @Override + public byte[] transform(ClassLoader loader, String className, Class< ? > clazz, ProtectionDomain domain, byte[] classBytes) { + return patch(className.replace('/', '.'), classBytes); + } + + private byte[] patch(String className, byte[] classBytes) { + + List< Modification > mods = modifications.get(className); + if (mods == null || mods.isEmpty()) { + return null; + } + + ClassPool pool = new ClassPool(); + pool.appendClassPath(new ByteArrayClassPath(className, classBytes)); + pool.appendClassPath(new LoaderClassPath(ClassLoader.getPlatformClassLoader())); + pool.appendClassPath(new LoaderClassPath(getClass().getClassLoader())); + + try { + CtClass clazz = pool.get(className); + for (Modification mod : mods) { + if (mod.isConstructor()) { + for (CtConstructor constructor : clazz.getConstructors()) { + constructor.setBody(mod.newBody); + } + } else { + CtMethod[] methods = mod.isAll() ? clazz.getDeclaredMethods() : clazz.getDeclaredMethods(mod.methodName); + if (methods.length == 0) { + System.err.println("ERROR: Method not found: " + className + "." + mod.methodName); + } + for (CtMethod method : methods) { + method.setBody(mod.newBody); + } + } + } + return clazz.toBytecode(); + } catch (NotFoundException | CannotCompileException | IOException e) { + e.printStackTrace(); + return null; + } + } + + private static Map< String, List< Modification > > loadModifications(Set< String > block) { + + Properties props = new Properties(); + try { + props.load(AegisAgent.class.getResourceAsStream("mods.properties")); + } catch (IOException e) { + e.printStackTrace(); + } + + List< Modification > mods = new ArrayList<>(); + for (String key : props.stringPropertyNames()) { + int first = key.indexOf('.'); + int last = key.lastIndexOf('.'); + String feature = key.substring(0, first).toLowerCase(); + if (block.contains(feature)) { + String className = key.substring(first + 1, last); + String methodName = key.substring(last + 1); + String newBody = props.getProperty(key); + Modification mod = new Modification(className, methodName, newBody); + mods.add(mod); + } + } + + return Collections.unmodifiableMap(new TreeMap<>( + mods.stream().collect(Collectors.groupingBy(mod -> mod.className, Collectors.toUnmodifiableList())) + )); + } + + private static final class Modification { + + public final String className; + public final String methodName; + public final String newBody; + + public Modification(String className, String methodName, String newBody) { + this.className = className; + this.methodName = methodName; + this.newBody = newBody; + } + + public boolean isConstructor() { + return className.substring(className.lastIndexOf('.') + 1).equals(methodName); + } + + public boolean isAll() { + return "*".equals(methodName); + } + } +} diff --git a/tools/aegis4j/src/main/resources/net/gredler/aegis4j/mods.properties b/tools/aegis4j/src/main/resources/net/gredler/aegis4j/mods.properties new file mode 100644 index 0000000000..81a01729cf --- /dev/null +++ b/tools/aegis4j/src/main/resources/net/gredler/aegis4j/mods.properties @@ -0,0 +1,64 @@ +# format: ..= + +# JNDI +# ---- +# Patches the 3 protected methods which InitialContext uses internally to get all Context instances, +# so that these methods always throw a NoInitialContextException (NICE). As a result, no JNDI lookups +# are possible. +JNDI.javax.naming.InitialContext.getURLOrDefaultInitCtx=throw new javax.naming.NoInitialContextException("JNDI context creation blocked by aegis4j"); +JNDI.javax.naming.InitialContext.getDefaultInitCtx=throw new javax.naming.NoInitialContextException("JNDI context creation blocked by aegis4j"); + +# RMI +# --- +# Outside of traditional JEE application servers, few people use the RMI libraries anymore. +RMI.java.rmi.registry.LocateRegistry.getRegistry=throw new java.rmi.StubNotFoundException("RMI registry creation blocked by aegis4j"); +RMI.java.rmi.registry.LocateRegistry.createRegistry=throw new java.rmi.StubNotFoundException("RMI registry creation blocked by aegis4j"); +RMI.java.rmi.server.RemoteObject.RemoteObject=throw new java.lang.RuntimeException("RMI remote object creation blocked by aegis4j"); + +# Process Execution +# ----------------- +# Many remote code execution exploits culminate in local process execution, but it's relatively rare to +# need to use these methods for legitimate purposes. +PROCESS.java.lang.Runtime.exec=throw new java.io.IOException("Process execution blocked by aegis4j"); +PROCESS.java.lang.ProcessBuilder.start=throw new java.io.IOException("Process execution blocked by aegis4j"); +PROCESS.java.lang.ProcessBuilder.startPipeline=throw new java.io.IOException("Process execution blocked by aegis4j"); + +# JDK HTTP Server +# --------------- +# The JDK HTTP server is intended for quick testing, especially for platform beginners. It is rarely (if +# ever) used in production, so we can eliminate this little bit of attack surface. +HTTPSERVER.com.sun.net.httpserver.HttpServer.HttpServer=throw new java.lang.RuntimeException("HTTP server creation blocked by aegis4j"); +HTTPSERVER.com.sun.net.httpserver.HttpsServer.HttpsServer=throw new java.lang.RuntimeException("HTTPS server creation blocked by aegis4j"); +HTTPSERVER.com.sun.net.httpserver.spi.HttpServerProvider.HttpServerProvider=throw new java.lang.RuntimeException("HTTP server provider creation blocked by aegis4j"); +HTTPSERVER.com.sun.net.httpserver.spi.HttpServerProvider.provider=throw new java.lang.RuntimeException("HTTP server provider lookup blocked by aegis4j"); + +# Java Serialization +# ------------------ +# Probably a bit more commonly used than most of the other features on this list, but a huge security +# liability for applications that don't use it. Best to live without it, if at all possible. +SERIALIZATION.java.io.ObjectInputStream.ObjectInputStream=throw new java.lang.RuntimeException("Java deserialization blocked by aegis4j"); +SERIALIZATION.java.io.ObjectOutputStream.ObjectOutputStream=throw new java.lang.RuntimeException("Java serialization blocked by aegis4j"); + +# Unsafe +# ------ +# Quite commonly used in the olden days, but many applications should be able to run without it these days. +UNSAFE.sun.misc.Unsafe.*=throw new java.lang.RuntimeException("Unsafe blocked by aegis4j"); + +# Scripting +# --------- +# Nashorn was removed from the platform in JDK 15. There are other JSR 223 script engines out there, +# but they're probably not common enough to leave this capability enabled by default. +SCRIPTING.javax.script.ScriptEngineManager.ScriptEngineManager=throw new java.lang.RuntimeException("Scripting blocked by aegis4j"); +SCRIPTING.javax.script.AbstractScriptEngine.AbstractScriptEngine=throw new java.lang.RuntimeException("Scripting blocked by aegis4j"); +SCRIPTING.javax.script.SimpleScriptContext.SimpleScriptContext=throw new java.lang.RuntimeException("Scripting blocked by aegis4j"); +SCRIPTING.javax.script.CompiledScript.CompiledScript=throw new java.lang.RuntimeException("Scripting blocked by aegis4j"); + +# JShell +# ------ +# Introduced in JDK 9, the Java Shell is intended for rapid prototyping and testing. It is not usually used in production. +JSHELL.jdk.jshell.JShell.JShell=throw new java.lang.RuntimeException("JShell blocked by aegis4j"); +JSHELL.jdk.jshell.JShell.create=throw new java.lang.RuntimeException("JShell blocked by aegis4j"); +JSHELL.jdk.jshell.JShell.builder=throw new java.lang.RuntimeException("JShell blocked by aegis4j"); +JSHELL.jdk.jshell.tool.JavaShellToolBuilder.builder=throw new java.lang.RuntimeException("JShell blocked by aegis4j"); +JSHELL.jdk.jshell.Snippet.Snippet=throw new java.lang.RuntimeException("JShell blocked by aegis4j"); +JSHELL.jdk.jshell.TaskFactory.parse=throw new java.lang.RuntimeException("JShell blocked by aegis4j"); diff --git a/tools/aegis4j/src/test/java/net/gredler/aegis4j/AegisAgentCommandLineTest.java b/tools/aegis4j/src/test/java/net/gredler/aegis4j/AegisAgentCommandLineTest.java new file mode 100644 index 0000000000..f8a240ee44 --- /dev/null +++ b/tools/aegis4j/src/test/java/net/gredler/aegis4j/AegisAgentCommandLineTest.java @@ -0,0 +1,118 @@ +/* Copyright (c) 2022, Daniel Gredler. All rights reserved. */ + +package net.gredler.aegis4j; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.ObjectOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +/** + *

Tests {@link AegisAgent} command line use (both static and dynamic attach). + * + *

NOTE: Code covered by these tests will not be included in the code coverage stats, because it runs + * in the manually-forked VMs, which JaCoCo does not know about. Because of this (and because forking extra VMs + * is extra slow), this class should only be used to test the agent entry points; most functional testing belongs + * in {@link AegisAgentTest} instead. + */ +public class AegisAgentCommandLineTest { + + @Test + public void testStaticAttach() throws Exception { + String jar = TestUtils.createAgentJar(); + // CONFIG EXPECTED ERROR + testStaticAttach(jar, "block=jndi", ""); + testStaticAttach(jar, "unblock=serialization", ""); + testStaticAttach(jar, "block=serialization", "Java serialization blocked by aegis4j"); + } + + @Test + public void testDynamicAttach() throws Exception { + String jar = TestUtils.createAgentJar(); + // PID CONFIG EXTRA EXPECTED APP ERROR EXPECTED AGENT ERROR + testDynamicAttach(jar, true, "block=jndi", false, "", ""); + testDynamicAttach(jar, true, "unblock=serialization", false, "", ""); + testDynamicAttach(jar, true, "block=serialization", false, "Java serialization blocked by aegis4j", ""); + testDynamicAttach(jar, true, "", false, "Java serialization blocked by aegis4j", ""); + testDynamicAttach(jar, false, "block=serialization", false, "", "Invalid process identifier"); + testDynamicAttach(jar, false, "", false, "", "ERROR: Missing required argument: pid"); + testDynamicAttach(jar, true, "block=serialization", true, "", "ERROR: Too many arguments provided"); + } + + private static void testStaticAttach(String jar, String config, String expectedErr) throws Exception { + + String main = Main.class.getName(); + String cp = System.getProperty("java.class.path"); + Process process = new ProcessBuilder("java", "-javaagent:" + jar + "=" + config, "-cp", cp, main).start(); + process.waitFor(5, TimeUnit.SECONDS); + assertFalse(process.isAlive()); + + String out = new String(process.getInputStream().readAllBytes(), UTF_8); + String err = new String(process.getErrorStream().readAllBytes(), UTF_8); + String summary = "OUT: " + out + "\nERR: " + err; + assertEquals(expectedErr.isEmpty(), out.endsWith("done" + System.lineSeparator()), summary); + assertEmptyOrContains(expectedErr, err, summary); + } + + private static void testDynamicAttach(String jar, boolean addPid, String config, boolean addThirdParam, String expectedErr, String expectedAttachErr) throws Exception { + + String main = Main.class.getName(); + String cp = System.getProperty("java.class.path"); + Process process = new ProcessBuilder("java", "-cp", cp, main).start(); + + List< String > cmd2 = new ArrayList<>(); + cmd2.addAll(Arrays.asList("java", "-jar", jar)); + if (addPid) cmd2.add(String.valueOf(process.pid())); + if (!config.isEmpty()) cmd2.add(config); + if (addThirdParam) cmd2.add("foo"); + Process process2 = new ProcessBuilder(cmd2).start(); + + process.waitFor(5, TimeUnit.SECONDS); + process2.waitFor(5, TimeUnit.SECONDS); + assertFalse(process.isAlive()); + assertFalse(process2.isAlive()); + + String out = new String(process.getInputStream().readAllBytes(), UTF_8); + String err = new String(process.getErrorStream().readAllBytes(), UTF_8); + String out2 = new String(process2.getInputStream().readAllBytes(), UTF_8); + String err2 = new String(process2.getErrorStream().readAllBytes(), UTF_8); + String summary = "OUT 1: " + out + "\nERR 1: " + err + "\nOUT 2: " + out2 + "\nERR 2: " + err2; + assertEquals(expectedErr.isEmpty(), out.endsWith("done" + System.lineSeparator()), summary); + assertEmptyOrContains(expectedErr, err, summary); + assertEmptyOrContains(expectedAttachErr, err2, summary); + } + + private static void assertEmptyOrContains(String expected, String actual, String message) { + if (expected.isEmpty()) { + // actual value should be empty, as well + assertEquals(expected, actual, message); + } else { + // actual value should contain the expected value + assertTrue(actual.contains(expected), message); + } + } + + public static class Main { + public static void main(String[] args) throws Exception { + // sleep until the agent finishes attaching, or time out after 30 * 100 ms = 3 seconds + // really only relevant for dynamic attach -- no wait will be needed with static attach + for (int i = 0; i < 30; i++) { + if (System.getProperty("aegis4j.blocked.features") != null) { + break; // agent finished attaching + } + Thread.sleep(100); + } + new ObjectOutputStream(new ByteArrayOutputStream()); // triggers serialization block (if enabled) + System.out.println("done"); + } + } +} diff --git a/tools/aegis4j/src/test/java/net/gredler/aegis4j/AegisAgentMonitoringTest.java b/tools/aegis4j/src/test/java/net/gredler/aegis4j/AegisAgentMonitoringTest.java new file mode 100644 index 0000000000..450fa21842 --- /dev/null +++ b/tools/aegis4j/src/test/java/net/gredler/aegis4j/AegisAgentMonitoringTest.java @@ -0,0 +1,22 @@ +/* Copyright (c) 2022, Daniel Gredler. All rights reserved. */ + +package net.gredler.aegis4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link AegisAgent} monitoring via system properties. + */ +public class AegisAgentMonitoringTest { + + @Test + public void testSystemProperty() throws Exception { + assertNull(System.getProperty("aegis4j.blocked.features")); + TestUtils.installAgent("unblock=jndi,rmi,unsafe,scripting"); + assertEquals("serialization,jshell,process,httpserver", System.getProperty("aegis4j.blocked.features")); + } + +} diff --git a/tools/aegis4j/src/test/java/net/gredler/aegis4j/AegisAgentTest.java b/tools/aegis4j/src/test/java/net/gredler/aegis4j/AegisAgentTest.java new file mode 100644 index 0000000000..89e442763d --- /dev/null +++ b/tools/aegis4j/src/test/java/net/gredler/aegis4j/AegisAgentTest.java @@ -0,0 +1,333 @@ +/* Copyright (c) 2022, Daniel Gredler. All rights reserved. */ + +package net.gredler.aegis4j; + +import static net.gredler.aegis4j.AegisAgent.toBlockList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.lang.reflect.Field; +import java.rmi.StubNotFoundException; +import java.rmi.registry.LocateRegistry; +import java.rmi.server.RMIClientSocketFactory; +import java.rmi.server.RMIServerSocketFactory; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +import javax.naming.InitialContext; +import javax.naming.Name; +import javax.naming.NoInitialContextException; +import javax.naming.ldap.LdapName; +import javax.script.CompiledScript; +import javax.script.ScriptContext; +import javax.script.ScriptEngine; +import javax.script.ScriptEngineManager; +import javax.script.SimpleScriptContext; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.springframework.objenesis.SpringObjenesis; + +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.spi.HttpServerProvider; + +import jdk.jshell.JShell; +import jdk.jshell.tool.JavaShellToolBuilder; +import sun.misc.Unsafe; + +/** + * Tests {@link AegisAgent}. + */ +public class AegisAgentTest { + + @BeforeAll + public static void installAgent() throws Exception { + TestUtils.installAgent(null); + } + + @Test + public void testParseBlockList() { + + assertEquals(Set.of("jndi", "rmi", "process", "httpserver", "serialization", "unsafe", "scripting", "jshell"), toBlockList("")); + assertEquals(Set.of("jndi", "rmi", "process", "httpserver", "serialization", "unsafe", "scripting", "jshell"), toBlockList(" ")); + assertEquals(Set.of("jndi", "rmi", "process", "httpserver", "unsafe", "scripting", "jshell"), toBlockList("unblock=serialization")); + assertEquals(Set.of("jndi", "rmi", "httpserver", "unsafe", "scripting", "jshell"), toBlockList("unblock=serialization,process")); + assertEquals(Set.of("jndi", "rmi", "httpserver", "unsafe", "scripting", "jshell"), toBlockList("UNbloCk=SERIALIZATION,Process")); + assertEquals(Set.of("jndi", "rmi", "httpserver", "unsafe", "scripting", "jshell"), toBlockList(" unblock\t= serialization , process\t")); + assertEquals(Set.of(), toBlockList("unblock=jndi,rmi,process,httpserver,serialization,unsafe,scripting,jshell")); + assertEquals(Set.of("jndi"), toBlockList("block=jndi")); + assertEquals(Set.of("jndi", "rmi", "process"), toBlockList("block=jndi,rmi,process")); + assertEquals(Set.of("jndi", "rmi", "process"), toBlockList("block = jndi\t, rmi ,\nprocess")); + assertEquals(Set.of("jndi", "rmi", "process"), toBlockList("BLOck = JNDI\t, rmi ,\nProcESs")); + + assertThrowsIAE(() -> toBlockList("blahblah"), "ERROR: Invalid agent configuration string"); + assertThrowsIAE(() -> toBlockList("foo=bar"), "ERROR: Unrecognized parameter name (should be one of 'block' or 'unblock'): foo"); + assertThrowsIAE(() -> toBlockList("block=incorrect"), "ERROR: Unrecognized feature name: incorrect"); + assertThrowsIAE(() -> toBlockList("unblock=incorrect"), "ERROR: Unrecognized feature name: incorrect"); + assertThrowsIAE(() -> toBlockList("block=serialization,process,incorrect,jndi"), "ERROR: Unrecognized feature name: incorrect"); + assertThrowsIAE(() -> toBlockList("unblock=serialization,process,incorrect,jndi"), "ERROR: Unrecognized feature name: incorrect"); + } + + @Test + public void testJndi() throws Exception { + + String string = "foo"; + Name name = new LdapName("cn=foo"); + Object object = new Object(); + InitialContext initialContext = new InitialContext(); + + assertThrowsNICE(() -> initialContext.lookup(string)); + assertThrowsNICE(() -> initialContext.lookup(name)); + assertThrowsNICE(() -> initialContext.bind(string, object)); + assertThrowsNICE(() -> initialContext.bind(name, object)); + assertThrowsNICE(() -> initialContext.rebind(string, object)); + assertThrowsNICE(() -> initialContext.rebind(name, object)); + assertThrowsNICE(() -> initialContext.unbind(string)); + assertThrowsNICE(() -> initialContext.unbind(name)); + assertThrowsNICE(() -> initialContext.rename(string, string)); + assertThrowsNICE(() -> initialContext.rename(name, name)); + assertThrowsNICE(() -> initialContext.list(string)); + assertThrowsNICE(() -> initialContext.list(name)); + assertThrowsNICE(() -> initialContext.listBindings(string)); + assertThrowsNICE(() -> initialContext.listBindings(name)); + assertThrowsNICE(() -> initialContext.destroySubcontext(string)); + assertThrowsNICE(() -> initialContext.destroySubcontext(name)); + assertThrowsNICE(() -> initialContext.createSubcontext(string)); + assertThrowsNICE(() -> initialContext.createSubcontext(name)); + assertThrowsNICE(() -> initialContext.lookupLink(string)); + assertThrowsNICE(() -> initialContext.lookupLink(name)); + assertThrowsNICE(() -> initialContext.getNameParser(string)); + assertThrowsNICE(() -> initialContext.getNameParser(name)); + assertThrowsNICE(() -> initialContext.addToEnvironment(string, object)); + assertThrowsNICE(() -> initialContext.removeFromEnvironment(string)); + assertThrowsNICE(() -> initialContext.getEnvironment()); + assertThrowsNICE(() -> initialContext.getNameInNamespace()); + } + + @Test + public void testRmi() { + + int integer = 9090; + String string = "foo"; + RMIClientSocketFactory clientSocketFactory = null; + RMIServerSocketFactory serverSocketFactory = null; + + assertThrowsSNFE(() -> LocateRegistry.getRegistry(integer)); + assertThrowsSNFE(() -> LocateRegistry.getRegistry(string)); + assertThrowsSNFE(() -> LocateRegistry.getRegistry(string, integer)); + assertThrowsSNFE(() -> LocateRegistry.getRegistry(string, integer, clientSocketFactory)); + assertThrowsSNFE(() -> LocateRegistry.createRegistry(integer)); + assertThrowsSNFE(() -> LocateRegistry.createRegistry(integer, clientSocketFactory, serverSocketFactory)); + } + + @Test + public void testProcess() { + + Runtime runtime = Runtime.getRuntime(); + String string = "foo"; + String[] array = new String[] { "foo" }; + File file = new File("."); + + assertThrowsIOE(() -> runtime.exec(string)); + assertThrowsIOE(() -> runtime.exec(array)); + assertThrowsIOE(() -> runtime.exec(string, array)); + assertThrowsIOE(() -> runtime.exec(array, array)); + assertThrowsIOE(() -> runtime.exec(string, array, file)); + assertThrowsIOE(() -> runtime.exec(array, array, file)); + + assertThrowsIOE(() -> new ProcessBuilder(string).start()); + assertThrowsIOE(() -> new ProcessBuilder(array).start()); + assertThrowsIOE(() -> new ProcessBuilder(List.of()).start()); + assertThrowsIOE(() -> ProcessBuilder.startPipeline(List.of())); + } + + @Test + public void testHttpServer() { + assertThrowsRE(() -> HttpServer.create(), "HTTP server provider lookup blocked by aegis4j"); + assertThrowsRE(() -> HttpServer.create(null, 0), "HTTP server provider lookup blocked by aegis4j"); + assertThrowsRE(() -> HttpServerProvider.provider(), "HTTP server provider lookup blocked by aegis4j"); + } + + @Test + public void testJShell() { + assertThrowsRE(() -> JShell.builder(), "JShell blocked by aegis4j"); + assertThrowsRE(() -> JShell.create(), "JShell blocked by aegis4j"); + assertThrowsRE(() -> JavaShellToolBuilder.builder(), "JShell blocked by aegis4j"); + } + + @Test + public void testScripting() { + assertThrowsRE(() -> new ScriptEngineManager(), "Scripting blocked by aegis4j"); + assertThrowsRE(() -> new ScriptEngineManager(getClass().getClassLoader()), "Scripting blocked by aegis4j"); + assertThrowsRE(() -> new SimpleScriptContext(), "Scripting blocked by aegis4j"); + assertThrowsRE(() -> new CompiledScript() { + @Override public Object eval(ScriptContext context) { return null; } + @Override public ScriptEngine getEngine() { return null; } + }, "Scripting blocked by aegis4j"); + } + + @Test + public void testSerialization() { + + ByteArrayInputStream bais = new ByteArrayInputStream(new byte[0]); + assertThrowsRE(() -> new ObjectInputStream(bais), "Java deserialization blocked by aegis4j"); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + assertThrowsRE(() -> new ObjectOutputStream(baos), "Java serialization blocked by aegis4j"); + } + + @Test + public void testUnsafe() throws Exception { + + Field f = Unsafe.class.getDeclaredField("theUnsafe"); + f.setAccessible(true); + Unsafe unsafe = (Unsafe) f.get(null); + + String msg = "Unsafe blocked by aegis4j"; + + assertThrowsRE(() -> Unsafe.getUnsafe(), msg); + assertThrowsRE(() -> unsafe.addressSize(), msg); + assertThrowsRE(() -> unsafe.allocateInstance(null), msg); + assertThrowsRE(() -> unsafe.allocateMemory(1), msg); + assertThrowsRE(() -> unsafe.arrayBaseOffset(null), msg); + assertThrowsRE(() -> unsafe.arrayIndexScale(null), msg); + assertThrowsRE(() -> unsafe.compareAndSwapInt(null, 0, 0, 0), msg); + assertThrowsRE(() -> unsafe.compareAndSwapLong(null, 0, 0, 0), msg); + assertThrowsRE(() -> unsafe.compareAndSwapObject(null, 0, null, null), msg); + assertThrowsRE(() -> unsafe.copyMemory(0, 0, 0), msg); + assertThrowsRE(() -> unsafe.copyMemory(null, 0, null, 0, 0), msg); + assertThrowsRE(() -> unsafe.ensureClassInitialized(null), msg); + assertThrowsRE(() -> unsafe.freeMemory(0), msg); + assertThrowsRE(() -> unsafe.fullFence(), msg); + assertThrowsRE(() -> unsafe.getAddress(0), msg); + assertThrowsRE(() -> unsafe.getAndAddInt(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.getAndAddLong(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.getAndSetInt(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.getAndSetLong(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.getAndSetObject(null, 0, null), msg); + assertThrowsRE(() -> unsafe.getBoolean(null, 0), msg); + assertThrowsRE(() -> unsafe.getBooleanVolatile(null, 0), msg); + assertThrowsRE(() -> unsafe.getByte(0), msg); + assertThrowsRE(() -> unsafe.getByte(null, 0), msg); + assertThrowsRE(() -> unsafe.getByteVolatile(null, 0), msg); + assertThrowsRE(() -> unsafe.getChar(0), msg); + assertThrowsRE(() -> unsafe.getChar(null, 0), msg); + assertThrowsRE(() -> unsafe.getCharVolatile(null, 0), msg); + assertThrowsRE(() -> unsafe.getDouble(0), msg); + assertThrowsRE(() -> unsafe.getDouble(null, 0), msg); + assertThrowsRE(() -> unsafe.getDoubleVolatile(null, 0), msg); + assertThrowsRE(() -> unsafe.getFloat(0), msg); + assertThrowsRE(() -> unsafe.getFloat(null, 0), msg); + assertThrowsRE(() -> unsafe.getFloatVolatile(null, 0), msg); + assertThrowsRE(() -> unsafe.getInt(0), msg); + assertThrowsRE(() -> unsafe.getInt(null, 0), msg); + assertThrowsRE(() -> unsafe.getIntVolatile(null, 0), msg); + assertThrowsRE(() -> unsafe.getLoadAverage(null, 0), msg); + assertThrowsRE(() -> unsafe.getLong(0), msg); + assertThrowsRE(() -> unsafe.getLong(null, 0), msg); + assertThrowsRE(() -> unsafe.getLongVolatile(null, 0), msg); + assertThrowsRE(() -> unsafe.getObject(null, 0), msg); + assertThrowsRE(() -> unsafe.getObjectVolatile(null, 0), msg); + assertThrowsRE(() -> unsafe.getShort(0), msg); + assertThrowsRE(() -> unsafe.getShort(null, 0), msg); + assertThrowsRE(() -> unsafe.getShortVolatile(null, 0), msg); + assertThrowsRE(() -> unsafe.invokeCleaner(null), msg); + assertThrowsRE(() -> unsafe.loadFence(), msg); + assertThrowsRE(() -> unsafe.objectFieldOffset(null), msg); + assertThrowsRE(() -> unsafe.pageSize(), msg); + assertThrowsRE(() -> unsafe.park(false, 0), msg); + assertThrowsRE(() -> unsafe.putAddress(0, 0), msg); + assertThrowsRE(() -> unsafe.putBoolean(null, 0, false), msg); + assertThrowsRE(() -> unsafe.putBooleanVolatile(null, 0, false), msg); + assertThrowsRE(() -> unsafe.putByte(0, (byte) 0), msg); + assertThrowsRE(() -> unsafe.putByte(null, 0, (byte) 0), msg); + assertThrowsRE(() -> unsafe.putByteVolatile(null, 0, (byte) 0), msg); + assertThrowsRE(() -> unsafe.putChar(0, 'x'), msg); + assertThrowsRE(() -> unsafe.putChar(null, 0, 'x'), msg); + assertThrowsRE(() -> unsafe.putCharVolatile(null, 0, 'x'), msg); + assertThrowsRE(() -> unsafe.putDouble(0, 0), msg); + assertThrowsRE(() -> unsafe.putDouble(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.putDoubleVolatile(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.putFloat(0, 0), msg); + assertThrowsRE(() -> unsafe.putFloat(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.putFloatVolatile(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.putInt(0, 0), msg); + assertThrowsRE(() -> unsafe.putInt(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.putIntVolatile(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.putLong(0, 0), msg); + assertThrowsRE(() -> unsafe.putLong(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.putLongVolatile(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.putObject(null, 0, null), msg); + assertThrowsRE(() -> unsafe.putObjectVolatile(null, 0, null), msg); + assertThrowsRE(() -> unsafe.putOrderedInt(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.putOrderedLong(null, 0, 0), msg); + assertThrowsRE(() -> unsafe.putOrderedObject(null, 0, null), msg); + assertThrowsRE(() -> unsafe.putShort(0, (short) 0), msg); + assertThrowsRE(() -> unsafe.putShort(null, 0, (short) 0), msg); + assertThrowsRE(() -> unsafe.putShortVolatile(null, 0, (short) 0), msg); + assertThrowsRE(() -> unsafe.reallocateMemory(0, 0), msg); + assertThrowsRE(() -> unsafe.setMemory(0, 0, (byte) 0), msg); + assertThrowsRE(() -> unsafe.setMemory(null, 0, 0, (byte) 0), msg); + assertThrowsRE(() -> unsafe.shouldBeInitialized(null), msg); + assertThrowsRE(() -> unsafe.staticFieldBase(null), msg); + assertThrowsRE(() -> unsafe.staticFieldOffset(null), msg); + assertThrowsRE(() -> unsafe.storeFence(), msg); + assertThrowsRE(() -> unsafe.throwException(null), msg); + assertThrowsRE(() -> unsafe.unpark(null), msg); + + // Spring should still work with Unsafe disabled + SpringObjenesis so = new SpringObjenesis(); + assertInstanceOf(SerializablePojo.class, so.newInstance(SerializablePojo.class)); + assertInstanceOf(TestUtils.class, so.newInstance(TestUtils.class)); + assertInstanceOf(LocalDate.class, so.newInstance(LocalDate.class)); + } + + private static void assertThrowsNICE(Executable task) { + assertThrows(task, NoInitialContextException.class, "JNDI context creation blocked by aegis4j"); + } + + private static void assertThrowsSNFE(Executable task) { + assertThrows(task, StubNotFoundException.class, "RMI registry creation blocked by aegis4j"); + } + + private static void assertThrowsIOE(Executable task) { + assertThrows(task, IOException.class, "Process execution blocked by aegis4j"); + } + + private static void assertThrowsIAE(Executable task, String msg) { + assertThrows(task, IllegalArgumentException.class, msg); + } + + private static void assertThrowsRE(Executable task, String msg) { + assertThrows(task, RuntimeException.class, msg); + } + + private static void assertThrows(Executable task, Class< ? extends Throwable > type, String msg) { + Throwable root; + try { + task.execute(); + root = null; + } catch (Throwable t) { + root = getRootCause(t); + } + assertNotNull(root, "No exception thrown"); + assertInstanceOf(type, root, "Exception is wrong type"); + assertEquals(msg, root.getMessage(), "Exception has wrong message"); + } + + private static Throwable getRootCause(Throwable t) { + while (t.getCause() != null) { + t = t.getCause(); + } + return t; + } +} diff --git a/tools/aegis4j/src/test/java/net/gredler/aegis4j/CVE_2015_7501.java b/tools/aegis4j/src/test/java/net/gredler/aegis4j/CVE_2015_7501.java new file mode 100644 index 0000000000..a8d60a52b9 --- /dev/null +++ b/tools/aegis4j/src/test/java/net/gredler/aegis4j/CVE_2015_7501.java @@ -0,0 +1,104 @@ +/* Copyright (c) 2022, Daniel Gredler. All rights reserved. */ + +package net.gredler.aegis4j; + +import static net.gredler.aegis4j.TestUtils.OWNED; +import static net.gredler.aegis4j.TestUtils.installAgent; +import static net.gredler.aegis4j.TestUtils.toBytes; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.ByteArrayInputStream; +import java.io.ObjectInputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.PriorityQueue; + +import org.apache.commons.collections4.FunctorException; +import org.apache.commons.collections4.Transformer; +import org.apache.commons.collections4.comparators.TransformingComparator; +import org.apache.commons.collections4.functors.ChainedTransformer; +import org.apache.commons.collections4.functors.ConstantTransformer; +import org.apache.commons.collections4.functors.InvokerTransformer; +import org.junit.jupiter.api.Test; + +/** + * Tests mitigation of CVE-2015-7501, both at the process execution level and at the serialization level. + * We test using a {@link PriorityQueue} with a custom {@link Comparator}. The comparator is actually a + * {@link ChainedTransformer} which uses reflection to invoke {@link Runtime#exec(String)}. The comparator + * magic is invoked when the queue needs to be sorted, which happens when items are added to the queue + * and also as a last step when the queue is deserialized. + * + * @see + * @see Exploit POC + */ +public class CVE_2015_7501 { + + @Test + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void test() throws Exception { + + Path temp = Files.createTempFile("aegis4j-", ".tmp"); + temp.toFile().deleteOnExit(); + String path = temp.toAbsolutePath().toString(); + + boolean windows = System.getProperty("os.name").toLowerCase().contains("windows"); + String[] cmd = windows ? new String[] { "cmd.exe", "/c", "echo " + OWNED + ">" + path } + : new String[] { "sh", "-c", "echo " + OWNED + ">" + path }; + + Transformer transformerChain = new ChainedTransformer(new Transformer[] { + new ConstantTransformer(Runtime.class), + new InvokerTransformer("getMethod", new Class[] { String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }), + new InvokerTransformer("invoke", new Class[] { Object.class, Object[].class }, new Object[] { null, new Object[0] }), + new InvokerTransformer("exec", new Class[] { String[].class }, new Object[] { cmd }), + new ConstantTransformer(1) + }); + + // trigger directly, verify owned + PriorityQueue queue = new PriorityQueue(2, new TransformingComparator(transformerChain)); + queue.add(1); + queue.add(1); + Thread.sleep(500); // wait for file changes to sync + assertEquals(OWNED + System.lineSeparator(), Files.readString(temp), path); + + // reset + Files.write(temp, new byte[0]); + assertEquals("", Files.readString(temp), path); + + // trigger via deserialization, verify owned again + byte[] serialized = toBytes(queue); + new ObjectInputStream(new ByteArrayInputStream(serialized)).readObject(); + Thread.sleep(500); // wait for file changes to sync + assertEquals(OWNED + System.lineSeparator(), Files.readString(temp), path); + + // reset + Files.write(temp, new byte[0]); + assertEquals("", Files.readString(temp), path); + + // install aegis4j agent + installAgent(null); + + // trigger again directly, verify not owned + try { + PriorityQueue queue2 = new PriorityQueue(2, new TransformingComparator(transformerChain)); + queue2.add(1); + queue2.add(1); + fail("Exception expected"); + } catch (FunctorException e) { + Thread.sleep(500); // wait for file changes to sync + assertEquals("", Files.readString(temp), path); + assertEquals("Process execution blocked by aegis4j", e.getCause().getCause().getMessage()); + } + + // trigger again via deserialization, verify not owned + try { + new ObjectInputStream(new ByteArrayInputStream(serialized)).readObject(); + fail("Exception expected"); + } catch (RuntimeException e) { + Thread.sleep(500); // wait for file changes to sync + assertEquals("", Files.readString(temp), path); + assertEquals("Java deserialization blocked by aegis4j", e.getMessage()); + } + } +} diff --git a/tools/aegis4j/src/test/java/net/gredler/aegis4j/CVE_2019_17531.java b/tools/aegis4j/src/test/java/net/gredler/aegis4j/CVE_2019_17531.java new file mode 100644 index 0000000000..2631b79fa4 --- /dev/null +++ b/tools/aegis4j/src/test/java/net/gredler/aegis4j/CVE_2019_17531.java @@ -0,0 +1,44 @@ +/* Copyright (c) 2022, Daniel Gredler. All rights reserved. */ + +package net.gredler.aegis4j; + +import static net.gredler.aegis4j.TestUtils.testLdap; + +import org.apache.log4j.receivers.db.JNDIConnectionSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Tests mitigation of CVE-2019-17531. No setup is required besides starting the LDAP server that + * serves serialized {@link SerializableDataSource} instances. The vulnerability is triggered when + * we deserialize a JSON payload which contains a {@link JNDIConnectionSource} which references + * our LDAP server, and re-serializing the deserialized {@link JNDIConnectionSource} triggers a + * JNDI lookup. + * + * @see CVE-2019-17531 + * @see Jackson issue #2498 + * @see On Jackson CVEs + * @see Understanding Jackson deserialization + */ +public class CVE_2019_17531 { + + @Test + public void test() throws Throwable { + + Executable setup = () -> { + // only the LDAP server is needed + }; + + Executable trigger = () -> { + ObjectMapper mapper = new ObjectMapper(); + String json = "{ \"property\": { \"@class\": \"org.apache.log4j.receivers.db.JNDIConnectionSource\", \"jndiLocation\": \"ldap://localhost:8181/dc=foo\" } }"; + JsonPayload payload = mapper.readValue(json, JsonPayload.class); + mapper.writeValueAsString(payload); // triggers payload.property.getConnection() + }; + + testLdap(setup, trigger, SerializableDataSource.class, true); + } + +} diff --git a/tools/aegis4j/src/test/java/net/gredler/aegis4j/CVE_2021_44228.java b/tools/aegis4j/src/test/java/net/gredler/aegis4j/CVE_2021_44228.java new file mode 100644 index 0000000000..658b1da7ab --- /dev/null +++ b/tools/aegis4j/src/test/java/net/gredler/aegis4j/CVE_2021_44228.java @@ -0,0 +1,65 @@ +/* Copyright (c) 2022, Daniel Gredler. All rights reserved. */ + +package net.gredler.aegis4j; + +import static net.gredler.aegis4j.TestUtils.testLdap; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.core.config.Configurator; +import org.apache.logging.log4j.core.config.builder.api.AppenderComponentBuilder; +import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilder; +import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilderFactory; +import org.apache.logging.log4j.core.config.builder.api.LayoutComponentBuilder; +import org.apache.logging.log4j.core.config.builder.api.RootLoggerComponentBuilder; +import org.apache.logging.log4j.core.config.builder.impl.BuiltConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +/** + * Tests mitigation of CVE-2021-44228 (a.k.a. Log4Shell). During setup we simply configure log4j to + * perform basic logging, and set up our LDAP server to serve serialized {@link SerializablePojo} + * instances. The vulnerability is triggered when we log a message which contains a JNDI lookup. + * + * @see CVE-2021-44228 + * @see Everybody freaking out + * @see log4j-jndi-be-gone + */ +public class CVE_2021_44228 { + + @Test + public void test() throws Throwable { + + Executable setup = () -> { + configureLog4J2(); + }; + + Executable trigger = () -> { + Logger logger = LogManager.getLogger(); + logger.info("${jndi:ldap://localhost:8181/dc=foo}"); + }; + + testLdap(setup, trigger, SerializablePojo.class, false); + } + + // https://logging.apache.org/log4j/2.x/manual/customconfig.html + // https://www.baeldung.com/log4j2-programmatic-config + private static void configureLog4J2() { + + ConfigurationBuilder< BuiltConfiguration > builder = ConfigurationBuilderFactory.newConfigurationBuilder(); + + LayoutComponentBuilder standard = builder.newLayout("PatternLayout"); + standard.addAttribute("pattern", "%d [%t] %-5level: %msg%n"); + + AppenderComponentBuilder console = builder.newAppender("stdout", "console"); + console.add(standard); + builder.add(console); + + RootLoggerComponentBuilder root = builder.newRootLogger(Level.INFO); + root.add(builder.newAppenderRef("stdout")); + builder.add(root); + + Configurator.initialize(builder.build()); + } +} diff --git a/tools/aegis4j/src/test/java/net/gredler/aegis4j/JsonPayload.java b/tools/aegis4j/src/test/java/net/gredler/aegis4j/JsonPayload.java new file mode 100644 index 0000000000..4cbca4c803 --- /dev/null +++ b/tools/aegis4j/src/test/java/net/gredler/aegis4j/JsonPayload.java @@ -0,0 +1,16 @@ +/* Copyright (c) 2022, Daniel Gredler. All rights reserved. */ + +package net.gredler.aegis4j; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; + +/** + * Playing with deserialization (polymorphic type handling) fire. + */ +public class JsonPayload { + + @JsonTypeInfo(use = Id.CLASS) + public Object property; + +} diff --git a/tools/aegis4j/src/test/java/net/gredler/aegis4j/SerializableDataSource.java b/tools/aegis4j/src/test/java/net/gredler/aegis4j/SerializableDataSource.java new file mode 100644 index 0000000000..09176b8b70 --- /dev/null +++ b/tools/aegis4j/src/test/java/net/gredler/aegis4j/SerializableDataSource.java @@ -0,0 +1,69 @@ +/* Copyright (c) 2022, Daniel Gredler. All rights reserved. */ + +package net.gredler.aegis4j; + +import static net.gredler.aegis4j.TestUtils.OWNED; + +import java.io.PrintWriter; +import java.io.Serializable; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.logging.Logger; + +import javax.sql.DataSource; + +/** + * Sets a system property when {@link #getConnection()} is called, as proof of vulnerability. + */ +public class SerializableDataSource implements DataSource, Serializable { + + private static final long serialVersionUID = 1663970917224862202L; + + @Override + public Connection getConnection() throws SQLException { + System.setProperty(OWNED, Boolean.TRUE.toString()); + return null; + } + + @Override + public Connection getConnection(String username, String password) throws SQLException { + System.setProperty(OWNED, Boolean.TRUE.toString()); + return null; + } + + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + return null; + } + + @Override + public < T > T unwrap(Class< T > iface) throws SQLException { + return null; + } + + @Override + public boolean isWrapperFor(Class< ? > iface) throws SQLException { + return false; + } + + @Override + public PrintWriter getLogWriter() throws SQLException { + return null; + } + + @Override + public void setLogWriter(PrintWriter out) throws SQLException { + // empty + } + + @Override + public void setLoginTimeout(int seconds) throws SQLException { + // empty + } + + @Override + public int getLoginTimeout() throws SQLException { + return 0; + } +} diff --git a/tools/aegis4j/src/test/java/net/gredler/aegis4j/SerializablePojo.java b/tools/aegis4j/src/test/java/net/gredler/aegis4j/SerializablePojo.java new file mode 100644 index 0000000000..a5e023cb1d --- /dev/null +++ b/tools/aegis4j/src/test/java/net/gredler/aegis4j/SerializablePojo.java @@ -0,0 +1,21 @@ +/* Copyright (c) 2022, Daniel Gredler. All rights reserved. */ + +package net.gredler.aegis4j; + +import static net.gredler.aegis4j.TestUtils.OWNED; + +import java.io.ObjectInputStream; +import java.io.Serializable; + +/** + * Sets a system property upon deserialization, as proof of vulnerability. + */ +public class SerializablePojo implements Serializable { + + private static final long serialVersionUID = -3148228827965096990L; + + private void readObject(ObjectInputStream input) { + // code executed when object is deserialized + System.setProperty(OWNED, Boolean.TRUE.toString()); + } +} diff --git a/tools/aegis4j/src/test/java/net/gredler/aegis4j/TestUtils.java b/tools/aegis4j/src/test/java/net/gredler/aegis4j/TestUtils.java new file mode 100644 index 0000000000..baabe3d2d9 --- /dev/null +++ b/tools/aegis4j/src/test/java/net/gredler/aegis4j/TestUtils.java @@ -0,0 +1,133 @@ +/* Copyright (c) 2022, Daniel Gredler. All rights reserved. */ + +package net.gredler.aegis4j; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.ObjectOutputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.function.Executable; + +import com.sun.tools.attach.VirtualMachine; +import com.unboundid.ldap.listener.InMemoryDirectoryServer; +import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; +import com.unboundid.ldap.listener.InMemoryListenerConfig; +import com.unboundid.ldap.sdk.DN; +import com.unboundid.ldap.sdk.Entry; +import com.unboundid.ldap.sdk.LDAPException; + +/** + * Base test class which makes it easy to set up an embedded LDAP server, trigger an LDAP-related + * vulnerability, enable the Java agent, and then verify that the vulnerability is no longer exploitable. + */ +public final class TestUtils { + + public static final String OWNED = "owned"; + + private TestUtils() {} + + public static void testLdap(Executable setup, Executable trigger, Class< ? > ldapPayload, boolean expectException) throws Throwable { + + setup.execute(); + InMemoryDirectoryServer ldapServer = createLdapServer(8181, "dc=foo", ldapPayload); + assertNull(System.getProperty(OWNED)); + + trigger.execute(); + assertTrue(Boolean.valueOf(System.getProperty(OWNED))); + + System.clearProperty(OWNED); + assertNull(System.getProperty(OWNED)); + + installAgent(null); + + try { + trigger.execute(); + assertFalse(expectException); + assertNull(System.getProperty(OWNED)); + } catch (Exception e) { + assertTrue(expectException); + assertNull(System.getProperty(OWNED)); + assertTrue(e.getMessage().contains("JNDI context creation blocked by aegis4j")); + } + + ldapServer.shutDown(true); + } + + // https://docs.oracle.com/javase/jndi/tutorial/objects/representation/ldap.html + // https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE.pdf + private static InMemoryDirectoryServer createLdapServer(int port, String partitionSuffix, Class< ? > payload) + throws IOException, LDAPException, InstantiationException, ReflectiveOperationException { + + InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(partitionSuffix); + config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("LDAP", port)); + config.setSchema(null); + + Entry entry = new Entry(new DN(partitionSuffix)); + entry.addAttribute("objectClass", "javaNamingReference"); + entry.addAttribute("javaClassName", "Test"); + entry.addAttribute("javaCodeBase", "http://localhost/"); + entry.addAttribute("javaSerializedData", toBytes(payload.getDeclaredConstructor().newInstance())); + + InMemoryDirectoryServer directoryServer = new InMemoryDirectoryServer(config); + directoryServer.add(entry); + directoryServer.startListening(); + return directoryServer; + } + + public static String createAgentJar() throws IOException { + Class< ? > clazz = AegisAgent.class; + Path jar = Files.createTempFile("aegis4j-", ".jar"); + jar.toFile().deleteOnExit(); + Manifest manifest = new Manifest(); + manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); + manifest.getMainAttributes().putValue("Main-Class", clazz.getName()); + manifest.getMainAttributes().putValue("Agent-Class", clazz.getName()); + manifest.getMainAttributes().putValue("Premain-Class", clazz.getName()); + manifest.getMainAttributes().putValue("Can-Redefine-Classes", "true"); + manifest.getMainAttributes().putValue("Can-Retransform-Classes", "true"); + manifest.getMainAttributes().putValue("Can-Set-Native-Method-Prefix", "false"); + try (OutputStream os = Files.newOutputStream(jar); JarOutputStream jos = new JarOutputStream(os, manifest)) { + JarEntry entry = new JarEntry(clazz.getName().replace('.', '/') + ".class"); + entry.setTime(System.currentTimeMillis()); + jos.putNextEntry(entry); + jos.write(toBytes(clazz)); + jos.closeEntry(); + } + return jar.toAbsolutePath().toString(); + } + + private static byte[] toBytes(Class< ? > clazz) throws IOException { + String path = clazz.getName().replace('.', '/') + ".class"; + InputStream stream = clazz.getClassLoader().getResourceAsStream(path); + return stream.readAllBytes(); + } + + public static byte[] toBytes(Object object) throws IOException { + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream out = new ObjectOutputStream(bos)) { + out.writeObject(object); + out.flush(); + return bos.toByteArray(); + } + } + + /** + * Requires {@code -Djdk.attach.allowAttachSelf=true} on the command line. + */ + public static void installAgent(String options) throws Exception { + long pid = ProcessHandle.current().pid(); + VirtualMachine jvm = VirtualMachine.attach(String.valueOf(pid)); + jvm.loadAgent(createAgentJar(), options); + jvm.detach(); + } +}