[Перевод] Составляем DNS-запрос вручную
Об авторе. Джеймс Рутли — бэкенд-разработчик в компании 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. Все диаграммы и бóльшая часть информации для этой статьи взята в данном 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 бит) двумя шестнадцатеричными символами. ↑