Об авторе. Джеймс Рутли — бэкенд-разработчик в компании Monzo.

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

Мы узнаем, как:

  • Написать запросы DNS в двоичном формате
  • Отправить сообщение в теле датаграммы UDP с помощью Python
  • Прочитать ответ от DNS-сервера

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

Обзор DNS


DNS используется для перевода человекочитаемых доменных имён (таких как example.com) в машиночитаемые IP-адреса (такие как 93.184.216.34). Для использования DNS нужно отправить запрос на DNS-сервер. Этот запрос содержит доменное имя, которое мы ищем. DNS-сервер пытается найти IP-адрес этого домена в своём внутреннем хранилище данных. Если находит, то возвращает его. Если не может найти, то перенаправляет запрос на другой DNS-сервер, и процесс повторяется до тех пор, пока IP-адрес не будет найден. Сообщения DNS обычно отправляются по протоколу UDP.

Стандарт DNS описан в RFC 1035. Все диаграммы и бoльшая часть информации для этой статьи взята в данном RFC. Я бы рекомендовал обратиться к нему, если что-то непонятно.

В этой статье мы используем шестнадцатеричный формат для упрощения работы с бинарником. Ниже я добавил краткое пояснение, как они переводятся друг в друга [1].

Формат запроса


У всех сообщений DNS одинаковый формат:

+---------------------+
|       Заголовок     |
+---------------------+
|        Вопрос       | Вопрос для сервера имён
+---------------------+
|         Ответ       | Ресурсные записи (RR) с ответом на вопрос
+---------------------+
|       Authority     | Записи RR с указанием на уполномоченный сервер
+---------------------+
|     Дополнительно   | Записи RR с дополнительной информацией
+---------------------+

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

Заголовок


У заголовка следующий формат:

0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F 
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                      ID                       |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|QR|   Opcode  |AA|TC|RD|RA|   Z    |   RCODE   |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    QDCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    ANCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    NSCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    ARCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

На этой диаграмме каждая ячейка представляет единственный бит. В каждой строчке 16 колонок, что составляет два байта данных. Диаграмма поделена на строки для простоты восприятия, но реальное сообщение представляет собой непрерывный ряд байтов.

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

Для нас имеют значение следующие поля:

  • ID: Произвольный 16-битный идентификатор запроса. Такой же ID используется в ответе на запрос, так что мы можем установить соответствие между ними. Возьмём AA AA.
  • QR: Однобитный флаг для указания, является сообщение запросом (0) или ответом (1). Поскольку мы отправляем запрос, то установим 0.
  • Opcode: Четырёхбитное поле, которое определяет тип запроса. Мы отправляем стандартный запрос, так что указываем 0. Другие варианты:
    • 0: Стандартный запрос
    • 1: Инверсный запрос
    • 2: Запрос статуса сервера
    • 3-15: Зарезервированы для будущего использования
  • TC: Однобитный флаг, указывающий на обрезанное сообщение. У нас короткое сообщение, его не нужно обрезать, так что указываем 0.
  • RD: Однобитный флаг, указывающий на желательную рекурсию. Если DNS-сервер, которому мы отправляем вопрос, не знает ответа на него, он может рекурсивно опросить другие DNS-серверы. Мы хотим активировать рекурсию, так что укажем 1.
  • QDCOUNT: 16-битное беззнаковое целое, определяющее число записей в секции вопроса. Мы отправляем 1 вопрос.

Полный заголовок


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

AA AA - ID
01 00 – Параметры запроса
00 01 – Количество вопросов
00 00 – Количество ответов
00 00 – Количество записей об уполномоченных серверах
00 00 – Количество дополнительных записей

Для получения параметров запроса мы объединяем значения полей от QR до RCODE, помня о том, что не упомянутые выше поля установлены в 0. Это даёт последовательность 0000 0001 0000 0000, которая в шестнадцатеричном формате соответствует 01 00. Так выглядит стандартный DNS-запрос.

Вопрос


Секция вопроса имеет следующий формат:

0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F 
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                                               |
/                     QNAME                     /
/                                               /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                     QTYPE                     |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                     QCLASS                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

  • QNAME: Эта секция содержит URL, для которого мы хотим найти IP-адрес. Она закодирована как серия надписей (labels). Каждая надпись соответствует секции URL. Так, в адресе example.com две секции: example и com.

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

    Секция QNAME завершается нулевым байтом (00).
  • QTYPE: Тип записи DNS, которую мы ищем. Мы будем искать записи A, чьё значение 1.
  • QCLASS: Класс, который мы ищем. Мы используем интернет, IN, у которого значение класса 1.

Теперь можно записать всю секцию вопроса:

07 65 – у 'example' длина 7, e
78 61 – x, a
6D 70 – m, p
6C 65 – l, e
03 63 – у 'com' длина 3, c
6F 6D – o, m
00    - нулевой байт для окончания поля QNAME 
00 01 – QTYPE
00 01 – QCLASS

В секции QNAME разрешено нечётное число байтов, так что набивка байтами не требуется перед началом секции QTYPE.

Отправка запроса


Мы отправляем наше DNS-сообщение в теле UDP-запроса. Следующий код Python возьмёт наш шестнадцатеричный DNS-запрос, преобразует его в двоичный формат и отправит на сервер Google DNS по адресу 8.8.8.8:53.

import binascii
import socket


def send_udp_message(message, address, port):
    """send_udp_message sends a message to UDP server

    message should be a hexadecimal encoded string
    """
    message = message.replace(" ", "").replace("\n", "")
    server_address = (address, port)

    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        sock.sendto(binascii.unhexlify(message), server_address)
        data, _ = sock.recvfrom(4096)
    finally:
        sock.close()
    return binascii.hexlify(data).decode("utf-8")


def format_hex(hex):
    """format_hex returns a pretty version of a hex string"""
    octets = [hex[i:i+2] for i in range(0, len(hex), 2)]
    pairs = [" ".join(octets[i:i+2]) for i in range(0, len(octets), 2)]
    return "\n".join(pairs)


message = "AA AA 01 00 00 01 00 00 00 00 00 00 " "07 65 78 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01"

response = send_udp_message(message, "8.8.8.8", 53)
print(format_hex(response)) 

Можете запустить этот скрипт, скопировав код в файл query.py и запустив в консоли команду $ python query.py. У него нет никаких внешних зависимостей, и он должен работать на Python 2 или 3.

Чтение ответа


После выполнения скрипт выводит ответ от DNS-сервера. Разобьём его на части и посмотрим, что можно выяснить.

Заголовок


Сообщение начинается с заголовка, как и наше сообщение с запросом:

AA AA – Тот же ID, как и раньше
81 80 – Другие флаги, разберём их ниже
00 01 – 1 вопрос
00 01 – 1 ответ
00 00 – Нет записей об уполномоченных серверах
00 00 – Нет дополнительных записей

Преобразуем 81 80 в двоичный формат:

8    1    8    0
1000 0001 1000 0000

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

  • QR = 1: Это сообщение является ответом
  • AA = 0: Этот сервер не является уполномоченным для доменного имени example.com
  • RD = 1: Для этого запроса желательна рекурсия
  • RA = 1: На этом DNS-сервере поддерживается рекурсия
  • RCODE = 0: Ошибки не обнаружены

Секция вопроса


Секция вопроса идентична такой же секции в запросе:

07 65 – у 'example' длина 7, e
78 61 – x, a
6D 70 – m, p
6C 65 – l, e
03 63 – у 'com' длина 3, c
6F 6D – o, m
00    - нулевой байт для окончания поля QNAME 
00 01 – QTYPE
00 01 – QCLASS

Секция ответа


У секции ответа формат ресурсной записи:

0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F 
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                                               |
/                                               /
/                      NAME                     /
|                                               |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                      TYPE                     |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                     CLASS                     |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                      TTL                      |
|                                               |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                   RDLENGTH                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--|
/                     RDATA                     /
/                                               /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

C0 0C - NAME
00 01 - TYPE
00 01 - CLASS
00 00 
18 4C - TTL
00 04 - RDLENGTH = 4 байта
5D B8 
D8 22 - RDDATA

  • NAME: Этой URL, чей IP-адрес содержится в данном ответе. Он указан в сжатом формате:

    0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F 
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    | 1  1|                OFFSET                   |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

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

    В данном случае смещение составляет c0 0c или двоичном формате:

    1100 0000 0000 1100

    То есть смещение байт составляет 12. Если мы отсчитаем байты в сообщении, то можем найти, что оно указывает на значение 07 в начале имени example.com.
  • TYPE и CLASS: Здесь используется та же схема имён, что и в секциях QTYPE и QCLASS выше, и такие же значения.
  • TTL: 32-битное беззнаковое целое, которое определяет время жизни этого пакета с ответом, в секундах. До истечения этого интервала результат можно закешировать. После истечения его следует забраковать.
  • RDLENGTH: Длина в байтах последующей секции RDDATA. В данном случае её длина 4.
  • RDDATA: Те данные, которые мы искали! Эти четыре байта содержат четыре сегмента нашего IP-адреса: 93.184.216.34.

Расширения


Мы увидели, как составить DNS-запрос. Теперь можно попробовать следующее:

  • Составить запрос для произвольного доменного имени
  • Запрос на другой тип записи
  • Отправить запрос с отключенной рекурсией
  • Отправить запрос с доменным именем, которое не зарегистрировано



1. Шестнадцатеричные числа (base 16) часто используются как удобная краткая запись для 4 битов двоичных данных. Вы можете конвертировать данные между этими форматами по следующей таблице:

Десятичный Hex Двоичный Десятичный Hex Двоичный
0 0 0000 8 8 1000
1 1 0001 9 9 1001
2 2 0010 10 A 1010
3 3 0011 11 B 1011
4 4 0100 12 C 1100
5 5 0101 13 D 1101
6 6 0110 14 E 1110
7 7 0111 15 F 1111

Как видите, можно представить любой байт (8 бит) двумя шестнадцатеричными символами. ^

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


  1. PapaBubaDiop
    05.01.2018 13:10
    +3

    Спасибо за перевод. Согласно протоколу получается поддоменное имя не может быть более 255 символов. Если послать имя с большей длиной — DNS сломается?


    1. bano-notit
      05.01.2018 15:29

      Вроде даже в переводе сказано, что сообщение можно разбить на части.
      Хотя да, получается что один лейбл может максимум иметь 255 символов…


      1. PapaBubaDiop
        05.01.2018 15:49

        И я про то же, слово this_is_an_example_of_the_label_which_has_more_then_two_handreds_and_fifty_five_symbols_but_i_suppose_you_can_not_register_a_domain_that_has_the_sublabel_with_this_long_long_name_where_are_more_then_two_handreds_and_fifty_five_symbols_but_i_am_not_sure_if_this_name_has_more_then_two_handreds_and_fifty_five_symbols_if_you_can_please_chack_the_length_of_this_labal_title_thank_you_very_much_my_friend
        не разобьешь на два


        1. bano-notit
          05.01.2018 16:04
          +1

          400…
          Мне кажется, что всё же есть там какой-то способ по разбиению этих слов при достижении «порога».

          p.s. И не chack, а check)


      1. SPROg
        05.01.2018 18:45

        Согласно RFC1035 — лейбл не более 63 октетов, а само DNS имя — 255.

        Видел реализации, где валидация DNS имени валидируется по следующему принципу:
        (63 letters).(63 letters).(63 letters).(62 letters)


    1. dragoangel
      05.01.2018 18:45

      Вы не посылаете информацию, а опрашиваете существующие домены поэтому в природе просто не будет такого домена и вы ничего не сломаете. И вы не совсем правы — 255 символов это максимальная длинна вообще на весь FQDN домен целиком. А метки (каждый отрезок разделенный точками) ограничен по 63 символа.


  1. none7
    06.01.2018 09:28

    QNAME не до конца объяснили. Каждая запись и запроса и ответа начинается с QNAME. Максимальная длина QNAME <= 255 ради простоты реализации. Каждая метка QNAME может быть именем или ссылкой. Если первые 2 бита длины метки равны нулю, то последующие 6 бит указывают на длину имени метки, то есть длина <= 63. Если оба первых бита равны 1, то последующие 14 бит равны адресу следующей метки. То есть не обязательно будет \xC0\x0C. Где то в дополнительный записях может быть и QNAME \x03ns1\xC0\x0C(n1.example.com.) или \x03ns1\x02ex\xC0\x13(ns1.ex.com.). Если реализовать парсер ссылок не думая, то можно и зависнуть от неправильным пакета или даже выжрать всю доступную память. Возможно по этой причине число меток должно быть менее 127.


  1. maxx_s
    07.01.2018 10:25

    Статья интересная, но не путайте начинающих — habrahabr.ru/post/346098 — вот это URL,
    а то что у Вас упоминается по тексту как URL, называется hostname.