利用Windows电话服务中的RCE漏洞:深入分析CVE-2026-20931

4 阅读16分钟

Who’s on the Line? Exploiting RCE in Windows Telephony Service

Written by Sergey Bliznyuk on January 19, 2026

几十年来,Windows一直支持计算机电话集成,为应用程序提供管理电话设备、线路和通话的能力。虽然现代部署越来越依赖基于云的电话解决方案,但经典电话服务在Windows中仍然开箱即用,并继续在特定环境中使用。因此,遗留的电话组件仍然是默认Windows攻击面的一部分。

本研究探讨了我在电话服务的服务器模式中发现的一个漏洞,该漏洞允许低权限客户端向服务可访问的文件写入任意数据,并在特定条件下实现远程代码执行。

Windows电话技术概述

Windows通过电话应用程序编程接口(TAPI)公开电话功能,该接口允许用户模式应用程序通过统一的抽象层与电话设备和服务进行交互。

TAPI有两种主要形式:TAPI 2.x,提供过程式的C风格API;以及TAPI 3.x,使用COM实现。虽然API不同,但两者都依赖于相同的基础架构:应用程序与TAPI运行时通信,后者将请求转发给电话服务提供商(TSP)。

TSP是供应商提供的组件,封装了特定于设备或服务的逻辑,并与底层电话后端(如物理电话硬件、PBX系统或VoIP端点)交互。从客户端应用程序的角度来看,这些差异隐藏在TAPI抽象层之后。

什么是电话服务?

应用程序通过调用tapi32.dll导出的TAPI 2.x函数或使用tapi3.dll提供的TAPI 3.x COM接口来与Windows电话堆栈交互。在这两种情况下,这些库主要充当客户端包装器:它们编排请求并将其转发给实际实现电话逻辑的系统服务。

该服务就是电话服务(TapiSrv)。它实现了实际的TAPI功能,并通过tapsrv RPC接口将其暴露给客户端应用程序。当应用程序调用TAPI调用时,请求最终由TapiSrv处理,TapiSrv选择合适的TSP并编排相应的低级交互。

该服务在NETWORK SERVICE账户下运行,并配置为手动启动类型,但在进程首次通过tapi32.dll或tapi3.dll调用TAPI请求时会按需自动启动。整个实现位于tapisrv.dll库中。

(MSDN上的图表已经过时,但它提供了基本的理解)

TAPSRV RPC接口

概述

TAPI客户端与电话服务之间的通信通过名为tapsrv的经典MSRPC接口进行。相应的协议MS-TRP是公开记录的。默认情况下,此接口仅限于本地调用者。

然而,在Windows Server系统上,TAPI可以配置为接受远程客户端连接。此行为由HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Telephony\Server\DisableSharing注册表值控制,也可以通过电话管理MMC管理单元(TapiMgmt.msc)进行管理。

虽然远程访问本地调制解调器或电话设备很少有用,但此功能适用于服务器端电话部署,如PBX系统或电话交换机。在此类场景中,电话硬件和相关的TSP集中安装在服务器上,多个支持TAPI的客户端远程连接,而不是维护各自的TSP安装。客户端可以通过tcmsetup /c <SERVER NAME>命令配置为使用远程TAPI服务器。

启用远程访问时,该接口通过tapsrv命名管道暴露,这意味着客户端必须首先通过SMB进行身份验证以建立连接。在此配置中,TAPI服务器还会将服务相关信息发布到Active Directory,使其在域环境中相对容易被发现。

请求分发模型

tapsrv RPC接口非常精简,仅包含三个可调用的方法:ClientAttachClientDetachClientRequest。会话初始化和拆除由前两个调用处理,而ClientRequest用于调用所有与电话相关的操作。

ClientRequest接受一个代表序列化请求数据包的二进制块。此数据包的前四个字节包含一个Req_Func字段,该字段充当内部调度表的索引。缓冲区的其余部分包含特定于所选操作的编组参数。

支持的Req_Func值和相应的数据包布局主要在MS-TRP规范中记录,并紧密镜像Win32 TAPI 2.x API接口。从概念上讲,这导致在MSRPC之上有一个额外的分发层——实际上是一种“RPC中的RPC”设计。类似的模式出现在其他Windows服务中,例如RasMan服务暴露的RASRPC接口(几个月前我在该服务中也发现了一个LPE漏洞)。

客户端会话设置

在TAPI术语中,客户端是连接到TAPI服务器接口的机器,而线路应用程序是该客户端系统上发出电话请求的程序。通过调用ClientAttach来建立客户端会话,其签名如下:

long ClientAttach(
     [out]   PCONTEXT_HANDLE_TYPE *pphContext,
     [in]    long    lProcessID,
     [out]   long   *phAsyncEventsEvent,
     [in, string]    wchar_t *pszDomainUser,
     [in, string]    wchar_t *pszMachine
    );

在会话初始化期间,服务评估调用者的安全上下文,并为客户端分配内部权限标志。随后,各种电话操作会参考这些标志来控制对敏感功能的访问。

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对应管理访问权限(本地管理员或SYSTEM),而标志1被分配给服务账户。当TAPI服务器模式启用时,明确列在C:\Windows\TAPI\tsec.ini文件中[TapiAdministrators]部分下的用户也会被授予提升的权限。

为了调用与线路抽象相关的方法,客户端随后必须通过发送Initialize请求来初始化线路应用程序实例。

异步事件处理

电话本质上是事件驱动的:来电、状态更改和媒体事件可能独立于客户端请求而发生。由于MSRPC遵循同步请求-响应模型,MS-TRP协议实现了自己的机制,用于将异步事件从电话服务传递给连接的客户端。

事件传递模型在初始的ClientAttach调用期间协商,并根据客户端是本地还是远程而有所不同。

对于本地客户端,异步事件使用共享的同步对象传递。客户端在ClientAttach期间提供其进程标识符(lProcessID),并接收事件对象的句柄。当事件数据可用时,电话服务发出此事件的信号,提示客户端通过发出GetAsyncEvents请求来检索待处理的数据。

当TAPI服务器模式启用时,协议提供两种替代机制来传递异步事件:推送和拉取。所选模型由提供给ClientAttach的参数决定。

在推送模型中,客户端将pszDomainUser参数留空,并在pszMachine参数中提供以引号分隔的RPC字符串绑定(例如CLIENT-PC-NAME"ncacn_ip_tcp"31337")。电话服务建立到端点的反向RPC连接,绑定到remotesp接口,并在异步事件发生时调用RemoteSPEventProc方法。

在拉取模型中,客户端在会话初始化期间在pszDomainUser参数中指定一个邮件槽名称。电话服务定期向此邮件槽发送DWORD大小的数据报,指示事件可用于检索。然后客户端应使用GetAsyncEvents获取相应的事件数据。

在所有情况下,服务器使用客户端在Initialize数据包中提供的InitContext字段值将事件与特定的线路应用程序关联。该值被视为不透明的4字节标识符,并由服务器作为事件通知的一部分回显给应用程序。

邮件槽的把戏

邮件槽是一种遗留的Windows IPC机制,设计用于传输小的单向消息。邮件槽写入器向命名端点发送数据报,而接收者被动地读取传入的消息。从客户端角度来看,邮件槽使用标准的Win32文件API访问,如CreateFileWriteFileCloseHandle

邮件槽使用特殊路径语法寻址,形式如下:

\\<COMPUTERNAME>\MAILSLOT\<MailslotName>

从客户端的角度来看,生成的句柄表现得像一个只写文件。通过网络,邮件槽消息使用NetBIOS-over-UDP数据报传输(或者曾经传输过——自Windows 11 24H2以来,远程邮件槽已被禁用)。由于通信严格是单向的,发送方不会收到远程邮件槽存在或消息正在被处理的确认。

如前所述,电话服务使用拉取异步事件模型,通过定期向客户端提供的邮件槽名称发送数据报来通知远程客户端有关待处理的事件。ClientAttach中负责初始化邮件槽句柄的相关代码路径如下所示:

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账户可写,电话服务将成功打开它,随后将异步事件数据写入其中。换句话说,基于邮件槽的事件传递机制可以被重新利用,在服务的安**全上下文下成为任意文件写入原语。

构建文件写入原语

此时,攻击者控制了电话服务写入数据的位置。剩下的问题是写入什么数据。

如前所述,在拉取异步事件模型中,电话服务通过向客户端指定的邮件槽写入单个DWORD值来发送通知。该值实际上对应于生成事件的线路应用程序初始化期间提供的InitContext字段。

由于InitContext完全由用户控制,并且邮件槽路径本身可以被重定向到任意文件,每个生成的事件都会导致向选定文件进行可控的4字节写入。剩下的挑战是可靠地按需触发此类事件。

跟踪入队异步事件的代码路径显示,许多事件都深嵌在电话呼叫处理逻辑中。与其尝试直接到达这些路径,一个更简单可靠的方法是通过NotifyHighestPriorityRequestRecipient触发事件。

这个辅助函数将事件传递给单个全局“最高优先级”线路应用程序。关键的是,它可以通过未记录的TRequestMakeCall数据包(Req_Func = 121)远程调用,该数据包是已记录的tapiRequestMakeCall API的后端实现。

当客户端通过未记录的LRegisterRequestRecipient处理器(Req_Func = 61)注册或取消注册为请求接收者时(该处理器支持lineRegisterRequestRecipient API),最高优先级线路应用程序会重新计算。

相关逻辑如下所示:

if (dwRequestMode & LINEREQUESTMODE_MAKECALL)
            {
                if (!ptLineApp->pRequestRecipient)
                {
                    // Add to request recipient list

                    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;

                    // Recalculate global highest-priority client

                    TapiGlobals.pHighestPriorityRequestRecipient = GetHighestPriorityRequestRecipient();

                    if (TapiGlobals.pRequestMakeCallList)
                    {
                        NotifyHighestPriorityRequestRecipient();
                    }
                }
                ...
            }

优先级基于应用程序模块名称在列表中的顺序确定:

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)
    {
        // Fetch the priority list for current user
        GetPriorityListTReqCall(&pszPriorityList);
    }

    while (pRequestRecipient)
    {
        // Calculate the index of app's module name in priority list
        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) )
            {
                // Load the value from the specified registry key
                GetPriorityList(hKey, L"RequestMakeCall", ppszPriorityList);
                RegCloseKey(hKey);
            }
        RegCloseKey(phkResult);
    }
  LeaveCriticalSection(&gPriorityListCritSec);
  return RpcRevertToSelf();
}

具体来说,服务读取客户端HKCU配置单元下的以下键:

HKCU\Software\Microsoft\Windows\CurrentVersion\Telephony\HandoffPriorities\RequestMakeCall

默认情况下,此列表通常包含一个条目:DIALER.EXE。如果需要,可以使用未记录的LSetAppPriority请求(Req_Func = 69)插入其他条目。

用于优先级比较的pszModuleName字段由客户端作为Initialize数据包的一部分提供,使攻击者能够完全控制其线路应用程序的排名。

有了这些部分,就可以在NETWORK SERVICE安全上下文中构建可靠的任意DWORD写入原语。

首先,攻击者通过调用ClientAttach来建立客户端会话,在pszDomainUser参数中指定目标文件路径。这导致电话服务打开文件一次,并为后续事件通知保留生成的句柄。

对于要写入的每个4字节值,攻击者随后执行以下步骤:

  1. 提交Initialize数据包(Req_Func = 47),设置:
    • InitContext为所需的DWORD值
    • pszModuleNameDIALER.EXE(或其他高优先级条目)
  2. 使用LRegisterRequestRecipientReq_Func = 61, dwRequestMode = LINEREQUESTMODE_MAKECALL, bEnable = 1)将线路应用程序注册为请求接收者。
  3. 通过提交TRequestMakeCall数据包(Req_Func = 121)触发事件。
  4. 使用GetAsyncEventsReq_Func = 0)出队事件,完成写入。
  5. 取消注册请求接收者(LRegisterRequestRecipient, bEnable = 0)。
  6. 使用ShutdownReq_Func = 86)关闭线路应用程序。

重复此序列允许攻击者向电话服务可写的任意预先存在的文件中写入任意数据。

从文件写入到RCE

在这个阶段,漏洞利用需要一个NETWORK SERVICE可写的现有文件。一个特别明显的候选文件是前面提到的C:\Windows\TAPI\tsec.ini。在服务器模式下运行电话服务的系统上,该文件始终存在且服务账户可写。

该文件除了其他配置设置外,还定义了电话服务将哪些用户视为管理员。通过在[TapiAdministrators]下添加一个条目(例如"[TapiAdministrators]\r\nDOMAIN\\attacker=1"),远程无权限的域用户可以授予自己在电话服务中的管理权限。在此修改后通过ClientAttach建立新会话会导致客户端上下文设置了管理权限标志。

有了电话服务的管理访问权限,额外的攻击面变得可用。一个特别强大的原语通过GetUIDllName请求暴露,该请求在MS-TRP协议中有记录。

根据规范:

GetUIDllName数据包与TUISPIDLLCallback数据包和FreeDialogInstance数据包一起用于在服务器上安装、配置或删除TSP。

审查实现后发现,虽然非管理调用者仅限于从注册表中预定义列表中选择提供商,但管理客户端被允许从任意路径加载提供商DLL。

switch (pParams->dwObjectType)
    {
        case TUISPIDLL_OBJECT_LINEID:
            ...
        case TUISPIDLL_OBJECT_PHONEID:
            ...
        case TUISPIDLL_OBJECT_PROVIDERID:
            // If the client is not admin and is requesting to
            // remove a provider or to install one from the path
            // supplied in request (rather than by index in registry),
            // return an error
            if ((ptClient->dwFlags & 8) == 0 && (pParams->bRemoveProvider || pParams->dwProviderFilenameOffset != TAPI_NO_DATA)) {
                pParams->lResult = LINEERR_OPERATIONFAILED;
                return;
            }

            if (pParams->dwProviderFilenameOffset != TAPI_NO_DATA) {
                // The path is supplied in request
                TCHAR   *pszProviderFilename = pDataBuf + pParams->dwProviderFilenameOffset;
                if (ptDlgInst->hTsp = LoadLibrary(pszProviderFilename)) {
                    if (pfnTSPI_providerUIIdentify = (TSPIPROC) GetProcAddress(ptDlgInst->hTsp,"TSPI_providerUIIdentify")) {
                        pParams->lResult = pfnTSPI_providerUIIdentify(pszProviderFilename);
                    } else {
                        ...
                    }
                } else {
                    ...
                }
            } else {
                ....
            }
    }

通过提交一个dwObjectType设置为TUISPIDLL_OBJECT_PROVIDERID并指定攻击者控制的DLL路径的GetUIDllName请求,我们可以让电话服务加载该DLL并调用导出的TSPI_providerUIIdentify函数。这就在服务上下文中提供了一个直接且可靠的代码执行原语。此外,如果导出的函数返回非零值,服务在调用后卸载DLL,从而允许随后从磁盘删除负载。

一个明显的传递机制是指向攻击者控制的SMB共享的UNC路径。实际上,当共享托管在同一个域内的标准Windows机器上时,这可以可靠地工作。然而,攻击者托管的SMB服务器,如impacket-smbserver或Samba,可能会触发访客访问限制,导致LoadLibrary失败并显示ERROR_SMB_GUEST_LOGON_BLOCKED

由于已经拥有任意文件写入原语,本地DLL放置提供了可靠的替代方案。

可以使用accesschk识别合适的可写文件。例如,以下文件几乎存在于任何系统上:

  • 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字节事件写入写入负载大小的DLL相对较慢,但它完全消除了对外部基础设施的需求。

为了演示代码执行,可以构建一个最小的概念验证TSP DLL。在以下示例中,TSPI_providerUIIdentify导出——在提供商安装期间由电话服务调用——执行命令并将结果写入磁盘:

#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的返回值传播回RPC客户端,提供了负载已执行的明确信号。

披露与修复时间线

  • 2025年11月6日 – 漏洞报告给微软。
  • 2025年12月22日 – 微软确认该问题为安全漏洞。
  • 2025年12月23日 – 根据微软漏洞赏金计划获得5,000美元赏金。
  • 2025年12月29日 – 分配CVE-2026-20931。
  • 2026年1月13日 – 修复作为2026年1月补丁星期二更新的一部分发布。
  • 2026年1月19日 – 本文发布。

此漏洞是根据协调漏洞披露实践披露的。微软的公告可在2026年1月安全更新指南中的CVE-2026-20931下找到。

结论

这项研究表明,即使很少使用的遗留Windows子系统仍然可以暴露复杂而强大的攻击面。探索TAPI的结果比我预期的要有趣得多——这提醒我们,最有价值的研究往往隐藏在平台中容易被忽视的部分。

最后值得注意的是,这里描述的漏洞仅影响配置为服务器模式的TAPI系统——这是一种相对不常见的设置,用于集中式电话基础设施,这极大地限制了实际暴露范围。 pXE4VDOqwlf/p8ApaIB8OCxgz+ALcVOdg2L9/hndJQNyiFBvCEz/KALTGRbGq86VL7t8a6/Kf4QiNsvvHrxnDiW3OdSmDENZvq8g0+WAuLT0xzhp79Pqs18qZFNS2WjN