
В логах RDP-подключений иногда встречается запись, которая выглядит как ::%16777216 — и это вместо привычного IP-адреса. Про такой артефакт пишут в отраслевых отчетах уже несколько лет. Он всплывает в описаниях атак с туннелированием RDP, и практически всегда авторы упоминают утилиту ngrok. Но при этом почти никто не объясняет, что это за значение, какова его природа и почему оно записано именно так. Складывается впечатление, что авторы либо не знают ответа, либо считают эту деталь слишком мелкой для пояснений.

Меня зовут Константин Грищенко, я отвечаю за развитие технологий SOC в Positive Technologies. Работаю в этой сфере больше пяти лет, а всего в практической информационной безопасности — уже 23 года.
В ноябре 2024 года в одном из докладов на конференции SOC Forum я в очередной раз увидел упоминание этого артефакта и решил все-таки попробовать разобраться в том, что это такое.
Как выглядит аномалия в логах
Напомню, о каких логах пойдет речь. В процессе подключения по протоколу RDP на целевом хосте после того, как будет установлено сетевое соединение на порт службы удаленных рабочих столов, в журнале Microsoft → Windows\TerminalServices-RemoteConnectionManager → Operational регистрируется событие с идентификатором EventID 1149.

В большинстве случаев в этом событии регистрируются три ключевых параметра: имя пользователя и домен, которые были указаны клиентом в ходе подключения, и IP-адрес источника. С первыми двумя все понятно, а вот адрес источника по логике должен быть обычным IP-адресом. Обычно в этом поле можно увидеть IPv4-адрес хоста. Но в тех случаях, когда хакеры в ходе атаки использовали утилиту ngrok для установки туннелированного соединения, через которое затем подключались по RDP, вместо адреса мы видим строку ::%16777216. Причем в исходном XML-представлении события значение выглядит точно так же, так что это не проблема форматирования.
Возникает закономерный вопрос: может быть, по какой-то причине IP-адрес источника записан в такой необычной форме? Чтобы ответить на него, пришлось заглянуть в RFC, погуглить и напрячь память.
IPv4-адрес — это просто 32-битное число, 4 байта. Кроме всем известной формы записи в виде четырех чисел, разделенных точкой, его можно записывать по-разному: в восьмеричном или шестнадцатеричном виде, и даже одним большим целым числом — библиотечная функция inet_aton (и другие аналоги, используемые в большинстве операционных систем) такие варианты успешно переваривает. Подробности, как и почему это работает, — в материалах:
🔗 datatracker.ietf.org/doc/html/rfc791
🔗 learn.microsoft.com/ru-ru/windows/win32/api/wsipv6ok/nf-wsipv6ok-inet_addr

Это интересно, но не похоже на то, что мы видим в логе: большое число есть, но знака процента и двоеточий нет.
С IPv6-адресами ситуация сложнее: 128 бит, 16 байт. В текстовом виде адрес обычно записывается как восемь групп по четыре шестнадцатеричных цифры, разделенных двоеточиями. Например:
FEDC:BA98:7654:3210:FEDC:BA98:7654:32101080:0:0:0:8:8:800:200C:417A
Нули, идущие подряд, можно сокращать до двойного двоеточия:
FF01:0:0:0:0:0:0:0:43 → FF01::43
Кроме того, существует совсем необычный, но рабочий способ записи IPv6-адресов, предусмотренный для случая, когда нужно указать адрес там, где нельзя использовать двоеточия, например для указания UNC-путей в командной строке.
Вместо обычной формы записи с двоеточиями нужно записать адрес, заменив все двоеточия на знак - и дописать в конце суффикс .ipv6-literal.net . Получится что-то вроде 2620-0-862-ed1a—1.ipv6-literal.net, что выглядит как DNS-имя и ведет себя в Windows практически как доменное имя (правда, в действительности не резолвится через протокол DNS) — при попытке обратиться по такому имени произойдет его преобразование в соответствующий IPv6-адрес, к которому и будет дальше устанавливаться сетевое соединение.

И снова, несмотря на наличие разных форм записи IP-адресов, чего-то не хватает. Двойное двоеточие есть, большое число есть, но знака процента нет. А ведь именно процент — ключевая деталь.
Тогда я вспомнил про zone index — если говорить упрощенно, это способ указать на локальной системе, к какому сетевому интерфейсу относится IPv6-адрес. Например, %3 означает третий интерфейс, а %eth2 — интерфейс с именем eth2. Такая запись имеет смысл только для конкретного устройства. Подробности описаны в RFC4007.
По отдельности в разных формах записи разных видов IP-адресов можно встретить все элементы: двоеточия, знак процента, большие числа. Но в комбинации ::%16777216 ничего похожего, на первый взгляд, нет.
Первая гипотеза и ее проверка
Если присмотреться, кое-что похожее все-таки находится. Возьмем число 16777216 и переведем его в шестнадцатеричный вид. Получается 01 00 00 00 — единица и три нулевых байта следом. Это сразу наводит на мысль об IPv6-адресе ::1, то есть адресе localhost, хоть тут всего 4 байта, а не 16. Этот адрес фактически тоже представляет из себя одну единицу и много нулей.
Думаю, гипотеза уже очевидна: ::%16777216 — это какая-то искаженная форма адреса ::1. Действительно, при RDP-подключении через предварительно установленный туннель TCP-соединение на порт службы удаленных рабочих столов «приходит» с localhost самого хоста назначения. Туннель замыкает соединение на себя, и источником оказывается локальный хост. Поэтому, ожидать появления в логе какого-то IP-адреса, соответствующего адресу localhost вполне логично.
При этом авторы отчетов о расследованных атаках почти всегда привязывают этот артефакт к факту использования хакерами утилиты ngrok. Прежде чем пытаться понять, как именно ngrok может влиять на странное значение в логах, я решил проверить другую версию: а вдруг дело не в ngrok, а в самом Windows или механизме туннелирования? Если моя догадка верна и ngrok тут не важен, то для экспериментов подойдет любая утилита, позволяющая построить подобный туннель.
Я взял SSH. В современных сборках Windows он доступен по умолчанию. Для тестирования я развернул простейший стенд из двух хостов, а для создания туннеля использовал команду:
ssh -L 33389:[::1]:3389 pc.testlab.lan.
То есть на хосте, к которому потом я буду подключаться по RDP, я открываю локальный порт 33389 и пробрасываю его на RDP-порт 3389 того же хоста.

Хакеры обычно делают reverse-туннель, но для эксперимента это неважно. Когда TCP-соединение установлено, данные идут в обе стороны независимо от того, кто был инициатором. Я подключился по RDP через этот туннель и увидел в логе то, что и ожидал, — то самое значение ::%16777216. Таким образом, довольно быстро я убедился, что действительно утилита ngrok здесь ни при чем — проблема воспроизводится без нее.
Расследование: что портит адрес
Теперь нужно понять, что именно генерирует неправильные данные. Можно взять отладчик, загрузить бинарные модули и копаться в дизассемблере. А можно пойти другим путем.
Я вспомнил про старую, но до сих пор работающую утилиту API Monitor. Несмотря на то, что последняя версия — 2.0 Alpha-r13 — вышла в 2013 году, она до сих пор прекрасно справляется со своей задачей. Утилита перехватывает API-вызовы и показывает, что попало на вход и что вернулось на выходе. Это удобно, когда примерно понимаешь, что ищешь, но не хочешь сразу лезть в отладчик.
Чтобы воспользоваться API Monitor, нужно предварительно определить процесс, в котором необходимо включить перехват вызовов. Служба удаленных рабочих столов «живет» внутри процесса svchost.exe, но таких процессов много. Чтобы найти нужный, я воспользовался еще одной известной полезной утилитой — Process Explorer из пакета Sysinternals. Чтобы понять, какой процесс мне нужен, достаточно обратить внимание на открытые порты, или посмотреть внимательно на командные строки, или свериться со списком загруженных библиотек — нужный процесс находится быстро.

Я подключил API Monitor к найденному процессу и включил мониторинг сетевых функций. Вскоре нашелся интересный вызов GetNameInfoW из библиотеки ws2_32.dll. На входе — структура с IP-адресом, на выходе — его строковое представление.

Это документированная функция сетевого API Windows, которая при вызове с флагом NI_NUMERICHOST возвращает строковое представление IP-адреса, не выполняя DNS-запросов.
После нескольких экспериментов с подключением через туннель и использованием IPv6-адреса и анализа результатов перехвата в API Monitor мне удалось найти среди вызовов GetNameInfoW найти тот, в котором на вход приходит структура, описывающая IPv6-адрес клиента, то есть ::1, а на выходе в буфере, где должно оказаться его строковое представление, оказывается ::%16777216. Очередной шаг на пути к разгадке сделан.

Теперь нужно было разобраться, как именно происходит искажение. Для этого пришлось покопаться в структурах данных. Во многих функциях, связанных с сетевым стеком в Windows, используются структуры SOCKADDR, которые умеют описывать адреса разных семейств. Для IPv4 эта структура «ссылается» на структуру sockaddr_in размером 16 байт, для IPv6 — на структуру sockaddr_in6 размером 28 байт.
Утилита API Monitor отлично показывает не только информацию о факте вызова той или иной функции и о переданных параметрах, но и содержимое памяти по указателям до и после вызова. Но когда я захотел подробно изучить содержимое памяти при обработке адреса ::1, API Monitor показал только 0x17 в начале структуры и много нулей — а где хотя бы одна единица? Оказалось, что проблема в самой утилите. Описание именно этой структуры, которое используется для того, чтобы понять, сколько байтов памяти нужно записывать в лог при перехвате вызовов, рассчитано на то, для IPv4-адреса хватит 16 байт, но не учитывает, что при использовании IPv6-адреса на хранение всех данных о нем нужно минимум 28. К счастью, параметры API Monitor не «прибиты гвоздями» и описания структур лежат в XML-файлах, которые можно редактировать.
Описание структуры sockaddr содержится в файле C:\Program Files\rohitab.com\API Monitor\API\Headers\sockets.h.xml.

Я поправил описание поля sa_data, увеличив размер с 14 до 30 байт (пару лишних байтов добавил на всякий случай). После этого в результатах перехвата я смог увидеть все необходимые для изучения данные.

Теперь стало видно, что IPv6-адрес записывается в память со смещением: последняя единица из адреса "уезжает" в поле scope ID (на рисунке я схематично указал стрелками сопоставление отдельных полей структуры и соответсвующих им данных в памяти). Данные съезжают, но не пропадают — они просто интерпретируются неправильно.
В экспериментах с адресом ::1, который в памяти состоит из 15 нулевых байтов и одного байта с единицей, сложно точно понять, какие именно байты и в какую сторону сместились или как-то по другому исказились в памяти — все нули одинаковые.
Я провел еще один эксперимент: вместо адреса ::1 взял нормальный IPv6-адрес, подключился по RDP напрямую, без туннеля, и посмотрел на результат в логах и в памяти. Убедился, что съезжает вправо весь адрес целиком — в строковом представлении адреса в начале появляются четыре лишних нулевых байта, а в конце возникает странное число, и в логе вместо ожидаемого адреса fe80::6e1d:980d:9401:719b получается строка 0:0:fe80::6e1d:980d%2607874452. Теперь дополнительно стало понятно, что «испорченный» таким образом IP-адрес можно привести к нормальному виду, если будет нужно.
Кстати, проблема проявляется не только в событии 1149. Я проверил события безопасности 4778 и 4779 (подключение и отключение RDP-сессии) — там все происходит аналогично.
Интересный факт. В интернете мне не удалось найти описания или хоть каких-то вопросов или обсуждений проблемы с порчей адреса в логе RDP, но нашелся один вопрос по очень похожей проблеме.

В том старом кейсе 2015 года Microsoft ответила, что проблема связана с новой структурой WTS_SOCKADDR, которая стала использоваться для хранения IP-адреса. Компания ее починила, выпустив необходимое обновление. Как выяснилось, починила, но не во всех местах.
Глубинная причина и статус исправления
С учетом информации о схожих проблемах в прошлом и новой структуре WTS_SOCKADDR я решил продолжить свое расследование, чтобы найти точное место возникновения проблемы. Для этого мне пришлось не только изучить логи работы API Monitor, но и заняться самостоятельной отладкой. С SSH экспериментировать удобно, но для отладки это не всегда подходит. Можно быстро арендовать виртуалку с актуальной Windows, но возиться с пробросом портов наружу не хочется. Можно арендовать две виртуалки рядом, но это дороже и требует настройки.
Я взял утилиту Microsoft Dev Tunnels — она работает похоже на ngrok. На одном хосте поднимается маппинг, на другом подключаются к туннелю, и порты пробрасываются.

Есть и вторая сложность: отлаживать RDP-подключение неудобно. Если поставить точку останова, которая должна сработать где-то в моменте подключения, весь процесс встанет на паузу — и вы окажетесь в подвешенном состоянии. Клиент ждет ответа, сервер висит в отладчике, ощущение как будто ты «застрял в текстурах». Конечно, можно поднять полноценный терминальный сервер, где можно запустить отладчик в отдельной сессии, но мне не хотелось возиться еще и с настройкой сервера.
Решение нашлось: Dev Tunnels умеет пробрасывать несколько портов одновременно. Многие отладчики поддерживают удаленную отладку по TCP. Пробрасываю порт для отладчика и отлаживаю удаленно, не застревая на экране ввода пароля.
Отлаживать Windows-бинарники в каком-то смысле удобно — код не обфусцирован, есть отладочные символы. Но кода очень много, и непонятно, за что хвататься. После API Monitor я уже знал, где проблема проявляется, оставалось найти место, где она рождается. Также стоит упомянуть известную вещь, которая мне существенно упростила исследования. В современных процессорах давным-давно реализована возможность использовать аппаратные точки останова при отладке — их можно ставить не только на код, но и на доступ к памяти. Используя эту возможность и устанавливая точки останова на проблемные участки памяти за несколько итераций я смог найти то место в коде, где «портится» IP-адрес — оно оказалось в библиотеке rdpcorets.dll, в методе CUMRDPConnection::GetClientData.

При внимательном взгляде на участок кода, который приведен на рисунке, все становится ясным. В этом участке кода инструкция movdqu xmmword ptr [r14+3088], xmm0 заносит 128 бит IPv6-адреса клиента из регистра xmm0 в память по смещению r14+3088. Чуть выше по коду видно, что значение 17h записывается по смещению r14+3076 и это соответствует типу структуры AF_INET6. Запомним, что разница между этими смещениями — 12 байт.
Теперь ключевой момент: структура WTS_SOCKADDR, которая тоже используется в ряде модулей Windows для хранения информации об IP-адресе, устроена чуть иначе, хоть ее определение на языке C выглядит довольно похоже на SOCKADDR.

После компиляции оказывается, что для хранения данных структуры WTS_SOCKADDR предусмотрено четыре дополнительных байта для выравнивания. И именно эти четыре «лишних» байта дают то самое смещение IPv6-адреса из-за того, что одни и те же данные об IP-адресе, лежащие в памяти, обрабатываются разными модулями, использующими разные определения структур.
В процессе обработки данных в ходе установления RDP-соединения один программный модуль записывает данные об адресе в память со смещением 12 байт относительно начала базовой структуры, а другой читает их так, как будто смещение должно быть 8 байт. Из-за этого «съезжает» всё и IPv6-адрес оказывается на четыре байта правее.
Строка для записи в лог формируется в коде библиотеки termsrv.dll через вызов GetNameInfoW. В качестве параметра ожидается указатель на SOCKADDR. Для IPv6, когда sin_family = 17h, используется смещение 8 байт. Это на 4 меньше, чем 12. И получается та самая каша: ::%16777216.
На момент прошлогодних тестов ошибка воспроизводилась на нескольких версиях Windows. В их числе Windows Server 2012 R2, Windows 10 Pro 22H2, Windows Server 2019 Standard и Windows 11 Enterprise 23H2. Мой опыт позволяет предположить, что проблеме подвержены все версии начиная с Windows 8 и Windows Server 2012.

Скорее всего, почти никто всерьез не использует IPv6-адреса в реальной работе с RDP. Логи начинают внимательно читать уже после инцидента, когда приходят DFIR-специалисты, для которых само значение ::%16777216 является заметным и достаточным индикатором.
Мы с коллегами зарепортили подробности по описанной в исследовании проблеме, причины ее возникновения и точное место ошибки в коде, в Microsoft. Репорт в MSRC мы отправили 10 июня 2025 года. Ответ по существу получили 8 ноября 2025 года, и его краткое содержание было таким: «Moderate, no CVE, no additional updates will be provided». Ответ ожидаем — указанная проблема не приводит к возникновению никакой уязвимости. Больше никаких апдейтов от Microsoft мы действительно не получили.
В конце мая 2026 года я провел несколько экспериментов, которые показали, что проблема уже исправлена в новых сборках Windows 11.
На 25 мая 2026 года ситуация выглядела так:
Windows 10 Pro 22H2 со всеми доступными обновлениями — ошибка есть;
Windows 11 24H2 (сборка 26100.1742) — ошибка есть;
Windows 11 24H2 (сборка 26100.8457) — ошибки нет;
Windows 11 26H1 (сборка 28000.2113) — ошибки нет.
Так как Windows 10 уже является устаревшей, возможно для нее исправления не будет.

Что делать с этим знанием
Теперь вернемся к главному вопросу: полезно ли это знание для практической работы? Ответ — да, и вот почему.
::%16777216 — это не артефакт ngrok. Это результат ошибки Windows. Ошибка связана с несогласованной обработкой одних и тех же данных разными модулями операционной системы. Из-за нее при подключениях к RDP с использованием IPv6 в лог попадает неверный адрес источника.
Но это не значит, что значение бесполезно. Напротив, его можно и нужно использовать как индикатор компрометации (IoC). Появление ::%16777216 в журнале событий указывает на туннелированное RDP-подключение. И неважно, какой софт использовался для организации туннеля: ngrok, SSH, Dev Tunnels или что-то иное. Механизм один и тот же. Расследованию такое значение обычно не мешает. Оно просто говорит о том, что подключение пришло с localhost через туннель. В большинстве сценариев этого достаточно, чтобы понять происходящее.
Но что делать, если нужен точный IPv6-адрес? Например, для корреляции событий в SIEM-системе или для того, чтобы не пропустить искомый адрес при ретроанализе. Ошибочное значение можно восстановить, так как данные не портятся безвозвратно — они просто сдвинуты.
Алгоритм восстановления выглядит так:
Шаг 1. Взять значение из лога:
0:0:fe80::6e1d:980d%2607874452
Шаг 2. Часть после % перевести в HEX
2 607 874 452 dec = 9b 71 01 94 hex
Шаг 3. Убрать слева два нуля, дописать справа полученные цифры с учетом обратного порядка байт
fe80::6e1d:980d:9401:719b
Эту логику можно реализовать практически в любой SIEM-системе. Например, написать правило нормализации, которое преобразует искаженный адрес в корректный прямо при сборе событий. Или использовать функцию преобразования при построении дашбордов и корреляций.
Выводы
В старых версиях Windows, которые уже не получают обновлений, продолжайте обращать внимание на
::%16777216в логах — это по-прежнему отличный индикатор туннелированного подключения к RDP.В актуальных версиях Windows, где, возможно, были установлены обновления, дополнительно проверяйте логи на наличие
::1там, где такого адреса не должно быть в норме (в качестве источника при доступе по RDP и в других местах).Учитывайте, что есть множество различных утилит для создания туннелей, и подобный индикатор никак не подтверждает использование именно ngrok.
По желанию — доработайте свой парсинг логов так, чтобы восстанавливать нормальный IP-адрес: это поможет учитывать адрес в корреляционных правилах и получать больше релевантных результатов при ретроспективном поиске событий или тредхантинге.

