Привет, Хабр! Меня зовут Алексей, и я разработчик в Cloud.ru. Бороздя просторы Github в поисках вдохновения для проекта выходного дня, наткнулся на репозиторий Pi.Alert. В его описании первой строкой было емкое «WIFI / LAN intruder detector». Мне понравилась концепция устройства, которое выполняет мониторинг подключенных к сети девайсов, и я захотел сделать что-то подобное. Но выделять под эту задачу машинку, способную исполнять Python-код, показалось избыточным. Было решено: пора сдуть пыль с давно заказанного, но так и не нашедшего применения ESP32, и наконец-то поупражняться в написании кода под эту SoC. Целью эксперимента стало создание анализатора домашней сети, отображающего подключенные устройства. В качестве дополнительной фичи добавим сигнализирование о ситуациях возможного arp-spoofing.

Теория

Определять, какие устройства подключены к сети будем при помощи Address Resolution Protocol (ARP). Данный протокол используется в компьютерных сетях для сопоставления IP-адресов MAC-адресам.

Суть его работы можно описать так:

Когда устройству нужно связаться с другим устройством в той же сети, оно сначала шлет широковещательный ARP-запрос: «У кого IP-адрес 192.168.1.3? Сообщи свой MAC-адрес». Все устройства в сети получают этот запрос, но отвечает только то, у которого совпал IP. Оно шлет ответ: «Это мой IP, мой MAC-адрес — D1:EA:1D:3F:4A:06».

Именно эту особенность я и буду использовать: просто отправлять ARP-запросы со всеми адресами домашней сети. Девайсы, которые ответят — подключены к сети, которые промолчат — не подключены (либо не хотят об этом сообщать ?).

Ситуации, когда один и тот же MAC-адрес «отзывается» на несколько адресов, являются тревожными звоночками возможного arp-spoofing — атаки типа man-in-the-middle (MitM), при которой целью злоумышленника является перехват/модификация пакетов, передаваемых между узлами.

Реализация

В качестве среды разработки я выбрал VSCode + Platformio. Фреймворк — Arduino. Правда, спойле��, он быстро «заканчивается» и оставляет один на один с FreeRTOS и ESP IDF. Начнем с самого базового блока: с отправки arp-запроса. К сожалению, фреймворк Arduino не предоставляет подобной ручки. Не желая углубляться в самые дебри, я остановился на уровне, где приходится оперировать API lwIP (lightweight IP — стек TCP/IP с открытым исходным кодом, предназначенный для встраиваемых устройств):

void make_arp_request(struct netif *netif, ip4_addr_t *target_ip) {
    if (netif == NULL) {
        return;
    }
    if (target_ip == NULL) {
        return;
    }

    for (uint32_t i = 0; i < RETRY_COUNT; i++) {
        etharp_request(netif, target_ip);
    }
}

После вызова etharp_request, пара MAC и IP адресов откликнувшегося устройства будет занесена в предоставляемую lwIP arp-таблицу. Для ее построения реализована следующая функция:

void build_arp_table(struct netif *netif, uint32_t from, uint32_t to) {
    ip4_addr_t target_ip;
    for (uint32_t i = from; i < to; i++) {
        target_ip.addr = esp_netif_htonl(i);
        make_arp_request(netif, &target_ip);
    }
}

IP-адрес мы представляем как простое 32-битное число, каждый раз инкрементируя которое, преобразуем его в представление, понятное для «сети» (вопрос порядка байт) и отправляем arp-запрос. Далее, нужно проверить, на какие IP-адреса нашлись MAC, и сообщить об их статусе:

void send_address_status(struct netif *netif, ip4_addr_t *target_ip) {
    const ip4_addr_t *found_ip = NULL;
    struct eth_addr *eth_ret = NULL;
    if(etharp_find_addr(NULL, target_ip, &eth_ret, &found_ip) != -1) {
        send_address_status_online(*eth_ret, *found_ip);
        return;
    }
    send_address_status_offline(*target_ip);
}

void check_arp_table(struct netif *netif, uint32_t from, uint32_t to) {
    ip4_addr_t target_ip;
    for (uint32_t i = from; i < to; i++) {
        target_ip.addr = esp_netif_htonl(i);
        send_address_status(netif, &target_ip);
    }
}

Объединяющая все это функция выглядит следующим образом:

void arp_scan(void *) {
    // хак, позволяющий получить wifi интерфейс
    esp_netif_t* esp_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
    struct netif *netif = (struct netif *)esp_netif_get_netif_impl(esp_netif);

    // получаем информацию о сети - адрес сети, шлюз, адрес сетевого интерфейса
    esp_netif_ip_info_t netif_ip_info;
    esp_netif_get_ip_info(esp_netif, &netif_ip_info);
    
    for ( ;; ) {
        // начать сразу со шлюза
        uint32_t ip_iterator = ntohl(netif_ip_info.ip.addr & netif_ip_info.netmask.addr) + 1;
        
        // последний адрес - предшествующий широковещательному
        uint32_t stop_addr = (ip_iterator | 0xFF) - 1;

        // перебираем все адреса сети
        while(ip_iterator < stop_addr) {
            uint32_t to_addr = ip_iterator + ARP_TABLE_SIZE;
            if (to_addr > stop_addr) {
                to_addr = stop_addr;
            }

            build_arp_table(netif, ip_iterator, to_addr);
            // здесь решил добавить задержку - чтобы точно все, кто хотел, успели ответить на запрос
            vTaskDelay(1000 / portTICK_PERIOD_MS);
            check_arp_table(netif, ip_iterator, to_addr);
            
            ip_iterator = to_addr;
        }

        vTaskDelay(10000 / portTICK_PERIOD_MS);
    }
}

Загвоздка, с которой столкнулся — ограничение размера arp-таблицы в lwip значением макроса ARP_TABLE_SIZE. Поэтому пришлось запрашивать адреса как бы «пачками» размером в ARP_TABLE_SIZE. В рамках логики выставления статусов просто происходит занесение информации в таблицу статусов адресов. Из интересного: при сообщении об активном адресе есть проверка на случай, если переданному IP-адресу до этого соответствовал другой MAC-адрес или «откликнувшийся» MAC уже отзывался на иной IP-адрес.

void send_address_status_online(eth_addr eth, ip4_addr_t addr) {
    uint32_t ip_addr = ntohl(addr.addr);
    // пока что решение нацелено на домашнюю /24 сеть, поэтому
    // как ключ таблицы используется последний октет адреса
    uint32_t device_info_key = ip_addr & 0xFF;
    
    uint32_t status = DEVICE_STATUS_ONLINE;

    uint32_t device_connected = device_infos[device_info_key].status == DEVICE_STATUS_ONLINE;
    if (device_connected) {
        if (compare_mac(device_infos[device_info_key].mac, eth.addr) != 0) {
            status = DEVICE_STATUS_SUSPICIOUS;
        }
        set_status(device_info_key, addr, eth.addr, status);
        return;
    }

    for (uint32_t i = 1; i < DEVICES_MAX_COUNT; i++) {
        if (i == device_info_key) {
            continue;
        }

        if (compare_mac(device_infos[i].mac, eth.addr) == 0) {
            status = DEVICE_STATUS_SUSPICIOUS;
            break;
        }
    }

    set_status(device_info_key, addr, eth.addr, status);
}

Пример вывода

Подключим ESP32 к ПК и посмотрим вывод последовательного порта:

Отлично, устройства в сети обнаружены. Теперь, смоделируем arp-spoofing. Для этого воспользуемся утилитой arpspoof:

sudo ./arpspoof -r 1 -i wlan0 -g 192.168.3.58 192.168.3.23

Смотрим, что говорит ��аш сканер:

Знак восклицания обозначает, что с обнаруженной парой IP-MAC что-то не так. Ура! Сканер работает, как и было задумано.

А давайте это логировать еще и на удаленный сервер

Учитывая возможности ESP, очень трудно остановиться и не добавить еще каких-то особенностей. И одна из очевидных вещей — логирование на удаленный сервер. Для этого я набросал простой сервер на Go, который принимает POST-запрос и логирует его тело.

func main() {
    r := chi.NewRouter()
    r.Route("/log", func(r chi.Router) {
        r.Use(middleware.Logger)
        r.Use(jwtauth.Verifier(tokenAuth))
        r.Use(jwtauth.Authenticator(tokenAuth))
        r.Post("/", func(w http.ResponseWriter, r *http.Request) {
            if r.Body == nil {
                http.Error(w, "Request body is empty", http.StatusBadRequest)
                return
            }
            defer r.Body.Close() 

            body, err := io.ReadAll(r.Body)
            if err != nil {
                log.Error("read request body", err)
                http.Error(w, "Error reading request body", http.StatusInternalServerError)
                return
            }

            log.Info(string(body))
            w.WriteHeader(http.StatusOK)
        })
    })

    http.ListenAndServe(":8888", r)
}

Со стороны ESP спасает Arduino, предоставляя удобный HTTP-клиент:

void send_log(const char *msg) {
    HTTPClient http;
    if ( !http.begin(http_logger.logger_endpoint) ) {
        Serial.println("failed to begin http log");
    }
    http.addHeader("Authorization", http_logger.token);
    uint32_t code = http.POST(msg);
    if ( code != HTTP_STATUS_CODE_OK) {
        Serial.printf("failed to send post request, code: %d\n", code);
    }
    http.end();
}

Поднимаем сервер, включаем ESP, смотрим логи:

Заключение

Было очень интересно поиграться с данной платкой, без каких-либо внешних модулей она уже может предоставить простор для творчества и исследований. Проекту есть куда расти: можно добавить веб-интерфейс с настройкой доверенных пар адресов; для красоты добавить определение производителя по MAC-адресу (OUI); уведомлять о подозрительных ситуациях через Telegram; подключить светодиод, сигнализирующий о новых устройствах в сети. Да и часть функционала «старшего брата» в виде Pi.Alert можно перенести на данный контроллер. Если есть советы или рекомендации по проекту, буду рад пообщаться в комментариях.

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