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

admin
Mon, 19 Jan 2026 05:00:50 -0800

Windows has supported computer telephony integration for decades, providing applications with the ability to manage phone devices, lines, and calls. While modern deployments increasingly rely on cloud-based telephony solutions, classic telephony services remain available out of the box in Windows and continue to be used in specialized environments. As a result, legacy telephony components still form part of the default Windows attack surface. This research explores a vulnerability I discovered in the Telephony Service’s server mode, which allows low-privileged client to write arbitrary data to files accessible by the service and, under certain conditions, achieve remote code execution. ## Windows Telephony Overview Windows exposes telephony functionality through the Telephony Application Programming Interface (TAPI), which allows user-mode applications to interact with telephony devices and services via a unified abstraction layer. TAPI exists in two primary forms: TAPI 2.x, which provides a procedural C-style API, and TAPI 3.x, which is implemented using COM. While the APIs differ, both rely on the same underlying architecture: applications communicate with the TAPI runtime, which forwards requests to Telephony Service Providers (TSPs). TSPs are vendor-supplied components that encapsulate device- or service-specific logic and interface with the underlying telephony backend, such as physical telephony hardware, PBX systems, or VoIP endpoints. From the perspective of client applications, these differences are hidden behind the TAPI abstraction. ## What is Telephony Service Applications interact with the Windows telephony stack either by calling TAPI 2.x functions exported by `tapi32.dll` or by using the TAPI 3.x COM interfaces provided by `tapi3.dll`. In both cases, these libraries mostly act as client-side wrappers: they marshal requests and forward them to a system service that actually implements the telephony logic. That service is the _Telephony_ service ( `TapiSrv`). It implements the actual TAPI functionality and exposes it to client applications through the `tapsrv` RPC interface. When an application invokes a TAPI call, the request is ultimately handled by `TapiSrv`, which selects the appropriate TSP and orchestrates the corresponding low-level interactions. The service runs under the `NETWORK SERVICE` account and is configured with a manual startup type, but is automatically started on demand when a process first invokes a TAPI request via `tapi32.dll` or `tapi3.dll`. The whole implementation resides inside `tapisrv.dll` library. _(The diagram from MSDN is outdated but it provides the general understanding)_ ## TAPSRV RPC Interface ### Overview Communication between TAPI clients and the Telephony service occurs over a classic MSRPC interface named `tapsrv`. The corresponding protocol, MS-TRP, is publicly documented. **By default, this interface is restricted to local callers only**. On Windows Server systems, however, TAPI _can_ be configured to accept remote client connections. This behavior is controlled by the `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Telephony\Server\DisableSharing` registry value and can also be managed through the _Telephony_ MMC snap-in ( `TapiMgmt.msc`). While remote access to local modems or telephony devices is rarely useful, this feature exists for server-side telephony deployments such as PBX systems or phone switches. In such scenarios, the telephony hardware and associated TSPs are installed centrally on the server, and multiple TAPI-aware clients connect remotely instead of maintaining individual TSP installations. Clients can be configured to use a remote TAPI server via the `tcmsetup /c ` command. When remote access is enabled, the interface is exposed over `tapsrv` named pipe, which implies that a client must first authenticate over SMB to establish a connection. In this configuration, the TAPI server also publishes service-related information to Active Directory, making it relatively easy to discover within a domain environment. ### Request Dispatch Model The `tapsrv` RPC interface is minimalistic and consists of only three callable methods: `ClientAttach`, `ClientDetach`, and `ClientRequest`. Session initialization and teardown are handled by the first two calls, while `ClientRequest` is used to invoke all telephony-related operations. `ClientRequest` accepts a single binary blob that represents a serialized request packet. The first four bytes of this packet contain a `Req_Func` field, which acts as an index into an internal dispatch table. The remainder of the buffer contains marshaled parameters specific to the selected operation. The set of supported `Req_Func` values and corresponding packet layouts is mostly documented in the MS-TRP specification and closely mirrors the Win32 TAPI 2.x API surface. Conceptually, this results in an extra dispatch layer on top of MSRPC – effectively an “RPC within RPC” design. Similar patterns appear in other Windows services, such as the RASRPC interface exposed by the `RasMan` service (where I also discovered an LPE several months ago). ### Client Session Setup In TAPI terminology, a _client_ is a machine that connects to the TAPI server interface, while a _line application_ is a program on that client system that issues telephony requests. A client session is established by calling `ClientAttach`, which has the following signature: ``` long ClientAttach( [out] PCONTEXT_HANDLE_TYPE *pphContext, [in] long lProcessID, [out] long *phAsyncEventsEvent, [in, string] wchar_t *pszDomainUser, [in, string] wchar_t *pszMachine ); ``` During session initialization, the service evaluates the caller’s security context and assigns internal privilege flags to the client. These flags are later consulted by various telephony operations to gate access to sensitive functionality. ``` 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; } } } ``` Based on this logic, flag value `8` corresponds to administrative access (local administrators or SYSTEM), while flag `1` is assigned to service accounts. When TAPI server mode is enabled, users explicitly listed under the `[TapiAdministrators]` section of `C:\Windows\TAPI\tsec.ini` are also granted elevated permissions. To call methods associated with the _line_ abstraction, the client then has to initialize _line application_ instance by sending an Initialize request. ### Asynchronous Event Processing Telephony is inherently event-driven: incoming calls, state changes, and media events may occur independently of client requests. Since MSRPC follows a synchronous request-response model, the MS-TRP protocol implements its own mechanism for delivering asynchronous events from the Telephony service to connected clients. The event delivery model is negotiated during the initial `ClientAttach` call and differs depending on whether the client is local or remote. For local clients, asynchronous events are delivered using a shared synchronization object. The client supplies its process identifier ( `lProcessID`) during `ClientAttach` and receives a handle to an event object. When event data becomes available, the Telephony service signals this event, prompting the client to retrieve the pending data by issuing a `GetAsyncEvents` request. When TAPI server mode is enabled, the protocol offers two alternative mechanisms for delivering asynchronous events: _push_ and _pull_. The selected model is determined by the arguments supplied to `ClientAttach`. In the _push_ model, the client leaves `pszDomainUser` argument blank and provides quote-separated RPC string binding (e.g. `CLIENT-PC-NAME"ncacn_ip_tcp"31337"`) in `pszMachine` argument. The Telephony service establishes a reverse RPC connection to the endpoint, binds to remotesp interface and invokes the `RemoteSPEventProc` method whenever asynchronous events occur. In the _pull_ model, the client specifies a mailslot name in the `pszDomainUser` argument during session initialization. The Telephony service periodically sends `DWORD`-sized datagrams to this mailslot indicating that events are available for retrieval. The client is then expected to fetch the corresponding event data using `GetAsyncEvents`. In all cases, the server associates events with a specific line application using the `InitContext` field value supplied by the client in the `Initialize` packet. This value is treated as an opaque 4-byte identifier and is echoed back by the server as part of the event notification for the application. ## Mailslot Shenanigans Mailslots are a legacy Windows IPC mechanism designed for transmitting small, unidirectional messages. A mailslot writer sends datagrams to a named endpoint, while the receiver passively reads incoming messages. From the client side mailslots are accessed using standard Win32 file APIs such as `CreateFile`, `WriteFile`, and `CloseHandle`. A mailslot is addressed using a special path syntax of the form: `\\\MAILSLOT\` From the client’s perspective, the resulting handle behaves like a write-only file. Over the network, mailslot messages are transported using NetBIOS-over-UDP datagrams (or _were_ transported – remote mailslots are disabled since Window 11 24H2). Because the communication is strictly one-way, the sender receives no confirmation that a remote mailslot exists or that messages are being processed. As discussed in the previous section, the Telephony service uses the _pull_ asynchronous event model to notify remote clients about pending events by periodically sending datagrams to a client-supplied mailslot name. The relevant code path in `ClientAttach` responsible for initializing the mailslot handle is shown below: ``` 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; } ... } ``` Crucially, the service passes the user-controlled `pszDomainUser` string directly to `CreateFileW` without validating that it refers to a mailslot path – no checks are performed to ensure that the path begins with the `\\*\MAILSLOT\` namespace or otherwise corresponds to a mailslot object. As a result, a client can supply an arbitrary file path instead of a mailslot name. Provided that the target file already exists and is writable by the `NETWORK SERVICE` account, the Telephony service will successfully open it and subsequently write asynchronous event data to it. In other words, the mailslot-based event delivery mechanism can be repurposed into an arbitrary file write primitive under the service’s security context. ## Building a File Write Primitive At this point, the attacker controls _where_ the Telephony service writes data. The remaining question is _what_ data is written. As described earlier, in the _pull_ asynchronous event model the Telephony service sends notifications by writing a single `DWORD` value to the client-specified mailslot. This value actually corresponds to the `InitContext` field supplied during initialization of the line application that generated the event. Because `InitContext` is fully user-controlled, and because the mailslot path itself can be redirected to an arbitrary file, each generated event results in a controlled 4-byte write to a chosen file. The remaining challenge is to reliably trigger such events on demand. Tracing the code paths that enqueue asynchronous events shows that many are deeply embedded in telephony call-handling logic. Rather than attempting to reach these paths directly, a simpler and more reliable approach is to trigger events through `NotifyHighestPriorityRequestRecipient`. This helper function delivers an event to a single global “highest-priority” line application. Crucially, it can be invoked remotely via the undocumented `TRequestMakeCall` packet ( `Req_Func = 121`), which serves as the backend implementation of the documented tapiRequestMakeCall API. The highest-priority line application is recalculated when clients register or unregister as request recipients via the undocumented `LRegisterRequestRecipient` handler ( `Req_Func = 61`), which backs the lineRegisterRequestRecipient API. The relevant logic is shown below: ``` 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(); } } ... } ``` The priority is determined based on the order of application module name in a list: ``` 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; } ``` This list is retrieved from the registry while impersonating the client: ``` 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(); } ``` Specifically, the service reads the following key under the client’s `HKCU` hive: `HKCU\Software\Microsoft\Windows\CurrentVersion\Telephony\HandoffPriorities\RequestMakeCall` By default, this list typically contains a single entry: `DIALER.EXE`. If necessary, additional entries can be inserted using the undocumented `LSetAppPriority` request ( `Req_Func = 69`). The `pszModuleName` field used for priority comparison is supplied by the client as part of the `Initialize` packet, giving the attacker full control over how their line application is ranked. With these pieces in place, it becomes possible to construct a reliable arbitrary `DWORD` write primitive under the `NETWORK SERVICE` security context. First, the attacker establishes a client session by calling `ClientAttach`, specifying the target file path in the `pszDomainUser` parameter. This causes the Telephony service to open the file once and retain the resulting handle for subsequent event notifications. For each 4-byte value to be written, the attacker then performs the following steps: 1. Submit an `Initialize` packet ( `Req_Func = 47`), setting: - `InitContext` to the desired `DWORD` value - `pszModuleName` to `DIALER.EXE`(or another high-priority entry) 2. Register the line application as a request recipient using `LRegisterRequestRecipient` ( `Req_Func = 61`, `dwRequestMode = LINEREQUESTMODE_MAKECALL`, `bEnable = 1`). 3. Trigger an event by submitting a `TRequestMakeCall` packet ( `Req_Func = 121`). 4. Dequeue the event using `GetAsyncEvents`( `Req_Func = 0`), completing the write. 5. Unregister the request recipient ( `LRegisterRequestRecipient`, `bEnable = 0`). 6. Shut down the line application using `Shutdown`( `Req_Func = 86`). Repeating this sequence allows an attacker to write arbitrary data to an arbitrary, pre-existing file that is writable by the Telephony service. ## From File Write to RCE At this stage, exploitation requires an existing file that is writable by `NETWORK SERVICE`. One particularly obvious candidate is `C:\Windows\TAPI\tsec.ini`, which was described earlier. On systems running the Telephony service in server mode, this file is always present and writable by the service account. The file, among the other configuration settings, defines which users the Telephony service treats as administrators. By adding an entry under the `[TapiAdministrators]` (e.g. `"[TapiAdministrators]\r\nDOMAIN\\attacker=1"`), a remote unprivileged domain user can grant themselves administrative permissions within the Telephony service. Establishing a new session via `ClientAttach` after this modification results in a client context with the administrative privilege flag set. With administrative access to the Telephony service, additional attack surface becomes available. One particularly powerful primitive is exposed through the GetUIDllName request, documented as part of the MS-TRP protocol. According to the specification: > The GetUIDllName packet, along with the TUISPIDLLCallback packet and the FreeDialogInstance packet, is used to install, configure, or remove a TSP on the server. Reviewing the implementation reveals that while non-administrative callers are restricted to selecting providers from the pre-defined list in the registry, administrative clients are permitted to load provider DLLs from arbitrary paths. ``` 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 { .... } } ``` By submitting a `GetUIDllName` request with `dwObjectType` set to `TUISPIDLL_OBJECT_PROVIDERID` and specifying an attacker-controlled DLL path, we can make the Telephony service load the DLL and invoke the exported `TSPI_providerUIIdentify` function. This provides a straightforward and reliable code execution primitive in the context of the service. Moreover, if the exported function returns a non-zero value, the service unloads the DLL after invocation, allowing the payload to be removed from disk afterwards. An obvious delivery mechanism would be to specify a UNC path pointing to an attacker-controlled SMB share. In practice, this works reliably when the share is hosted on a standard Windows machine within the same domain. However, attacker-hosted SMB servers such as `impacket-smbserver` or Samba may trigger guest access restrictions, causing `LoadLibrary` to fail with `ERROR_SMB_GUEST_LOGON_BLOCKED`. Since an arbitrary file write primitive is already available, a local DLL drop provides a reliable alternative. Suitable writable files can be identified using `accesschk`. For example, the following files tend to exist on almost any system: - `C:\Windows\System32\catroot2\dberr.txt` - `C:\Windows\ServiceProfiles\NetworkService\AppData\Local\Temp\MpCmdRun.log` - `C:\Windows\ServiceProfiles\NetworkService\AppData\Local\Temp\MpSigStub.log` Although writing a payload-sized DLL using 4-byte event writes is relatively slow, it completely eliminates the need for external infrastructure. To demonstrate code execution, a minimal proof-of-concept TSP DLL can be constructed. In the following example, the `TSPI_providerUIIdentify` export – invoked by the Telephony service during provider installation – executes a command and writes the result to disk: ``` #include 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; } ``` The return value of `TSPI_providerUIIdentify` is propagated back to the RPC client, providing a clear signal that the payload was executed: ## Disclosure and Patch Timeline - **Nov 6, 2025**– Vulnerability reported to Microsoft. - **Dec 22, 2025**– Microsoft confirmed the issue as a security vulnerability. - **Dec 23, 2025**– $5,000 bounty awarded under the Microsoft Bug Bounty Program. - **Dec 29, 2025**– CVE-2026-20931 assigned. - **Jan 13, 2026**– Fix released as part of the January 2026 Patch Tuesday updates. - **Jan 19, 2026**– This write-up published. This vulnerability was disclosed in accordance with coordinated vulnerability disclosure practices. Microsoft’s advisory is available in the January 2026 Security Update Guide under CVE-2026-20931. ## Conclusion This research shows that even rarely used legacy Windows subsystems can still expose complex and powerful attack surfaces. Exploring TAPI turned out to be far more interesting than I expected – a reminder that some of the most rewarding research is often hidden in parts of the platform that are easy to overlook. On a final note, it is worth reminding that the vulnerability described here **only affects systems where TAPI is configured in server mode** – a relatively uncommon setup intended for centralized telephony infrastructure, which significantly limits the practical exposure.