# ::%16777216 — артефакт ngrok или баг в Windows? ## Метаданные Доклад: «::%16777216 — артефакт ngrok или баг в Windows?» Спикер: Константин Грищенко, Positive Technologies ## Вступление Давайте продолжать. Меня зовут Константин Грищенко. Сегодня я снова, так получается, представляю компанию Positive Technologies и расскажу про странный артефакт, который изображен на титульном слайде: `::%16777216`. Про него много где и много лет написано: где он возникает, в каких атаках встречается. Он появляется во время атак, когда злоумышленники туннелируют подключение к RDP через какой-нибудь туннель. Часто это связывают с утилитой ngrok. Но почему-то почти нигде не описано, что это такое, какова природа этого значения и почему оно записано в таком странном виде. Пара слов о себе. В компании я отвечаю за развитие технологий SOC в PT X. На разных позициях в SOC я больше пяти лет, а всего в практической ИБ, страшно подумать, уже 23 года. Я немного младше протокола IPv4, сильно старше протокола IPv6. И однажды в жизни смог установить Windows 95 с кучи дискеток. Правда, со второго раза, но все-таки смог. Наверное, некоторые из этих факторов повлияли на то, что я решил покопаться в этой истории. ## Где встречается `::%16777216` Примеры упоминаний этих атак многим защитникам, расследователям и специалистам incident response знакомы. Они описаны в отчетах иностранных и отечественных компаний. Я не помню точных дат по каждому примеру, но на слайдах есть, например, публикации 2022 года, то есть история довольно давняя. Чаще всего авторы либо явно упоминают ngrok, либо говорят о протоколировании в контексте туннелирования RDP. И встречается такой тезис: это значение не несет никакой информации о подключившейся системе. Вроде бы да. Но тогда что же оно несет? Я читал эти заметки, и мне всегда казалось: наверное, расследователи, которые это описывают, в курсе, что это такое. Просто это какая-то мелочь, о которой они не хотят рассказывать, поэтому ничего не пишут. Интерес во мне терпел до 2024 года, когда я сходил на конференцию. Вот зачем ходить на конференции? Можно просто ходить, а можно еще задавать вопросы. На той конференции один из известных экспертов, Олег Скуркин, делал доклад на свою тему и в этом же докладе снова упомянул известные атаки, RDP, ngrok и этот артефакт. Где-то в зале, в темноте, сидел я с коллегами и решил задать вопрос: что это такое? Может быть, это уже известно? И снова не получил ответа. Ответ был примерно такой: это какой-то непонятный артефакт, мы не разбирались, для целей расследования, наверное, неважно. А мне стало интересно. В ближайшие дни я спал примерно как на слайде: вроде бы сплю, но мне не дают покоя само число, два двоеточия, знак процента и вопрос, почему все это вместе собрано там, где, казалось бы, по логике должен быть обычный адрес. ## Событие 1149 и поле Source Network Address На слайде показан скриншот события журнала аудита, которое описано во всех этих исследованиях. Когда злоумышленники попадают на систему, пробрасывают с помощью ngrok туннель обратно к себе, а потом через этот туннель подключаются по RDP, в журнале `Microsoft > Windows > TerminalServices-RemoteConnectionManager > Operational` появляется событие `Event ID 1149`. У него в параметрах есть три интересных поля: пользователь, домен и адрес источника. С пользователем и доменом все понятно. А вот адрес источника по логике должен быть адресом. В обычных нормальных событиях там обычно записан IP-адрес: привычный IPv4-адрес, того самого IPv4, которого я чуть-чуть младше. Для наглядности справа на слайде показано то же событие в исходном XML-виде: вдруг это какое-то форматирование. Нет, в параметрах действительно записано ровно такое значение: `::%16777216`. ## Какие бывают записи IP-адресов Я решил разобраться и напрячь память: может быть, бывают IP-адреса, записанные в таком виде? Пошел гуглить, читать, вспоминать литературу и RFC. IPv4-адреса мы все знаем. Это просто целое число из четырех байт, которое используется как идентификатор узла в интернете или локальной сети. Но это число можно записывать по-разному: привычно, десятичными числами через точки; в восьмеричном виде; в шестнадцатеричном виде; можно миксовать разные варианты. Иногда можно записать адрес просто как большое целое число, и это будет работать. Работает это потому, что где-то под капотом используется библиотечная функция вроде `inet_aton` или совместимые функции, которые умеют такие записи конвертировать. Я посмотрел на это все: интересно, но не похоже на то, что я видел в логе. Возможно, на большое целое число похоже, но все равно не оно. Еще есть IPv6. Там интереснее: 16 байт, 128 бит. В текстовом виде IPv6 обычно записывается как восемь 16-битных шестнадцатеричных фрагментов, разделенных двоеточиями. Нули, которые идут подряд, можно сокращать, и тогда появляются два двоеточия. Есть еще интересная запись, которую я нашел в интернете. В некоторых случаях, особенно в командной строке Windows, двоеточия использовать неудобно. Microsoft решила эту проблему через доменное имя вида `ipv6-literal.net`: если перед ним записать IPv6-адрес, заменив двоеточия на дефисы, то это будет работать. При этом DNS-запросы никуда не уходят, это резолвится внутри какой-то библиотеки на хосте. По сути это похоже на FQDN, но не совсем настоящий. Но опять чего-то не хватает. Два двоеточия есть, знака процента нет. Большое число я нашел в IPv4, два двоеточия — в IPv6, а процента нет. Я пошел искать дальше и нашел запись, где встречается процент: scoped literal IPv6 addresses, IPv6-адреса с zone index. Если грубо по-русски, это способ на локальной системе явно указать, к какому интерфейсу относится IPv6-адрес. Например, `%3` — третий интерфейс, или `%eth2` — интерфейс `eth2`. Обычно эта штука имеет смысл только внутри конкретной локальной системы. В сумме все элементы по отдельности встречаются: двоеточия, процент, большие числа. Но в таком виде ничего явно похожего на `::%16777216` на первый взгляд нет. ## Первая гипотеза Но если приглядеться, кое-что похожее есть. Что это за число конкретно? Во всех описанных атаках встречалось именно `16777216`, а не какое-то другое число. Многие, наверное, уже по дороге догадались или быстро посчитали на калькуляторе. Я тоже посчитал. Если представить `16777216` в шестнадцатеричном виде, получается `01 00 00 00`. То есть одна единица и много нулей. На что это наводит? Есть IPv6-адрес, который соответствует localhost, самому себе: `::1`. Это тоже одна единица и много нулей, только выглядит чуть по-другому: единица в другом месте. Нули можно сократить, и получается `::1`. И у меня стала вырисовываться гипотеза: `::%16777216` — это какая-то ошибочная, странная форма записи localhost-адреса `::1`. Гипотеза довольно тривиальна и логична. Хакерское подключение по RDP через туннель по сути происходит с localhost на эту же машину: какой-то клиентский туннель локально подключается к службе удаленных рабочих столов. Когда я готовил презентацию, я формализовал это так: `::%16777216` в логе — это странная форма записи IPv6-адреса `::1`. Перед проверкой я уточнил гипотезу. Везде эта константа описана вместе с ngrok. Можно предположить, что ngrok как-то с этим связан, взять ngrok, проводить эксперименты, ковырять его и смотреть, не делает ли он так специально. А можно выделить другую гипотезу: ngrok не виноват, а дело в самом туннеле или в Windows. Вторую гипотезу мне было проще проверить. В тот момент с ngrok были какие-то сложности: то ли он перестал нормально работать в России, то ли стал просить деньги, я уже не помню. Кроме ngrok есть другие утилиты, но в целом проще было проверить на чем-то другом. Итоговая проверяемая гипотеза стала такой: `::%16777216` в логе — это странная форма записи IPv6-адреса `::1`, возникающая не из-за ngrok. ## Эксперимент с SSH-туннелем Как проверять? Нужно взять что-нибудь другое и провести эксперимент. На слайде есть ссылка на GitHub-репозиторий `awesome-tunneling`, где собраны разные утилиты для туннелирования. Их десятки, но разбираться со всеми я не стал. Я вспомнил про SSH, который с недавних пор есть в Windows по умолчанию, и решил, что для цели эксперимента этого достаточно. Слева у меня ноутбук, с которого я подключаюсь, как будто я хакер. Справа — специальный сервер, к которому я буду подключаться по RDP через туннель и смотреть, что запишется в лог. Между ними я прокидываю SSH: `ssh -L 33389:[::1]:3389 pc.testlab.lan` Можно сказать: хакеры-то обычно пробрасывали бы SSH справа налево, взломав правый компьютер, а я пробрасываю слева направо. Но для цели эксперимента это неважно. Когда TCP-соединение установлено, уже неважно, кто был инициатором: данные передаются в обе стороны. Порт слева — это порт, который будет открыт у меня на машине. Порт справа — это порт, куда я хочу пробросить подключение. `::1` записан в квадратных скобках, чтобы отличать, где порты, а где IP-адрес. Такую форму записи я тоже с интересом узнал, когда начал разбираться с IPv6. Я подключился и увидел, что все работает. SSH — отличная утилита. Через `netstat` можно проверить, что соединение установлено. Это не два разных соединения, а одно и то же соединение, видимое с двух сторон: у одного процесса есть соединение, у другого процесса тоже есть. Я подключился по RDP через туннель и убедился, что проблемное значение действительно пишется в лог. Значит, я могу воспроизводить эту историю без ngrok. ## API Monitor и поиск места, где ломается адрес Что делать дальше? Нужно понять, кто и в какой момент сгенерировал неправильные данные. Можно взять отладчик, бинарный модуль, дизассемблер и зарыться туда. А можно пойти чуть другим путем. Я вспомнил про старую, но не бесполезную утилиту API Monitor. Последняя актуальная версия, которую я нашел, — 2.0 alpha 2013 года, но она до сих пор отлично работает. API Monitor позволяет перехватывать API-вызовы и смотреть, что попало на вход и что получилось на выходе. Это удобно, когда примерно понимаешь, что ищешь, но не хочешь сразу лезть отладчиком неизвестно куда или уже забыл, как это делается. В моем случае я много лет этим не занимался, и было трудно вспоминать. Возникает вопрос: что мониторить? Процессов много, надо найти тот, который участвует в обработке RDP-подключения. Вариантов несколько. Можно посмотреть `netstat` с нужным ключом, чтобы увидеть процессы. Я для наглядности использовал Process Explorer из пакета Sysinternals. Достаточно вспомнить, что служба управления подключениями к удаленным рабочим столам — это Terminal Services, что большинство Microsoft-сервисов живет внутри процессов `svchost.exe`, и что такие процессы только на первый взгляд одинаковые. Можно посмотреть командные строки процессов, открытые порты, подгруженные библиотеки с названиями, связанными с RDP, и так найти нужный процесс. Я нашел процесс, подключил к нему API Monitor и включил мониторинг сетевых функций. Внимательно посмотрел вызовы и нашел функцию `GetNameInfoW`. На вход ей передается структура, похожая на `SOCKADDR`, которая содержит IP-адрес. На выходе получается строковое представление адреса. Сначала я подключался через IPv4, просто чтобы проверить, как это вообще работает. Все работало нормально: тот же IP-адрес, который был на выходе `GetNameInfoW`, появлялся в логе. Потом я повторил эксперимент с IPv6 через туннель и увидел ровно то, что хотел найти. На вход `GetNameInfoW` приходит структура, которая по смыслу должна описывать IPv6-адрес клиента, то есть localhost-адрес. А на выходе в буфере оказывается та самая сломанная строка: `::%16777216`. Пару слов о самой функции. `GetNameInfoW` документирована Microsoft. У нее есть параметры и флаги, которые описывают поведение. В общем случае она может, например, из IP-адреса получить hostname. Но с флагом `NI_NUMERICHOST` она возвращает числовую форму имени узла, то есть строковое представление IP-адреса. Именно это меня и интересовало. В экспериментах были два варианта структуры: размер 16 байт и размер 28 байт. Несложно догадаться, что для IPv4 это 16 байт, а для IPv6 — 28. ## `SOCKADDR`, `sockaddr_in6` и доработка API Monitor Со структурами `SOCKADDR`, видимо, так сложилось исторически: в сетевых стеках разных операционных систем используются структуры, которые пытаются однотипно описать разные адреса и используют трюки языка C, чтобы интерпретировать одну и ту же память по-разному. Есть структура `SOCKADDR`, указатель на которую потом может приводиться к `sockaddr_in` для IPv4 или к `sockaddr_in6` для IPv6. Если посчитать размеры этих структур, получится как раз 16 и 28 байт. Дальше я запустил API Monitor и увидел результат эксперимента: испорченное значение записалось в буфер. Но когда я захотел подробно посмотреть содержимое памяти и понять, что куда съехало, API Monitor показал только `17` и много нулей. А где хотя бы одна единица? Она же должна быть. Оказалось, причина в самом API Monitor. Автор написал утилиту и набор конфигурационных файлов, которые описывают API-структуры. В описании не было учтено, что данных может быть больше: он знал про значение `17`, соответствующее `AF_INET6`, но для отображения структуры оставил только 16 байт, а нужно было 28. К счастью, API Monitor не прибит гвоздями. Несмотря на то что утилита старая, она работает, а описания структур лежат в XML-файлах, которые можно редактировать. В файле `C:\Program Files\rohitab.com\API Monitor\API\Headers\sockets.h.xml` я поправил описание: вместо поля `char[14]` добавил свой тип длиной 30 байт, чтобы видеть всю структуру. С первого раза не получилось, но там хороший отладочный лог, и я понял, что нужно поправить два места. После этого я повторил эксперимент. Теперь были видны все нужные данные. Стрелками на слайде я показал связь полей структуры с данными в памяти. И стало видно, что та самая последняя единица действительно есть, но уехала в поле, которое отвечает за `scope ID`, локальный номер интерфейса. Она выехала из IPv6-адреса. Пока было непонятно, почему именно, но гипотеза подтвердилась: данные куда-то съезжают. Оставалось уточнить, в каком месте и как именно. Адрес localhost содержит всего одну единицу, поэтому по нему не очень понятно, что именно съехало: один байт, середина или весь адрес. Поэтому я провел еще один эксперимент: взял какой-нибудь нормальный IPv6-адрес, подключился, посмотрел результат и убедился, что съезжает весь адрес. В начале появляются четыре лишних байта, а в конце появляется странное число. Логика стала понятна. Можно взять это число после `%`, перевести его в шестнадцатеричный вид, переставить байты в обратном порядке и получить обратно недостающую часть IPv6-адреса. ## Где проявляется проблема Проблема проявляется в событии `1149`, с которого я начал. Кроме того, минимум в двух событиях Security Log: `4778` и `4779`, которые относятся к подключению и отключению RDP-сессии. Там все происходит аналогичным образом. Я спросил себя: может быть, это уже описано в интернете? Оказалось, что именно такую проблему я не нашел, но нашел похожую. Это история примерно времен перехода с RDP 7 на RDP 8. Человек заметил неправильный адрес в событии `4624`, обычном событии аутентификации. Ответ Microsoft был примерно такой: мы разобрались, причина в том, что поменяли стек, появилась структура `WTS_SOCKADDR`, из-за несогласованности что-то поехало, но мы все починили. Оказалось, что починили, но не все. ## Отладка: сложности и лайфхаки Дальше настало время немного подебажить. Здесь я ускорюсь. С SSH удобно, но не всегда. Например, можно быстро арендовать виртуалку с актуальной версией Windows, чтобы проверить гипотезу на разных версиях, но не хочется долго возиться с открытием SSH наружу и пробросом портов. Можно арендовать рядом две виртуалки, но это в два раза дороже и тоже требует настройки. Я взял утилиту Microsoft Dev Tunnels. Она работает примерно как ngrok: на одном хосте поднимаем маппинг, на другом подключаемся к туннелю, порты проброшены — можно работать. Вторая сложность: отлаживать RDP-подключение неудобно. Если поставить точку останова в момент подключения, все может встать на паузу, и вы окажетесь нигде: слева клиент, справа окно ввода пароля, а процесс остановлен. Можно поднять полноценный терминальный сервер с несколькими сессиями, но не хотелось возиться. Решение: Dev Tunnels может пробросить сразу несколько портов, а многие отладчики умеют удаленную отладку по TCP. Пробрасываем порт для отладчика и отлаживаем удаленно, не застревая в этой ситуации. Еще одна сложность: отлаживать Windows-бинарники в каком-то смысле удобно. Там нормальный код, нет обфускации, есть символы. Но кода очень много, и непонятно, за что хвататься. После API Monitor я уже знал место, где проблема проявляется, и нужно было найти место чуть раньше, где она рождается. В архитектуре Intel есть удобная вещь: аппаратные точки останова. Их можно ставить не только на код, но и на доступ к памяти. Поэтому я ставлю breakpoint на проблемный участок памяти, запускаю отладку еще раз и смотрю, кто обращался к этой памяти раньше. Так за несколько операций я нашел проблемное место. ## Где рождается ошибка Проблемное место оказалось в библиотеке `rdpcorets.dll`, в методе `CUMRDPConnection::GetClientData`. На слайде показан фрагмент кода: `movdqu xmmword ptr [r14+3088], xmm0` Он заносит 128 бит IPv6-адреса клиента из регистра `xmm0` в память по смещению `r14+3088`. Чуть выше по коду видно, что значение `17h`, то есть `AF_INET6`, записывается по смещению `r14+3076`. Разница между `3088` и `3076` — 12 байт. Это важно. Та самая структура `WTS_SOCKADDR`, которая уже возникала, в другом модуле выглядит иначе. В ней есть четыре «лишних» байта для выравнивания. В итоге один модуль заполняет данные со смещением 12, хотя для другой интерпретации должно быть 8, а другой модуль читает эти данные со смещением 8. Строка для записи в лог формируется в коде библиотеки `termsrv.dll` через вызов `GetNameInfoW` из `ws2_32.dll`. В качестве параметра ожидается указатель на `SOCKADDR`. В случае IPv6, когда `sin_family = 17h`, используется смещение 8 байт. Это на 4 меньше, чем 12. И из-за разного выравнивания в структурах, которые в разных модулях должны описывать одни и те же данные, получается ошибка: один модуль кладет IPv6-адрес на четыре байта правее, другой читает его как обычный `sockaddr_in6`. ## Версии Windows и статус исправления На момент прошлогодних тестов ошибка воспроизводилась на таких версиях: - Windows Server 2012 R2; - Windows 10 Pro 22H2 19045.5854; - Windows Server 2019 Standard 1809; - Windows 11 Enterprise 23H2 22631.5335. Мой опыт позволяет предположить, что проблеме подвержены все версии начиная с Windows 8 и Windows Server 2012. Я, конечно, не протестировал абсолютно все, но протестированные версии и найденное старое сообщение в интернете хорошо укладываются в эту картину. Неужели всем все равно? Видимо, да. Скорее всего, почти никто всерьез не использует IPv6-адреса в реальной работе с RDP. А логи начинают внимательно читать уже после инцидента, когда приходят DFIR-специалисты, все расследуют, а само значение `::%16777216` для расследования часто и правда не критично. Мы с коллегами зарепортили это в Microsoft. Коллеги помогли сделать репорт красивым. Так как здесь нет уязвимости, а есть ошибка в сообщении журнала, ответ был примерно такой: спасибо, мы учтем; когда-нибудь передадим это нужным людям; CVE не будет, bounty не будет, дополнительных обновлений по репорту не будет. По слайдам хронология такая: 10.06.2025 — репорт в MSRC, 08.11.2025 — ответ: `moderate, no CVE, no additional updates will be provided`. До сих пор отдельного ответа по исправлению не было, но я провел эксперимент буквально на днях. Судя по всему, проблема уже исправлена в новых сборках Windows 11. Есть еще похожий вопрос на Microsoft Q&A: человек спрашивал, почему IPv6-адреса в событиях логируются неправильно. Первый ответ был ни о чем, второй появился примерно через два года, буквально недавно: да, проблема есть, адрес можно пересчитать, я буду писать баг-репорт. Теперь, видимо, будет два баг-репорта: наш и еще один. На 25.05.2026 ситуация такая: - Windows 10 Pro 22H2 19045.6456 — ошибка есть; - Windows 11 24H2 26100.1742 — ошибка есть; - Windows 11 24H2 26100.8457 — ошибки нет; - Windows 11 26H1 28000.2113 — ошибки нет. Можно сделать предположение, что проблему поправили в свежих версиях Windows 11. На скриншоте со слайда показана актуальная на утро доклада версия: `::1` уже отображается нормально. ## Итоги `::%16777216` — это результат ошибки Windows, а не артефакт ngrok. Ошибка связана с несогласованной обработкой одних и тех же данных разными модулями. Из-за нее при подключениях к RDP с использованием IPv6 в лог попадает неверный адрес источника. При этом ошибка не мешает использовать `::%16777216` как IoC для выявления подключений к RDP по IPv6 через туннель. Причем это относится не только к ngrok, а к любым туннелям, которые приводят к такому сценарию. На расследование это обычно не влияет критически. Более того, ошибочное значение из лога можно восстановить и использовать при сборе событий в SIEM, в корреляциях, при threat hunting и других задачах. Данные не портятся безвозвратно. Схема восстановления такая: 1. Взять значение из лога, например `0:0:fe80::6e1d:980d%2607874452`. 2. Часть после `%` перевести в HEX: `2607874452 dec = 9b 71 01 94 hex`. 3. Убрать слева два нуля и дописать справа полученные цифры с учетом обратного порядка байт. В примере получается нормальный IPv6-адрес: `fe80::6e1d:980d:9401:719b` Такую логику можно реализовать практически в любой SIEM или другой системе обработки журналов аудита. У меня все. Спасибо за внимание. Давайте вопросы. ## Неоднозначные места - В начале доклада в сырой транскрипции есть фраза «в Сибирь и опережающем сайту в ПТХ». По слайдам восстановлено как «развитие технологий SOC в PT X», но устная формулировка могла отличаться. - Несколько шуток и отсылок к картинкам на слайдах восстановлены только по смыслу. Название фильма/персонажа в середине доклада в аудио распознано неуверенно и в редакторском тексте опущено как несущественное для технического содержания. - В месте про старую похожую проблему Microsoft в сырой транскрипции звучит «переход с РТП-7 на РТП-8». По контексту оставлено как RDP 7/RDP 8. - Фраза про «DFIR-специалистов» в сырой транскрипции распознана как «ребята из Дефир»; термин восстановлен по контексту incident response.