Скрипты и песочницы в JVM

Довольно распространенная ситуация, когда приложение требует кастомизации, но это невозможно выполнить одними только настройками, а модификация исходного кода затруднительна или же отсутствует возможность обновления с необходимой частотой. Примером такой проблемы может быть, к примеру, вычисление комиссии по сложным правилам или же формирование кастомного запроса в биллинг на основе профиля абонента и тому подобное. В этом случае можно воспользоваться Java Scripting API (JSR-223) и выразить логику через изменяемые в рантайме скрипты. Более того, при наличии вменяемого DSL или API, полномочия изменения и настройки скриптов можно переложить на админов, аналитиков и тд. В этом посте мы бы хотели рассказать об эволюции одного приложения, которое активно использует скриптинг в своей работе — с чего начинали и к чему это привело.

 

Первый этап — Javascript

На первых шагах достаточно было работать со строками — отфильтровать, обрезать и т.п. Для этих целей javascript был наиболее подходящим вариантом. Наш выбор пал на Nashorn. С этим решением можно

  • Использовать свои js-функции
  • Получить доступ к Java-классам
  • Вызывать Java-методы из скрипта
  • Вызывать javascript из Java-кода
  • Реализовать песочницу
  • etc

Библиотека входит в состав JDK и даже имеет командный интерпретатор.

jjs
jjs> print (‘giggity’)
giggity

Интеграция также не составляет труда

ScriptEngine engine = new ScriptEngineManager().getEngineByName("nashorn");
engine.eval("print('giggity');");

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

Groovy engine

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

Binding binding = new Binding();
GroovyShell shell = new GroovyShell(binding);
Script scrpt = shell.parse(new File("app.groovy"));
binding.setVariable("foo", "bar");
shell.evaluate("println(foo)")

Теперь нам доступна вся мощь JVM. Пару слов о приложении — оно содержало большое количество различных очень чувствительных данных. В нем были сложные правила доступа к этим данным, которые защищали их от несанкционированного доступа. И в связи с этим у нас возникла новая проблема — безопасность. Скриптовый движок обрабатывал чувствительные данные, а поскольку он имеет доступ ко всей виртуальной машине, то любое неосторожное движение могло привести к уязвимости класса RCE.

Таким образом, требования для нового движка были следующие:

  • Запускать произвольный Groovy-код
  • Подключать произвольные Groovy-библиотеки
  • Подключать произвольные Java-библиотеки
  • Не допустить утечку данных
  • Не допустить DoS из пользовательского скрипта
  • Возможно поддержать некоторую многопоточность

Типичный процесс введения скрипта в эксплуатацию был таким:

  • Написать код
  • Запустить его в песочнице
  • Если что-то не работает — goto 1
  • Если всё хорошо — отправляем на ревью
  • Если ревью не ок — goto 1
  • Публикация и запуск в продакшене

Давайте пройдемся по пунктам из требований

Запуск произвольного Groovy-кода

Тут кажется всё хорошо

Использование произвольных Groovy-библиотек

Реализовано путем подключения скриптов (обычного текста, не jar) из репозитория. Выглядит как обычная конкатенация строк.

Использование произвольных Java-библиотек

Здесь случай более сложный, нежели в предыдущем пункте. Тут мы не стали мудрить, делать загрузку произвольных классов и прочего. Все необходимые библиотеки складывались в classpath через систему сборки после ручного ревью. Не так гибко как с Groovy, но легко имплементировать и нас это вполне устроило.

Не допустить утечку данных

Самое интересное требование, ведь, как уже было сказано выше — любая ошибка превращает систему в один огромный RCE. Чтобы снизить вероятность такого конфуза система безопасности должна быть устроена как переборки на корабле — если в корпусе будет пробоина — переборки должны защитить корабль от затопления. Многие атаки имеют многоуровневую структуру и могут объединять несколько не критичных уязвимостей в одну огромную дыру.

vulnerabilty

Хороший пример такой атаки можно посмотреть здесь. Из готовых решений для песочницы на тот момент был только sandbox от Kohsuke Kawaguchi, но он не подошёл нам по причине того, что работал по принципу белого списка, что не очень укладывалось в наши требования

Первый шаг — необходимо вынести скриптовый движок за пределы основного приложения, т.к. при некорректной настройке SecurityManager’а скрипт получит доступ ко всем внутренностям вроде базы данных сервисов и прочего.

groovy sandbox before

До

groovy sandbox step 1

После

Второй шаг — вынести песочницу за пределы хостов, в которых запущены основные процессы. Если вдруг наша песочница даст течь, то она не должна получить доступ к конфигурационным файлам, ключам шифрования и так далее. Контейнер или хост, в котором запущено новое приложение должно быть также защищено от входящих и исходящих соединений путем настройки фаервола (кроме обслуживающих портов типа ssh и связи с “большой землей”).

groovy sandbox step 3

Третий шаг — конфигурирование SecurityManager’а. Для тех кто сталкивается с ним впервые — это класс, который осуществляет контроль за вашими телодвижениями внутри виртуальной машины. Вы можете разрешить или запретить определенные действия внутри своей JVM. Внутри это выглядит как вызов метода checkSomething(java.security.Permission permission). Существует некоторое конечное количество разрешений — FilePermission, SocketPermission, RuntimePermission, etc. Как его настраивать узнаем немного позже. Воспользуемся ещё одной интересной штукой модели безопасности JVM — подпись кода. Вкратце — можно подписать код своим ключом и дать подписанному коду больше привилегий, нежели обычному. Мы подпишем код приложения и это будет отличать его от groovy-классов, которые останутся неподписаными.

groovy sandbox step 5

Как подписать jar-файл

Создать ключницу и сгенерировать ключ

keytool -genkey -keyalg RSA -alias sandbox -keystore /pass/to/keystore.jks -storepass <<password>>

Теперь можно подписать готовую jar

jarsigner -keystore /path/to/keystore sandbox.jar sandbox

Или используя Jarsigner Plugin

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jarsigner-plugin</artifactId>
            <version>3.0.0</version>
            <executions>
                <execution>
                    <id>sign</id>
                    <goals>
                        <goal>sign</goal>
                    </goals>
                </execution>
            </executions>
            <configuration>
                <keystore>/path/to/keystore.jks</keystore>
                <alias>sandbox</alias>
                <storepass>storepass</storepass>
                <keypass>keypass</keypass>
            </configuration>
        </plugin>
    </plugins>
</build>

Eсли вы вдруг используете Spring Boot, с исполняемым jar, то перед этим нужно будет пройтись shadow plugin вместо стандартного плагина Spring Boot.

<build>
    <plugins>
        <plugin>
            <artifactId>maven-shade-plugin</artifactId>
            <version>3.1.0</version>
            <configuration>
                <minimizeJar>false</minimizeJar>
                <transformers>
                    <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                        <resource>META-INF/spring.handlers</resource>
                    </transformer>
                    <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                        <resource>META-INF/spring.schemas</resource>
                    </transformer>
                    <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                        <resource>META-INF/spring.factories</resource>
                    </transformer>
                </transformers>
            </configuration>
            <executions>
                <execution>
                    <phase>package</phase>
                    <goals>
                        <goal>shade</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Файл с настройками политик для SecurityManager выглядит так

keystore "file:///path/to/keystore.jks", "JKS";

keystorePasswordURL "file:///path/to/file/with/keystore/password.txt";

grant signedBy "sandbox {
  permission java.security.AllPermission;
};

grant {
     permission java.net.SocketPermission "main-app-host:port "connect,resolve";
};

Секция grant signedBy «sandbox” разрешает все действия коду, подписанному sandbox’ом

Всем остальным можно лишь ходить на main-app-host:port, где находится API основной системы, которое в свою очередь защищено авторизацией и аутентификацией.

Теперь мы можем вызывать API из нашей песочницы и не бояться протечек

@Autowired
private ApiClient apiClient;

.....

Binding binding = new Binding();
binding.setVariable("apiClient", apiClient);
Object res = new GroovyShell(binding).evaluate(script);

groovy sandbox step 5

Не допустить DoS из пользовательского скрипта

Тоже достаточно интересное требование. Никто не запрещает запустить из пользовательского скрипта что-то вроде

while(true){
   apiClient.callSomethingSlow();
}

Самые простые решения, которые напрашиваются здесь

  1. Ограничить количество одновременно выполняемых скриптов на стороне песочницы
  2. Защитить API через рейт-лимитер
  3. Ограничить количество отдаваемых данных с главного приложения (paging, etc)

 

На стороне песочницы нас поджидает еще одна проблемка. Хоть мы и защитили программу через SecurityManager, но он всё же не всемогущий. Он может защитить от изменения потока, но никто не запрещает запустить пользователю бесконечный цикл в потоке

Thread.start {
   while(true)
}

Чтобы такого не допустить можно пойти несколькими путями

  • Попробовать использовать CompilationCustomizers типа SecureASTCustomizer чтоб запретить использовать нежелательные классы, но можно промахнуться и не попасть в начальные требования
  • Попробовать использовать ClassLoader чтобы отключить загрузку некоторых классов

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

  • Когда запускается скрипт — он запускается в отдельной группе потоков, которая потом добавляется в специальную коллекцию
  • Когда создается новый ThreadGroup происходит вызов SecurityManager.checkAccess(ThreadGroup). Мы можем запретить доступ ко всем системным группам и не дать потоку спрятаться

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

Stopping a thread causes it to unlock all the monitors that it has locked. (The monitors are unlocked as the ThreadDeath exception propagates up the stack.) If any of the objects previously protected by these monitors were in an inconsistent state, other threads may now view these objects in an inconsistent state. Such objects are said to be damaged. When threads operate on damaged objects, arbitrary behavior can result.

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

Но тут есть ещё одна проблема — что будет если кто-то запустит пул потоков (например кэшированный) прямо из скрипта? Убить засланный в него поток не получится — пул тут же очухается и создаст новый.

groovy sandbox hreads

 

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

Собираем всё вместе

public class SandboxSecurityManager extends SecurityManager {

    private final Set<ThreadGroup> threadGroups = new HashSet<>();

    public void addThreadGroup(ThreadGroup threadGroup) {
        threadGroups.add(threadGroup);
    }

    public void removeThreadGroup(ThreadGroup threadGroup) {
        threadGroups.remove(threadGroup);
    }

    @Override
    public void checkAccess(ThreadGroup g) {
        if (g != Thread.currentThread().getThreadGroup() 
                && threadGroups.contains(Thread.currentThread().getThreadGroup())) { 
            //throw an exception when current thread is started from script,
            //but trying to access to another TG
            throw new SecurityException("Access denied to ThreadGroup " + g.getName());
        }
        super.checkAccess(g);

    }
}
public class EngineService {


    private final ApiClient apiClient;

    .....


    public Object run(Script script) throws Exception {

        Worker worker = new Worker(script, apiClient);
        ThreadGroup threadGroup = new ThreadGroup("script-" + UUID.randomUUID());
        Thread t = new Thread(threadGroup, worker);
        SandboxSecurityManager securityManager = (SandboxSecurityManager) System.getSecurityManager();
        securityManager.addThreadGroup(threadGroup);

        t.start();

        if(!waitForThreadCompleted(threadGroup, Duration.ofSeconds(TIMEOUT))){
            threadGroup.stop();
            if(hasActiveThreads(threadGroup)){
                sendToGuard(threadGroup);
            }
            throw new TimeoutException();
        }

        return worker.result;
    }


    public class Worker implements Runnable {

        private volatile Object result;
        private final Script script;
        private final ApiClient apiClient;

        public Worker(Script script, ApiClient apiClient) {
            this.script = script;
            this.apiClient = apiClient;
        }

        private GroovyShell createShell() {

            Binding binding = new Binding();
            binding.setVariable("apiClient", apiClient);
            return new GroovyShell(binding);
        }

        public void run() {
            result = createShell().evaluate(script.getScript());
        }

    }

    ....

}

 

groovy sandbox final step

Заключение

Конечно, все вопросы технически решить не получится. Если злоумышленник захочет — он может просто скопировать информацию в буфер обмена. Это можно пофиксить уже только на административном уровне. И в подавляющем большинстве случаев вам не нужно строить такую параноидальную защиту, достаточно воспользоваться готовой песочницей, особенно если вы можете определить достаточный набор классов и операций. Храните ваши скрипты в надежном месте, иначе они могут превратиться в рассадник самых неприятных уязвимостей

Оставить комментарий

Ваш адрес email не будет опубликован.