
Привет, Хабр! Меня зовут Алексей, и я разработчик в 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, ð_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 можно перенести на данный контроллер. Если есть советы или рекомендации по проекту, буду рад пообщаться в комментариях.