DNS over TLS — Шифруем наши DNS запросы с помощью Stunnel и Lua


    источник изображения


    DNS (англ. Domain Name System — система доменных имён) — компьютерная распределённая система для получения информации о доменах.

    TLS (англ. transport layer security — Протокол защиты транспортного уровня) — обеспечивает защищённую передачу данных между Интернет узлами.

    После новости "Google Public DNS тихо включили поддержку DNS over TLS" я решил попробовать его. У меня есть Stunnel который создаст шифрованный TCP туннель. Но программы обычно общаются с DNS по UDP протоколу. Поэтому нам нужен прокси который будет пересылать UDP пакеты в TCP поток и обратно. Мы напишем его на Lua.


    Вся разница между TCP и UDP DNS пакетами:


    4.2.2. TCP usage
    Messages sent over TCP connections use server port 53 (decimal). The message is prefixed with a two byte length field which gives the message length, excluding the two byte length field. This length field allows the low-level processing to assemble a complete message before beginning to parse it.

    RFC1035: DOMAIN NAMES — IMPLEMENTATION AND SPECIFICATION


    То есть делаем туда:


    1. берём пакет из UDP
    2. добавляем к нему в начале пару байт в которых указан размер этого пакета
    3. отправляем в TCP канал

    И в обратную сторону:


    1. читаем из TCP пару байт тем самым получаем размер пакета
    2. читаем пакет из TCP
    3. отправляем его получателю по UDP

    Настраиваем Stunnel


    1. Скачиваем корневой сертификат Root-R2.crt в директорию с конфигом Stunnel
    2. Конвертируем сертификат в PEM
      openssl x509 -inform DER -in Root-R2.crt -out Root-R2.pem -text
    3. Пишем в stunnel.conf:


      [dns]
      client = yes
      accept  = 127.0.0.1:53
      connect = 8.8.8.8:853
      CAfile = Root-R2.pem
      verifyChain = yes
      checkIP = 8.8.8.8


    То есть Stunnel:


    1. примет не шифрованное TCP по адресу 127.0.0.1:53
    2. откроет шифрованный TLS туннель до адреса 8.8.8.8:853 (Google DNS)
    3. будет передавать данные туда и обратно

    Запускаем Stunnel


    Работу тунеля можно проверить командой:


    nslookup -vc ya.ru 127.0.0.1

    Опция '-vc' заставляет nslookup использовать TCP соединение к DNS серверу вместо UDP.


    Результат:


    *** Can't find server name for address 127.0.0.1: Non-existent domain
    Server:  UnKnown
    Address:  127.0.0.1
    
    Non-authoritative answer:
    Name:    ya.ru
    Address:  (здесь IP яндекса)

    Пишем скрипт


    Я пишу на Lua 5.3. В нём уже доступны бинарные операции с числами. Ну и нам понадобится модуль Lua Socket.


    Имя файла: simple-udp-to-tcp-dns-proxy.lua


    local socket = require "socket" -- подключаем lua socket

    --[[--


    Напишем простенькую функцию которая позволит отправить дамп пакета в консоль. Хочется видеть что делает прокси.


    --]]--


    function serialize(data)
        -- Преобразуем символы не входящие в диапазоны a-z и 0-9 и тире в HEX представление '\xFF'
        return "'"..data:gsub("[^a-z0-9-]", function(chr) return ("\\x%02X"):format(chr:byte()) end).."'"
    end

    --[[--


    UDP в TCP и обратно


    Пишем две функции которые будут оперировать двумя каналами передачи данных.


    --]]--


    -- здесь пакеты из UDP пересылаются в TCP поток
    function udp_to_tcp_coroutine_function(udp_in, tcp_out, clients)
        repeat
            coroutine.yield() -- возвращаем управление главному циклу
            packet, err_ip, port = udp_in:receivefrom() -- принимаем UDP пакет
            if packet then
                -- > - big endian
                -- I - unsigned integer
                -- 2 - 2 bytes size
                tcp_out:send(((">I2"):pack(#packet))..packet) -- добавляем размер пакета и отправляем в TCP
                local id = (">I2"):unpack(packet:sub(1,2))    -- читаем ID пакета
                if not clients[id] then
                    clients[id] = {}
                end
                table.insert(clients[id] ,{ip=err_ip, port=port, packet=packet}) -- записываем адрес отправителя
                print(os.date("%c", os.time()) ,err_ip, port, ">", serialize(packet)) -- отображаем пакет в консоль
            end
        until false
    end
    
    -- здесь пакеты из TCP потока пересылаются к адресату по UDP
    function tcp_to_udp_coroutine_function(tcp_in, udp_out, clients)
        repeat
            coroutine.yield() -- возврашяем управление главному циклу
            -- > - big endian
            -- I - unsigned integer
            -- 2 - 2 bytes size
            local packet = tcp_in:receive((">I2"):unpack(tcp_in:receive(2)), nil) -- принимаем TCP пакет
            local id = (">I2"):unpack(packet:sub(1,2))                            -- читаем ID пакета
    
            if clients[id] then
                for key, client in pairs(clients[id]) do
                    -- сравниваем query в запросе и ответе
                    if packet:find(client.packet:sub(13, -1), 13, true) == 13 then -- находим получателя
                        udp_out:sendto(packet, client.ip, client.port) -- отправляем пакет получателю по UDP
                        clients[id][key] = nil                         -- очищаем ячейку
                        -- отображаем пакет в консоль
                        print(os.date("%c", os.time()) ,client.ip, client.port, "<", serialize(packet))
                        break
                    end
                end
                if not next(clients[id]) then
                    clients[id] = nil
                end
            end
        until false
    end

    --[[--


    Обе функции сразу после запуска выполняют coroutine.yield(). Это позволяет первым вызовом передать параметры функции а дальше делать coroutine.resume(co) без дополнительных параметров.


    main


    А теперь main функция которая выполнит подготовку и запустит главный цикл.


    --]]--


    function main()
        local tcp_dns_socket = socket.tcp() -- подготавливаем TCP сокет
        local udp_dns_socket = socket.udp() -- подготавливаем UDP сокет
    
        local tcp_connected, err = tcp_dns_socket:connect("127.0.0.1", 53) -- соединяемся с TCP тунелем
        assert(tcp_connected, err) -- проверяем что соединились
        print("tcp dns connected") -- сообщаем что соединились в консоль
    
        local udp_open, err = udp_dns_socket:setsockname("127.0.0.1", 53) -- открываем UDP порт
        assert(udp_open, err)      -- проверяем что открыли
        print("udp dns port open") -- сообщаем что UDP порт открыт
    
        -- пользуемся тем что таблицы Lua позволяют использовать как ключ что угодно кроме nil
        -- используем как ключ сокет чтобы при наличии данных на нём вызывать его сопрограмму
        local coroutines = {
            [tcp_dns_socket] = coroutine.create(tcp_to_udp_coroutine_function), -- создаём сопрограмму TCP to UDP
            [udp_dns_socket] = coroutine.create(udp_to_tcp_coroutine_function)  -- создаём сопрограмму UDP to TCP
        }
    
        local clients = {} -- здесь будут записываться получатели пакетов
    
        -- передаём каждой сопрограмме сокеты и таблицу получателей
        coroutine.resume(coroutines[tcp_dns_socket], tcp_dns_socket, udp_dns_socket, clients) 
        coroutine.resume(coroutines[udp_dns_socket], udp_dns_socket, tcp_dns_socket, clients)
    
        -- таблица из которой socket.select будет выбирать сокет готовый к получению данных
        local socket_list = {tcp_dns_socket, udp_dns_socket} 
    
        repeat -- запускаем главный цикл
            -- socket.select выбирает из socket_list сокеты у которых есть данные на получение в буфере
            -- и возвращает новую таблицу с ними. Цикл for последовательно возвращает значения из новой таблицы  
            for _, in_socket in ipairs(socket.select(socket_list)) do
                -- запускаем ассоциированную с полученным сокетом сопрограмму
                local ok, err = coroutine.resume(coroutines[in_socket])
                if not ok then
                    -- если сопрограмма завершилась с ошибкой то
                    udp_dns_socket:close() -- закрываем UDP порт
                    tcp_dns_socket:close() -- закрываем TCP соединение
                    print(err) -- выводим ошибку
                    return     -- завершаем главный цикл
                end
            end
        until false
    end

    --[[--


    Запускаем главную функцию. Если вдруг будет закрыто соединение мы через секунду установим его заново вызвав main.


    --]]--


    repeat
        local ok, err = coroutine.resume(coroutine.create(main)) -- запускаем main
        if not ok then
            print(err)
        end
        socket.sleep(1) -- перед рестартом ждём одну секунду
    until false

    проверяем


    1. Запускаем stunnel


    2. Запускаем наш скрипт


      lua5.3 simple-udp-to-tcp-dns-proxy.lua

    3. Проверяем работу скрипта командой


      nslookup ya.ru 127.0.0.1

      На этот раз без '-vc' так так мы уже написали и запустили прокси который заворачивает UDP DNS запросы в TCP тунель.



    Результат:


    *** Can't find server name for address 127.0.0.1: Non-existent domain
    Server:  UnKnown
    Address:  127.0.0.1
    
    Non-authoritative answer:
    Name:    ya.ru
    Address:  (здесь IP яндекса)

    Если всё нормально можно указать в настройках соедидения как DNS сервер "127.0.0.1"


    заключение


    Теперь наши DNS запросы под защитой TLS.


    P.S. Не даём гуглу лишней информации о нас


    Сразу после соединения Windows пытается зарегистрировать нас на DNS серверах гугла через наш туннель. Это отключается в продвинутых настройках DNS снятием галочки.



    Есть ещё запрос на time.windows.com. Он уже не такой личный но как его отключить я так и не нашёл. Автоматическая синхронизация времени отключена.


    ссылки


    1. RFC1035: DOMAIN NAMES — IMPLEMENTATION AND SPECIFICATION
    2. DNS поверх TLS
    3. simple-udp-to-tcp-dns-proxy.lua
    4. Составляем DNS-запрос вручную
    Share post

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 21

      0
      Осталось добавить кэширование DNS записей.
        +1

        Чтобы вычитать из пакета TTL записи надо будет разбирать пакеты а это уже не так просто. А топорный кэш который будет хранить все записи определённое время добавить легко. Я просто отрезал от запроса и ответа ID и помещал в кэш. И если приходил идентичный запрос я возвращал ответ из кэша.


        Это основа а дальше с ней можно играться. Можно фильтры например сделать. Или к двум DNS слать запросы.

          0
          А вот это — не надо. С кэшированием это уже был бы недо-DNS сервер.
          Кэшированием DNS записей занимается OS
          +3
          Теперь наши DNS запросы под зашитой TLS.
          Нет, не под защитой. В предложенном конфиге stunnel нет проверки сертификата.

          По коду — ну такое. Нужно запускать два демона. Почему бы не обойтись тогда просто одним, который сам TLS-коннект в апстрим-резолвер открывает? Как оно будет работать в случаях, когда DNS-ответ не умещается в одну UDP-датаграмму?

          И для чего тратить на это время на эту крайне спорную реализацию, если есть unbound и knot, которые поддерживают DoT нативно?

          Помимо этого, есть куча готовых стабов для DoH/DoT:
          (DoH) doh-proxy от Facebook
          (DoT) stubby от GetDNS
          (DoH) cloudflared от Cloudflare
            0
            Нет, не под защитой. В предложенном конфиге stunnel нет проверки сертификата.

            Поправил. Теперь проверяет сертификат.


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

            TLS на Lua я не умею делать. А stunnel полагаю DNS не знает. Мне было интересно решить задачу тем что есть.


            Как оно будет работать в случаях, когда DNS-ответ не умещается в одну UDP-датаграмму?

            Полагаю её просто отбросит. Надо тестировать. Но там не большая разница между максимальным размером TCP DNS пакета (65535 байт) и максимальной UDP-датаграммой (65507 байт). Программа также может обратится и по TCP каналу напрямую в туннель если рассчитывает получить большой ответ.


            И для чего тратить на это время на эту крайне спорную реализацию, если есть unbound и knot, которые поддерживают DoT нативно?

            Не знаю. Каждый сам решит для себя.

              0
              Но там не большая разница между максимальным размером TCP DNS пакета (65535 байт) и максимальной UDP-датаграммой (65507 байт).

              Есть лимит 512 байт на размер DNS-ответа. Обсуждение на SO о причинах.

              Там промежуточный серт получается по сути захардкожен — это не круто и он протухнет через 3 года. Лучше использовать системный набор сертификатов.
              [dns]
              client = yes
              accept = 127.0.0.1:53
              connect = 8.8.8.8:853
              CApath = /usr/lib/ssl/certs
              verifyChain = yes
              checkIP = 8.8.8.8


              verify — устаревшая опция, ей лучше не пользоваться.

              Далее, как я вижу, для маршрутизации ответов используется только ID запроса, который каждый клиент выбирает случайно. Нормальные резолверы так не делают, так как велика вероятность коллизии. Они ещё помнят с какого адреса-порта что запрашивалось, а ID ответа в основном проверяют для защиты от DNS-отравлений. По моим подсчётам по формуле для парадокса дней рождения, вероятность совпадения ID хотя бы у двух запросов в группе из 100 запросов составляет 7,3%. Довольно скверно, согласитесь.

              Не знаю. Каждый сам решит для себя.

              Ну, пока не выглядит даже как добрый совет.
                0
                Есть лимит 512 байт на размер DNS-ответа.

                У меня UDP пакеты не уходят дальше локальной машины. Проверил лимиты. Нормально уходят и приходят пакеты до 8192 байт. Дальше только отправка работает а на приёме ошибка что слишком большой пакет.


                Предлагаете ограничить?


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


                Лучше использовать системный набор сертификатов.

                Только вот они похоже реестре лежат на Windows.


                Далее, как я вижу, для маршрутизации ответов используется только ID запроса

                Да. Я не придумал как не разбирая пакета без сложностей использовать больше иформации. Можно конечно попробовать написать лёгкую фунцию парсинга запросов.


                Но опять же мы на локальной машине. Запросов от неё не так много.

                  0
                  Предлагаете ограничить?

                  Протокол DNS предусматривает передачу длинных ответов через TCP, вероятнее всего нужно ещё реализовать TCP-слушатель для них.
                  Да. Я не придумал как не разбирая пакета без сложностей использовать больше иформации. Можно конечно попробовать написать лёгкую фунцию парсинга запросов.
                  Качественная реализация какого-либо DNS-сервера требует много работы, которая уже была сделана.
                    –2
                    вероятнее всего нужно ещё реализовать TCP-слушатель

                    Он есть. Это Stunnel.

                      0

                      Я скриптом как раз решал проблему того что Stunnel UDP не понимает. А в начале статьи я как раз отправляю тестовый TCP запрос через Stunnel напрямую.


                      nslookup -vc ya.ru 127.0.0.1


                      Опция vc заставляет nslookup использовать TCP соединение к DNS серверу вместо UDP.
                    +1
                    для маршрутизации ответов используется только ID запроса

                    Поправил. Теперь не только ID сравниваются но и query часть.

                      –1
                      Есть лимит 512 байт на размер DNS-ответа.

                      Почитайте на досуге про EDNS0. И это всего лишь лимит для UDP…
                  0
                  Можно для чайников — тех кто с трудом отличает Domain Name System от Direct Numerical Simulation — что это дает обычным людям?
                  Хотя бы в виде абзаца-абстракта. А то уж очень специфично/терминологично.
                    0
                    DNS (англ. Domain Name System — система доменных имён) — компьютерная распределённая система для получения информации о доменах. Чаще всего используется для получения IP-адреса по имени хоста (компьютера или устройства)

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


                    Скрипт в сочетании со Stunnel отправляет все приходящие на него DNS запросы через шифрованный канал напрямую к Google DNS. Тем самым провайдер не может прочитать или подменить ответы DNS сервера.


                    При сочетании DNS over TLS с Encrypted SNI провайдер перестанет понимать на какие сайты ходит пользователь. Всё что ему будет видно это только IP адреса на которые обращается пользователь.

                      0
                      Спасибо! Ага, т.е. вообще такое надо ставить — а то вдруг мало ли что… Зайду куда-нибудь, где ругают не того кого можно и станет одним хабраюзером меньше.
                        +1
                        Пару дней назад Mozilla объявила о поддержке Encrypted SNI. blog.mozilla.org/security/2018/10/18/encrypted-sni-comes-to-firefox-nightly

                        По их заявлению включение этой опции одновременно с DNS over HTTPS приведет к большей анонимности
                        провайдер перестанет понимать на какие сайты ходит пользователь
                        . Кто-то сразу заявил, что вот оно средство против блокировок. Но у моего провайдера так и не заработало. Выяснилось, что Encrypted SNI он понимает как отсутствие SNI и режет такие пакеты. Вот обсуждение: github.com/ValdikSS/GoodbyeDPI/issues/71

                        Не знаю как насчет DNS over TLS в сочетании с Encrypted SNI
                        +1
                        DNS отвечает, например, за доступность веб-контента по URL. (Не)добросовестные провайдеры выполняют требования, например, РКН, и что-то блокируют. Для того, чтобы к этому заблокированному получить доступ, можно поднять, например, VPN. Но если VPN реализован туннелированием через абстракционно не низший чем DNS протокол, то DNS автоматически под туннелем не ходит (инкапсуляция почти как из ООП, возможно, с этим Вы больше знакомы, пример подменяемого DNS — VPN через SSH), и получается, что трафик хоть и идёт через другую точку света, но трансляции из доменного имени в IP всё равно от провайдера (тут есть пример, но в реальности подмена DNS — не самое зло, подробности тут, хотя и с большим злом можно справиться). А (не)добросовестные клиенты не желают, чтобы требования РКН выполнялись. Кто тут добросовестнее — выбирать Вам.
                        +4
                        С точки зрения изучения интересно. Но если нужно ехать, а не шашечки, то просто ставим Unbound и радуемся =)
                          +1

                          Спасибо за статью, но как тут не раз упомянули уже, лучше поставить готовое простое решение.
                          Лично мне больше всего понравился stubby
                          https://github.com/getdnsapi/stubby
                          Работает на маке, винде и линухе. Вроде даже на иОС. На дроиде уже не нужно, тк с девятки эта фича доступна на системном уровне.

                            0

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

                              0

                              Недавно решил попробовать DOH и ESNI в Firefox 65. (Уже и в 65.0.1 попробовал)


                              network.security.esni.enabled = true
                              network.trr.mode = 3

                              Сделал запрос на cloudflare.com который использует TLS 1.3. И ничего не изменилось. DNS запросы попрежнему идут на дефолтный DNS сервер без шифрования. А WireShark показывает не шифрованный SNI.

                              Only users with full accounts can post comments. Log in, please.