
Vulnerability Research
EnkiWhiteHat
2025. 5. 12.
1. Introduction
Hello!
We are Jongseong Kim and Dongjun Kim security researchers at ENKI WhiteHat and Windows bug hunters. Since 2024 and into 2025, we’ve been conducting research focused on uncovering local privilege escalation vulnerabilities in Windows.
Our work mainly targeted services that run with SYSTEM or user-level privileges, as well as attack surfaces accessible from a restricted Windows AppContainer environment. Through this research, we ended up reporting a total of 10+ vulnerabilities to Microsoft.
In this blog post, we’d like to share the story of our “COM-pletely Unplanned” journey—how we chose which components to look at, how we approached them, and what kinds of interesting vulnerabilities we discovered along the way.
Our findings were also presented at Off-By-One 2025, a security conference hosted by StartLabs in Singapore on May 8–9, 2025.
1.1 Why Local Privilege Escalation Still Matters
First, let us explain why we became interested in Windows LPE. Windows is one of the most widely used operating systems in the world, and it has long been a major target for both attackers and security researchers. Because of its large codebase, complex architecture, and various permission models, Windows has a structure that can lead to many types of vulnerabilities.
Among them, privilege escalation vulnerabilities have been a high-priority research topic for a long time. They are often used not only to gain system-level access, but also to escape sandboxes.
Modern exploit chains often need to combine multiple vulnerabilities to cross security boundaries, rather than relying on a single bug. ️

1.2 Alternative Attack Vectors
For a long time, most local privilege escalation (LPE) vulnerabilities have centered around the Windows kernel—especially components like win32k, ALPC, ntoskrnl, and various Windows Device Drivers. These parts of the OS sit right at the boundary between user mode and kernel mode, making them relatively accessible to attackers. As a result, they’ve remained key targets for exploitation for years.
In response, Microsoft has patched countless vulnerabilities in these areas and introduced a range of security mitigations. As these defenses grow stronger, both attackers and security researchers are finding it increasingly difficult to uncover new bugs in these traditional vectors.

https://cloud.google.com/blog/topics/threat-intelligence/2024-zero-day-trends
Naturally, this has led to a shift: more researchers are exploring alternative paths for privilege escalation—routes that don't rely on the usual kernel-level attack surfaces. We, too, began with that same question in mind:
Could there still be overlooked privilege boundaries outside the traditional kernel attack vectors?
That question led us to focus on components that can be accessed from low-privileged contexts but internally execute with higher privileges than the caller. Specifically, we chose to look for targets with the following characteristics:
Services that run with SYSTEM privileges
Services that can be accessed even from low-privileged contexts, such as Normal Integrity users or AppContainers
With those conditions as our filter, we started analyzing the Windows architecture—and one particular structure caught our attention:
COM (Component Object Model).
We didn’t set out to study COM from the beginning. But during our exploration, we found that several services—especially those built on Windows Runtime and certain local system services—use COM to receive requests from low-privileged clients and process them in higher-privileged processes.
This small but powerful observation became the starting point for our journey. And so began our COM-pletely Unplanned journey—an unintentional but surprisingly fruitful deep dive into the world of COM-based privilege escalations.

2. COM
Many of the Windows features we use every day are built on COM.Windows Explorer, Microsoft Office, Windows Firewall, and the Speech API all run on top of COM. So, what exactly is COM?

2.1 Basic Concepts of COM
Before diving into COM vulnerabilities, we first needed to understand exactly what COM is and how it works. So let’s start with a quick overview together.
COM (Component Object Model) is a component-based software architecture developed by Microsoft. It allows objects to be created and interacted with across different processes or modules. One of COM’s biggest strengths is its language independence—developers can create and use COM objects in many different programming languages. COM provides a unified interface that can be accessed consistently from languages like C/C++, C#, PowerShell, Python, and more.
For example, here’s how you can use PowerShell and Python to automate Excel—demonstrating how COM enables language-agnostic access to Windows components:

But how is that possible?
COM uses CLSID (Class ID) and IID (Interface ID) to identify objects. The CLSID is a unique identifier for a COM class, and it’s registered in the system registry to define how and where the object can be created. The IID uniquely identifies each interface that the COM class provides. Using these identifiers, a client can create an object with a given CLSID and access its interfaces using the corresponding IID. Thanks to this mechanism, even if the COM object is called from different languages, they all interact with the same binary interface.

2.2 COM Server Models
So, if we can use Excel features through COM, like we showed earlier,how do we know where the COM object actually runs and what privileges it has? The way a COM server runs can be divided into three main types.

First, there is the In-Process Server model. This is called the InProcServer model, and it’s implemented as a DLL.When you use a COM object through InProcServer,it runs inside the same process as the client. Because of this, it operates with the same privileges as the client. Simply put, your process loads the DLL that contains the COM server and uses it directly.
Second, there is the Out-of-Process Server model. This is also called the LocalServer model, and it’s implemented as an EXE. Unlike the InProcServer the LocalServer runs in a separate process. Because of this, the client’s access is controlled by the security settings of the COM server. Most of the time, Windows hosts these COM servers inside svchost.exe, which runs background services. Since the client sends requests to another process, the LocalServer uses a Proxy and Stub. We’ll explain that part later.
Finally, there is the Remote Server model. A Remote Server runs on a different computer from the client application.It supports remote calls over the network by using Distributed COM. We will not cover this in detail in this post.
2.3 COM Launch/Access Permission
Alright. We have now briefly gone over the three models of COM servers. But let's stop for a moment and think. What would happen if any process could call an Out-of-Process Server or a Remote Server without any checks?
If a low-privileged user could freely launch a COM server running as SYSTEM, and freely call methods provided by its interface, this could lead to serious privilege escalation or even remote code execution. To prevent this, COM server performs two important security checks. It checks for Launch Permissions and Access Permissions.
2.3.1 Launch Permission
First, let’s talk about Launch Permission. Launch Permission checks whether a client application is allowed to start, or activate, a COM server.
For example, if a user has Launch Permission for a certain COM class, they can start the COM server by calling a function like CoCreateInstance.
Then, what is the difference between Launch Permission and Access Permission?
2.3.2 Access Permission
Access Permission checks whether a client can call methods on an already running COM server.
If the client has Access Permission,they can connect to the running COM server and call methods provided by its interfaces.
On the other hand,if the client has only Launch Permission but not Access Permission,they can activate the COM server, but they cannot access the object or call its methods.

2.4 COM Data Transfer
Now, we have covered the COM server models and the security model. Next, let’s take a look at how COM sends and receives data.
COM allows communication between objects inside a single process,by using the In-Process Server model we talked about earlier. However, in the case of an Out-of-Process Server or a Remote Server, the processes or systems are different. So, an extra step is needed to pass data between them. This is where Proxy/Stub and Marshalling come into play.

2.4.1 Proxy/Stub
Proxy and Stub work together as a pair to enable remote object calls. The Proxy acts as a helper on the client side. The Stub acts as a receiver on the server side. For example, when a client wants to call a method on a COM object in another process, it cannot call the remote object directly. Instead, it calls a Proxy object inside the client process. The Proxy marshals the necessary data and sends it to the server. Then, the Stub on the server side unmarshals the data and calls the actual method on the real object. The result from the server also travels back along the same path and is returned to the client. So, what exactly are marshalling and unmarshalling?

2.4.2 Marshalling/Unmarshalling
Marshalling is the process of converting data from one process, so that it can be used by another process. In COM, marshalling follows the rules defined in the IDL, it transforms both the interface and its parameters into a format that can be sent over RPC.
Unlike general serialization, COM marshalling includes not just simple data types, but also complex objects like interface pointers and runtime object.
Unmarshalling is the reverse process. It takes the marshalled data and reconstructs it into its original structure, so the receiving process can use it just like the original object. To work correctly, the unmarshalled data must match the original. For this reason both sides must follow the types and order defined in the IDL.

2.5 COM Threading Model
Finally, we will take a quick look at COM’s threading model before wrapping up this chapter.
In COM, the concept of an Apartment is used to define the execution context between threads, and to manage thread synchronization. COM objects use different apartment models depending on their specific needs.
In COM, there are two mainly threading models.

2.5.1 STA (Single-Threaded Apartment)
STA (Single-Threaded Apartment) is a model where only one thread belongs to an Apartment. A COM object inside an STA can only receive method calls from the single thread that belongs to that Apartment. Also, the COM Runtime ensures thread safety internally, so when implementing a COM interface in STA, you don’t need to handle thread synchronization yourself.

2.5.2 MTA (Multi-Threaded Apartment)
MTA (Multi-Threaded Apartment) allows multiple threads to run within an Apartment. COM objects inside an MTA can receive method calls from multiple threads at the same time. Because of this, the COM server must handle thread synchronization for the objects very carefully. If not properly handled, this can lead to issues like race conditions.

3. Learning from the Past
3.1 Prior Work
We have now covered the basic background of COM. Now, let’s talk about how we started looking for vulnerabilities in COM. Before jumping into vulnerability discovery, we first reviewed previous research on COM vulnerabilities. ️
We searched through academic papers, and we found two studies / that stood out to us.
The first one is "Detecting Union Type Confusion in Component Object Model," also known as COMFUSION.
The second is “COMRace: Detecting Data Race Vulnerabilities in COM Objects," and both were published at USENIX Security.

3.2.1 COMFUSION
First, let me introduce COMFUSION. The COMFUSION paper highlights that improper use of union types and VARIANT structures can lead to critical type confusion in COM. You might already be familiar with union types. But VARIANT structures might feel a bit less familiar.

A VARIANT is a structure that contains a vt
field, which specifies the actual data type, and a second field / that holds the corresponding data. To use a VARIANT safely, the server must always check the vt
field first, and then read the correct data field based on that type. If this step is skipped or implemented incorrectly, it can lead to a type confusion vulnerability. For example, if you store VT_LPSTR
, which represents a char *
type, in the vt
field, and store a regular integer in the data field, the integer will be interpreted as a pointer, leading to an Arbitrary Address Read.

You might wonder: when sending requests to an Out-of-Process Server or a Remote Server, isn’t the data always interpreted correctly because of marshalling and unmarshalling?
The answer is no. Marshalling and unmarshalling follow the rules defined in the IDL, but they don’t validate the internal contents of a VARIANT structure. So if the COM server doesn’t properly check VARIANT structures, a type confusion vulnerability can still occur.

3.2.2 COMRace
Alright, now let’s take a look at the next paper — COMRace. The COMRace paper explains race conditions that can occur in COM objects running in an MTA model. It also describes how the researchers built a tool to detect these issues, and uncovered several vulnerabilities. One interesting finding from this research was that some of the vulnerabilities followed a specific pattern.

Here’s an example. This code shows the point where CVE-2020-1146 occurs. If you look closely, the put_AuthData
method is called on the object, and it triggers the Set
function of the HSTRING class. ️
Inside the Set
function, the field stored in this
is first released using WindowsDeleteString
, and then re-assigned using WindowsDuplicateString
.
At first glance, this code looks fine. However, in an MTA model, two threads can enter put_AuthData
at the same time and both call WindowsDeleteString
. This can lead to a Double-Free bug.

3.3 What We Learned and …
Through these two studies, we learned that type confusion can happen with VARIANT structures, and that race conditions can occur in the MTA model. We thought both papers were very impressive.
At first, we were ready to move on with these insights. But when we checked when the vulnerabilities were found, we noticed something interesting. Although more than 30 vulnerabilities were reported in the two papers, almost all of them were discovered around 2020.
Also, since the implementations in COMFUSION and COMRace were manually created, we began to wonder if there was something the earlier researchers might have missed.
So, in our own research, we mainly focused on the LocalServer model and the MTA threading model, where we expected to find new cases of Type Confusion and Race Condition. With that question in mind, we began our first COM bug hunting journey.

4. Discovered Vulnerabilities
From this point on, we will walk you through the vulnerabilities we discovered during our bug hunting journey. To give you a quick preview: by the end of this journey, we reported over 10 vulnerabilities to MSRC and successfully earned several CVEs.
4.1 Four Key Vulnerabilities
Next, we’ll introduce the four vulnerabilities we discovered.
Case 87975: Type Confusion in LxpSvc
CVE-2025-27475: Double Free Bug in InstallService
CVE-2024-49095: Use-After-Free in PrintWorkflowUserSvc
CVE-2025-21234: Improper Input Validation in PrintWorkflowUserSvc
4.1.1 Case 87975: Type Confusion in LxpSvc
Let's move on to the first vulnerability. This vulnerability is a type confusion issue that occurs in the Windows LxpSvc (Language Experience Service). The vulnerability is triggered in the SetLanguageOperationState
method of an internal class called DeviceLanguageManager
.
The SetLanguageOperationState
method takes four parameters, and the fourth parameter is a pointer to a VARIANT structure provided by the user. At steps [1] and [2], the method stores the vt
field (the type information of the VARIANT) and the associated data field into v12
and v12 + 0x8
, respectively. Later, this data is passed as the third argument to the SetLanguageOperationStateInRegistry
method, where it is used without proper validation.

In the SetLanguageOperationStateInRegistry
method, if a2 is zero at step [1], the method stores the user-provided VARIANT data field into the data
variable at step [2].
Then, the address of data
is passed as the fifth argument to the SetRegValue
function

So, what is the RegSetKeyValueW
function? The RegSetKeyValueW
function is used to create or modify a value under a specified registry key.

In other words, at this point, the Status
registry key is updated by writing 4 bytes of data from the VARIANT's data field that we provided.
But doesn’t something seem strange here? If you think back to the code we’ve seen so far, you’ll notice that when a2 is zero in the SetLanguageOperationStateInRegistry
method, there is no validation of the VARIANT's data type at all!
Now, what would happen if the VARIANT's type was something like BSTR, which holds a pointer instead of a simple integer? This is exactly where Type Confusion occurs. Instead of writing an integer value like 0 or 1, the lower 4 bytes of the pointer address are written into the registry key.

Since this registry path is readable from a user-level context, it allows an attacker to leak the lower 4 bytes of a pointer address.

Personally, I think this was a really interesting vulnerability. Although MSRC determined that the report did not meet their bar for immediate security servicing, they committed to addressing the issue in a future version
4.1.2 CVE-2025-27475: Double Free Bug in InstallService
The next vulnerability is a Double Free Bug found in the Windows Update Stack. The vulnerability occurs in the put_CrossGenSetId
method of an internal class called FulfillmentDataInfo
.
To trigger the vulnerable function, we first need to call the CreateFulfillmentData
method of the InstallControl
class, which will return an IFulfillmentDataInfo
object. The condition for obtaining this object is very simple: At step [1], it checks whether the length of the string we provide as a2 is exactly 12. If the condition is met, the object is created at step [2] and returned to the client.
Now, let's take a look at the put_CrossGenSetId
method of the FulfillmentDataInfo
object. At step [1], the method loads the HSTRING variable stored at this + 200
into v3
. Then, at step [2], it frees the memory pointed to by v3. Wait.. doesn’t this look like a pattern we've seen before?
This issue is very similar to CVE-2020-1146, which was previously introduced in COMRace paper. Even after five years, vulnerabilities following the same pattern still remain. This was the moment when our initial suspicions, based on prior work, turned into certainty — there are still many types of race condition vulnerabilities left to be discovered.

4.1.3 CVE-2024-49095: Use-After-Free in PrintWorkflowUserSvc
To continue hunting for race condition vulnerabilities, we turned our attention to PrintWorkflowUserSvc, the service responsible for managing the Windows Print Workflow. In this service, we were able to discover several vulnerabilities. Among them, we will introduce two of the most interesting cases.
The first vulnerability is a Use-After-Free issue. It occurs in the SetPrintTicket
method of the CWorkflowSession
object.
Looking at the code, we can see that *((_QWORD *)this + 10)
holds a heap address that was allocated during a previous call to SetPrintTicket
. However, because the CWorkflowSession
COM interface belongs to the MTA (Multi-Threaded Apartment) model, the object can be accessed concurrently by different threads.


We attempted to exploit this vulnerability, but due to several constraints, we were not able to achieve a successful exploitation.
However, based on our analysis, we believe that with certain techniques and ideas, exploitation could be conceptually possible.
First, let's talk about bypass ASLR. In the case of ASLR, if the attacker-controlled process and the higher-privileged process load the same DLL, the DLL will be mapped at the same virtual address in both processes. This allows the attacker to infer a sufficient amount of address information.
This idea forms the fundamental basis for many LPE and sandbox escape techniques using IPC mechanisms.

Second, we needed a leak some heap addresss. Since the CWorkflowSession
object exposes several other methods, I checked whether any of them could access the this + 10
address.

Eventually, I found a function that does exactly that. By using the GetPrintTicket
method, we can copy the value located at this + 10
— which may already have been freed — into a output buffer. In other words, we can leak useful heap addresses through the following scenario.
First, we need a of three threads. Each thread will call different COM interface methods, as illustrated in the diagram. In Thread 2, if a value already exists at this + 10
, calling SetPrintTicket
will free the heap memory that was previously allocated there. Then, before a new allocation occurs, Thread 3 may allocate an object — such as one containing a vtable or any heap address — into the freed space.
After that, when Thread 1 calls GetPrintTicket
, it will reference the this + 10
address, effectively leaking the value from the victim object back to the attacker. Since the size field for heap allocation can be fully controlled through SetPrintTicket
, we can reliably place a victim object into the freed hole within the race window, without any size constraints.

Next, we move on to RIP control. Again, three threads are used. Unlike the previous leak scenario, here two of the threads start a race by both calling SetPrintTicket
.
First, if this + 10
is not NULL, SetPrintTicket
will free the heap memory stored there. During this window, an attacker can allocate an arbitrary object to occupy the freed hole. After that, the attacker can overwrite the victim object with fully controlled data and size. If the victim object contains a vtable, this can lead to successful control flow hijacking.

Even though we came up with these ideas, we were not able to develop a fully working exploit. We encountered several challenges:
First, even if we successfully allocated a victim object into the freed space through the race condition and managed to overwrite it, it was extremely difficult to avoid a double free situation.
Second, because both threads must call the same interface to trigger the race, and the race window is extremely narrow, precise control over the timing was very difficult to achieve.

4.1.4 CVE-2025-21234: Improper Input Validation in PrintWorkflowUserSvc
Now, we would like to introduce the final vulnerability. Just like the previous cases, this vulnerability was also found in PrintWorkflowUserSvc.
Let's take a closer look at the RequestSpoolingHandlesForWrite
method of the IPrintSupportSourceSession
class. This method takes six parameters, from a2 to a7, and all of them are used as output parameters. Now, let me ask you:When you're hunting for bugs, do you usually bother analyzing functions that have no input parameters at all? Our answer was no — at least, until we discovered this vulnerability.

In this function, at step [1], the address of the WorkflowSessionCommon
's vftable, which is stored in this, is loaded into v9
. Then, using this address as an argument, the RequestSpoolingHandlesForWrite
method of the WorkflowSessionCommon
class is called at step [2].

In this method, at step [1], it retrieves the client's PID (Process ID). At step [2], it obtains a handle to the client's process. Then, at step [3], it references this
again through v22
and stores some value. Finally, at step [4], it calls the DuplicateHandle
function. Now it's time to see what Duplicatehandle
function really works.

The DuplicateHandle
function is a Windows API that duplicates a handle to an object. To briefly summarize the function arguments: the first and second parameters are the handle to the source process and the handle to be duplicated, respectively. The third parameter is the handle to the target process that will receive the duplicated handle, and the fourth parameter is where the newly duplicated handle will be returned. According to the MSDN documentation, this operation effectively means that the handle from v22
inside PrintWorkflowUserSvc is duplicated into the client process. While this pattern can sometimes be seen in COM, it is usually used to return event handles to the client process for implementing callbacks or method notifications.

It was confirmed by simple reverse engineering that the v22
value was actually set where. Through analysis, we found that it is initialized in the constructor of the PrintSupportSession
class. The v22
value is set to -1
. In Windows, a handle value of -1
represents a pseudo-handle that points to the current process itself.

This means that in the DuplicateHandle
call, the handle of the PrintWorkflowUserSvc process is duplicated into the client process. In other words, if a lower-privileged client calls this method, it can successfully duplicate the PrintWorkflowUserSvc process handle into its own process.
Next, we checked which processes could access PrintWorkflowUserSvc by reviewing its access permissions. As a result, we found that this COM object is accessible not only by all UWP apps, but also by low-privileged AppContainer processes that have specific capabilities. This means that even a sandboxed process can duplicate the PrintWorkflowUserSvc process handle into its own process.

This vulnerability is truly a perfect logic bug. There is no need to exploit the complex Use-After-Free vulnerabilities we discussed earlier to achieve privilege escalation. Now, by using the duplicated process handle, we can write shellcode into the memory space of PrintWorkflowUserSvc and execute it by calling the CreateRemoteThread API.

Now it’s time for the demo. To demonstrate the vulnerability, we assumed a compromised renderer in the Adobe Acrobat sandbox and performed a sandbox escape by injecting shellcode, achieving a privilege escalation from AppContainer to Medium integrity level.
Surprisingly, the bug was declared "Exploit Less Likelly." We still don't know why the MSRC made this decision :D

5. Results
Finally, here’s our conclusion. From 2024 until now, we have reported more than 10 vulnerabilities to Microsoft, including Type Confusion and Race Condition issues, and we successfully earned CVEs for our findings.

These achievements were made possible thanks to the information shared by previous researchers and the availability of outstanding tools like oleviewdotnet. We would especially like to express our deep gratitude to the earlier researchers and to James Forshaw, who laid the foundation that allowed us to begin our journey into COM vulnerability research.

Finally, before we close, we would like to share some reflections from our journey.
We believe that a thorough analysis of prior research is essential when trying to discover new vulnerabilities in a new domain. It is important not to simply follow existing research, but to focus on the areas that were not fully explored, and to seek out possibilities for further expansion.
Of course, there were some regrets as well. Although we were able to discover several race condition vulnerabilities, we were not able to achieve complete exploitation in some cases — and we recognize this as an area we need to improve moving forward.
For our future work, we aim to develop a systematic tool for detecting these types of vulnerability patterns, and through it, discover even more vulnerabilities. Our journey is far from over — it will continue 😄