[하루한줄] CVE-2026-50343: Windows InstallService의 Improper Privilege Management로 인한 LPE 취약점
URL
Target
- 2026년 7월 14일 Microsoft 누적 업데이트(KB5101650) 이전 빌드
Explain
InstallService는 Windows 서비스 관리자인 SCM이 LocalSystem 권한으로 시작한 svchost.exe 안에서 동작하는 DLL 기반 서비스입니다. 이때 svchost.exe는 %SystemRoot%\System32\InstallService.dll을 로드한 뒤 DLL의 ServiceMain 함수를 호출해 서비스를 시작합니다. 따라서 InstallService.dll 내부에서 CLSCTX_INPROC_SERVER 방식으로 활성화한 COM 플러그인 DLL도 별도 프로세스가 아닌 동일한 SYSTEM svchost.exe 내부에 로드되며, 그 코드 역시 SYSTEM 권한으로 실행됩니다.
CVE-2026-50343(Dark Elevator)은 ①Medium IL 일반 사용자가 SYSTEM InstallService가 활성화할 COM 클래스의 CLSID를 지정할 수 있는 레지스트리 권한 문제와 ②해당 COM 클래스의 DLL 경로에 일반 사용자가 파일을 배치할 수 있는 문제를 연결해, 공격자 DLL을 SYSTEM InstallService 프로세스에 로드하는 LPE 취약점입니다.
이를 통해 공격자는 관리자 권한이 필요한 새로운 HKLM COM 클래스를 등록할 필요 없이, Windows에 이미 등록된 CrossDevice COM 클래스를 재사용합니다. CrossDevice CLSID의 InProcServer32가 가리키는 경로에 공격자 DLL을 배치한 뒤, InstallService가 해당 CLSID를 플러그인으로 활성화하도록 유도할 수 있습니다.
Root Cause
이 취약점은 서로 독립적인 두 결함이 결합되면서 발생합니다.
결함 ①. 일반 사용자에게 허용된 StaticPluginMap Write 권한
먼저 InstallService는 작업 요청에 포함된 플러그인 ID를 이용해 어떤 플러그인을 활성화할지 결정합니다. 그리고 요청은 아래 경로를 통해 핵심 플러그인 처리 함수에 도달합니다.
InstallServiceControl::CreateInstallServiceWork
→ InstallQueue2::CreateWork
→ InstallQueue2::CreateWorkForCatalogItem
→ CreateInstallServiceWorkByPlugin
중간 함수들은 작업을 분배하는 역할을 하고, 중요한 부분은 PluginHelpers::IsPluginAvailable부터입니다. PluginHelpers::IsPluginAvailable 함수는 WU, XVC, ChainedWork 같은 내장 ID면 즉시 허용하고, 그 외 ID는 StaticPluginMap에 대응하는 값이 있는 경우에 한해 사용 가능한 플러그인으로 판단합니다.
if (pluginId == L"WU" ||
pluginId == L"XVC" ||
pluginId == L"ChainedWork")
{
return true;
}
if (!PluginHelpers::GetPluginFromStaticMap(pluginId).empty())
{
return true;
}
이때 StaticPluginMap은 플러그인 ID와 실제로 활성화할 COM / WinRT 클래스를 연결하는 레지스트리 키입니다.
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\InstallService\State\StaticPluginMap
각 레지스트리 값 이름은 플러그인 ID로 사용되고, 값 데이터에는 COM 클래스의 CLSID 혹은 WinRT 클래스 이름이 저장됩니다. GetPluginFromStaticMap은 이 키의 값들을 검색하고, 요청된 pluginId와 이름이 같은 항목의 데이터를 반환합니다.
for (auto const& entry : Registry::Enumerate<winrt::hstring>(staticPluginMap))
{
if (entry.name == pluginId)
return entry.value;
}
return {};
문제는 상위 키 InstallService\State에 다음 ACE가 적용되어 있다는 점입니다.
(A;CI;GRGW;;;IU)
ACE를 확인해보면 IU는 NT AUTHORITY\INTERACTIVE, GRGW는 Generic Read/Write, CI는 해당 권한이 하위 레지스트리 키에 상속됨을 나타냅니다.
즉 해석해보면 INTERACTIVE 그룹에 레지스트리 GRGW를 제공하고 있다는 것인데, 문제는 데스크톱이나 원격 데스크톱으로 로그인한 일반 사용자의 액세스 토큰에는 INTERACTIVE SID가 포함된다는 것입니다. 따라서 해당 사용자는 관리자 권한 없이 Medium IL 프로세스에서 StaticPluginMap 값을 수정할 수 있게 됩니다.
VRStaticMap REG_SZ {E9F83CF2-E0C0-4CA7-AF01-E90C70BEF496}
예시로 PoC가 StaticPluginMap에 추가한 값을 확인해보면, VRStaticMap(공격자가 정한 플러그인 ID)은 InstallService에 전달할 플러그인 ID고, 값 데이터의 GUID는 서비스가 활성화할 CrossDevice COM 클래스의 CLSID인 것을 확인할 수 있습니다.
이후 InstallService가 요청 속성에서 플러그인 ID를 전달받으면 먼저 PluginHelpers::IsPluginAvailable에서 해당 플러그인을 사용할 수 있는지 확인합니다. 그리고 WU, XVC, ChainedWork와 같은 내장 플러그인 ID가 아니라면 GetPluginFromStaticMap을 호출합니다. 여기서는 값 데이터가 유효한 CLSID나 WinRT 클래스 이름인지 검사하지 않고, 대응하는 레지스트리 값이 존재하는지만 확인합니다.
if (pluginId == L"WU" ||
pluginId == L"XVC" ||
pluginId == L"ChainedWork")
{
return true;
}
mappedPlugin = PluginHelpers::GetPluginFromStaticMap(pluginId);
return mappedPlugin != nullptr;
공격자가 지정한 VRStaticMap의 경우 내장 플러그인 ID는 아니지만, StaticPluginMap에 대응하는 값이 존재하기 때문에 사용 가능한 플러그인으로 판단됩니다. 이 검사를 통과하면 CreateInstallServiceWorkByPlugin은 PluginHelpers::ActivatePlugin을 호출합니다. ActivatePlugin은 실제로 활성화할 클래스를 확인하기 위해 GetPluginFromStaticMap을 다시 호출합니다.
mappedPlugin = PluginHelpers::GetPluginFromStaticMap(pluginId);
if (mappedPlugin)
{
GUID clsid = {};
if (IIDFromString(mappedPlugin.c_str(), &clsid) >= 0)
{
return CoCreateInstance(
clsid, // StaticPluginMap에서 가져온 COM 클래스
NULL,
CLSCTX_INPROC_SERVER, // InprocServer32 DLL을 InstallService 프로세스에 로드
IID_IInstallServicePlugin); // 생성된 객체에 요청할 플러그인 인터페이스
}
return ActivateWinRTPlugin(mappedPlugin);
}
첫 번째 조회에서 IsPluginAvailable은 해당 플러그인 ID의 매핑이 존재하는지만 확인합니다. 이후 ActivatePlugin은 같은 값을 다시 조회하고, 값 데이터에 저장된 CLSID 또는 WinRT 클래스 이름을 실제 활성화 대상으로 사용합니다. 그리고 IIDFromString이 문자열을 GUID로 변환하는 데 성공하면 해당 GUID를 COM 클래스의 CLSID로 사용하고, 실패하면 WinRT 런타임 클래스 이름으로 처리합니다.
즉 정리하면, 첫 번째 결함으로 공격자는 StaticPluginMap에 기록한 GUID를 InstallService가 CoCreateInstance의 CLSID로 사용하도록 만들 수 있게 됩니다. 하지만 해당 CLSID가 정상적인 보호 DLL을 가리킨다면 공격자 코드는 실행되지 않기 때문에, 실제 코드 실행을 위해 COM 서버 DLL까지 제어할 수 있어야 합니다.
결함 ②. 일반 사용자가 선점할 수 있는 CrossDevice COM 서버
첫 번째 결함을 실제 코드 실행으로 연결하려면, 일반 사용자가 해당 클래스를 구현하는 DLL을 배치할 수 있는 COM 클래스가 필요합니다. CrossDevice COM 클래스가 이 조건을 만족했습니다.
Windows는 DLL로 구현된 COM 클래스의 경로를 InProcServer32 하위 키의 기본값에 저장합니다. CrossDevice COM 클래스는 다음과 같이 등록되어 있었습니다.
HKLM\SOFTWARE\Classes\CLSID\
{E9F83CF2-E0C0-4CA7-AF01-E90C70BEF496}\InProcServer32
(Default) REG_EXPAND_SZ %PROGRAMDATA%\CrossDevice\CrossDevice.Streaming.Source.dll
여기서 문제는 등록된 CrossDevice.Streaming.Source.dll이 실제로 존재하지 않았고, 일반 사용자가 C:\ProgramData 아래에 CrossDevice 디렉터리를 만든 뒤 같은 이름의 DLL을 배치할 수 있었습니다. 즉, HKLM의 COM 등록은 보호되어 있었지만 그 등록이 가리키는 파일 경로가 보호되지 않았습니다.
이를 통해 공격자는 COM 등록을 수정하지 않고 다음 위치에 악성 DLL을 먼저 배치할 수 있습니다.
C:\ProgramData\CrossDevice\CrossDevice.Streaming.Source.dll
이후 첫 번째 결함을 통해 InstallService가 CrossDevice CLSID를 활성화하도록 만들면, COM은 기존 InProcServer32 등록을 따라 공격자가 배치한 DLL을 SYSTEM svchost.exe 내부에 로드할 수 있게 됩니다. 이를 통해 최종적으로 일반 사용자에서 NT AUTHORITY\SYSTEM으로 상승하는 공격 체인이 완성됩니다.
exploit
PoC는 공격을 준비하고 InstallService를 호출하는 실행 파일과, SYSTEM 권한으로 실행될 공격자 DLL로 구성됩니다. 공격자 DLL은 PoC 실행 파일에 리소스 형태로 포함되어 있으며, COM으로 활성화되면 InstallService가 요청하는 IInstallServicePlugin 객체를 반환합니다.
실제 실행 흐름은 아래와 같습니다.
1. CrossDevice 경로에 공격자 DLL 배치
먼저 PoC 실행 파일은 내부에 포함된 DLL을 추출해 CrossDevice COM 등록이 가리키는 위치에 기록합니다.
C:\ProgramData\CrossDevice\CrossDevice.Streaming.Source.dll
2. StaticPluginMap에 CLSID 매핑 추가
다음으로 VRStaticMap 플러그인 ID를 CrossDevice CLSID에 연결하는 값을 StaticPluginMap에 추가합니다.
VRStaticMap REG_SZ {E9F83CF2-E0C0-4CA7-AF01-E90C70BEF496}
PoC는 PluginList\VRStaticMap = 1도 함께 기록합니다. 이후 요청이 시작되면 IsPluginAvailable은 StaticPluginMap에서 VRStaticMap의 존재 여부를 확인하고, ActivatePlugin은 같은 값에 저장된 CrossDevice CLSID를 가져옵니다.
3. InstallService에 플러그인 작업 요청
PoC 실행 파일은 RoActivateInstance로 Windows.Internal.InstallService.Control.InstallServiceControl을 활성화한 뒤 IInstallServiceControl::CreateInstallServiceWork를 호출합니다.
hr = RoActivateInstance(cls, &obj);
hr = IInspectable_QueryInterface(
obj,
&IID_IInstallServiceControl,
(void **)&control);
hr = control->lpVtbl->CreateInstallServiceWork(
control,
correlationVector,
callerContext,
productId,
skuId,
propertiesJson,
mutableJson,
&out);
요청 속성의 FulfillmentPluginId에는 앞서 등록한 VRStaticMap을 지정합니다.
{
"SkipCatalogLookup": true,
"FulfillmentPluginId": "VRStaticMap",
"IsInstallServicePlugin": true,
"RequiresElevation": false,
"IsInteractive": false
}
4. SYSTEM 서비스가 매핑을 따라 DLL 로드
SYSTEM 권한의 InstallService는 StaticPluginMap에서 VRStaticMap을 발견하고 이를 사용 가능한 플러그인으로 받아들입니다. 이어서 ActivatePlugin은 값 데이터의 GUID를 CrossDevice CLSID로 변환하고 CoCreateInstance를 호출합니다.
HRESULT hr = CoCreateInstance(
clsid,
nullptr,
CLSCTX_INPROC_SERVER,
IID_IInstallServicePlugin,
reinterpret_cast<void **>(&plugin));
CLSCTX_INPROC_SERVER가 지정되어 있으므로 COM은 CrossDevice CLSID의 InProcServer32 등록을 조회합니다. 그 결과 1에서 배치한 CrossDevice.Streaming.Source.dll이 InstallService가 실행 중인 SYSTEM svchost.exe 안에 로드됩니다.
5. 공격자 DLL이 SYSTEM 셸 실행
COM은 공격자 DLL을 로드한 뒤 DllGetClassObject를 호출해 클래스 팩터리를 가져옵니다. 이어서 클래스 팩터리의 CreateInstance를 호출해 InstallService가 요청한 IInstallServicePlugin 객체를 생성합니다.
DllGetClassObject
→ IClassFactory 반환
→ IClassFactory::CreateInstance
→ StartShellOnce
→ IInstallServicePlugin 객체 반환
CreateInstance에서 호출된 StartShellOnce는 SYSTEM 프로세스 안에서 셸 실행 스레드를 시작합니다. 이 스레드는 named pipe를 표준 입출력으로 연결한 cmd.exe /Q를 실행합니다.
si.hStdInput = pipe_in;
si.hStdOutput = pipe_out;
si.hStdError = pipe_out;
CreateProcessW(
NULL,
L"cmd.exe /Q",
NULL,
NULL,
TRUE,
CREATE_NO_WINDOW,
NULL,
NULL,
&si,
&pi);
별도의 사용자 토큰을 지정하지 않았기 때문에 cmd.exe는 부모 프로세스인 SYSTEM svchost.exe의 토큰을 상속합니다. PoC 실행 파일은 named pipe에 연결해 사용자가 입력한 명령을 cmd.exe로 보내고, 실행 결과를 다시 화면에 출력합니다.

Patch
취약 빌드(26200.8457)와 수정 빌드(26200.8875) InstallService.dll diffing 결과, GetPluginFromStaticMap → IIDFromString → CoCreateInstance 흐름은 그대로 남아 있었습니다.
실제 변경점은 Windows 업데이트에 포함된 InstallService 구성 파일에서 나타납니다. 이 파일은 InstallService\State 레지스트리 키에 적용할 접근 권한을 정의하며, 수정 버전에서는 INTERACTIVE 그룹에 Write 권한을 부여하던 ACE가 제거됐습니다.
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\InstallService\State
WRP_REGKEY_STORE_AGENT_RW_SDDL
- (A;CI;GRGW;;;IU)
즉, 정상 플러그인 활성화 기능은 유지하면서 StaticPluginMap 하위 키가 일반 사용자 쓰기 권한을 상속하지 못하게 막았습니다. 이 변경으로 일반 사용자가 플러그인 ID와 CLSID의 매핑을 추가하는 첫 번째 공격 경로가 차단됩니다.
다만 2026년 7월 패치에는 CrossDevice의 사용자 선점 가능 COM 등록은 그대로 남아 있었습니다. 그리고 이후 Project Zero에서 공개한 CVE-2026-66804에 따르면, 수정 빌드 계열인 26200.8894에서 이 등록을 재활용할 수 있음이 확인되었습니다.
최종적으로 2026년 8월 패치를 확인해보면, InProcServer32가 일반 사용자가 DLL을 배치할 수 있던 %PROGRAMDATA% 경로에서 보호된 System32 경로로 변경된 것을 확인할 수 있습니다.
HKLM\SOFTWARE\Classes\CLSID\
{E9F83CF2-E0C0-4CA7-AF01-E90C70BEF496}\InProcServer32
- %PROGRAMDATA%\CrossDevice\CrossDevice.Streaming.Source.dll
+ %SystemRoot%\System32\CrossDeviceVirtualCameraSource.dll
Reference
https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-50343
https://projectzero.google/2026/09/windows-dangling-com.html
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.