Привет, Хабр! В этой статье мне бы хотелось поговорить о поддержке протокола ICMP в контексте разработки приложений под Android. Информации по этой теме в открытом доступе на удивление мало, она обрывочная, часто встречается дезинформация, до многого приходилось доходить «методом тыка», а что-то — буквально выуживать из исходников AOSP. От читателя я ожидаю, что он не полный новичок в разработке под Android, понимает C/C++, а также знаком с интеграцией нативного кода в приложение. В примерах я буду использовать C/C++ — хотя многое и можно сделать из управляемого кода, но из нативного доступно значительно больше. При этом я буду сильно сокращать примеры. Я пробовал использовать реальный код для примеров, но получается слишком «душно»… моя задача — показать вектор движения. Кому будет очень интересно, то в конце будет ссылка, где можно почитать код во всех подробностях. Чтобы не раздувать материал, буду рассматривать только работу с IPv4, оставив IPv6 за кадром.
Часть первая, в которой мы осматриваем края кроличьей норы
Я — автор приложения «Knock on Ports». Знаю, что среди читателей Хабра есть мои пользователи — я неоднократно видел упоминания моего приложения и в статьях, и в комментариях. Спасибо, что вы есть.
В далеком 2018 году, практически сразу после релиза первой версии, меня попросили добавить поддержку ICMP протокола. Так начался мой путь по этой тернистой тропинке… Поиск библиотек для интеграции ICMP в приложения не дал никаких результатов — те единицы, в которых было хоть что-то про ICMP, абсолютно не подходили для моих целей. Пришлось углубляться в изучение этого вопроса, и я практически сразу споткнулся о тезис — «ICMP без root невозможен».
Попробуем понять откуда пошел этот миф (иначе я не могу это назвать). Для этого взглянем на строку для создания ICMP-сокета, как это приводится в «учебниках».
int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
Видите проблему? Проблема в SOCK_RAW — создание «сырых» сокетов без прав root невозможно.
Однако, это не единственный способ создания ICMP-сокета. Соответствующий сокет можно создать как датаграмм-сокет (SOCK_DGRAM), это будет тот же ICMP-сокет, но не требующий root. Да, он отличается от RAW-сокета, но для «пользовательских» задач его более, чем достаточно. Приводить полный список различий я не буду, но дальше по тексту отмечу некоторые нюансы. Так же, прошу обратить внимание, что это не какой-то «хак», а вполне официальный режим работы — он появился в ядре Linux версии 3.0 в 2011 году.
Часть вторая, в которой мы спускаемся в кроличью нору
Окей, миф — это не более, чем миф, остальное — дело техники, можно действовать почти по «учебнику». Хотя тут сразу вылезает, как минимум, пара моментов, чем ICMP RAW-сокеты отличаются от DGRAM-сокетов.
Момент номер один — с RAW-сокетами мы можем контролировать содержимое IP-заголовков, в DGRAM — система берет это полностью в свои руки. Момент номер два — с RAW-сокетами ты обязан считать контрольную сумму ICMP-пакета, в DGRAM — система берет заполнение этого поля на себя.
Что ж, попробуем с этими знаниями сделать что-то простенькое вроде пинга…
Но перед этим ВАЖНОЕ ЗАМЕЧАНИЕ. Любые тестирования с ICMP надо проводить на реальных, физических устройствах. На эмуляторе весь ICMP ограничен localhost. Внятного объяснения причин этому у меня нет, остается предполагать что-то связанное с безопасностью и принять как данность. Так что — никаких эмуляторов!
void send_ping() {
// Создаем ICMP DGRAM-сокет
int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP);
if (sock < 0) {
ALOGE("Error creating socket: %d %s", errno, strerror(errno));
return;
}
// Инициализируем sockaddr_in - адрес получателя
struct sockaddr_in dest{};
dest.sin_family = AF_INET;
// На всякий случай напоминаю, что мы говорим только об IPv4
inet_pton(AF_INET, "8.8.8.8", &dest.sin_addr);
struct icmphdr icmp_hdr{};
icmp_hdr.type = ICMP_ECHO; // Выполняем эхо-запрос
icmp_hdr.un.echo.id = htons(static_cast<uint16_t>(getpid()));
// Идентификатор запроса.
// Исторически так сложилось, что часто используют идентификатор процесса,
// но фактически здесь подойдет любое значение
// (хотя лучше придерживаться уникальности в пределах устройства)
icmp_hdr.un.echo.sequence = htons(1);
// Номер последовательности. Если мы отправляем несколько пакетов,
// то с каждой отправкой инкрементируем это поле
// Обратите внимание, в "классическом" пинге мы здесь должны заполнить
// поле с контрольной суммой.
// В случае с DGRAM-сокетами нам не надо обращать внимание на это поле -
// система заполнит его самостоятельно.
// Отправляем наш ICMP-пакет
auto sent = sendto(sock, &icmp_hdr, sizeof(icmp_hdr), 0, reinterpret_cast<sockaddr*>(&dest), sizeof(dest));
if (sent < 0) {
ALOGE("Error sending probe: %d %s", errno, strerror(errno));
close(sock);
return;
}
// Ждем ответ
struct sockaddr_in src{};
socklen_t src_len = sizeof(src);
char buf[1024]{};
ssize_t recv_len = recvfrom(sock, buf, sizeof(buf), 0, reinterpret_cast<sockaddr*>(&src), &src_len);
if (recv_len > 0) {
// Обработка ICMP_ECHO_REPLY
// Оставим за кадром, тут мало интересного
}
close(sock);
}
Именно в таком виде (упрощенно) выглядит отправка пинга и получение ответа на низком уровне под Android.
Отмечу тут ещё одно отличие RAW ICMP сокета от DGRAM ICMP. Когда приложение создает RAW ICMP, то на вход оно получает весь ICMP трафик, приходящий на устройство (разумеется, после фильтрации). Поэтому при получении ICMP-пакета в «классическом» пинге необходимо проводить проверку — а ответ ли на наш ICMP запрос получили или это что-то другое? В DGRAM-сокете мы получаем только, грубо говоря, «наш» трафик. Мы не получим ответы на пинги из других приложений, мы получим только ответы на то, что сами отправили. Это одновременно и плюс, и минус. Плюс — потому что нам не надо заниматься ручной фильтрацией всего ICMP-трафика. Минус — потому что мы не видим чужой трафик, даже если захочется.
Именно на этом в свое время завершилась моя эпопея с добавлением ICMP в приложение — все, что нужно было — это отправлять кастомизированные пакеты. Пользователи довольны, у всех все работает, knocker обзавелся уникальной фишкой, все счастливы.
Но, как обычно, этого кажется мало, и тогда…
Часть третья, в которой мы закапываемся в кроличью нору ещё глубже
ICMP — это не только пинг. Даже скорее всего совсем не пинг, пинг — это только малая часть ICMP. ICMP — это контрольные сообщения, все таки не зря полное его название Internet Control Message Protocol. Его основная цель — передача сообщений об ошибках.
Теперь снова личная история. В какой-то момент мне понадобился карманный MTR (если кто-то не в курсе, то MyTraceRoute — это такой гибрид трейсроута и пинга, позволяющий мониторить путь в реальном времени). На удивление, опять решений не нашлось. Пришлось снова засучивать рукава и ступать на тернистую тропинку…
Небольшой экскурс в алгоритм работы traceroute. Всё элементарно. Просто отправляем пакеты получателю с увеличивающимся TTL (от 1 до N). Каждый хоп на пути уменьшает TTL на единицу, и как только TTL достигает 0, отбрасывает пакет и отправляет в ответ ICMP с сообщением «TTL Time Exceeded», при этом сообщая свой IP адрес. Утилиты вроде traceroute или MTR именно на этом и построены — они «прощупывают» с помощью таких ошибок весь путь до получателя.
В классическом виде это опять требует RAW-сокета, чтобы «ловить» ответы. Однако и здесь есть лазейка. У DGRAM-сокетов есть так называемая «очередь ошибок», в которые он может принять входящие ICMP (опять же — только «свои», к чужим у нас нет доступа). Для этого используется опция сокета IP_RECVERR. Включив эту опцию, мы получаем доступ к специальной очереди, в которую кладутся ICMP-сообщения, относящиеся к нашим запросам. На всякий случай еще раз скажу, что это не какой-то «хак», а вполне официальное поведение ядра Linux. Ошибки из очереди можно извлечь с помощью recvmsg с флагом MSG_ERRQUEUE. Это не стандартное чтение данных, это — чтение из отдельной очереди.
Обладая этими данными мы уже можем получить доступ к ошибкам:
void send_something() {
int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP);
// Из важных параметров тут только SOCK_DGRAM
int enable = 1;
setsockopt(sock, IPPROTO_IP, IP_RECVERR, &enable, sizeof(enable));
// Обязательно включаем очередь ошибок
// ... Предположим, мы тут что-то отправляем.
// И пробуем прочитать ошибку
// Для начала нам надо подготовить структуры для получения ошибок
char cbuf[512];
char data[512];
struct iovec iov{
.iov_base = data,
.iov_len = sizeof(data),
}
struct msghdr msg{
.msg_iov = &iov,
.msg_iovlen = 1,
.msg_control = cbuf,
.msg_controllen = sizeof(cbuf),
};
auto len = recvmsg(sock, &msg, MSG_ERRQUEUE);
if (len >= 0) {
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
while (cmsg) {
if (cmsg->cmsg_level == IPPROTO_IP && cmsg->cmsg_type == IP_RECVERR) {
auto *err = reinterpret_cast<struct sock_extended_err *>(CMSG_DATA(cmsg));
// Вот этот момент, где мы получаем ошибку и можем ее обработать
// ...
// Здесь мы можем прочитать, что именно и где произошло с нашим пакетом
struct sockaddr *offender = SO_EE_OFFENDER(err);
// Например, получить адрес хопа, который сгенерировал ошибку
// Описание структуры sock_extended_err можно найти в man
// https://man7.org/linux/man-pages/man7/ip.7.html
}
cmsg = CMSG_NXTHDR(&msg, cmsg);
}
} else {
ALOGE("Error reading error: %d %s", errno, strerror(errno));
}
close(sock);
}
Пример сильно упрощен, но, надеюсь, общий принцип понятен. Имея под рукой очередь ошибок, нам больше не нужен RAW ICMP сокет. Аналогичным образом можно получать практически все ICMP-ошибки.
Другие полезные опции сокета, кроме IP_RECVERR:
IP_RECVTTL — позволяет получить TTL из входящих пакетов
IP_MTU_DISCOVER — позволяет получать информацию об MTU
Но не все так радужно… Остается проблема с TCP-сокетами. Увы, должен признать свое поражение, мне не удалось найти решения. TCP сокет — это не DGRAM сокет, как я ни старался, решения без прав root найдено не было. Все вышеупомянутое работает только с датаграмм-сокетами, то есть ICMP и UDP.
И тут мы постепенно переходим в…
Часть четвертая, где мы просто выдыхаем от облегчения
Возможно, все это выглядит просто и элементарно, но мне понадобилось немало времени, чтобы всю информацию систематизировать и оформить во что-то реальное.
Итак, что мы вынесли из путешествия по этой кроличьей норе…
Первое. Root не нужен. Используйте SOCK_DGRAM с IPPROTO_ICMP. Это более чем достаточно для большинства случаев.
Второе. Ограничения Android, как ни странно, могут играть вам на руку. Фильрация «чужих» пакетов, забота о заголовках — ну замечательно же. Да, за это приходится платить, ну а что поделаешь?
Третье. Все имеет границы. TCP — увы, решения так и не нашлось.
Несмотря на то, что я сильно срезал углы, надеюсь мне удалось показать, что низкоуровневая сеть на Android — это не так уж и страшно.
Все наработки за эти годы я вынес в библиотеку icmpenguin, которая отдана в open source 31 декабря 2025 года (да, таков был мой новый год — в обнимку с Android Studio). Документация доступна так же на GitHub. Если вам понадобится поддержа ICMP в приложении — пользуйтесь на здоровье. Изучайте, используйте, и да пребудет с вами Сила!
Ах, да... карманный аналог MTR я всё-таки сделал.