
Представьте: вы теряете контроль над SCCM — одним из самых критичных инструментов управления инфраструктурой. А точкой входа становится обычное подключение к MSSQL, где он хранит свои данные. Злоумышленник перехватывает NTLM-аутентификацию и перенаправляет её на нужный сервер — так работает NTLM relay. Мы в команде Security Engineering решили не ждать эксплуатации этой уязвимости.
Меня зовут Булат Гафуров, я инженер по информационной безопасности в Яндексе. В этой статье я расскажу, почему стандартного решения оказалось недостаточно и как мы добавили поддержку механизма EPA в популярные библиотеки, чтобы переключить защиту на стороне MSSQL в режим Require, не лишив Linux- и Windows-сервисы доступа к данным.
С чего всё начиналось
SCCM (System Center Configuration Manager) — инструмент управления Windows-устройствами: позволяет централизованно устанавливать и обновлять приложения, управлять конфигурациями и, по сути, является RCE as a Service в масштабах всей инфраструктуры.
Для хранения конфигурации, прав доступа, инвентаризации и данных для деплоя SCCM использует MSSQL. Это и делает базу данных привлекательной целью: с доступом к ней атакующий получает контроль над SCCM, а через него — и доступ ко всем управляемым устройствам.
В нашем случае к MSSQL подключались не только серверы SCCM: нужно было забирать данные для мониторинга и экспортировать инвентаризацию в сторонние системы. Сетевой доступ к базе был открыт с нескольких сервисов — как на Windows, так и на Linux.
Стоит заметить, что файрвол, как и любая сегментация, — это очень действенная мера для ограничения возможностей атакующих, однако при проектировании систем файрвол не может быть основным средством обеспечения безопасности.
SCCM — общеизвестная цель для NTLM-relay-атак, и рекомендации по защите давно существуют: PREVENT14. Наша цель была в том, чтобы закрыться от NTLM relay, не сломав при этом подключения ни с Linux-, ни с Windows-систем.
Любопытная деталь: поддержка защитного механизма EPA в инструментах аудита безопасности появилась гораздо раньше, чем в клиентских библиотеках под Linux, включая официальный клиент от Microsoft.
Есть несколько статей, в которых добавляют поддержку EPA в подобные инструменты:
A journey implementing Channel Binding on MSSQLClient.py (как добавляли поддержку EPA в Impacket, PR).
Dissecting NTLM EPA with love & building a MitM proxy (статья о разборе NTLM-сообщения с EPA и создании MitM-прокси Prox-Ez).
Реализация Channel Binding в Certipy.
Ldap3: поддержка Channel Binding (поддержка добавлена почти три года назад).
Но просто переключить EPA в режим Require мы не могли: это сразу отрезало бы все Linux-клиенты. Оставалось два пути:
Отказаться от TLS и использовать Service Binding.
Дописать поддержку EPA в популярные библиотеки — FreeTDS и microsoft/go-mssqldb.
Первый вариант нас не устраивал: Service Binding вынуждает нас отказаться от TLS. Поэтому мы пошли по второму пути. Но прежде чем погружаться в код — вспомним, что такое NTLM relay, Channel Bindings и EPA.
Вспомним базу NTLM relay
NTLM relay — техника, при которой атакующий перехватывает аутентификационные данные клиента и пересылает их на нужный сервер, чтобы получить доступ к сервису от имени жертвы (подробнее о NTLM relay).
Ключевая идея: злоумышленнику достаточен сам факт аутентификации клиента — неважно, куда именно тот подключается. Для этого подходят как классические MITM-атаки, так и coercion-атаки — техники, которые позволяют принудительно инициировать аутентификацию компьютера под его машинной учётной записью на произвольный IP-адрес или DNS-имя.
Coercion стоит отличать от атак спуфинга протоколов разрешения имён. Последние не дают свободы в выборе жертвы и почти всегда ограничены одним L2-сегментом. Зато иногда удаётся перехватить аутентификацию реального пользователя, а не машинной учётной записи. Подробнее о coercion-техниках — в The Ultimate Guide to Windows Coercion Techniques in 2025.
Особенно уязвимы сервисы, которые работают под SYSTEM или Network Service и аутентифицируются в сети именно из-под машинной учётной записи. SCCM — типичный пример такого сервиса.
Что такое Channel Binding и зачем он нужен
Кажется, что настроенный TLS и проверка сертификата сервера надёжно защищают от MITM-атак. Но с NTLM relay всё сложнее. Relay-атаки могут быть кросс-протокольными: клиент подключается к атакующему по SMB без TLS, а тот перенаправляет аутентификацию на целевой сервер уже по HTTPS. Клиент в этом случае вообще не проверяет никакого сертификата. И наличие TLS на целевом сервере ничего не даёт в случае coerce-атаки, потому что клиент проверяет сертификат атакующего.
Именно для защиты от таких сценариев существует Channel Binding (CB) — механизм, который даёт криптографическое доказательство того, что обе стороны общаются по одному и тому же TLS-каналу. Конкретное значение, которое клиент передаёт серверу для верификации, называется Channel Binding Token (CBT).
Существует несколько типов CB, описанных в RFC 5929:
tls-server-end-point— CBT вычисляется как SHA-256 (иногда SHA-384 или SHA-512) от leaf-сертификата из сообщения ServerHello;tls-unique— используется verify_data из сообщения TLS Client Finished;tls-unique-for-telnet— то же самое, но берётся TLS Server Finished.
С выходом TLS 1.3 вычисление tls-unique стало невозможным, поэтому появился RFC 9266 с четвёртым типом — tls-exporter. В нём используется Export Keying Material со статичным лейблом и длиной 32 байта.
Как именно CB защищает от атаки, хорошо видно на схеме ниже: атакующий перехватывает TLS-соединение, но изменить CBT не может — и сервер отклоняет аутентификацию.

В каких случаях Channel Binding может не спасти
Есть исследования, демонстрирующие обходы CB при определённых условиях:
В исследовании от Almond Consulting смогли обойти проверку CB благодаря функционалу STARTTLS в LDAPS. Во время relay-атаки атакующий сначала аутентифицировался по нешифрованному каналу, устанавливал TLS-соединение поверх существующего, и сервер уже не проверял CB. Для эксплуатации нужен отключённый LDAP-signing.
В исследовании от CrowdStrike (Preempt) смогли убрать MIC (подпись) из NTLM-пакетов, после чего можно подменить значение CB и сервер примет
NTLMSSP_AUTHENTICATE-сообщение.
Отдельно стоит отметить атаку 3Shake, в которой атакующий мог установить две сессии с одинаковыми мастер-ключами через механизм возобновления сессии в TLS 1.2. С RFC 7627 это исправили, и мастер-ключ был гарантированно разный для разных сессий.
EPA (Extended Protection for Authentication)
Пара слов о EPA
EPA — режим, в котором MSSQL-сервер (или любой другой сервис: IIS, WinRM (over TLS), LDAP/LDAPS) требует передачу Channel Binding или корректного Service Binding. Microsoft рассказывает о EPA на двух страницах в своей документации: обзор EPA и Microsoft Docs: Supporting Extended Protection for Authentication (EPA) in a service.
Service Binding (SB) нужен на случай, если нет возможности использовать TLS или проверить Channel Binding. Пример: когда другой сервис терминирует TLS, это может быть балансировщик для IIS, или бастион, или если сервис не поддерживает TLS.
SB позволяет серверу получить атрибут SECPKG_ATTR_CLIENT_SPECIFIED_TARGET и понять, по какому имени изначально подключался клиент.
Таким образом, если аутентификацию клиента перехватили, то сервер увидит название другого сервиса, поймёт, что эти пакеты адресовались не ему, и оборвёт соединение.
Атрибут SECPKG_ATTR_CLIENT_SPECIFIED_TARGET так же защищён MIC.
На MSSQL EPA, согласно документации, может быть в трёх режимах:
Off
Allowed
Required
Режим Off выключает проверку CB и SB, в то время как Required не даст подключиться без CB или SB.
Но с режимом Allowed всё гораздо интереснее: он очень полезен, когда не все клиенты поддерживают EPA, и позволяет не отправлять CB для успешного подключения. Но если клиент отправил хоть какое-то значение, то сервер обязательно проверит его. Здесь мы полагаемся на то, что все клиенты по возможности будут отправлять CB и их не получится зарелеить.
Если аутентификация проходит по нешифрованному каналу, допустим SMB или WebDAV, то клиент может указать в качестве CB null-значение (00000000000000000000000000000000). И если атакующий дальше решит использовать эту аутентификацию по TLS каналу, то сервер отклонит её. Если клиент не передал CB в InitializeSecurityContext, SSPI (то есть только на Windows) всегда отправляет null CB.
Добавляем поддержку EPA в клиент MSSQL
Выбор клиента
Всю нашу логику выбора клиента можно понять, взглянув на следующую табличку:

Наш выбор пал на FreeTDS, который можно использовать на многих языках, где реализован ODBC, допустим Python с pyodbc, и на go-mssqldb для Golang (можно, конечно, взять ODBC-клиент на Go, но у нас в одной части инфраструктуры использовался Telegraf, поэтому решили добавить поддержку и в него).
Соответствующие PR в проекты: FreeTDS и microsoft/go-mssqldb.
Немного о TDS и TLS
Tabular Data Stream (TDS) — протокол прикладного уровня для взаимодействия с MSSQL Server, работает поверх TCP (TLS-, NTLM-, SSPI-пакеты чаще всего передаются внутри TDS, но не всегда).
Также есть разница между TDS 7.4 и 8.0. В TDS 7.4 клиент отправляет Pre-Login-сообщение о поддержке (или её отсутствии) шифрования и других опций, сервер ему отвечает с поддерживаемыми или обязательными опциями. Если клиент и сервер договорились, что будет шифрование, то клиент дальше устанавливает TLS-рукопожатие внутри TDS.
Но в TDS 8.0 добавилась возможность Strict-шифрования. Когда у клиента и сервера в параметрах подключения Encryption выставлен в Strict, то клиент при подключении устанавливает сразу TLS-соединение и только после отправляет TDS-пакеты. Для TDS 8.0 если использовать TLS ≤ 1.2, то CB считаем всё так же, но если используется TLS 1.3, то пока нет публичной информации от Microsoft том, как MSSQL считает CBT.
TDS 7.4 же поддерживает TLS не выше 1.2.
Разбираем TDS-пакеты в Wireshark
Для того чтобы увидеть наш CB, нужно записать наше подключение в MSSQL, а затем расшифровать его.
Для того чтобы расшифровать TLS-соединение, нам понадобятся ключи, которые можно достать переменной окружения SSLKEYLOGFILE OpenSSL, GnuTLS. Если этот вариант вам не подходит, то можно попробовать воспользоваться какой-нибудь утилитой для захвата этих ключей на уровне ядра, допустим ecapture.
Wireshark не парсит TDS в TLS, он это делает только для TDS поверх TCP, поэтому мы написали простой диссектор, который вызывает TDS:
-- Get the TDS dissector local tds_dissector = Dissector.get("tds") -- Create our custom dissector local tls_to_tds_proto = Proto("tls_to_tds", "TLS to TDS Decrypted Data") -- Define fields for our protocol local f_tls_to_tds_data = ProtoField.bytes("tls_to_tds.data", "Decrypted TDS Data") local f_tls_to_tds_length = ProtoField.uint32("tls_to_tds.length", "Data Length", base.DEC) -- Add fields to protocol tls_to_tds_proto.fields = {f_tls_to_tds_data, f_tls_to_tds_length} -- Main dissector function function tls_to_tds_proto.dissector(tvb, pinfo, tree) local length = tvb:len() if length == 0 then return end -- Create our protocol tree local subtree = tree:add(tls_to_tds_proto, tvb()) subtree:add(f_tls_to_tds_length, length) -- subtree:add(f_tls_to_tds_data, tvb) -- Parse with TDS dissector if tds_dissector then local tds_subtree = subtree:add(tds_dissector, tvb) tds_dissector:call(tvb, pinfo, tds_subtree) else subtree:add_expert_info(PI_PROTOCOL, PI_WARN, "TDS dissector not available") end end myproto_dissector_table = DissectorTable.get("tls.port") myproto_dissector_table:add(1433, tls_to_tds_proto) myproto_dissector_table:add(2433, tls_to_tds_proto) myproto_dissector_table:add_for_decode_as(tls_to_tds_proto) print("TLS to TDS dissector loaded successfully")
Этого нам будет достаточно, чтобы прочитать CB в NTLM, но для Kerberos нам нужен keytab-файл учётной записи, под которой запущен MSSQL.
Как передаём CB в аутентификации
Способ передачи CBT зависит от типа аутентификации. Нам важно, чтобы атакующий не смог повлиять на CBT в сообщениях клиента, иначе он (атакующий) сможет посчитать CBT и подменить его. То есть нам нужна проверка целостности для CBT. MSSQL использует TLS-unique для генерации CB.
GSS-API
Если мы захотим аутентифицироваться с unix-машин по Kerberos, то, скорее всего, будем использовать GSS-API, который требует от нас собрать правильную структуру gss_channel_bindings_struct.
Собирать нужно таким образом:
initiator_addrtype = 0 initiator_address = gss_buffer_desc_struct(length=0, value=NULL) acceptor_addrtype = 0 acceptor_address = gss_buffer_desc_struct(length=0, value=NULL) gss_buffer_desc application_data = gss_buffer_desc_struct(length=N, value=M);
в N мы укажем длину M в байтах, а M будет представлять из себя строку без NULL-байта в конце tls-unique: и значение verify_data.
Эту структуру нужно будет передать как параметр при вызове gss_init_sec_context. Сервер на своей стороне сделает то же самое, и структуры должны совпасть. Если разобраться, внутри вызова gss_init_sec_context после формирования структуры CB от неё считается MD5-хеш. Из этого хеша, сессионного ключа и других флагов считается поле Checksum структуры Authenticator сообщения AP-REQ (Authentication Protocol Request).
Это можно увидеть в следующем коммите в github.com/krb5/krb5: checksum implementation of gss_channel_bindings_struct.
Если мы хотим посмотреть CB в Kerberos в Wireshark, то нам нужно смотреть TDS7-Login-пакет, в нём ap-req authenticator с полем Bnd.
Вот так это будет выглядеть в Wireshark, когда мы откроем TDS7-Login-сообщение:

Подробнее о GSS-API: GSS-API Programming Guide: Channel Binding, об использовании CB в GSS-API: RFC 5554.
NTLM
Для NTLM нам нужно собрать структуру таким же образом, но самостоятельно посчитать от неё MD5-хеш и положить её в набор атрибутов (так называемых AV_PAIR) NTLM Response в AUTH-сообщении.
Подробнее о AV_PAIR: в документации протокола от Microsoft.
MIC обеспечивает целостность для CBT, атакующий не сможет подменить CBT, пока у него нет возможности подделать MIC, то есть пока у него нет NTLMv2-хеша или пароля пользователя.
В Wireshark CB можно посмотреть в сообщении NTLMSSP_AUTH, как показано на следующей картинке:

WinSSPI
WinSSPI — имплементация GSS-API от Microsoft, которая реализует и делает доступными через API для приложений в user-mode такие протоколы, как NTLM, Kerberos, Negotiate, Schannel, CredSSP.
Тут чуть иначе. Microsoft требует от нас передавать немного другую структуру и параметры для InitializeSecurityContext, которые отличаются от gss_init_sec_context.
Итак, Microsoft требует структуру SEC_CHANNEL_BINDINGS. Отличается она тем, что сама структура описывает только тип, длину и смещение данных, такую структуру нужно передавать в буфере, а после и сами данные. Мы также ставим параметры Initiator и Acceptor в 0 и заполняем cbApplicationDataLength и dwApplicationDataOffset. В последнем мы указываем размер самой структуры, что означает, что сами данные будут лежать сразу после структуры.
Передавать нужно такой буфер в функцию InitializeSecurityContext как часть pInput, но только после первого вызова, потому что в первом вызове pInput должен быть NULL.
Подробнее об InitializeSecurityContext.
Заключение
Мы разобрали, как работает механизм EPA, и добавили его поддержку в ключевые библиотеки для Linux — PR в FreeTDS и PR в microsoft/go-mssqldb. Теперь ничто не мешает включить режим Required и защитить свои MSSQL-серверы от NTLM-relay-атак.
Для удобства — сводная таблица клиентов и их поддержки EPA:

Одна ремарка напоследок: добавить поддержку Kerberos в go-mssqldb пока не удалось: в экосистеме Golang нет подходящего krb5-репозитория, который был бы готов к интеграции. В качестве обходного решения можно рассмотреть подключение через ODBC.
Спасибо, что дочитали. Желаем вашему MSSQL всегда оставаться в безопасности.