Сканирование сети
Это ответвление от серии девлогов про мой путь в сетевую разработку MMO RPG (начало - в mmodevlog.0). На этот раз меня утянуло под UDP/TCP сокеты, в низкоуровневую сторону сетей, и это оказалось полезным крюком: чем лучше понимаешь, что там происходит, тем увереннее писать сам неткод. А задача была простая - написать небольшую программку, которая сканирует локальную сеть на доступные устройства. По мере её делания я узнал много нового, о чём и хочу рассказать.
Подготовка
Что я хочу вообще от программы? На самом деле много, но в первой версии я бы просто хотел, чтобы программа выводила мой локальный адрес сети, а так же выводила все доступные устройства, желательно с именем, что это за устройство. Для этого нужно сделать несколько вещей:
- Найти адрес локальный сети и её маску
- Составить список возможных IP адресов сети
- Проверить доступность каждого IP адреса
- Узнать, что за устройство скрыто за каждым IP адресом
Изначально я хотел сделать приложение кроссплатформенным (Unix и Windows), но в итоге решил сосредоточиться на Linux - низкоуровневая работа с сетью сильно завязана на конкретную ОС, а кроссплатформенность можно добавить позже. В качестве языка программирования я, конечно же, выбрал Golang. И сразу оговорюсь: везде дальше речь идёт про IPv4 - так проще, да и в домашних сетях он всё ещё основной.
Адрес локальной сети и её маска
Чтобы достать эту информацию руками достаточно знать 2 команды - ipconfig на Windows и ifconfig на Unix системах. Потом из выведеных в консоль интерфейсов (а если вы разработчик, то скорее всего у вас их не мало), мы на глаз можем найти наш настоящий интерфейс который доступен интернету. На глаз мы можем определить благодаря полю Default Gateway. Если он присутствует то скорее всего это наш адрес локальной сети, которая смотрит наружу. Но как это сделать в программе?
Хитрость с UDP-коннектом
Можно, конечно, парсить вывод терминальных команд, но это неудобно и ненадёжно. Поэтому расскажу про первый интересный приём — определение адреса через UDP-коннект. UDP же работает без соединения? Верно, но в коде мы можем его сэмулировать: чтобы «подключиться», ОС выдаёт нам базовую информацию о сокете и заодно помогает определить наш локальный адрес. Вот как это делается на Golang:
func retrieveLocalAddrIp() net.IP {
addr, err := net.ResolveUDPAddr("udp4", "8.8.8.8:53")
if err != nil {
log.Fatal("Unable to resolve udp addr of 8.8.8.8:53", err)
}
udpConn, err := net.DialUDP("udp4", nil, addr)
if err != nil {
log.Fatal("Unable to resolve connect", err)
}
defer udpConn.Close()
localAddr := udpConn.LocalAddr().(*net.UDPAddr)
return localAddr.IP
}
Берём любой внешний маршрутизируемый адрес (я взял гугловский DNS 8.8.8.8) и «подключаемся» к нему. Адрес именно внешний неспроста: по нему ОС выбирает интерфейс с маршрутом по умолчанию (тот, что смотрит в интернет) и сама отдаёт его локальный адрес - нам остаётся забрать его из коннекта, и вот наш локальный IP готов. Дальше просто перебираем сетевые интерфейсы и находим нужный, сравнивая с этим адресом. Самое интересное: UDP-коннект не делает хендшейка и не шлёт ни одного пакета наружу - то есть свой адрес мы определили, ничего не отправив во внешнюю сеть. Пример кода:
type NetInterface struct {
Interface net.Interface
IP net.IPNet
}
func retrieveLocalIpInterface(localAddr net.IP) (*NetInterface, error) {
ints, err := net.Interfaces()
if err != nil {
return nil, err
}
for _, i := range ints {
addrs, err := i.Addrs()
if err != nil {
log.Printf("Unable to retrieve interface addrs: %v\n", err)
}
for _, addr := range addrs {
ipAddr, ok := addr.(*net.IPNet)
if !ok {
continue
}
if localAddr.Equal(ipAddr.IP) {
return &NetInterface{
Interface: i,
IP: *ipAddr,
}, nil
}
}
}
return nil, nil
}
С помощью встроенных функций в стандартную библиотеку мы получаем список интерфейсом, пробегаемся по каждому адресу в интерфейсе и если находим наш локальный адрес, который получили на предыдущем шаге то возвращаем этот интерфейс вместе с доп информацией об IP (например маска).
Вот и всё! Мы получили первую нужную информацию - локальный IP адрес, маска подсети и наш сетевой интерфейс.
Составляем список возможных IP адресов
Тут всё просто, мы вспоминаем, что такое маска подсети. Маска подсети - числовой показатель, который определяет размер сети. Чтобы получить размер сети например для маски /24, мы должны понимать что IP - это 4 байта или 32 бита, если маска подсети /24, значит 24 бита выделено под адрес сети, а все остальные 8 битов могут занимать айпи адреса внутри этой сети, 8 битов это 256 адресов, где первый адрес это адрес сети, а последний адрес это broadcast адрес, все адреса между ними могут быть потенциально заняты устройством в сети. Пример кода:
maskOnes, maskBits := addrWithMask.Mask.Size()
networkSize := uint32(1 << (maskBits - maskOnes)) // замена math.Pow(2, maskBits-maskOnes), чтобы не работать с float
// Начинаем с 1 и заканчиваем на -1, чтобы убрать адрес сети и broadcast адрес
for i := uint32(1); i < networkSize-1; i++ {
ipUint32 += 1
bytes := make([]byte, 4)
binary.Encode(bytes, binary.BigEndian, ipUint32)
ipByte := net.IP(bytes)
// Делаем что-то с IP
}
Превращая IP в uint32, мы можем делать обычный цикл с прибавлением +1 и обратным преобразованием числа в байты IP, тем самым мы можем делать с этим списком IP что угодно.
Проверка доступности IP
Почему обычный пинг не работает
У нас есть список возможных IP, теперь самое простое, просто пингануть каждое устройство, просто идём по списку и делаем TCP соединение с каждым IP с каким-то таймаутом, если он не ответил за отведенное время значит устройства нет в сети. Вы можете попробовать так сделать и увидите, что, скорее всего, у вас будет 0 устройств в вашей сети за исключение может быть роутера. Все дело в том что внутренние фаерволы устройств (телефона и ноутбука) не дадут вам установить соединение с ними по локальному адресу, фаервол молча дропнет ваши пакеты и вы не узнаете есть такое устройство и просто не отвечает, или в целом устройство отсутствует в сети.
ARP спешит на помощь
Но все же есть способ, с помощью которого можно надёжно просканировать сеть - правда, работает он в пределах одного локального сегмента (одного broadcast-домена). Дело в том, что у нас существует модель OSI состоящая из 7 уровней, в рамках данной статьи нам хватит модели TCP/IP, состоящей из 4 уровней. И вот на 2 уровни каждый пакет обязан содержать MAC адрес устройства, которому будет направлен пакет, но если IP мы в теории можем и сами знать, то MAC адрес мы почти никогда не знаем и нам откуда-то надо его взять. И тут нам на помощь приходить ARP.
ARP - протокол в компьютерных сетях, предназначенный для определения MAC-адреса другого компьютера по известному IP-адресу. И если устройство хочет полноценно работать в сети, оно обязано ответить на ARP-запрос со своим IP - иначе другие устройства просто не смогут до него достучаться. Отвечает при этом только то устройство, чей IP запрашивают, остальные запрос игнорируют. Хостовые фаерволы обычно фильтруют трафик на 3-4 уровне (IP/TCP), а ARP работает на 2 уровне, поэтому такой фаервол его, как правило, не трогает. Вы прямо сейчас можете пойти и вызывать команду arp -a на Windows и увидеть список адресов с их физическими адресами и может прямо сейчас ужей найдете в таблицы ваш телефон или ноутбук.
Способ попроще: спросить у ОС
Но как вызывать этот запрос? На самом деле делая любой запрос UDP/TCP/HTTP ОС под капотом сначала идёт в свою ARP таблицу, и смотрит есть ли у неё MAC адрес, если его нет, то она делает ARP запрос, чтобы раздобыть MAC адрес, так что мы можем просто вызывать отправку UDP пакета на все адреса, потом проверить arp таблицу и получить всю информацию, и сначала я так и сделал, но у этого способа есть минусы и плюсы. Минус то что не очень понятны тайминги когда запись попадёт в таблицу в итоге придётся делаем паузы в коде, чтобы ОС точно записала все адреса, но есть и плюсы для этого способа нам не нужны особые права администратора, так как ОС сама всё делает за нас.
Собираем пакет вручную
Но я пошёл дальше, я подумал что если, мы сами будем вызывать ARP пакет, я никогда не занимался отправкой пакетов второго уровня и подумал что это будет интересная задачка. На самом деле в этом абсолютно ничего сложно нет, нам просто нужно знать структуру пакета, который мы должны собрать и как потом эти сырые байты отправить на 2 уровне.
Ethernet-хедер
Ethernet frame состоит из небольшого хедера на 14 байт:
| Поле | Размер | Что кладём в запросе |
|---|---|---|
| Target MAC Address | 6 байт | ff:ff:ff:ff:ff:ff (broadcast) |
| Source MAC Address | 6 байт | наш MAC (i.HardwareAddr) |
| Ether Type | 2 байта | 0x0806 (ARP) |
Вы спросите, а как мы вообще должны взять Target MAC Adress, если вся суть нашего запроса это получить этот MAC Address? Тут надо понимать что мы хотим узнать MAC адресс устройства по IP, соотвественно мы должны опросить все машины в сети "Если у вас такой-то IP, скажите свой MAC адресс", как нам отправить пакет всем устройствам? Для этого существует broadcast, отправляя пакет на него, он перенаправить его всем устройствам в сети, и ответит на него только то устройство непосредственно которое мы ищем. MAC адрес broadcast это ff:ff:ff:ff:ff:ff. Ether Type - мы должны указать 0x0806 это тип данных которые будут лежать в пакете, а так как мы хотим отправить ARP запрос, мы указываем тип ARP (0x0806). Пример кода сбора хедера:
broadcastMacAddr := []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}
ethPacketBytes := make([]byte, 0)
ethPacketBytes, _ = binary.Append(ethPacketBytes, binary.BigEndian, broadcastMacAddr) // Target MAC
ethPacketBytes, _ = binary.Append(ethPacketBytes, binary.BigEndian, i.HardwareAddr) // Sender MAC
ethPacketBytes, _ = binary.Append(ethPacketBytes, binary.BigEndian, uint16(unix.ETH_P_ARP)) // Ether Type (ARP 0x0806)
ARP-пакет
Дальше идёт уже ARP пакет, он побольше - 28 байт и состоит из:
| Поле | Размер | Что кладём в запросе |
|---|---|---|
| Hardware Type | 2 байта | 0x0001 (Ethernet) |
| Protocol Type | 2 байта | 0x0800 (IPv4) |
| Hardware Length | 1 байт | 6 (длина MAC) |
| Protocol Length | 1 байт | 4 (длина IPv4) |
| Operation | 2 байта | 1 (request) |
| Sender Hardware Address | 6 байт | наш MAC |
| Sender Protocol Address | 4 байта | наш IP |
| Target Hardware Address | 6 байт | 00:00:00:00:00:00 (нули) |
| Target Protocol Address | 4 байта | искомый IP |
Hardware Type - это тип канального (2 уровень) адреса, который мы резолвим. Дело в том, что ARP протокол универсальный и умеет работать не только поверх Ethernet, поэтому в пакете надо явно сказать, с каким типом железных адресов мы имеем дело. Для Ethernet это 0x1, и это же значение используется для Wi-Fi, так как там такие же 48-битные MAC адреса. Protocol Type - по аналогии, это тип сетевого (3 уровень) адреса, который мы хотим получить по MAC-у, то есть тот, что лежит в полях Sender/Target Protocol Address. Мы работаем с IPv4, поэтому указываем 0x0800 - то же самое значение, что и EtherType для IPv4. Из этих двух полей автоматически следуют и следующие два: раз Hardware Type - Ethernet, то Hardware Length составляет 6 (длина MAC адреса), а раз Protocol Type - IPv4, то Protocol Length - 4 (длина IPv4 адреса). Дальше идёт Operation у нас есть два варианта 1 или 2, 1 это запросить MAC адрес, 2 это ответить, нам соотвественно нужен 1. С sender частей думаю всё понятно, просто подставляем наш мак адрес и наш айпи, а вот с Target опять не ясность, если IP понятно откуда брать, то откуда снова взять мак адрес, снова подставлять broadcast? На самом деле всё проще, так как мы запрашиваем MAC адрес это поле будет игнорироваться и соотвественно туда просто записываем нули. Пример кода:
zeroDistMacAddr := make([]byte, 6)
arpPacketBytes := make([]byte, 0)
arpPacketBytes, _ = binary.Append(arpPacketBytes, binary.BigEndian, uint16(unix.ARPHRD_ETHER)) // Hardware Type (Ethernet)
arpPacketBytes, _ = binary.Append(arpPacketBytes, binary.BigEndian, uint16(unix.ETH_P_IP)) // Protocol Type (IPv4 0x0800)
arpPacketBytes, _ = binary.Append(arpPacketBytes, binary.BigEndian, uint8(6)) // Hardware Length (for Ethernet is 6)
arpPacketBytes, _ = binary.Append(arpPacketBytes, binary.BigEndian, uint8(4)) // Protocol Length (for IPv4 is 4)
arpPacketBytes, _ = binary.Append(arpPacketBytes, binary.BigEndian, uint16(1)) // Operation (1 for request, 2 for reply)
arpPacketBytes, _ = binary.Append(arpPacketBytes, binary.BigEndian, i.HardwareAddr) // Sender hardware address (MAC)
arpPacketBytes, _ = binary.Append(arpPacketBytes, binary.BigEndian, sourceIp.To4()) // Sender Protocol Address (IP)
arpPacketBytes, _ = binary.Append(arpPacketBytes, binary.BigEndian, zeroDistMacAddr) // Target hardware address (MAC)
arpPacketBytes, _ = binary.Append(arpPacketBytes, binary.BigEndian, distIp.To4()) // Target Protocol Address (IP)
packet := append(ethPacketBytes, arpPacketBytes...)
Ну вот и всё, ничего сложно, главное разобраться как составлять пакет, теперь осталось его отправить и получить ответ.
Отправка ARP пакета
Так как мы хотим отправим пакет на 2 уровне, нам нужно открыть сокет в операционной системе, который отвечает за отправку сырых пакетов. В Linux это делается следующим образом (AF_PACKET - это Linux-специфичный механизм, на macOS/BSD сырые пакеты второго уровня идут через BPF, а на Windows - вообще иначе):
fd, err := unix.Socket(unix.AF_PACKET, unix.SOCK_RAW, int(htons(unix.ETH_P_ARP))) // ARP Protocol ID 0x806
if err != nil {
return err
}
defer unix.Close(fd)
Отправляем пакет и читаем ответ
Функция htons нужна чтобы перевести число в BigEndian. Открыв сокет, мы получае файловый дескриптор используя который вместе с функциями Sendto и Recvfrom мы можем отправлять и получать данные. Работаем это следующим образом:
sockAddr := unix.SockaddrLinklayer{
Ifindex: i.Index,
}
if err := unix.Sendto(fd, packet, 0, &sockAddr); err != nil {
return err
}
timeoutDuration := time.Millisecond * 100
tv := unix.Timeval{Sec: 0, Usec: timeoutDuration.Microseconds()}
unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv)
recvPacket := make([]byte, 128)
readBytes, _, err := unix.Recvfrom(fd, recvPacket, 0)
if err != nil {
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
return ErrARPTimeout
}
return err
}
data := recvPacket[:readBytes]
Достаём MAC-адрес из ответа
Сначала мы заполняет структуру адреса сокета, на самом деле там больше полей, но для нашей цели их заполнять не нужно так как либо они заполняются дефолтными значениями либо берутся непосредственно из байтов пакеты которые мы собрали ранее. Перед чтением ответа мы устанавливаем на сокет таймаут, чтобы не ждать ответа бесконечно (а то несуществующие IP просто не ответят и мы будем ждать вечно). Если получили ответ, мы должны достать мак адрес. Вспоминаем структура пакетов и думаем где находится MAC адрес, а он находится в ARP пакете на месте Sender hardware address, соотвественно мы читаем эти 6 байт с нужным нам смещением, а после переводим байты в hex, для более знакомого вида MAC адреса (было бы конечно лучше ещё добавить разделитель, но опустим этот момент)
offsetToMacAddr := 14 + 2 + 2 + 1 + 1 + 2
log.Println(hex.EncodeToString(data[offsetToMacAddr : offsetToMacAddr+6]))
Вот мы и получили MAC-адреса наших устройств. Сразу оговорюсь: оптимизации в рамках этой статьи опустим. Сейчас мы отправляем один пакет и блокирующе ждём ответ — это медленно. Лучше было бы открыть один сокет, разом отправить в него все пакеты и выставить общий таймаут, скажем, 500ms: кто успел ответить за это время — запоминаем его MAC, кто не ответил — считаем, что устройства нет.
Узнаем устройство, скрытое за IP адресом
Мы уже проделали огромную работу, мы просканировали сеть, узнали все устройства в ней, а так же их MAC адреса, но что если пойти дальше и узнать что за устройство скрыто за IP адресом. На самом деле сделать это не так сложно, первые 3 байта MAC адреса являются уникальным идентификатором производителя, так что получив MAC адрес мы сможем легко достать производителя используя базу данных производителей - https://standards-oui.ieee.org/oui/oui.txt. Заранее загрузив всю базу себе в код, таким простым кодом мы можем вывести MAC адрес:
log.Printf("MAC: %s\n", organizationNameByMac[strings.ToUpper(hex.EncodeToString(macAddr[:3]))])
Почему определяются не все устройства
Запустив этот код на своей сети, скорее всего вы увидите, что не все устройства определяются и на самом деле это не баг, я немного соврал что очень просто определить устройство, на самом деле много устройств намеренно генерируют мак адрес который не подходит не под одно йстройство ради анонимности, допустим мой телефо IPhone генерирует специальный мак адрес по которому нельзя определить что это за устройство, и я более скажу в предпоследнем бите в первом байте мак адреса есть об этом информация, если он выставлен в 0 то мак адрес выдан IEEE и его можно найти в базе данных, если он 1 то мак адрес сгенерирован самим устройством и мы не сможем понять что это за устройство, так что код вывода мак адреса прервращается в это:
if macAddr[0]&0x02 != 0 {
log.Printf("MAC: %s\n", hex.EncodeToString(macAddr))
} else {
log.Printf("MAC: %s\n", organizationNameByMac[strings.ToUpper(hex.EncodeToString(macAddr[:3]))])
}
Заключение
Вот мы и завершили программу, получилась небольшая, но очень интересная программа по знаниям, которые пришлось получить или попробовать, пока делал эту программу. Сразу скажу, что можно многое доработать, как я уже сказал оптимизировать ARP запрос по всем IP адресам, сделать кросс платформенным, так как когда мы работаем с низкоуровневыми вещами приходится очень много работать с ОС и это в наших интересах выносить общие функции чтобы асбтрагироваться от особенностях ОС. Можно пойти дальше и добить историю с определением имён используя DNS/mDNS, но это всё возможные темы следующих статей. Мне было интересно разобраться во всем этом, узнать и попробовать что то новое, надеюсь и вам было интересно узнавать это со мной.