CVE-2025-21297 — RD Gateway (aaedge.dll) Singleton Race → Use-After-Free RCE

Last updated: 2026-07-02
Severity: Critical (CVSS 8.1)
Component: aaedge.dll — Windows Remote Desktop Gateway (RD Gateway) service
Bug Class: Race Condition (unsynchronised singleton init) → reference-count corruption → Use-After-Free
Privilege: Remote, pre-auth → RCE in the RD Gateway service context
Patch: January 2025 Patch Tuesday — mutex added around singleton initialisation
Related: Use After Free, Race Conditions, CVE-2024-38148 (Schannel UAF), Windows RPC

Vulnerability Summary

A remote, unauthenticated use-after-free in the Windows Remote Desktop Gateway service (aaedge.dll). The root cause is an unsynchronised “lazy singleton” initialisation of a global instance pointer, CTsgMsgServer::m_pMsgSvrInstance. Multiple concurrent client connections can race the initialiser, corrupting the singleton’s reference count and leaving a dangling pointer that a later connection dereferences — a virtual-call UAF that is exploitable for remote code execution.


Affected Code Path

Client connect (HandShakeRequest / TunnelRequest)
  → CTsgMsgServer::GetCTsgMsgServerInstance()   // lazy singleton getter
      a1: m_pMsgSvrInstance = new CTsgMsgServer(...)   // assign to GLOBAL before init/refcount settle
      a2: return m_pMsgSvrInstance                     // returns the GLOBAL, not a local

Key function: CTsgMsgServer::GetCTsgMsgServerInstance in aaedge.dll.
Affected global: CTsgMsgServer::m_pMsgSvrInstance.


Root Cause Analysis

The getter follows the classic broken double-checked-locking pattern without any lock:

  1. Null-check the global m_pMsgSvrInstance with no synchronisation.
  2. Heap-allocate a CTsgMsgServer instance.
  3. Assign the pointer to the global (position a1).
  4. Return the value read back from the global (position a2), not from a local variable.

Because both the store and the return read the shared global, two threads that pass the null-check simultaneously each allocate their own instance and stomp the global in turn. The reference count kept on the instances no longer matches the number of live users:

  • Socket 1 and Socket 2 both enter before the global is set; each allocates a separate block.
  • Socket 1 writes its block to the global first.
  • Socket 2 immediately overwrites the global with its own block. Socket 1’s block is now unreferenced by the global but Socket 1 still holds/uses it; Socket 2’s block has ref = 1 while two threads increment/decrement it.
  • The mismatched refcount lets a block be freed while a pointer to it is still live.
  • Socket 3 connects later and dereferences the dangling pointer (virtual method call) → UAF.

The heap crash confirms it: address …bfb4f90 found in free-ed allocation, faulting on a virtual dispatch through the freed object.


Exploitation Technique

RD Gateway is internet-facing (RPC-over-HTTP / the TSGU tunnelling protocol), and the initialisation path is reached pre-authentication via the handshake/tunnel-setup messages. The PoC opens many concurrent sockets sending HandShakeRequest and TunnelRequest messages, tightly synchronised to widen the race window during CTsgMsgServerInstance initialisation, then uses a third connection to operate on the freed object. Controlling the contents of the reclaimed allocation (heap grooming) turns the dangling virtual-call into control of execution — remote code execution in the gateway service.


Patch Analysis

Microsoft added a mutex around the singleton initialiser so only one thread can run the allocate-and-publish sequence at a time; concurrent callers block and then observe the fully-initialised global. This is the standard fix for the broken-lazy-init class and removes the refcount race entirely.


Variant Notes

The bug is a textbook instance of “lazy singleton without a lock” — a pattern worth hunting across other Get*Instance getters in the same and sibling services (any if (!g_x) g_x = new …; return g_x; reachable from multiple threads). See Race Conditions.


References