Написание DNS-серверов
Мастер Йода говорил: «И как всегда двое их, не больше и не меньше». Он, конечно же, говорил об отношениях «клиент — сервер», и поскольку вы являетесь мастером клиентов, то пришло время стать мастером серверов. В этом разделе с помощью того же пакета Go DNS мы напишем простой сервер и прокси. DNS-серверы можно использовать для нескольких вредоносных задач, включая туннелирование сетей с ограниченным доступом и совершение спуфинг-атак с помощью поддельных беспроводных точек доступа.
Для начала нужно настроить лабораторную среду. Она позволит вам симулировать реалистичные сценарии, не требуя наличия действительных доменов и использования дорогостоящей инфраструктуры. Но при желании вы без проблем можете зарегистрировать домены и применять реальный сервер.
Настройка лаборатории и знакомство с сервером
Лаборатория состоит из двух виртуальных машин (VM): Microsoft Windows VM, выступающей в роли клиента, и Ubuntu VM, действующей в качестве сервера.
В этом примере для каждой машины используются VMWare Workstation и сетевой мост. Допустимо применение частной виртуальной сети, но при этом необходимо убедиться, что обе машины принадлежат одной сети. Сервер будет выполнять два экземпляра Cobalt Strike Docker, собранных из официального образа Java Docker (Java — необходимое условие для Cobalt Strike). Снизу показано, как будет выглядеть лаборатория.
Сначала нужно создать виртуальную машину Ubuntu (Ubuntu VM). Для этого мы используем дистрибутив 16.04.1 LTS. Никаких особых требований здесь нет, но VM необходимо настроить на использование не менее 4 Гбайт ОЗУ и двух CPU. Если есть, можно задействовать существующую VM или хост. Закончив с операционной системой, необходимо установить среду разработки Go (см. главу 1).
После создания Ubuntu VM займитесь установкой утилиты контейнера виртуализации Docker. В разделе этой главы, посвященном прокси, мы будем использовать Docker для запуска нескольких экземпляров Cobalt Strike. Для установки Docker выполните в терминале:
После этого повторно войдите в систему и убедитесь, что Docker установлен, выполнив следующую команду:
После установки Docker с помощью следующей команды скачайте образ Java:
Эта команда получит базовый образ Java Docker, не создавая контейнеры. Таким образом мы подготавливаемся к скорому выполнению сборок Cobalt Strike.
В завершение необходимо убедиться в том, что dnsmasq не запущен, потому что он слушает порт 53. В противном случае ваши DNS-серверы не смогут работать, так как они должны использовать именно этот порт. Если процесс dnsmasq запущен, завершите его по ID:
Теперь нужно создать виртуальную машину Windows (Windows VM). Опять же можно использовать существующую машину. Никаких особых настроек делать не требуется, достаточно минимальных. Когда система заработает, установите для DNS-сервера IP-адрес системы Ubuntu.
Чтобы протестировать настройку лабораторного стенда и перейти к написанию DNS-серверов, мы начнем с создания простого сервера, который возвращает только А-записи. В GOPATH системы Ubuntu создайте каталог github.com/blackhat-go/bhg/ch-5/a_server и файл для хранения кода main.go. Снизу будет показан весь код для создания простого DNS-сервера.
Код начинается с вызова HandleFunc(), он во многом напоминает пакет net/http. Первый аргумент функции является шаблоном запроса для сопоставления. Он станет применяться для указания DNS-серверам, какие запросы будут обрабатываться переданной функцией. Используя точку, мы сообщаем серверу, что предоставляемая во втором аргументе функция будет обрабатывать все запросы.
Следующий передаваемый в Handlefunc() аргумент — это функция, содержащая логику обработчика. Она получает два аргумента: ResponseWriter и сам запрос. Внутри обработчика сначала создается новое сообщение и устанавливается ответ. Затем создается ответ на каждый вопрос с помощью А-записи, которая реализует интерфейс RR. Эта часть будет различаться в зависимости от типа искомого вами ответа. Указатель на А-запись добавляется в поле Answer ответа с помощью append(). По завершении ответа его сообщение записывается вызывающему клиенту с помощью w.WriteMsg(). В конце для запуска сервера вызывается ListenAndServe(). Этот код интерпретирует все запросы в IP-адрес 127.0.0.1.
Запустив сервер, можно протестировать его с помощью dig. Убедитесь, что имя хоста, для которого выполняются запросы, разрешается в 127.0.0.1. Это будет означать, что все работает как надо:
Обратите внимание на то, что сервер нужно будет запускать с помощью sudo или через корневую учетную запись (root), потому что он прослушивает привилегированный порт 53. Если сервер не запускается, может потребоваться завершить dnsmasq.
Создание DNS-сервера и прокси:
DNS-туннелирование — это техника извлечения данных, дающая возможность установить C2-канал из сетей с контролем исходящего трафика. Используя авторитетный DNS-сервер, злоумышленник может проложить маршрут через внутренние DNS-серверы организации и выйти через интернет, причем ему не потребуется прямое подключение к собственной инфраструктуре. Несмотря на медлительность такой атаки, защититься от нее сложно. DNS-туннелирование выполняется с помощью ряда открытых и проприетарных полезных нагрузок, одной из которых является Beacon от Cobalt Strike. В текущем разделе мы напишем собственный DNS-сервер и прокси, а также научимся с помощью Cobalt Strike мультиплексировать полезные нагрузки C2 для DNS-туннелирования.
Настройка Cobalt Strike:
Если вам доводилось использовать этот инструмент, то вы наверняка замечали, что по умолчанию team-сервер прослушивает порт 53. В связи с этим и с тем, что советует документация, в системе должен быть запущен только один сервер, поддерживая соотношение один к одному. Это может стать проблемой для средних и больших команд. Например, если у вас есть 20 команд, реализующих наступательные мероприятия против 20 отдельных организаций, то поддержка 20 систем, способных выполнять team-сервер, может стать затруднительной. Эта проблема касается не только Cobalt Strike и DNS, но и других протоколов, включая полезные нагрузки HTTP, такие как Metasploit Meterpreter и Empire. Несмотря на то что можно установить слушатели на различные порты, есть большая вероятность выхода трафика через такие стандартные TCP-порты, как 80 и 443. Отсюда возникает логичный вопрос: как вы и другие команды можете совместно использовать один порт и делать перенаправление на разных слушателей? Ответом будет, конечно же, прокси-сервер. Пора вернуться в лабораторию.
ПРИМЕЧАНИЕ
В реальных сценариях противодействия вам потребуется иметь несколько уровней маневрирования, абстрагирования и переадресации для маскировки team-сервера. Это можно реализовать с помощью UDP- и TCP-переадресации через небольшие вспомогательные серверы, использующие разных хостинг-провайдеров. Основной team-сервер и прокси также могут работать на разных системах. В этом случае кластер коллективного сервера размещается в обширной системе с большим объемом ОЗУ и мощным CPU.
Давайте запустим два экземпляра коллективного сервера в двух контейнерах Docker. Это позволит им прослушивать порт 53, а также даст каждому серверу возможность использовать собственную систему и, следовательно, собственный стек IP. Для сопоставления UDP-портов с хостом из контейнера мы будем применять встроенный в Docker сетевой механизм. Для начала скачайте пробную версию Cobalt Strike с https://trial.cobaltstrike.com/1. Для этого нужно создать пробную учетную запись, получив возможность скачать tar-архив. Теперь можно запускать team-серверы.
Для запуска первого контейнера выполните в терминале следующий код:
Эта команда выполняет несколько действий. С ее помощью вы сообщаете Docker о необходимости удаления контейнера после выхода, а также о том, что после запуска будете с ним взаимодействовать. Далее идет сопоставление порта 2020 системы хоста с портом 53 в контейнере и порта 50051 с портом 50050. Затем каталог, содержащий архив Cobalt Strike, сопоставляется с каталогом данных в контейнере. Здесь можно указать любое имя каталога, и Docker без проблем его создаст. В завершение предоставляется образ, который нужно использовать (в данном случае Java), а также команда для выполнения при запуске.
Оказавшись внутри контейнера, запустите team-сервер с помощью следующих команд:
Указываемый IP-адрес должен соответствовать текущей виртуальной машине, а не адресу контейнера.
Далее откройте новое окно терминала в хосте Ubuntu и перейдите в каталог с архивом Cobalt Strike. Выполните следующие команды для установки Java и запуска клиента Cobalt Strike:
Должен запуститься Cobalt Strike GUI. После сообщения о пробной версии измените порт team-сервера на 50051, а также установите соответствующие имя пользователя и пароль.
Вы успешно подключились к серверу, полностью работающему в Docker-контейнере. Теперь повторим тот же процесс для запуска второго сервера. На этот раз будем сопоставлять другие порты. При этом вполне логичным будет увеличить значение порта на единицу. Выполните следующую команду в новом окне терминала, чтобы запустить новый контейнер и прослушивать порты 2021 и 50052:
Из клиента Cobalt Strike создайте новое подключение, выбрав Cobalt Strike New Connection, изменив порт на 50052 и нажав Connect. Подключившись, вы должны увидеть в нижней части консоли две вкладки, с помощью которых можно переключаться между серверами.
Успешно завершив подключение к двум коллективным серверам, пора запустить два DNS-слушателя. Для создания слушателя выберите в меню пункт Configure Listeners. Он обозначен значком с изображением наушников. Из этого меню выберите Add, чтобы вызвать окно New Listener. Введите в нем следующее:
Name: DNS 1;
Payload: windows/beacon_dns/reverse_dns_txt;
Host: <IP address of host>;
Port: 0.
В этом примере установлен порт 80, но наша полезная нагрузка DNS по-прежнему использует порт 53. Это нормально. Порт 80 специально задействуется для гибридных полезных нагрузок. На скрине снизу показаны окно New Listener и информация, которую необходимо ввести.
Далее, как показано на скрине снизу, вам будет предложено указать домены, которые будут использоваться для установки маячков.
Введите в качестве DNS-маячка домен attacker1.com. Он должен соответствовать имени домена, куда полезная нагрузка будет отправлять сигналы. Далее отобразится сообщение о запуске нового слушателя. Повторите этот процесс на другом team-сервере, используя значения DNS2 и attacker2.com. Прежде чем задействовать этих двух слушателей, нужно написать промежуточный сервер, который будет проверять DNS-сообщения и соответствующим образом их перенаправлять. Это и будет ваш прокси.
Создание DNS-прокси:
Используемый вами на протяжении этой главы DNS-пакет облегчает написание функции-посредника — вы уже работали с некоторыми такими функциями в предыдущих разделах. Наш прокси должен уметь:
создавать функцию-обработчик для приема входящего запроса;
проверять в этом запросе вопрос и извлекать имя домена;
определять вышестоящий DNS-сервер, соответствующий этому имени домена;
обмениваться вопросом с этим вышестоящим DNS-сервером и писать ответ клиенту.
В функции можно прописать обработку attacker1.com и attacker2.com как статических значений, но такой вариант не удастся поддерживать. Вместо этого следует искать записи во внешнем для программы источнике, например в базе данных или файле конфигурации. В приведенном далее коде это реализуется с помощью формата domain.server, который перечисляет входящие домен и вышестоящий сервер через точку. Чтобы запустить программу, создайте функцию для парсинга файла, содержащего записи в этом формате. Запишите код снизу в новый файл main.go.
В этом коде сначала мы определяем функцию, которая парсит файл с информацией о конфигурации и возвращает map[string]string. Эта карта будет использоваться для поиска входящего домена и извлечения вышестоящего сервера.
Введите в терминале первую команду приведенного далее кода, чтобы записать следующую за echo строку в файл proxy.config. Затем нужно скомпилировать и запустить dns_proxy.go.
Что мы здесь видим? Вывод представляет сопоставление между именами доменов team-серверов и портом, который прослушивает DNS-сервер Cobalt Strike. Напомним, что в двух отдельных контейнерах Docker мы сопоставили порты 2020 и 2021 с портом 53. Здесь же использовали быстрый и грязный путь создания основной конфигурации для инструмента, чтобы вам не пришлось хранить его в базе данных или другом постоянном хранилище.
Определив карты записей, можно написать обработчик. Давайте уточним код, добавив в функцию main() приведенный далее фрагмент, который должен следовать за парсингом файла конфигурации:
Код начинается с вызова HandleFunc() с точкой для обработки всех входящих запросов, а также определения анонимной функции, то есть функции, которую мы не собираемся использовать повторно (у нее нет имени). Это удобная структура на случай, когда вам не нужно повторно задействовать некий блок кода. Если же ее применение в нескольких местах все же подразумевается, то необходимо объявлять и вызывать ее как именованную функцию. Далее идет проверка среза входящих вопросов, гарантирующая, что все вопросы переданы. Если же нет, происходит вызов HandleFailed() и возврат для раннего выхода из функции. Такой шаблон используется во всем обработчике. Если присутствует хотя бы один вопрос, можно безопасно получить запрашиваемое имя из первого вопроса. Разделять имя точкой нужно для извлечения имени домена. В результате этого не должно получаться значение меньше 1, но на всякий случай стоит проверить. Хвост среза — элементы в его конце — можно получить, применив в срезе оператор slice. Теперь нужно извлечь вышестоящий сервер из карты записей.
При извлечении значения из карты могут возвращаться одна или две переменные. Если ключ (в нашем случае имя домена) в карте присутствует, будет возвращено соответствующее значение. Если же домен отсутствует, возвращается пустая строка. Можно проверять, является ли возвращенное значение пустой строкой, но это окажется неэффективным, когда вы начнете работать с более сложными типами. Вместо этого мы задаем две переменные: первая — это значение ключа, а вторая — логическое значение, возвращающее true, если ключ найден. Убедившись в совпадении, мы обмениваемся запросом с вышестоящим сервером. Здесь мы просто подтверждаем, что имя домена, для которого получен запрос, настроено в постоянном хранилище. Далее записывается ответ вышестоящего сервера клиенту. Определив функцию-обработчик, мы запускаем сервер. В завершение можно собирать и запускать прокси.
После запуска мы протестируем его с помощью двух слушателей Cobalt Strike. Для этого сначала нужно создать два самостоятельных (stageless) исполняемых файла. В верхнем меню Cobalt Strike нажмите значок с изображением шестеренки и измените формат вывода на Windows Exe. Повторите процесс из каждого team-сервера. Скопируйте эти исполняемые файлы в Windows VM и запустите. DNS-сервер Windows VM должен иметь IP-адрес вашего Linux-хоста. В противном случае тест не сработает.
На это уйдет какое-то время, но в итоге вы должны увидеть, что в каждом team-сервере установлен маячок. Миссия выполнена!
Финальные штрихи:
Все отлично, но когда вам нужно изменить IP-адрес team-сервера или переадресатора, а также в случаях добавления записи, потребуется перезапускать сервер. Маячки, скорее всего, переживут этот процесс, но зачем рисковать, если есть лучшее решение? Можно использовать сигналы процесса, сообщая выполняющейся программе о необходимости перезагрузки файла конфигурации. Об этом трюке я впервые узнал от Мэтта Холта (Matt Holt), который реализовал его на прекрасном Caddy Server. На скрине снизу показана вся программа с уже добавленной логикой отправки сигнала процесса.
package main
import (
"bufio"
"fmt"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
)
func parse(filename string) (map[string]string, error) {
records := make(map[string]string)
fh, err := os.Open(filename)
if err != nil {
return records, err
}
defer fh.Close()
scanner := bufio.NewScanner(fh)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, ",", 2)
if len(parts) < 2 {
return records, fmt.Errorf("%s is not a valid line", line)
}
records[parts[0]] = parts[1]
}
log.Println("records set to:")
for k, v := range records {
fmt.Printf("%s -> %s\n", k, v)
}
return records, scanner.Err()
}
func main() {
var recordLock sync.RWMutex
records, err := parse("proxy.config")
if err != nil {
panic(err)
}
dns.HandleFunc(".", func(w dns.ResponseWriter, req *dns.Msg) {
if len(req.Question) == 0 {
dns.HandleFailed(w, req)
return
}
fqdn := req.Question[0].Name
parts := strings.Split(fqdn, ".")
if len(parts) >= 2 {
fqdn = strings.Join(parts[len(parts)-2:], ".")
}
recordLock.RLock()
match := records[fqdn]
recordLock.RUnlock()
if match == "" {
dns.HandleFailed(w, req)
return
}
resp, err := dns.Exchange(req, match)
if err != nil {
dns.HandleFailed(w, req)
return
}
if err := w.WriteMsg(resp); err != nil {
dns.HandleFailed(w, req)
return
}
})
go func() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGUSR1)
for sig := range sigs {
switch sig {
case syscall.SIGUSR1:
log.Println("SIGUSR1: reloading records")
recordLock.Lock()
parse("proxy.config")
recordLock.Unlock()
}
}
}()
log.Fatal(dns.ListenAndServe(":53", "udp", nil))
}
Здесь есть несколько дополнений. Поскольку программа будет изменять карту, которая может в это время использоваться параллельными горутинами, необходимо применить мьютекс для контроля доступа. Мьютекс предотвращает одновременное выполнение чувствительных блоков кода, позволяя закрывать и открывать доступ. В этом случае мы применяем RWMutex, давая любой горутине возможность производить чтение, не блокируя другие горутины, но запрещая им доступ в процессе записи. Если же реализовать горутины без мьютекса на используемых ресурсах, то возникнет чередование, что может привести к состоянию гонки и даже худшим последствиям.
Перед обращением к карте в обработчике происходит вызов RLock для считывания значения в match. По завершении чтения вызывается RUnlock, освобождая карту для следующей горутины. В анонимной функции, выполняющейся в новой горутине, мы начинаем процесс прослушивания сигнала. Это делается с помощью канала типа os.Signal, передаваемого в вызове к signal.Notify() вместе с фактическим сигналом, получаемым каналом SIGUSR1, который сам является сигналом, зарезервированным для различных целей. В цикле перебора этих сигналов с помощью инструкции match определяется тип полученного сигнала.
Мы настроили мониторинг только одного сигнала, но в дальнейшем это можно изменить, так что данный шаблон окажется универсальным. В завершение перед перезагрузкой текущей конфигурации используется Lock() для блокирования всех горутин, которые могут попробовать произвести чтение из записей карты. Для продолжения выполнения применяется Unlock().
Давайте протестируем программу, запустив прокси и создав новый слушатель в существующем team-сервере. Используйте домен attacker3.com. При запущенном прокси измените файл proxy.config, добавив новую строку, направляющую домен на слушатель. Сигнализировать процессу о необходимости перезагрузки конфигурации можно с помощью kill, но сначала используйте ps и grep для определения его ID процесса:
Прокси должен перезагрузиться. Проверьте это, создав и выполнив новый самостоятельный исполняемый файл. Теперь прокси должен быть работоспособен и готов к использованию.