Инъекция блокнотом или история о том, как мы новые диагностики делали

от автора

В этой статье я немного расскажу о том, как в Java осуществляется вызов команд уровня операционной системы. Также поговорим про OS Command и Argument Injections и про то, как мы делали диагностики, которые могут помочь в их обнаружении.

Погружение

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

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

OS Command Injection — это уязвимость, позволяющая злоумышленнику выполнять вредоносные команды уровня операционной системы на какой-либо машине. Возможно это тогда, когда команда уровня ОС частично или полностью формируется из данных, пришедших извне.

Уязвимость серьёзная: в классификации OWASP Top Ten она находится в категории A03 – Injections. Уметь превентивно находить такое — важно. О том, как мы сделали диагностики, помогающие в этом, расскажу далее.

Adventure time

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

И при создании диагностики, ищущей OS Command Injections, мы прошли абсолютно через всё, что я перечислил выше. Но давайте по порядку 🙂

Выполнение ОС команд в Java

В стандартной Java-библиотеке для выполнения ОС команд есть 2 класса. Рассмотрим оба.

Runtime

Первый — это Runtime. Он позволяет Java-приложению взаимодействовать с окружением, в котором оно работает.

Для выполнения команды ОС нужно воспользоваться методом exec, передав в него нужную команду_._ Давайте сразу взглянем на пример:

public class Main {   public static void main(String[] args) throws IOException {     Process process = Runtime.getRuntime().exec("ping 127.0.0.1");     try {       System.out.println(getProcessOutput(process));     } catch (IOException | InterruptedException e) {       throw new RuntimeException(e);     }   }    private static String getProcessOutput(Process process)          throws IOException, InterruptedException {                  try (InputStream stream = process.getInputStream();           BufferedInputStream inputStream = new BufferedInputStream(stream)     ) {       return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);     } catch (IOException e) {       throw new RuntimeException(e);     }   } } 

Здесь происходит запуск команды ping 127.0.0.1 и вывод output’а процесса в консоль. Предлагаю взглянуть на фрагмент вывода:

Pinging 127.0.0.1 with 32 bytes of data: Reply from 127.0.0.1: bytes=32 time<1ms TTL=128 Reply from 127.0.0.1: bytes=32 time<1ms TTL=128 .... 

Всё работает — это отлично.

Если чуть более подробно: методу exec передаётся строка, представляющая команду операционной системы и её аргументы. Этот метод запускает процесс ОС и возвращает объект типа Process, который содержит информацию о нём.

А в getProcessOutput я просто получаю вывод выполняемого процесса и преобразую его в строку.

Очень важный момент: метод exec под капотом использует класс ProcessBuilder. О нём мы поговорим дальше.

ProcessBuilder

ProcessBuilder является вторым классом, который позволяет выполнять команды уровня ОС. Команду для исполнения можно задать через конструктор класса или с помощью метода command. Чтобы запустить процесс, достаточно вызвать метод start, который инициирует создание процесса и возвращает объект Process, связанный с ним.

Пример использования:

public class Main {   public static void main(String[] args) throws IOException {     ProcessBuilder processBuilder = new ProcessBuilder("ping",                                                         "127.0.0.1");     Process process = processBuilder.start();     try {       System.out.println(getProcessOutput(process));     } catch (IOException | InterruptedException e) {       throw new RuntimeException(e);     }   }    // .... } 

Как вы можете заметить, в сравнении с предыдущим примером изменились только первые строчки в методе main. В них создаётся объект, который запускает нужный нам процесс. Однако там и заключён один интересный момент. Конструктору ProcessBuilder мы передаём команду с параметрами «по частям». Первым параметром идёт сама команда, следующими — её аргументы.

И именно на основе этой команды метод start инициирует запускаемый процесс.

Ранее я упомянул, что у класса Runtime метод exec под капотом использует ProcessBuilder. Какие из этого следуют нюансы, я расскажу далее.

А это безопасно?

После того, как я узнал об этих методах, я задался вопросом: «А можно ли добавить вредоносную команду к основной, если команда формируется на основе данных, пришедших извне?».

Пошёл пробовать:

public class Main {   public static void main(String[] args) throws IOException {     String notTaintedCommand = "ping ";     String taintedData = args[0];     Process process = Runtime.getRuntime().exec(notTaintedCommand +                                                  taintedData);     try {       System.out.println(getProcessOutput(process));     } catch (IOException | InterruptedException e) {       throw new RuntimeException(e);     }   }    // .... } 

Данными, пришедшими извне, здесь выступают элементы массива args. В рамках гипотетического примера представим, что нам оттуда пришла строка 127.0.0.1 & notepad.

Ожидается, что нам поступит ip-адрес, который мы попробуем пропинговать. Но злосчастные «данные извне» пытаются испортить нам всю картину. Подразумевается, что после выполнения этой команды сначала пропингуется какой-то ip адрес, а потом произойдёт «инъекция блокнотом». По крайней мере, выполни это в оболочке cmd, и всё бы было именно так.

Но, как говорится, выкуси. Вывод будет следующим:

Bad parameter &.

Это сообщение выброшено самой утилитой ping.

Всё дело в том, что «под капотом» метод exec разбивает строку на массив строк по символам-разделителям. Этот массив передаётся в конструктор создаваемого объекта ProcessBuilder. А сам ProcessBuilder, как я уже говорил выше, создаёт процесс на основе команды, которая представляет из себя первый элемент массива.

То есть вызова оболочки (cmd в Windows или bash в Unix-системах) не происходит. Из-за этого метасимволы оболочки, такие как & или |, интерпретируются не так, как этого можно было бы ожидать. Они действительно передаются вызываемой программе как отдельные параметры. Из-за этого мы и увидели такое сообщение.

Складывается ощущение, что если команда формируется лишь частично на основе внешних данных, то и угроза не такая серьёзная, какой могла бы быть (ну, если конечно команда не rm). Всегда ли это так? Чуть позже я отвечу на этот вопрос, оставайтесь на связи.

Время диагностики

Мы рассмотрели, как реализуется вызов ОС команд в Java. Теперь можно переходить к созданию диагностики.

Для нас изначально это казалось достаточно простой задачей. По сути, это должно было быть простой taint-диагностикой.

Если резюмировать вкратце, то:

  • есть источники, т.е. места, откуда внешние данные приходят;

  • есть сами непроверенные данные;

  • есть стоки, т.е. места, в которых непроверенные данные могут спровоцировать эксплуатацию уязвимости;

  • есть санитизация, т.е. процесс проверки / очистки внешних данных. Происходить она должна перед тем, как данные попадают в сток.

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

Сначала планировалось просто разметить источники, стоки и санитизирующие методы. Но преградой, не позволившей нам сразу это сделать, стали две разные возможные ситуации — Command Injection и Argument Injection.

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

  • Command Injection (CWE-78) — команда и аргументы полностью приходят извне.

  • Argument Injection (CWE-88) — команда задана изначально, извне приходят только её параметры.

Почему их разделили? Как мне кажется, уровень опасности у них довольно-таки разный. В случае с Command Injection мы, как гипотетические хакеры, можем управлять машиной. В случае же с Argument Injection всё зависит от обстоятельств.

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

Если в случае с ProcessBuilder всё достаточно просто — нам нужно посмотреть, в каком по счёту параметре находятся непроверенные данные. Если в первом, то это Command Injection, а если в любом другом, то это Argument Injection.

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

В целом, решение по своей задумке достаточно тривиальное. Мы смотрим, как формируется строка, передаваемая в exec. Если она полностью пришла извне, то это Command Injection. Но если команда определена изначально, а строка, пришедшая извне, с ней конкатенируется, то мы воспринимаем это как Argument Injection.

Осилив эту сложность, диагностики мы сделали. И теперь можем ловить, к примеру, такое:

@RestController("/osInjection") public class OSCommandController {    @GetMapping("/execute")   public String execute(@RequestParam("command")                     String command) throws IOException {      String taintedData = command;     Process process = Runtime.getRuntime().exec(taintedData);     try {       return getProcessOutput(process);     } catch (IOException | InterruptedException e) {       throw new RuntimeException(e);     }   }     // .... } 

V5310 Possible command injection. Potentially tainted data in ‘taintedData’ variable is used to create OS command. OSCommandController.java 20

Или такое:

@RestController("/os_injection") public class OSCommandController {    @GetMapping("/execute")   public String execute(@RequestParam("argument")                       String argument) throws IOException {      String notTaintedData = "ping";     String taintedData = argument;     Process process = Runtime.getRuntime().exec(notTaintedData + " " +                                                  taintedData);     try {       return getProcessOutput(process);     } catch (IOException | InterruptedException e) {       throw new RuntimeException(e);     }   }    // .... } 

V5311 Possible argument injection. Potentially tainted data in ‘taintedData’ variable is used to create OS command. OSCommandController.java 21

Ура-ура!

Инъекция блокнотом

Теперь хочется обговорить тот момент, который я даже в название статьи вынес. Помните, я рассказывал выше, что процессы формируются напрямую, а не через оболочку? И, соответственно, конкатенировать к ней дополнительно вредоносную команду не то чтобы и возможно.

Да, изначально ситуация действительно обстоит так. Но не зря я вам выше говорил, что в случае Argument Injection серьёзность зависит от обстоятельств.

Что, если мы хотим, чтобы для вызова команд всё же использовалась оболочка ОС? Так сделать действительно можно. В случае с Windows, к примеру, достаточно, чтобы вызываемой командой была cmd, а первым параметром — /c. Тогда нужная нам команда выполнится через оболочку cmd.

То есть, раз вызов происходит через оболочку, значит и конкатенирующими метасимволами оболочки можно будет воспользоваться? Давайте посмотрим:

public class Main {   public static void main(String[] args) throws IOException {     String notTaintedCommand = "cmd /c ping ";     String taintedData = args[0];     Process process = Runtime.getRuntime().exec(notTaintedCommand +                                                  taintedData);     try {       System.out.println(getProcessOutput(process));     } catch (IOException | InterruptedException e) {       throw new RuntimeException(e);     }   }     // .... } 

Представим, что в args[0] лежит 127.0.0.1 & notepad. Я запустил и угадайте, что произошло. Пропинговался нужный ip-адрес и… действительно открылся блокнот.

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

И, казалось бы, эта ситуация нивелирует разницу между уровнями серьёзности Command и Argument уязвимостей. Тогда зачем нам две отдельные диагностики? Мы руководствовались следующим: вызов оболочки — лишь частный случай. Соответственно, степень серьёзности Argument Injection зависит от ситуации. А вот с Command Injection ситуация понятна всегда: её степень серьёзности высокая вне зависимости от обстоятельств. Отсюда и разделение на две отдельные диагностики.

А что, если не всё так просто?

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

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

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

Заключение

С данными, пришедшими в программу извне, всегда нужно быть осторожным.

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

Также не могу не сказать, что теперь в Java-анализаторе PVS-Studio с релиза 7.35 есть диагностики, которые помогут вам обнаружить вышеописанные потенциальные уязвимости. Если есть желание попробовать, то сделать это можно по ссылке.

А на этом буду с вами прощаться! До скорых встреч!

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Vladislav Bogdanov. Notepad injection or the story of writing new diagnostic rules.


ссылка на оригинал статьи https://habr.com/ru/articles/894872/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *