[Research] 해키보이즈 막내들이 말아주는 Windows LPE 버그헌팅 체험기 Part 2 (KR)

Introduction
다들 새해 잘 보내셨을까요! 저의 두 번째 연구글이자 막내즈의 Part 2 글을 맡게 된 gongjae 입니다 😀 앞서 banda님이 Part 1을 야무지게 써주신 나머지 제가 설명할 게 남아있을까 하지만… 그래도 직접 찾은 취약점에 대한 설명과 함께 버그헌팅에 관심 있으신 분들에게 조금이나마 도움이 되고 싶네요..ㅎㅎ
오늘은 제가 Part 2에서 직접 찾은 Named Pipe에서 발생할 수 있는 LPE 취약점들을 살펴보며 ‘이런 방식으로도 취약점이 발생할 수 있구나’를 소개하고자 합니다!
Target
자, 우선 제가 찾은 취약점은 안티바이러스 프로그램에서 발생한 Named Pipe 취약점인데요, 현재는 패치된 상황이고 ZDI에 제보하여 CVE까지 발급이 된 상태입니다!

그 중에서 오늘 제가 보여드릴 취약점은 File 관련, Registry 관련, Service 관련으로 나뉘어 있는데요! Named Pipe를 과도하게 개방된 상태로 IPC를 제공하면 어떻게 되는지, Case 별로 확인해볼까요!
0. Attack Surface
이 취약점들을 소개하기 전에, 가장 먼저 확인해야 할 부분입니다. Everyone 권한이 System 수준의 권한을 얻기 위한 첫 걸음이라고 할 수 있죠! Part 1 때와 거의 유사하지만! 그래도 보여드리는 것이 인지상정 그래도 같이 확인해볼까요?

타겟 프로그램을 설치했을 때 Process Explorer를 확인해보면, 다음과 같이 SASCore64.exe 라는 System 권한으로 실행되는 Process가 있는 것을 확인할 수 있죠! 거기다 Handles 부분에 Named Pipe 가 있다는 것 까지 확인이 가능합니다.

거기다 가장 큰 관문인 ACL 부분이죠..!! SysinternalsSuite의 accesschk를 사용해 권한을 확인해보니 Everyone RW 권한이 있는 것을 확인할 수 있었습니다~ 따라서 medium user가 특별한 권한 없이도 System 권한으로 실행 중인 프로세스에 접근할 수 있다는 의미겠죠!
자 그렇다면 이제 직접 SASCore64.exe 바이너리를 추출하여 IDA로 열어본 뒤, Imports에서 Named Pipe 관련 api가 있는지 확인해봅시다.

CreateNamedPipe api가 있는 sub_140013F60 함수로 가보겠습니다.

코드를 살펴보면 CreateNamedPipeA 를 통해 named pipe를 만들고, Connect 하는 걸 볼 수 있습니다. 여기서 핵심은 Named Pipe용 SECURITY_ATTRIBUTES 를 구성하는데, 모든 로컬 사용자 완전 접근이 가능하다는 것이죠! StartAddress 함수로 가보겠습니다.

StartAddress 함수는 클라이언트 1개당 Named Pipe 세션을 처리하는 전용 스레드로, sub_14000F7E0 함수에서 파이프 메시지를 해석하여 수행하고 있습니다. 이 sub_14000F7E0 로 가보겠습니다.

v6에 우리의 input 값을 넣을 수 있고, 이를 case로 나뉘어서 여러 기능들을 사용할 수 있습니다! 그러나 기능들을 사용하기 위해서는 아래 0x103A 분기를 통하여 Token을 먼저 획득해야합니다.
case 0x103A:
{
// ... (입력/시간 검증 및 기타 부수 로직)
if (ImpersonateNamedPipeClient(a3))
{
// ... (TLS에 레지스트리 컨텍스트 저장 등)
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, *(DWORD *)(input + 8));
if (hProc)
{
HANDLE hToken = NULL;
if (OpenProcessToken(hProc, TOKEN_ALL_ACCESS, &hToken) && hToken)
{
// ... (토큰을 내부 세션/테이블에 저장)
CloseHandle(hToken);
}
CloseHandle(hProc);
}
RevertToSelf();
FileSize = 1; // session established
}
// ... (응답 구성)
WriteFile(a3, Buffer, 0x10018u, &written, 0);
return 1;
}
- ImpersonateNamedPipeClient, OpenProcessToken(…Pid…) 성공 시 내부 리스트(a1 + 0x68)에 새 노드 + 토큰 이 저장됩니다.
- 응답 0x10018 바이트 중 오프셋 0x04 에 토큰 값을 돌려주므로 이 Token을 저장하여 사용할 수 있게됩니다.
이런 방식으로 토큰을 얻은 후에 다른 기능들을 사용할 수 있는데요, 이 기능들이 바로 제가 말씀드릴 취약점 벡터입니다.
1. File 관련 API를 활용한 LPE
먼저 File 관련 API들에 대해서 먼저 알아볼까요? Windows에는 여러가지 API들이 존재하고, 대부분은 안전하게 사용할 수 있도록 검증을 합니다. 중요한 부분은 사용자가 건들지 못하도록 말이죠.. 하지만 만약 사용자가 중요한 인자를 조작할 수 있다면 어떻게 될까요?
1-1. CopyFileW()
MS에서 CopyFileW() 함수를 보면, 기존 파일을 새로운 파일로 복사하는 기능을 하는 것을 볼 수 있습니다.
BOOL CopyFileW(
[in] LPCWSTR lpExistingFileName,
[in] LPCWSTR lpNewFileName,
[in] BOOL bFailIfExists
);
그렇다면, System 권한으로 원하는 경로의 파일을 악성 파일로 바꿀 수 있다면 권한 상승이 이루어지겠죠?
case 0x100D:
{
// ...
BOOL ok = CopyFileW(
(LPCWSTR)(input + 8), // attacker-controlled: src
(LPCWSTR)(input + 0x1048), // attacker-controlled: dst
*(DWORD *)(input + 0x2088) // attacker-controlled: bFailIfExists
);
DWORD err = GetLastError();
// ... (응답 Buffer에 ok/err 담아서 반환)
WriteFile(a3, Buffer, 0x10018u, &written, 0);
return 1;
}
Token을 얻은 뒤 case 0x100D 로 분기하면 input+8에 Copy할 파일 경로를, 덮어 쓸 파일 경로를 input+0x1048 에 넣어 API를 호출 할 수 있습니다!

이게 왜 LPE냐구요..? 저희가 일반적인 권한으로는 바꾸거나 삭제할 수 없는 파일들이 있죠. 거기다가 몇 초마다 실행되는 파일이 있다면? 이 파일을 System 권한으로 cmd를 실행할 수 있는 악성파일로 바꾼다면 말그대로 몇 초마다 System 권한 cmd가 켜지겠죠!! 그런 원리입니다.👽

이렇게 직접 Process Monitor로 확인해볼까요? 이건 Chrome updater 프로세스이고, 몇 초마다 실행되고, 권한이 높아 일반 권한으로는 접근이 불가능합니다. 하지만 위 취약점을 활용해 악성 파일로 바꾼다면!?

이런식으로 System cmd가 켜지는 걸 확인할 수 있죠! 👏
동적 분석을 통해 조금 더 확실하게 확인해볼까요?

Windbg로 해당 Process에 Attach를 한 후 lm 명령어를 통해 base 주소를 구하고, IDA에서 base 설정을 통해 CopyFileW() 함수 부근에 bp를 걸고 확인해보겠습니다.


다음과 같이 Dst 에는 앞서 말한 updater 경로가 써져 있습니다.

그리고 Src에는 복사할 파일 경로가 그대로 들어가 있는 것을 확인 할 수 있습니다. 이 가짜 updater.exe 는 System 토큰으로 cmd를 켜게 해주는 악성 파일이고, 이렇게 복사가 되면서 LPE에 성공하게 되는것이죠!
2. Registry 관련 API를 활용한 LPE
자, 다음은 Registry 관련 API들을 살펴볼까요!
2-1. SHSetValueW()
LSTATUS SHSetValueW(
[in] HKEY hkey,
[in, optional] LPCWSTR pszSubKey,
[in, optional] LPCWSTR pszValue,
[in] DWORD dwType,
[in, optional] LPCVOID pvData,
[in] DWORD cbData
);
SHSetValueW() 함수는 레지스트리 키의 값을 설정하는 기능을 하는데요, 이 인자 세팅을 보면 SubKey와 ValueName 을 설정해줄 수 있습니다. 이게 무슨 말이냐, 이 인자들을 조작할 수 있다면 원하는 경로의 레지스트리의 Value 값을 마음대로 바꿀 수 있다는 뜻이겠죠!! 직접 코드를 통해 확인해보겠습니다.
case 0x102C:
{
const WCHAR *SubKey = (const WCHAR *)(input + 16); // attacker-controlled
const WCHAR *ValueName = (const WCHAR *)(input + 4176); // attacker-controlled
HKEY RootKey = *(HKEY *)(input + 8); // attacker-controlled
DWORD Type = *(DWORD *)(input + 0xA094); // attacker-controlled
LPCVOID Data = (LPCVOID)(input + 0x2090); // attacker-controlled
DWORD DataSize = *(DWORD *)(input + 0xA090); // attacker-controlled
// ... (RootKey == HKCR 래핑 / HKCU TLS 치환 / 테이블 핸들 로직 등)
LSTATUS st = SHSetValueW(
RootKey,
SubKey,
ValueName,
Type,
Data,
DataSize
);
DWORD err = GetLastError();
// ... (응답 Buffer에 st/err 담아서 반환)
WriteFile(a3, Buffer, 0x10018u, &written, 0);
return 1;
}
case 0x102C 분기로 가면 결과적으로 SHSetValueW 함수를 통해 원하는 레지스트리 값을 설정해 줄 수 있습니다.
offset +0x0008 → HKEY Root (예: HKEY_LOCAL_MACHINE)
offset +0x0010 → SubKey
offset +0x1050 → ValueName
offset +0xA068 → Type (REG_SZ 등)
offset +0x2090 → Buffer (설정할 값)
offset +0xA064 → Buffer size
입력 구조는 다음과 같습니다.
if (RootKey == HKEY_CLASSES_ROOT)
{
// 내부 클래스로 래핑
CClassesRoot ctx;
ctx.h1 = TlsGetValue(...);
ctx.h2 = TlsGetValue(...);
SHSetValueW(ctx.h1 or h2, SubKey, ValueName, Type, Data, Size);
}
else if (RootKey & 0x80000000) // e.g., HKEY_LOCAL_MACHINE, HKEY_USERS 등
{
keyctx = get_handle_from_table(RootKey);
SHSetValueW(keyctx, SubKey, ValueName, Type, Data, Size);
}
else
{
// 일반 HKEY_CURRENT_USER 일 경우 TLS 기반 키로 치환
if (RootKey == HKEY_CURRENT_USER)
RootKey = TlsGetValue(...);
SHSetValueW(RootKey, SubKey, ValueName, Type, Data, Size);
}
핵심 취약 로직은 위와 같은데, 결국 사용자가 제공한 임의 HKEY + SubKey + Value + Data가 System 권한으로 그대로 SHSetValueW 함수의 인자로 들어갑니다.
그렇다면 이번에도 같은 방식으로 System 권한으로 실행되면서 Registry에 ImagePath가 있는 프로세스를 찾아볼까요?

저는 타겟으로 MicrosoftEdgeElevationService 서비스를 선정하여 진행하였습니다. Process Monitor 를 통해 확인해보면, Edge를 켰을 때 서비스가 자동으로 System 권한으로 Load 되는것을 확인할 수 있습니다.

그 후 이 서비스가 어느 경로에서 실행되는지, 서비스와 레지스트리 편집기에서 확인해보겠습니다.


위 사진과 같이 MicroSoftEdgeElevationService 인 것을 확인 할 수 있고, ImagePath 가 “C:\Program Files (x86)\Microsoft\Edge\Application\138.0.3351.77\elevation_service.exe” 인 것을 확인 할 수 있습니다. 이제 취약점을 트리거 한 뒤 다시 확인해보겠습니다.


다음과 같이 서비스의 Path와 레지스트리의 ImagePath가 바뀐 것을 확인 할 수 있습니다!! 이제 MicrosoftEdge를 실행시키면 System 권한을 가진 cmd가 켜지게 됩니다!
이번에도 동적 분석을 안 해볼 수 없겠죠?


Value 부분을 보면 ImagePath 가 들어간 것을 확인 할 수 있습니다.

SubKey 부분을 보면 앞에서 말한 레지스트리 경로가 들어간 것을 확인 할 수 있습니다.

마지막으로 key는 HKEY_LOCAL_MACHINE 를 의미하는 0x80000002 가 들어간 것을 볼 수 있습니다. 해당 기능을 사용하면 제가 원하는 높은 경로의 레지스트리 Image Path를 원하는 값으로 바꿀 수 있게 되는 것이죠!
3. Service 관련 API를 활용한 LPE
그렇다면 마지막으로 Service 관련 API를 살펴볼까요! 이번엔 3가지의 API를 활용하여 LPE까지 달성 할 수 있는데요, 하나씩 살펴볼까요?
3.1 OpenSCManagerW()
SC_HANDLE OpenSCManagerW(
[in, optional] LPCWSTR lpMachineName,
[in, optional] LPCWSTR lpDatabaseName,
[in] DWORD dwDesiredAccess
);
먼저 OpenSCManagerW() 함수입니다. 뒤에 나올 CreateService나 StartService는 되게 직관적으로 무슨 기능인지 알 것 같지만, 이 OpenSCManagerW() API를 먼저 실행해야만 하기 때문이죠!
서비스는 ‘SCM 데이터베이스’에 등록되는 객체라서, 그 DB에 대한 핸들(컨텍스트)이 먼저 있어야 이후 작업(생성/수정/시작)을 할 수 있기 때문입니다. 그 핸들을 열고 반환해주는 것이 바로 이 API라고 할 수 있습니다.
case 0x103B:
{
const WCHAR *Machine = (const WCHAR *)(input + 8); // attacker-controlled (optional)
const WCHAR *Database = (const WCHAR *)(input + 0x1048); // attacker-controlled (optional)
DWORD Access = *(DWORD *)(input + 0x2088); // attacker-controlled
// ... (empty string -> NULL 처리 등)
SC_HANDLE hScm = OpenSCManagerW(Machine, Database, Access);
DWORD err = GetLastError();
// ... (응답에 hScm/err 반환)
WriteFile(a3, Buffer, 0x10018u, &written, 0);
return 1;
}
case 0x103B 분기를 통해 해당 API를 호출하여 핸들을 얻을 수 있는데요,
- input + 8 → lpMachineName
- input + 0x1048 → lpDatabaseName
- input + 0x2088 → dwDesiredAccess
여기서 중요한 것은 dwDesiredAccess 로, SC_MANAGER_ALL_ACCESS(0xF003F) 로 설정해줘야 한다는것!
3.2 CreateServiceW()
SC_HANDLE CreateServiceW(
[in] SC_HANDLE hSCManager,
[in] LPCWSTR lpServiceName,
[in, optional] LPCWSTR lpDisplayName,
[in] DWORD dwDesiredAccess,
[in] DWORD dwServiceType,
[in] DWORD dwStartType,
[in] DWORD dwErrorControl,
[in, optional] LPCWSTR lpBinaryPathName,
[in, optional] LPCWSTR lpLoadOrderGroup,
[out, optional] LPDWORD lpdwTagId,
[in, optional] LPCWSTR lpDependencies,
[in, optional] LPCWSTR lpServiceStartName,
[in, optional] LPCWSTR lpPassword
);
그럼 이제 핸들도 얻었으니, Service를 생성해볼까요?
case 0x103F:
{
SC_HANDLE hScm = *(SC_HANDLE *)(input + 8);
const WCHAR *SvcName = (const WCHAR *)(input + 16); // attacker-controlled
const WCHAR *DispName = (const WCHAR *)(input + 4176); // attacker-controlled
const WCHAR *BinPath = (const WCHAR *)(input + 8352); // attacker-controlled
DWORD DesiredAccess = *(DWORD *)(input + 0x2090); // attacker-controlled
DWORD ServiceType = *(DWORD *)(input + 0x2094); // attacker-controlled
DWORD StartType = *(DWORD *)(input + 0x2098); // attacker-controlled
DWORD ErrorControl = *(DWORD *)(input + 0x209C); // attacker-controlled
// ... (Group/Deps/StartName/Password 포인터 정리 및 NULL 처리)
SC_HANDLE hSvc = CreateServiceW(
hScm,
SvcName,
DispName,
DesiredAccess,
ServiceType,
StartType,
ErrorControl,
BinPath,
/* ... */ 0, 0, 0, 0, 0
);
DWORD err = GetLastError();
// ... (응답에 hSvc/err 반환)
WriteFile(a3, Buffer, 0x10018u, &written, 0);
return 1;
}
case 0x103F 분기에서 CreateServiceW() 로 직접 임의 파일을 서비스로 등록할 수 있습니다!
- input + 0x08 →
hSCManagerSC_HANDLE(이전0x103B호출로 얻은 SCM 핸들)
- input + 0x10 →
lpServiceNameLPCWSTR서비스 내부 이름 (예: L”LPE”)
- input + 0x1050 →
lpDisplayNameLPCWSTR사용자에게 보일 서비스 이름 (예: L”My Payload Service”)
- input + 0x2090 →
dwDesiredAccessDWORD접근권한 플래그 (예:SERVICE_ALL_ACCESS= 0xF01FF)
- input + 0x2094 →
dwServiceTypeDWORD서비스 타입 (예:SERVICE_WIN32_OWN_PROCESS= 0x10)
- input + 0x2098 →
dwStartTypeDWORD시작 유형 (예:SERVICE_DEMAND_START= 0x3)
- input + 0x209C →
dwErrorControlDWORD오류 제어 (예:SERVICE_ERROR_NORMAL= 0x1)
- input + 0x20A0 →
lpBinaryPathNameLPCWSTR실행 파일 경로 (예: L”C:\Temp\poc.exe”)
- input + 0x30E0 →
lpLoadOrderGroupLPCWSTR로드 순서 그룹 (예: L”Base”)
- input + 0x4120 →
lpDependenciesLPCWSTR의존 서비스 리스트
- input + 0x5160 →
lpServiceStartNameLPCWSTR로그온 계정 (예: L”NT AUTHORITY\System”)
- input + 0x61A0 →
lpPasswordLPCWSTR해당 계정 비밀번호 (없으면 NULL)
하지만 생성만 한다고 해서 완벽한 LPE라고 할 순 없겠죠? 이를 실행해주는 API 또한 있습니다.
3.3 StartServiceW()
BOOL StartServiceW(
[in] SC_HANDLE hService,
[in] DWORD dwNumServiceArgs,
[in, optional] LPCWSTR *lpServiceArgVectors
);
case 0x1040:
{
SC_HANDLE hSvc = *(SC_HANDLE *)(input + 8); // attacker-controlled (handle from prior call)
BOOL ok = StartServiceW(hSvc, 0, NULL);
DWORD err = GetLastError();
// ... (응답 Buffer에 ok/err 담아서 반환)
WriteFile(a3, Buffer, 0x10018u, &written, 0);
return 1;
}
이제 만들어진 서비스를 Start 시키는 case 0x1040 분기로 StartServiceW() 를 실행시킵니다.
- input + 8 →
hService(SC_HANDLE) - dwNumServiceArgs →
0(고정) - lpServiceArgVectors →
NULL(고정)
앞서 CreateServiceW() 의 반환 값으로 받은 핸들을 input + 8에 넣어 실행시키면 됩니다!
이번엔 동적 분석으로 먼저 한 단계 한 단계 확인해볼까요?

먼저 OpenSCManagerW() 함수 부근에 bp를 걸고 확인해보겠습니다.

dwDesiredAccess 부분에 저희가 집어넣은 SC_MANAGER_ALL_ACCESS(0xf003f) 가 들어간 것을 확인할 수 있죠!

이후 출력 화면을 보면 hSCM 핸들을 제대로 받아오는 것을 확인 할 수 있습니다.

이제 CreateServiceW() 부근에 bp를 걸고 확인해보겠습니다.

lpServiceStartName 에는 System 을 적어주었습니다.

그리고 lpBinaryPathName 에 저희가 서비스화 시킬 악성 파일 경로가 써져 있습니다.

이후 출력 루틴을 보면 서비스가 생성이 되고, 서비스 핸들 값도 얻어올 수 있는 걸 볼 수 있죠.


서비스를 확인해보면 LPE 서비스가 생겼고, 경로도 악성 파일 경로로 되어 있는 것을 볼 수 있습니다.
이제 StartServiceW() 함수를 통해 실행만 시킨다면? System 권한으로 악성 서비스가 실행되겠죠!!
마치며

이번 Part 2의 Named Pipe 취약점의 여러 LPE 벡터에 대한 설명이 여러분에게 도움이 되셨으면 좋겠는데요!! Named Pipe의 권한 설정으로 인해 medium 유저가 System 권한으로 행위를 할 수 있는 상황도 있다는 점! 쓰다 보니 너무 고봉밥처럼 쓴 것 같아 분량 조절에 실…패 한 것 같지만!! 그래도 이런 방식으로도 LPE가 가능하구나!! 하면서 봐주시면 감사하겠습니다 😉 다음에는 Windows LPE의 꽃이자 Kernel Driver에서 이루어지는 취약점으로 돌아오겠습니다~ 감사합니다!
Reference
- https://learn.microsoft.com/ko-kr/windows/win32/api/winsvc/nf-winsvc-openscmanagerw
- https://learn.microsoft.com/ko-kr/windows/win32/services/service-security-and-access-rights
- https://learn.microsoft.com/ko-kr/windows/win32/api/winsvc/nf-winsvc-createservicew
- https://learn.microsoft.com/ko-kr/windows/win32/api/winsvc/nf-winsvc-startservicew
- https://learn.microsoft.com/ko-kr/windows/win32/api/shlwapi/nf-shlwapi-shsetvaluew
- https://learn.microsoft.com/ko-kr/windows/win32/api/winbase/nf-winbase-copyfilew
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.