취약점 연구

윈도우 버그헌팅 | COM-pletely Unplanned: A Windows Bug Hunter’s Journey to LPE

윈도우 버그헌팅 | COM-pletely Unplanned: A Windows Bug Hunter’s Journey to LPE

엔키화이트햇

2025. 5. 12.

1. Introduction

※ 본 콘텐츠는 섹션별로 한국어 본문이 먼저 제공되며, 이후 영어 번역이 이어지는 형식으로 구성되어 있습니다.

※ This content is structured so that each section is first presented in Korean, followed by its English translation.

안녕하세요. 우리는 ENKI WhiteHat의 보안 연구원이자 Windows Bug Hunter인 김종성, 김동준입니다. 우리는 2024년부터 2025년 최근까지 윈도우에서 권한 상승 취약점을 찾기 위한 연구를 진행하였습니다.

우리는 윈도우에서 SYSTEM 권한과 User 권한으로 작동하는 서비스와, Windows AppContainer 권한에서 호출할 수 있는 공격 표면에 대한 연구를 진행하였고, 최종적으로 총 10+건의 취약점을 제보하였습니다.

이 블로그 포스트에서는 우리의 “COM-pletely Unplanned”한 여정을 따라, 어떤 컴포넌트를 선택했고 어떻게 접근했는지, 그리고 그 과정에서 어떤 재미있는 취약점들을 찾게 되었는지 이야기해보려 합니다.

우리의 연구 결과는 2025년 5월 8일부터 9일까지 싱가포르의 보안 기업 StartLabs가 주최한 Off-By-One 2025 컨퍼런스에서도 발표되었습니다.

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

Windows는 전 세계적으로 가장 널리 사용되는 운영체제 중 하나이며, 그만큼 공격자들과 보안 연구자들의 주요 관심 대상이 되어 왔습니다. 방대한 코드베이스, 복잡한 아키텍쳐, 그리고 다양한 권한 모델을 가진 Windows는 여러 유형의 취약점을 유발할 수 있는 구조를 갖고 있습니다.

이 중에서도 격리된 샌드박스 환경에서 일반 사용자 권한까지, 일반 사용자 권한에서 시스템 권한까지 도달할 수 있는 권한 상승 (Local Privilege Escalation, LPE) 취약점은 오랫동안 높은 우선순위를 가진 연구 주제였습니다.

현대의 많은 소프트웨어들은 각자의 방식으로 샌드박싱된 형태의 아키텍쳐를 구현하고 운영하고 있습니다. 이는, 단순히 하나의 취약점으로 끝나는 것이 아닌, Sandbox Escape, 백도어 설치 등 여러 공격 시나리오의 기반이 되기 때문입니다. 이로 인해 현대의 Exploit chain은 하나의 버그만 사용되는 것이 아닌 n개의 버그의 체이닝이 필수적이게 되었습니다.

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. ️

운영체제 점유율을 보여주는 통계 그래프로, Windows가 72.88%로 가장 높고, 그 뒤를 OS X(15.16%), Linux(4.11%) 등이 따르고 있습니다.


1.2 Alternative Attack Vectors

실제로 많은 권한 상승 취약점은 Windows 커널에 집중되어 왔으며, 대표적으로 win32k, ALPC, ntoskrnl, Windows Device Driver 등이 반복적으로 분석 대상이 되어왔습니다. 이러한 컴포넌트들은 사용자와 커널 사이의 경계 지점에 위치하며, 공격자가 비교적 직접적으로 접근할 수 있어, 오랫동안 권한 상승 공격의 핵심 벡터로 여겨졌습니다.

이에 대응해 Microsoft는 수많은 취약점 패치와, Security Mitigation을 적용하여, 공격자와 보연 연구자들이 취약점을 찾는 것이 점차 어려워지고 있습니다.

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

https://cloud.google.com/blog/topics/threat-intelligence/2024-zero-day-trends

이러한 흐름 속에서, 기존과는 다른 권한 상승 경로를 찾아보려는 시도가 자연스럽게 등장하고 있습니다. 우리도 마찬가지로, 전통적인 커널 공격 벡터 외의 경로에서 권한 상승이 가능한 구조가 남아있지 않을까 하는 의문에서 연구를 시작하게 되었습니다.

그 과정에서 우리가 주목한 것은, 낮은 권한에서 접근할 수 있지만 내부적으로는 호출자보다 높은 권한으로 동작하는 컴포넌트들이었습니다. 특히 다음과 같은 특성을 가진 대상들을 주요 탐색 후보로 선정하였습니다:

  1. SYSTEM 권한으로 실행되는 서비스

  2. 일반 사용자 (Normal Integrity User) 나 Low Privileged AppContainer와 같은 제한된 권한에서도 접근 가능한 서비스

이러한 조건을 바탕으로, Windows 아키텍쳐를 분석하던 중, 한 가지 흥미로운 구조가 눈에 들어왔습니다. 바로 COM (Component Object Model) 이었습니다.

사실 처음부터 COM을 겨냥하려 했던 것은 아니었습니다. COM으로 이루어진 Windows Runtime을 지원하는 일부 서비스와 다른 Local 서비스들이 낮은 권한 (User, AppContaine 등) 에서 높은 권한을 가진 프로세스에게 요청을 직접적으로 보낼 수 있다는 점을 발견했고, 이 작은 단서가 우리의 연구의 방향을 정해주었습니다.

그렇게, 전혀 계획에 없었지만 좋은 결과를 낼 수 있었던, “COM-pletely Unplanned”한 여정이 시작되었습니다.

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:

  1. Services that run with SYSTEM privileges

  2. 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.

주먹을 쥔 아기 밈(Success Kid) 이미지와 'YEAH! WE FOUND COM!' 문구.

2. COM

우리가 평소에 사용하는 Windows 기능 대부분이, COM이라는 것이 구현되어 있습니다. Windows Explorer, Microsoft Office, Windows Firewall, Speech API 등도 모두 COM을 기반으로 동작합니다. 그렇다면 도대체 이 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?

We have COM :D' 말풍선과 COM 기반 윈도우 및 오피스 프로그램 아이콘들.

2.1 Basic Concepts of COM

먼저 우리는 COM 취약점 분석을 시작하기 전, 먼저 COM이 무엇인지 정확히 이해할 필요가 있었습니다. 우리와 함께 간단하게 알아봅시다.

COM은 Microsoft에서 설계한 컴포넌트 기반 소프트웨어 아키텍쳐로, 서로 다른 프로세스나 모듈 간에 객체를 생성하고 데이터를 주고받을 수 있도록 해줍니다. 특히 COM의 가장 큰 장점 중 하나는, 다양한 프로그래밍 언어로 COM 개체를 만들 수 있다는 점입니다. COM은 C/C++, C#, PowerShell, Python 등 다양한 언어에서 동일한 방식으로 접근할 수 있는 인터페이스를 제공합니다.

예시로, 아래와 같이 PowerShell과 Python을 이용해 언어에 구애받지 않고, 공통된 방식으로 Excel (기타 Windows Components) 에 데이터를 입력할 수 있습니다.

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:

PowerShell과 Python에서 각각 COM을 이용해 Excel A1 셀에 'Hello COM'을 입력하는 코드 및 실행 결과 화면 비교.

그런데 이것이 어떻게 가능한 것일까요?

COM은 객체의 식별을 위해 CLSID (Class ID) 와 IID (Interface ID) 를 사용합니다. CLSID는 COM 클래스의 고유 식별자이며, 레지스트리에 등록되어 해당 객체를 생성할 수 있는 위치 및 방법을 정의합니다. IID는 해당 COM 클래스가 제공하는 각 인터페이스의 고유 식별자입니다. 이로써, 클라이언트는 특정 CLSID를 이용해 객체를 생성하고, 해당 객체가 제공하는 인터페이스를 IID를 통해 사용할 수 있게 되며, 다른 언어로 COM 개체를 호출해도 동일한 바이너리 인터페이스를 공유합니다.

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.

개발 도구에 표시된 'ApplicationLicenseManager' COM 클래스와 관련 인터페이스 ID(IID) 목록.

2.2 COM Server Models

그렇다면, 아까 예시처럼 COM을 통해 Excel의 기능을 사용할 수 있다면 이 COM 객체를 실제로 어디서 실행되며 어떤 권한으로 동작하는지는 어떻게 결정될까요? COM 서버는 실행 방식에 따라 다음과 같이 크게 세 가지로 나뉩니다.

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.

포켓몬 캐릭터로 비유한 COM 서버 모델: In-Process, Out-of-Process, Remote Process.
  1. In-Process Server (InProcServer) 방식은 COM Server가 DLL 형태로 구현되어 있습니다. 이 방식으로 COM 개체를 사용하면 클라이언트와 동일한 프로세스 내에서 실행되기 때문에 클라이언트와 같은 권한을 갖게 됩니다. 간단히 말해, 내 프로세스가 COM Server가 구현되어 있는 DLL을 로드하여 사용하는 것과 같습니다.

  2. Out-of-Process Server (LocalServer) 방식은 COM Server가 EXE 형태로 구현되어 있습니다. 이 방식은 클라이언트와, 별도의 프로세스에서 실행되기 때문에 COM 서버 프로세스의 보안 설정에 따라 권한이 달라지게 됩니다. spoolsv.exe나, msiserver.exe처럼 고유한 이름을 가진 프로세스 내에 COM 서버가 구현이 되어있는 경우도 있지만, 대부분은 Windows에서 서비스 호스팅을 담당하는 svchost.exe에서 COM 서버가 구현되어 있는 DLL을 로드하여 사용합니다.

    해당 방식은 클라이언트가 다른 프로세스로 요청을 보내는 것이기 때문에 Out-of-Process Server에서는 Proxy와 Stub 구조를 사용합니다. 해당 구조는 뒤에서 설명 드리겠습니다.

  3. Remote Server 방식은 클라이언트 애플리케이션과 다른 컴퓨터에서 실행되며, DCOM (Distributed COM) 을 이용해 네트워크를 통한 원격 호출을 지원합니다. 본 포스트에서는 자세히 다루지 않겠습니다.

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

그런데 여기서 잠깐, Out-of-Process Server와 Remote Server를 아무런 검증 없이 모든 프로세스가 호출할 수 있다면 어떻게 될까요?

만약 낮은 권한의 사용자도 SYSTEM 권한으로 동작하는 COM Server를 마음대로 실행하고, 인터페이스에서 제공하는 메소드를 호출할 수 있다면, 이는 심각한 권한 상승과 원격 코드 실행으로 이어질 수 있습니다. 이를 방지하기 위해 COM은 Launch 권한과 Access 권한이라는 두 가지 중요한 보안 체크를 수행합니다.

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

Launch Permission은 클라이언트 애플리케이션이 해당 COM 서버를 새롭게 실행 (Activation) 할 수 있는 지를 판단합니다.

예시로 어떤 사용자가 특정 COM Class의 대한 Launch Permission을 갖고 있다면, CoCreateInstance와 같은 함수를 통해 해당 COM Server를 새롭게 실행할 수 있습니다.

그렇다면 Access 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은 이미 실행 중인 COM 서버에 대해, 클라이언트가 해당 COM 객체의 메소드를 호출할 수 있는 지를 판단합니다. 해당 권한을 갖고 있다면, 이미 실행되고 있는 COM 서버에 접근하여 객체가 노출한 인터페이스의 메소드 호출 권한을 얻게 됩니다.

반대로 Launch Permission만 있고 Access Permission이 없다면, COM 서버는 실행되지만, 객체에 접근하여 메소드를 호출하는 것은 불가능합니다.

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.

COM 실행 권한만 있고 접근 권한은 없어 COM 객체 사용이 제한되는 상황을 표현한 밈.


2.4 COM Data Transfer

자 이제, 우리는 지금까지 COM의 Server 모델과 보안 모델에 대해 간단하게 알아보았습니다. 이번에는 COM이 어떻게 데이터를 주고 받는지 알아보겠습니다.

COM은 앞서 설명한 In-Process Server 방식을 사용해 단일 프로세스 내에서도 객체 간 통신을 가능하게 하지만, Out-of-Process Server나, Remote Server의 경우에는 프로세스나 시스템이 서로 다르기 때문에, 데이터 전달을 위해 추가적인 과정이 필요합니다. 바로 이 때 사용되는 것이 Proxy/Stub과 마샬링/언마샬링 입니다.

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.

COM 프록시, COM 스텁, 마샬링 개념 아이콘.

2.4.1 Proxy/Stub

ProxyStub은 원격으로 COM 개체 호출을 가능하게 만들어주는 구조입니다. Proxy는 클라이언트 측에서 대리자 역할을 수행하고, Stub은 서버 측에서 수신자 역할을 맡고 있습니다. 예를 들어, 클라이언트가 다른 프로세스에 있는 COM 객체의 메소드를 호출할 때 직접적으로 원격 객체에 대한 호출이 불가능하기 때문에, 대신 클라이언트 프로세스 내에 존재하는 Proxy 객체를 호출하게 됩니다. 이 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?

COM 프록시와 스텁 간의 데이터 마샬링 및 언마샬링 과정.

2.4.2 Marshalling/Unmarshalling

마샬링이란, 프로세스의 메모리 공간에 존재하는 객체 데이터를 다른 프로세스에서도 사용할 수 있도록 직렬화하는 과정입니다. COM에서는 이 과정을 통해 호출하고자 하는 인터페이스와 인자를 IDL (Interface Definition Language) 에 따라 정의된 규칙에 따라 변환하고, 이를 RPC 기반으로 전송합니다.

일반적으로 직렬화는 객체의 데이터를 단순히 바이트 스트림으로 변환하는 과정이라고 이해되지만, COM에서의 마샬링은 훨씬 더 넓은 개념입니다. COM에서의 마샬링은 기본적으로 메소드의 기본 호출 인자 (int, BSTR 등) 를 포함하여, IUnknown *, IInspectable *과 같은 인터페이스 포인터 등을 하나의 호출 컨텍스트로 묶어 전송합니다.

반대로 언마샬링은 이렇게 마샬링된 데이터를 원본 데이터로 바꿔, 서버 측에서 실제 객체 데이터처럼 사용할 수 있도록 만드는 과정입니다. 다시 한번 강조하지만, 언마샬링된 데이터는 마샬링 이전의 데이터와 동일한 구조여야 하며, 이를 위해 마샬링과 언마샬링 모두 IDL에 정의된 데이터 형식 및 순서를 정확히 따라야 합니다. 만약 이 규칙을 따르지 않으면, RPC 런타임에서 예외가 발생하거나 데이터 해석에 실패하게 됩니다.

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.

클라이언트, IDL 인터페이스 정의, 원격 COM 객체 간의 관계도.

2.5 COM Threading Model

마지막으로 COM의 Threading Model에 대해 간단히 살펴본 뒤, 본 챕터를 마무리하겠습니다.

COM은 Apartment라는 개념을 도입해 스레드 간의 실행 컨텍스트를 정의하고, 이를 기반으로 스레드 동기화 방식을 제어합니다. 이 Apartment는 하나의 프로세스 내에서 COM 객체가 속하게 되는 논리적인 실행 단위로, 해당 객체가 어떤 방식으로 호출되고 실행될 지를 결정짓는 중요한 기준입니다.

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.

COM의 STA (단일 스레드) 모델과 MTA (다중 스레드) 모델 비교 다이어그램.

2.5.1 STA (Single-Threaded Apartment)

STA (Single-Threaded Apartment) 는 한 Apartment에 오직 하나의 스레드만 소속될 수 있는 모델로, STA에 속해있는 COM 개체는 Apartment에 속한 하나의 스레드에서만 메소드 호출을 받을 수 있습니다. 또한 COM Runtime이 메시지 큐를 이용하여 스레드를 동기화하고 순차적으로 처리하기 때문에 멀티 스레딩 환경에서 동기화 문제를 보장해 줍니다. 즉, COM 인터페이스 구현에서 스레드 동기화를 제공할 필요가 없습니다.

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.

COM 단일 스레드 아파트먼트(STA) 모델: 하나의 스레드만 COM 객체에 접근, 나머지는 대기.

2.5.2 MTA (Multi-Threaded Apartment)

MTA (Multi-Threaded Apartment) 는 한 Apartment에 여러 스레드가 소속될 수 있는 모델입니다. 이 모델에 소속되어 있는 COM 객체는 여러 스레드로부터의 동시 호출을 허용하기 때문에, COM Server 측에서 객체에 대한 스레드 동기화를 반드시 제공해야 합니다. 만약 동기화를 제공하지 않거나, 잘못 구현했을 경우 이는 Race Condition과 같은 취약점으로 이어질 수 있습니다.

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.

COM 다중 스레드 아파트먼트(MTA) 모델: 여러 스레드가 동시에 COM 객체에 접근.

3. Learning from the Past

3.1 Prior Work

지금까지 COM에 대한 기본적인 Background을 알아봤습니다. 이제 우리가 처음 도전해보는 COM에서 취약점 발견을 하기 위해 어떤 과정을 거쳤는지 알아보겠습니다. 우리는 본격적인 취약점 발견에 앞서, 기존에 COM 취약점과 관련된 선행 연구를 먼저 살펴보기로 했습니다. 우리는 열심히 학술 자료를 찾아보았고, 눈에 띄인 COM 취약점 관련 연구가 두 개가 있었습니다. 바로 USENIX Security에 게재된 Detecting Union Type Confusion in Component Object Model (이하 COMFUSION)COMRace: Detecting Data Race Vulnerabilities in COM Objects 입니다.

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.

USENIX 보안 학회에 발표된 COMFUSION과 COMRace 논문 표지


3.2.1 COMFUSION

COMFUSION 논문은 메모리 공간을 공유하는 union 타입과, 다양한 데이터 타입을 저장할 수 있는 VARIANT 구조체가 COM에서 사용될 때, Type Confusion으로 인해 심각한 보안 취약점이 발생할 수 있다는 점을 지적합니다. 여기서 union 타입은 비교적 익숙하실 수 있지만, VARIANT 구조체는 다소 생소하게 느껴질 수도 있습니다.

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.

union 타입을 잘 아는 강한 도지(Swole Doge)와 VARIANT 타입을 잘 모르는 약한 도지(Cheems) 비교 밈

VARIANTvt라는 필드에 데이터의 실제 타입을 명시하고, 그에 따라 해석되어야 할 데이터 필드를 함께 포함하는 구조체입니다. 클라이언트는 다양한 타입을 지원하는 VARIANT 구조체를 안전하게 사용하기 위해, 반드시 vt 값을 먼저 확인한 뒤, 해당 타입에 맞는 데이터 필드를 읽어야 합니다. 하지만 이 과정이 누락되거나 잘못 구현될 경우, Type Confusion이 발생될 수 있습니다. 예시로 vt에 char * 타입을 나타내는 VT_LPSTR을 저장하고, 데이터 필드에 일반 정수를 저장하면, 정수를 포인터로 인식하여 Arbitrary Address Read가 발생하게 됩니다.

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.

어? 그런데 Out-of-Process Server나 Remote Server로 요청을 보낼 때는, 데이터가 마샬링/언마샬링 되기 때문에 항상 데이터가 올바르게 해석되지 않나요?

정답은 아니오입니다. 마샬링/언마샬링은 IDL에 정의된 규칙에 따라 해석되기 때문에 VARIANT 구조체 내부 데이터를 직접적으로 검사하지는 않습니다. 즉, COM Server에서 union 및 VARIANT 구조체에 대한 검증이 제대로 이루어지지 않는다면, Type Confusion 취약점이 발생될 수 있습니다.

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.

POV: VARIANT 타입 체크를 건너뛸 때' 문구와 불타는 집 배경의 소녀(재앙의 소녀) 밈.


3.2.2 COMRace

COMRace 논문은 MTA 환경에서 COM 객체에서 발생될 수 있는 Race Condition 취약점을 설명하며, 이를 감지하는 특별한 도구를 통해 여러 개의 취약점을 발견하였습니다. 해당 연구에서 흥미로운 점은, 찾은 취약점의 일부가 특별한 패턴을 보인다는 것이었습니다. 바로 아래와 같은 패턴입니다.

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.

USENIX 보안 학회 발표 논문 'COMRACE: COM 객체의 데이터 경쟁 취약점 탐지' 표지.

이 코드는, CVE-2020-1146가 발생한 지점입니다. 코드를 자세히 보면 put_AuthData 메소드가 this 객체를 지닌 채로 HSTRING 클래스의 Set 함수를 실행합니다.

Set 함수에서는 this에 저장된 HSTRING 필드를, WindowsDeleteString 함수를 통해 해제하고, WindowsDuplicateString 함수를 통해 다시 할당합니다.

해당 코드는 아무런 문제가 없는 거처럼 보이지만, MTA 환경에서는 두 스레드가 동시에 put_AuthData 메소드에 진입하고 WindowsDeleteString 함수를 호출하여, Double-Free Bug가 발생될 수 있습니다.

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 …

우리는 두 연구를 통해 union type과 VARIANT 구조체에서 Type Confusion이 발생할 수 있다는 점과, MTA 모델에서 객체 간의 Race Condition이 발생될 수 있다는 점을 알게 되었습니다. 우리는 이 두 연구가 모두 매우 훌륭한 연구라고 생각합니다.

그렇게 좋은 인사이트를 얻었다고 넘어가려던 찰나, 우리는 문득 해당 취약점들의 발견 시점을 확인하게 되었고, 흥미로운 사실을 알게 되었습니다. 두 연구에서 발견한 취약점은 30개 이상에 달했지만, 두 개의 취약점을 제외하고는 모두 2020년에 발견된 (CVE-2020-XXXXX) 취약점이라는 것을 알게 되었습니다.

또한 COMFUSION에서 제시한 구현체와 COMRace에서 제시한 구현체는, 당연하게도 모두 사람의 손을 거쳐야 했기 때문에, 혹시 선행 연구자들도 무언가 지나쳤던 부분이 있지 않을까? 라는 의문이 들기 시작했습니다.

그렇게 우리는, COM 버그 헌팅의 첫 시도로 Type Confusion과 Race Condition 이라는 두 키워드를 중심으로 새로운 여정을 시작하게 되었습니다. 그리고 그 여정의 끝에서, 예상치 못하게 발견한 취약점을, 소개할 예정입니다.

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

미리 이 버그 헌팅 여정의 끝을 스포하자면, 우리는 10개 이상의 취약점을 MSRC에 제보하였고 CVE를 획득 할 수 있었습니다.

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

다음은 우리가 소개할 총 4개의 버그입니다.

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

해당 버그는 Windows LxpSvc (Language Experience Service) 에서 발생한 Type Confusion 취약점입니다. 취약점은 내부 클래스인 DeviceLanguageManagerSetLanguageOperationState 메소드에서 발생합니다. SetLanguageOperationState 메소드는, 총 네 개의 인자를 받으며, 그중 네 번째 인자로 VARIANT 포인터를 전달 받습니다. [1]과 [2]에서 사용자가 입력한 VARIANTvt 필드와 데이터 필드를, v12 v12 + 0x8에 저장합니다. 이후 해당 데이터는SetLanguageOperationStateInRegistry 메소드의 세 번째 인자로 전달되어 사용됩니다.

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.

DeviceLanguageManager::SetLanguageOperationState 함수 코드: VARIANT 타입과 데이터를 가져와 하위 함수를 호출하는 부분.

SetLanguageOperationStateInRegistry 메소드에서는 [1]에서 a2가 0이면, [2]에서 data의 값을 사용자가 입력한 VARIANT의 데이터 필드로 저장합니다. 그리고 data의 주소를 SetRegValue 함수의 여섯 번째 인자로 사용합니다.

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].

그리고, data 값은 RegSetKeyValueW 함수의 다섯 번째 인자로 사용됩니다.

Then, the address of data is passed as the fifth argument to the SetRegValue function

SetLanguageOperationStateInRegistry 함수에서 처리된 데이터가 SetRegValue 함수로 전달되는 과정 다이어그램.

그렇다면 RegSetKeyValueW 함수는 어떤 함수일까요? RegSetKeyValueW 함수는, 지정한 레지스트리 키에 값을 생성하거나 수정할 때 사용됩니다.

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

RegSetKeyValueW Windows API 함수 정의 및 주요 매개변수(lpData, cbData) 설명.

즉, 여기서는 Status라는 레지스트리 키에 우리가 인자로 전달한 VARIANT의 데이터 필드를 4byte 만큼 적는 것이죠.

그런데 무언가 이상하지 않나요? 지금까지 우리가 봤던 코드에는 SetLanguageOperationStateInRegistry 메소드에서 a2가 0일 때, VARIANT의 데이터 타입을 검증하는 부분이 존재하지 않습니다. 그렇다면 만약, VARIANT의 데이터 타입이 BSTR과 같이, 포인터를 가리키는 값이면 어떻게 될까요?

바로 이 부분에서, Type Confusion이 발생하게 됩니다. Registry Key에는 0과 1 같은 정수 값이 적히는 것이 아닌, 데이터의 하위 4byte 주소가 적히게 됩니다.

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.

LxpSvc의 SetLanguageOperationStateInRegistry 함수 코드 일부: 사용자 VARIANT 값의 조건부 처리 및 SetRegValue 호출 부분.

해당 레지스트리 경로는, 일반 유저 권한에서 읽을 수 있기 때문에, 하위 4byte 주소를 유출할 수 있습니다.

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.

타입 혼란으로 인한 힙 주소 일부 유출(디버거/레지스트리)과 '괜찮아 울지마' 토이스토리 위로 밈.

개인적으로, 재미있는 취약점인 거 같습니다. 이 버그는 MSRC로부터 나중에 패치 될 것이라는 답변을 받았습니다.

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

다음 취약점은, Windows Update Stack에서 나온 Double Free Bug 입니다. 취약점은 내부 클래스인 FulfillmentDataInfoput_CrossGenSetId 메소드에서 발생합니다.

먼저 취약한 함수를 호출하기 위해서는, InstallControl 클래스의 CreateFulfillmentData 메소드를 호출하여 IFulfillmentDataInfo객체를 획득해야 합니다. 이 객체를 얻기 위한 조건은 매우 단순합니다. [1]에서 우리가 입력한 a2 문자열 길이가 12 인지 검사합니다. 조건을 만족할 경우 [2]에서 객체가 생성되어 클라이언트에게 반환됩니다.

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.

// Hidden C++ exception states: #wind=63
__int64 __fastcall WindowsUpdate::Internal::InstallControl::CreateFulfillmentData(
        WindowsUpdate::Internal::InstallControl *this,
        HSTRING a2,
        struct WindowsUpdate::Internal::IFulfillmentDataInfo **a3)
{
  HSTRING v5; // [rsp+38h] [rbp+10h] BYREF

  v5 = a2;
  if ( Utils::IsValidProductId(a2, a2) ) // [1] Verify that a2 is 0xC bytes 
    return Microsoft::WRL::Details::MakeAndInitialize<WindowsUpdate::Internal::FulfillmentDataInfo,WindowsUpdate::Internal::IFulfillmentDataInfo,HSTRING__ *>(
             a3,
             &v5); // [2] Return IFulfillmentDataInfo object
  *a3 = 0LL;
  return 2147942487LL;
}

이제, 획득한 FulfillmentDataInfo 객체의 put_CrossGenSetId 메소드를 확인해 봅시다. [1]에서는 this + 200에 저장된 HSTRING 변수를 v3에 저장합니다. 그리고, 이를 [2]에서 해제 합니다. 그런데, 이 패턴을 어디서 많이 보신 거 같지 않나요?

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?

__int64 __fastcall WindowsUpdate::Internal::FulfillmentDataInfo::put_CrossGenSetId(
        WindowsUpdate::Internal::FulfillmentDataInfo *this,
        HSTRING a2)
{
  unsigned int v2; // ebx
  HSTRING *v3; // rdi

  v2 = 0;
  v3 = (HSTRING *)((char *)this + 200); // [1] Get HSTRING from this + 200
  if ( !a2 || a2 != *v3 )
  {
    WindowsDeleteString(*v3); // [2] Free HSTRING
    *v3 = 0LL;
    return (unsigned int)WindowsDuplicateString(a2, v3);
  }
  return v2;
}

바로 COMRace에서 소개한 CVE-2020-1146과 매우 유사합니다. 5년이 지난 지금까지도 여전히 동일한 패턴의 취약점이 남아있었습니다. 이로써 우리가 선행 연구에서 느꼈던 의구심이 확신으로 바뀌는 순간이었습니다.

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.

Windows::System::Internal::SignInContext::put_AuthData(*this, HSTRING a2) {
    Microsoft::WRL::Wrappers::HString::Set(this + 15, &a2);
}

__int64 HString::Set(HSTRING *newString, HSTRING *a2) {
    unsigned int v2;
    v2 = 0;
    if(!*a2 || *a2 != *newString) {
        WindowsDeleteString(*newString); // Delete
        *newString = 0;
        v2 = WindowsDuplicateString(*a2, newString);
    }
}
또 COMRACE네, 같은 패턴이야' 문구와 놀란 표정의 피카츄 밈.

4.1.3 CVE-2024-49095: Use-After-Free in PrintWorkflowUserSvc

우리는 Race Condition 취약점을 계속해서 찾기 위해, Windows Print Workflow를 담당하는 PrintWorkflowUserSvc로 여정을 떠났습니다. 우리는 이 서비스에서 다양한 취약점을 발견할 수 있었습니다. 그 중 두 개의 취약점에 대해 소개하겠습니다.

첫 번째 취약점은 Use-After-Free 취약점입니다. 취약점은 CWorkflowSession 객체의 SetPrintTicket 메소드에서 발생합니다.

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.

*((_QWORD *)this + 10) 에는 이전에 SetPrintTicket 호출에서 할당된 힙 주소가 저장됩니다. 하지만 CWorkflowSession COM 인터페이스는 MTA 모델에 속해있기 때문에 서로 다른 스레드에 의해 동시에 접근할 수 있습니다. 따라서 적절한 락 메커니즘이 존재하지 않으므로 레이스 컨디션을 통해 다음과 같은 형태로 Use-After-Free 혹은 Double Free가 발생할 수 있습니다.

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.

CWorkflowSession::SetPrintTicket 함수 코드: 힙 메모리 해제 및 재할당, 데이터 복사 로직 강조.SetPrintTicket 메소드에서의 Use-After-Free 및 Double-Free 발생 시나리오 비교 다이어그램


몇 가지 제약 사항으로 인해 해당 취약점은 성공적으로 익스플로잇 하지 못했으나, 몇 가지 아이디어를 사용하면 개념적으로 익스플로잇이 가능해 보입니다.

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.

먼저 ASLR bypass 입니다. ASLR의 경우 공격자가 컨트롤 하는 프로세스와 상위 권한의 프로세스의 동일한 DLL은 같은 가상주소에 매핑 되기 때문에 적절한 수준의 주소 정보를 알아 낼 수 있습니다. 이러한 아이디어는 IPC 메커니즘을 통한 LPE, SBX Escape에서 가장 기본이 되는 아이디어입니다.

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.

두 msedge.exe 프로세스에서 ntdll.dll이 동일한 기준 주소에 매핑된 속성 창 (ASLR 우회 설명용)


두 번째로는 힙 주소 유출입니다.CWorkflowSession에는 다른 메소드가 노출되어있고 이중 this + 10 주소에 접근하는 다른 메소드가 있는지 확인해 보았습니다. 그리고 마침내 다음과 같은 함수를 찾았습니다.

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.

CWorkflowSession::GetPrintTicket 함수 코드: this + 10 주소의 데이터를 출력 버퍼로 복사하는 부분 강조

즉 이미 해제된 this + 10 에 있는 값을 Output Buffer에 완전히 안정적인 크기로 복사해주는 것을 알 수 있습니다. 즉 다음과 같은 시나리오로 유용한 힙 주소를 가져 올 수 있습니다.

먼저 총 세 개의 스레드가 필요합니다. 각각 그림과 같이 COM Interface를 호출하게 됩니다. 스레드 2에서 만약 this + 10에 값이 존재하면 SetPrintTicket에서 이미 존재하는 힙을 해제하게 됩니다. 이후 스레드 3에서 정상 할당이 진행되기 전에 vftable을 가지거나 힙 주소가 존재하는 객체를 할당하게 된다면 스레드 1에서 GetPrintTiket을 통해 this + 10 주소를 참조하기 때문에 Victim 객체의 값을 성공적으로 공격자에게 리턴 할 수 있습니다. 공격자는 힙이 할당되는 사이즈 필드를 SetPrintTicket에서 완전히 제어할 수 있기 때문에 Victim 객체의 크기 제약이 없어, Race Windows 내부에서 해제된 hole에 넣는 것은 매우 확정적으로 수행됩니다.

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.

힙 주소 유출' 표제와 함께 세 개의 스레드를 이용한 힙 데이터 유출 시나리오 다이어그램.


다음으로 RIP control은 어떻게 수행될 수 있을까요? 앞에서 설명한 힙 주소 유출 방법과 비슷한 방식으로 Corrupted된 vftable을 만들어 낼 수 있습니다.

총 세 개의 스레드가 사용됩니다. 앞선 방식과는 다르게 두 개의 스레드는 SetPrintTicket을 통해 레이스를 시작합니다. 먼저 this + 10 이 null이 아니라면 힙을 해제하게 됩니다. 이 사이에 임의의 객체를 할당하여 해당 Hole에 들어가게 합니다. 다음으로 공격자가 완전히 컨트롤 가능한 값과 사이즈로 해당 Victim 객체를 덮을 수 있습니다. 해당 객체에 vftable이 존재한다면 성공적으로 RIP를 조작할 수 있게 됩니다.

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.

RIP 컨트롤' 표제와 함께 세 개의 스레드를 이용한 힙 조작 시나리오 다이어그램

이러한 아이디어에도 불구하고 우리는 완전히 동작하는 익스플로잇을 개발하지 못했습니다. 우리가 마주한 몇 가지 문제는 다음과 같습니다.

만약 Race Condition을 통해 해제된 위치에 Victim 객체를 할당하고 성공적으로 덮어썼다고 가정하여도 Double Free로 인해 발생하는 메모리 충돌을 피하기 매우 힘들고, Race Window가 매우 작아 정밀한 컨트롤이 매우 힘들었습니다.

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

이제 마지막 취약점을 소개하고자 합니다. 이전 Case와 마찬가지로 PrintWorkflowUserSvc에서 발생합니다. IPrintSupportSourceSession 클래스의 RequestSpoolingHandlesForWrite 메소드를 살펴보겠습니다. 해당 메소드는 a2부터 a7까지 총 6개의 인자를 받으며, 모든 인자가 결과 값이 반환되는 Output Parameter로 사용됩니다. 여러분들은 버그를 찾을 때 Input Parameter가 하나도 존재하지 않는 함수를 분석하시나요? 우리의 대답 또한 아니오였습니다. 물론, 이 취약점을 찾기 전까지는 말입니다.

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.

IWorkflowSourceSession 인터페이스 IDL: 다수의 출력 파라미터를 가진 Proc14 메소드 강조 표시.

이 함수는 [1]에서 this에 저장되어 있는, WorkflowSessionCommon의 vftable을 가리키는 주소를 v9에 저장합니다. 그리고, 해당 주소를 인자로 하여, [2]에서 WorkflowSessionCommon 클래스의 RequestSpoolingHandlesForWrite 메소드를 호출합니다.

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].

IPrintSupportSourceSession::RequestSpoolingHandlesForWrite 함수 코드: WorkflowSessionCommon의 같은 이름 함수를 호출하는 래퍼.

이 메소드에서는 [1]에서 클라이언트의 PID를 가져와 [2]에서 클라이언트 프로세스의 핸들을 얻습니다. 이후, [3]에서 v22this를 참조하여 어떤 값을 저장하고, [4]에서 DuplicateHandle 함수를 호출합니다. 어라? DuplicateHandle 함수요?

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.

WorkflowSessionCommon::RequestSpoolingHandlesForWrite 함수 코드: DuplicateHandle을 호출하여 프로세스 핸들을 복제하는 로직.

DuplicateHandle 함수는, 객체의 핸들을 복제하는 Windows API 입니다. 함수 인자에 대한 설명을 요약하자면, 첫 번째와 두 번째 인자에 복제할 핸들을 소유한 프로세스 핸들과, 복제할 핸들이 들어갑니다. 그리고 세 번째에는 복제할 핸들을 받는 프로세스의 핸들이 들어가며 네 번째 인자에는 복제한 핸들의 값이 반환 됩니다. MSDN 설명에 의하면, PrintWorkflowUserSvc의 v22 핸들을, 클라이언트 프로세스의 복사하는 것을 뜻합니다. COM에서 이러한 패턴이 가끔 발견되기는 하지만, 보통은 콜백이나 메소드 구현을 위해 이벤트 핸들을 클라이언트 프로세스에 반환하고는 합니다.

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.

DuplicateHandle API 인수 설명: v22(-1, 현재 프로세스 핸들)가 복제될 소스 핸들로 사용됨을 시각화.

v22 값이 실제로 어디서 세팅되는것은 간단한 리버스 엔지니어링을 통해 확인 할 수 있었습니다.

해당 값은 PrintSupportSession 클래스의 생성자에서 초기화되는 것으로 확인됐습니다. v22-1로 초기화됩니다. Windows에서 -1이라는 핸들은 자기 자신을 가리키는 의사 핸들 값을 뜻합니다.

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.

PrintSupportSession 생성자 C++ 코드: 객체 멤버 v22 (this + 0xB0)를 -1로 초기화하는 부분.

즉, DuplicateHandle 함수에서 자신의 프로세스 핸들을, 클라이언트 프로세스에게 복제해주는 것을 의미합니다. 그렇다는 것은, PrintWorkflowUserSvc 보다 낮은 권한을 가진 클라이언트가 이 메소드를 호출하면, PrintWorkflowUserSvc의 핸들을 자신의 프로세스로 복제할 수 있습니다.

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.

어떤 프로세스가 PrintWorkflowUserSvc에 접근할 수 있는지, 이 서비스의 Access Permission을 확인해 봤습니다. 확인해본 결과, 이 COM 객체는 모든 UWP 앱과, 특정 Capability를 가진 Low Privileged AppContainer에서도 접근할 수 있다는 것을 확인했습니다. 이 뜻은 Sandox된 프로세스에서 PrintWorkflowUserSvc의 프로세스 핸들을 자신에게 복제할 수 있음을 의미합니다.

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.

COM 객체 ACL 설정: 'ALL APPLICATION PACKAGES' 등 AppContainer에 실행/활성화 권한 부여됨.

이 취약점은 정말 완벽한 로직버그 입니다. 권한 상승을 위해 전에 설명했던 복잡한 Use-After-Free 취약점을 익스플로잇할 필요가 전혀 없습니다. 이제 우리는, 복제된 핸들을 이용해 PrintWorkflowUserSvc 메모리에 쉘코드를 작성하고, 이를 CreateRemoteThread API를 이용해 실행시킬 것입니다.

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.

when we tried a race condition exploit

4.4.3 Exploit Demo

취약점을 시연하기 위해 Adobe Acrobat 샌드박스 내에서 실행되는 렌더러에서 임의 코드 실행을 수행할 수 있다고 가정하고, 쉘코드를 주입하여 Sandbox Escape를 수행했습니다. 이를 통해 AppContainer에서 Medium으로의 권한 상승에 성공하였습니다.

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.

놀랍게도 이 버그는, MSRC로부터 “Exploit Less Likely” 판정을 받았습니다. 우리는 아직까지 MSRC가 왜 이런 판단을 내렸는지 잘 모르겠습니다.

Surprisingly, the bug was declared "Exploit Less Likelly." We still don't know why the MSRC made this decision :D

Microsoft 보안 업데이트 가이드의 '악용 가능성 평가: 가능성 낮음(Exploitation Less Likely)' 화면.

5. Results

마지막으로 결론입니다.

최종적으로 우리는 2024년부터 최근까지 Type Confusion과 Race Condition 취약점을 포함해 10건 이상의 취약점을 Microsoft에 제보했고, CVE로 인정받을 수 있었습니다.

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.

연구팀이 보고한 CVE 및 취약 사례 목록표 (CVE ID, 컴포넌트, 영향도 명시).

이러한 성과를 낼 수 있었던 것은 선행 연구자들의 정보 공유와, oleviewdotnet이라는 매우 훌륭한 도구 덕분이었습니다. 특히 COM 취약점 분석을 시작할 수 있게 기반을 마련해주신, 선행 연구자들과 James Forshaw에게 깊은 감사의 말씀을 드립니다.

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.

COMRACE 논문과 oleviewdotnet GitHub 저장소 화면 (선행 연구 및 도구 감사 표시).

마지막으로 이번 여정을 마치면서 들었던 생각을 공유하고 마무리하겠습니다.

새로운 분야에서 새로운 취약점을 발견하기 위해서는 선행 연구에 대한 철저한 분석이 필수적이라고 생각합니다. 단순히 기존 연구를 따라가는 데 그치지 않고, 그 안에서 미처 다루지 못한 부분이나 확장 가능한 가능성에 주목하는 것이 중요하다는 점을 말씀드리고 싶습니다.

아쉬운 점도 있었습니다. 특히 여러 개의 Race Condition 취약점을 발견했음에도, 완전한 익스플로잇까지는 이뤄내지 못한 점은 앞으로 우리가 더 보완해야 할 부분이라고 생각합니다.

우리의 Future Work는, 이러한 취약점 패턴을 보다 체계적으로 탐지할 수 있는 도구를 개발하고, 이를 통해 더 많은 취약점을 찾아내는 것입니다. 우리의 여정은 앞으로도 계속될 것입니다.

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 😄

엔키화이트햇

엔키화이트햇

ENKI Whitehat
ENKI Whitehat

오펜시브 시큐리티 전문 기업, 공격자 관점으로 깊이가 다른 보안을 제시합니다.

오펜시브 시큐리티 전문 기업, 공격자 관점으로 깊이가 다른 보안을 제시합니다.

침해사고 발생 전,
지금 대비하세요

빈틈없는 보안 설계의 시작, NO.1 화이트 해커의 노하우로부터

침해사고 발생 전,
지금 대비하세요

빈틈없는 보안 설계의 시작, NO.1 화이트 해커의 노하우로부터

침해사고 발생 전,
지금 대비하세요

빈틈없는 보안 설계의 시작,
NO.1 화이트 해커의 노하우로부터

공격자 관점의 깊이가 다른 보안을 제시합니다.

Contact

biz@enki.co.kr

02-402-1337

서울특별시 송파구 송파대로 167
(테라타워 B동 1214~1217호)

ENKI WhiteHat Co., Ltd.

Copyright © 2025. All rights reserved.

공격자 관점의 깊이가 다른 보안을 제시합니다.

Contact

biz@enki.co.kr

02-402-1337

서울특별시 송파구 송파대로 167
(테라타워 B동 1214~1217호)

ENKI WhiteHat Co., Ltd.

Copyright © 2025. All rights reserved.