
출처 : PIKOTAROピコ太郎OFFICIAL CHANNEL
최근 Kernel Streaming에 관심이 생겨서 이것저것 보고 있는데요. Pwn2Own Vancouver 2024에서도 KS 관련 드라이버에서 취약점이 발생했고, Code Blue 2025에서도 새로운 취약점으로 발표가 진행되는 걸 보고 이 영역이 분석해 볼만한 공격 벡터라고 생각이 들었습니다.
분석을 해보다 보니 한 가지 의문이 생겼습니다. 제조사들은 드라이버를 개발할 때 디버깅이나 내부 테스트를 위해 표준 문서에 없는 기능들을 만들어 두곤 합니다. 만약 이 문서화 되지않은 기능들(Undocumented Property)들을 찾아내어 이 부분을 중점적으로 분석한다면 좀 더 효율적으로 취약점을 찾을 수 있겠다는 생각이 들었습니다.
그래서 이번에, 이 Undocumented Property를 V0부터 V4까지 단계별로 업그레이드하며 자동으로 찾아내는 도구 개발 과정을 공유하려고 합니다.
1. What is Kernel Streaming
KS는 윈도우 커널 모드에서 오디오, 비디오 같은 대용량 스트리밍 데이터를 낮은 지연시간(Low Latency)으로 처리하기 위한 프레임워크 입니다. 여기서 데이터는 Filter(장치), Pin(데이터 통로), Node(내부 기능)라는 객체들 사이를 흐르게 됩니다.
공격자 관점에서 가장 중요한 것은 유저 모드에서 커널 드라이버와 통신하는 DeviceIoControl 함수입니다. 그중에서도 IOCTL_KS_PROPERTY입니다. 이 IOCTL은 드라이버의 특정 설정을 변경하거나 조회할 때 사용되는데, 이때 KSPROPERY 구조체가 입력으로 들어갑니다.
typedef struct {
GUID Set; // Property Set GUID (기능의 카테고리)
ULONG Id; // Property ID (세부 기능 번호)
ULONG Flags; // GET, SET 등의 동작 플래그
} KSPROPERTY, *PKSPROPERTY;
구조체를 보면 GUID, Property ID, Flags 로 구성되어 있습니다. 이 3가지의 조합으로 드라이버가 특정 기능을 트리거 해 접근하는 구조입니다.
2. KS Flow
KS 아키텍처에서 유저모드 애플리케이션이 GUID와 Property ID를 사용해 드라이버 내부의 특정 기능을 어떻게 호출할까요? 이 과정은 크게 유저모드(요청 준비 및 전송)과 커널모드(요청 수신 및 처리)로 나뉩니다.

이 전체 흐름은 크게 요청 준비(User) → 전달(I/O Manager) → 분배(Dispatch) → 실행 이렇게 총 4단계로 나뉩니다. 이제 상세 내용을 확인해 볼까요?
2-1. User Mode: 요청 준비(Step 1~2)
모든 것은 유저 모드에서 시작됩니다. 공격자 또는 일반 유저는 드라이버가 수행해야 할 작업을 명시해야 합니다.
이때, 작업을 명시하기 위해 KSPROPERTY 구조체를 사용합니다.
typedef struct {
GUID Set; // Property Set GUID (기능의 카테고리)
ULONG Id; // Property ID (세부 기능 번호)
ULONG Flags; // GET, SET 등의 동작 플래그
} KSPROPERTY, *PKSPROPERTY;
Set은 GUID로 기능의 카테고리입니다. 예를 들어 KSPROPSETED_Audio 등이 있습니다. ID는 Property ID로 카테고리 내에서 구체적인 기능을 의미하는 인덱스입니다. 예를 들어 볼륨 조절, 음 소거 등의 기능을 의미합니다. Flags는 값을 읽을지(Get), 쓸지(Set)를 결정합니다.
이후 DeviceIoControl을 호출합니다. 여기서 가장 중요한 것은 Control Code입니다. KS 프레임 워크는 속성(Property) 제어를 위해 IOCTL_KS_PROPERTY라는 전용 IOCTL을 사용합니다. 이 코드를 사용해 DeviceIoControl을 호출하는 순간 OS는 이 요청이 단순 I/O 작업이 아닌 KS Property 요청임을 인지합니다.
DeviceIoControl(
hDevice,
IOCTL_KS_PROPERTY, // 핵심: KS 프로퍼티 요청임을 알림
&Property, // 앞서 설정한 GUID와 ID
sizeof(Property),
...
);
2-2. Crossing the Boundary(Step 3)
Syscall을 통해 커널 모드로 진입하면, I/O Manager가 등장합니다. I/O Manager는 유저의 요청을 IRP(I/O Request Packet)라는 상자에 포장합니다. 이때, IRP의 Major Function 코드는 IRP_MJ_DEVICE_CONTROL이 되고, 사용자가 요청한 IOCTL_KS_PROPERTY와 데이터 버퍼는 IRP의 CurrentStackLocation에 저장되어 드라이버로 전달 됩니다.
2-3. Kernel Mode : 디스패치 & 파싱(Step 4~5)
드라이버 객체의 DriverEntry에 등록된 DispatchDeviceControl 루틴이 IRP를 수신합니다. 드라이버는 IRP를 까보고 이건 IOCTL_KS_PROPERTY요청이구나 하고 판단합니다.
대부분의 KS 드라이버는 이 복잡한 IRP를 직접 파싱하지 않고 ks.sys 라이브러리의 KsPropertyHandler 함수에 IRP를 넘깁니다. 이 함수는 일종의 라우터 역할을 합니다. IRP 내부의 입력 버퍼에서 유저가 보낸 KSPROPERTY구조체(GUID, ID)를 추출합니다.
2-4. Automation Table & Execution(Step 6~8)
구조체의 구조를 확인해 보면 KSDEVICE_DESCRIPTOR -> KSFILTER_DESCRIPTOR -> KSAUTOMATION_TABLE -> KSPROPERTY_SET 순서로 연결되는 계층 구조를 가집니다. 그래서 이 Automation Table이 중요한 구조체 중 하나입니다.
또, 드라이버 개발자는 자신이 어떤 Property를 지원하는지 Automation Table이라는 구조체 배열에 정의해 둡니다. KsPropertyHandler는 유저가 보낸 GUID, ID 쌍과 드라이버 내부의 테이블을 비교(Match)합니다.
Match를 찾으면 해당 ID에 매핑된 콜백 함수 포인터를 가져오고, 찾지 못하면 에러를 리턴하고 종료합니다.
매칭에 성공하면 실제 핸들러 함수가 실행됩니다. 이 함수 안에서 하드웨어 레지스터를 건드리거나 내부 구조체를 변경하는 로직이 수행됩니다.
핸들러 함수가 작업을 마치면 성공/실패 여부를 리턴합니다.
2-5. Back to User(Step 9)
결과값은 다시 IRP를 타고 I/O Manager를 거쳐 유저모드의 DeviceIoControl의 리턴 값으로 돌아옵니다. BytesReturned 등을 통해 우리가 요청한 데이터가 얼마나 들어 왔는지 확인할 수 있습니다.
이렇게 KS는 필요한 기능을 GUID와 ID의 조합으로 호출하고 공격자가 이 조합을 이용해서 특정 기능을 트리거 하는 것이 가능합니다. 그렇다면 이 GUID, ID 조합을 공개되지 않은 조합까지도 밝혀낸 상태로 분석을 진행하면 좀 더 효율적이고 효과적으로 취약점 분석을 할 수 있지 않을까? 라는 생각을 했어요. 이제 공개되지 않은 GUID, ID를 찾는 Script를 만들어 봅시다.
3. V1 - 가능성 검증

먼저 특정 GUID를 가지고, 있을 때 공개되지 않은 Property ID를 찾을 수 있는지를 확인해 봤습니다. 테스트 타겟으로는 최근 여러 권한 상승 취약점의 진원지가 된 KSPROPSETID_Service를 선정했습니다.
먼저 GUID를 특정했으니 KSPROPERTY 구조체에 타겟 GUID와 임의의 ID(0~N)을 넣고 요청을 보냅니다. 이때 리턴되는 NTSTATUS코드를 분석해서 결과를 확인합니다.
총 3가지의 리턴이 발생하는데 유효하지 않은 ID는 STATUS_NOT_FOUND를 리턴합니다. 기능은 존재하지만, 요청한 버퍼가 너무 작다면 STATUS_BUFFER_TOO_SMALL을 리턴합니다. 그리고 전달한 파라미터가 잘못되면 STATUS_INVALID_PARAMETER를 리턴 합니다.
여기서 STATUS_BUFFER_TOO_SMALL, STATUS_INVALID_PARAMETER를 반환하면 유효한 ID로 판단하고 해당 ID를 출력하게끔 작성했습니다.
4. V2 - 공개되지 않은 GUID와 Property ID 추출

KS 드라이버가 자신이 어떤 기능을 지원하는지를 OS에 알려줘야 애플리케이션이 이 드라이버를 사용할 수 있어요. 그러면 드라이버 바이너리 내부 어딘가에는 “나 이거 지원해” 라고 적혀 있는 부분이 반드시 존재할 겁니다. 그래서 이 부분을 정적으로 추출해서 스캔 대상을 추출하고, 대상이 어떤 ID들을 가졌는지 스캔하는 코드를 만들어 보겠습니다.
4-1. 진입점 찾기
먼저 진입접을 찾아야 합니다. 대부분의 AVStream드라이버는 DriverEntry에서 KsInitializeDriver를 호출합니다.
NTSTATUS KsInitializeDriver(
[in] PDRIVER_OBJECT DriverObject,
[in] PUNICODE_STRING RegistryPathName,
[in, optional] const KSDEVICE_DESCRIPTOR *Descriptor // <-- 요놈!
);
세 번째 인자인 Descriptor가 바로 KSDEVICE_DESCRIPTOR입니다. x64 호출 규약에 따르면 첫 번째 인자는 RCX, 두 번째는 RDX, 세 번째는 R8레지스터에 들어갑니다. 즉, KsInitializeDriver를 호출하기 직전에 R8 레지스터에 어떤 주소를 넣는지를 찾으면 됩니다.
import idautils
import idc
import idaapi
def find_ks_descriptors():
# 1. KsInitializeDriver 주소 확보
ks_init_ea = idc.get_name_ea_simple("KsInitializeDriver")
if ks_init_ea == idc.BADADDR:
print("[-] KsInitializeDriver not found (Not an AVStream driver?)")
return
# 2. Xref(참조) 추적
for xref in idautils.XrefsTo(ks_init_ea):
call_addr = xref.frm
print(f"[+] Found KsInitializeDriver call at {hex(call_addr)}")
# 3. 인자 역추적 (Backward Slicing)
# CALL 명령어 이전의 명령어들을 거슬러 올라가며 R8(3번째 인자)을 세팅하는 LEA 명령어를 찾음
descriptor_addr = trace_argument(call_addr, 2) # arg index 2 corresponds to R8
if descriptor_addr:
print(f"[!] Found KSDEVICE_DESCRIPTOR at {hex(descriptor_addr)}")
parse_device_descriptor(descriptor_addr)
이 로직은 꽤 잘 동작했지만, 모든 드라이버가 KsInitializeDriver를 쓰는 건 아니었습니다. 일부는 KsCreateFilterFactory를 직접 호출하기도 했죠. 그래서 저는 타겟 API 리스트를 만들어 (KsInitializeDriver, KsCreateFilterFactory, KsFilterCreateNode 등) 각각에 대한 Xref를 모두 추적하도록 스크립트를 수정 했습니다.
4-2. 구조체 파싱과 GUID 추출
Descriptor 주소를 찾았다면, 이제 오프셋 계산의 영역입니다. KSDEVICE_DESCRIPTOR에서 FilterDescriptors 배열을 찾고, 각 필터에서 AutomationTable을 찾고, 거기서 PropertySets를 찾아 들어갑니다.
여기서 가장 큰 난관은 GUID 해석이었습니다. IDA에서 바이트 배열로 보이는 16바이트 데이터를 GUID 문자열({XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX})로 변환해야 매칭이 가능하니까요.
import struct
def get_guid_str(ea):
# 메모리에서 16바이트를 읽어옴
data = idc.get_bytes(ea, 16)
if not data or len(data) < 16:
return None
# Windows GUID는 Little-endian으로 저장됨 (Data1, Data2, Data3)
# Data4(8 bytes)는 Big-endian(배열)처럼 취급됨
g = struct.unpack("<IHH8B", data)
return "{%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X}" % (
g, g, g,
g, g, g, g, g, g, g, g
)
이 스크립트를 통해서 드라이버 바이너리만 있으면 해당 드라이버가 어떤 GUID의 어떤 Property ID(예: ID 1, 5, 7)를 공식적으로 지원하는지 추출할 수 있게 되었습니다.
4-3. Undocumented Property 탐지

출처 : 보다 BODA Youtube
정적 분석 결과가 {ID: 1, 2, 5, 10}이라고 가정해 봅시다. 여기서 Undocumented Property란 무엇일까요?
개발자가 enum으로 Property ID를 정의할 때, 보통 0부터 순차적으로 증가합니다. 그런데 위 목록을 보면 3, 4와 6, 7, 8, 9가 비어있습니다. 이 Gap(공백)이 수상하지 않나요? 이 갭은 개발자가 실수로 혹은 고의로(디버깅용, 백도어용) KSAUTOMATION_TABLE에는 등록하지 않고, KsPropertyHandler 내부의 switch-case 문에서 직접 처리하는 ID들일 수 있습니다. 이런 ID들이 문서화 되지 않은 기능들일 가능성이 높습니다.
5. V3 - Anckor알고리즘 도입

메모리 상에서 KS 구조체들의 연결 관계
일반적으로 정적 분석 도구로 드라이버를 열어보면 수많은 데이터 섹션과 함수들이 혼재되어 있어, 어떤 구조체가 KSPROPERTY_SET 테이블인지 식별하기 어렵습니다. 그래서 Anchor 개념을 도입해 해결하려고 했습니다. 앵커란 바이너리 내부에서 변하지 않는 신뢰할 수 있는 기준점을 의미합니다. 드라이버가 OS에 자신의 존재를 알리기 위해 반드시 호출해야 하는 커널 API들을 이 앵커 역할을 하게 했습니다.
위에서 진입점에 대해 설명을 했었습니다. 다시 상기해 보면 대부분의 AVStream 드라이버는 초기화 과정에서 KsInitializeDriver 함수를 호출한다 했습니다. 여기서 중요한 점은 KsInitializeDriver 함수의 세 번째 인자인 Descriptor가 제가 찾던 KSDEVICE_DESCRIPTOR 구조체를 가리킨다는 것 입니다. 호출규약에 따르면 R8 레지스터를 통해 전달되니 KsInitializeDriver 를 호출하는 지점을 찾고 앵커로 지정한 다음 그 직전의 명령어들을 분석해 R8 레지스터에 어떤 주소가 로드되는지를 역추적하면 구조체의 위치를 찾을 수 있습니다.
제가 개발한 스크립트는 총 3단계 과정을 수행합니다.
먼저 앵커를 식별합니다.
스크립트는 먼저 Import Table을 스캔해 KsInitializeDriver, KsCreateFilterFactory, KsFilterCreateNode 등 KS 관련 핵심 API들의 주소를 확보합니다. 이 주소들이 분석의 시작점이 되는 앵커입니다.
def find_ks_anchors():
anchors =
found_anchors =
for name in anchors:
ea = idc.get_name_ea_simple(name)
if ea!= idc.BADADDR:
found_anchors.append(ea)
return found_anchors
두번째로 역방향으로 데이터를 추적합니다.
확보된 앵커 함수를 가지고 이 앵커 함수를 호출하는 모든 지점을 Xref로 찾습니다. 각 호출 지점에서 명령어를 거슬러 올라가면서 인자 레지스터에 값을 설정하는 LEA 명령어를 탐색합니다.
이 방식으로 단순 바이트 스캔보다 정확성을 높일 수 있었습니다.
def trace_argument(call_addr, arg_index):
# 단순화된 역추적 로직: 호출 지점에서 위로 올라가며 레지스터 세팅 확인
current_addr = call_addr
target_reg = get_arg_register(arg_index) # e.g., arg 2 -> R8
for _ in range(20): # 최대 20 명령어 이전까지 탐색
current_addr = idc.prev_head(current_addr)
if is_lea_instruction(current_addr) and get_dest_reg(current_addr) == target_reg:
return get_operand_value(current_addr, 1) # 구조체 주소 반환
return None
세 번째로 구조체를 복원하고 GUID와 Property ID를 추출합니다.
앵커 알고리즘을 통해 KSDEVICE_DESCRIPTOR의 주소를 찾았으면, 이제 구조체 정의를 따라가면서 트리 구조를 파싱합니다.
Device Descriptor → Filter Descriptors → Automation Table → Property Sets 순서로 파고들어 최종적으로 GUID와 Property ID 목록을 추출합니다.
이때, GUID가 메모리에 바이트 배열로 존재하기 때문에 GUID를 읽기 좋게 문자열로 변환하는 기능도 필요했습니다.
def get_guid_tuple(ea):
data = ida_bytes.get_bytes(ea, 16)
if not data or len(data) != 16: return None
return struct.unpack('<IHHBBBBBBBB', data)
def guid_to_string(g):
if not g: return "{INVALID}"
d4_str = "".join(["{:02X}".format(b) for b in g[3:]])
return "{{{:08X}-{:04X}-{:04X}-{}-{}}}".format(g[0], g[1], g[2], d4_str[:4], d4_str[4:])

마지막으로 이렇게 찾은 GUID, Property ID와 이 기능에 접근하기 위한 조건들을 IDA 상에서 출력하는 것 외에 Txt 파일로 반환하는 것으로 마무리하였습니다.
제가 제작한 코드는 Github에 올라가 있으니 관심 있으신 분들을 가서 확인해 보시고 사용해 보면 좋을 거 같습니다!!
마치며

여기까지 Kernel Streaming에서 공개되지 않은 GUID와 Property ID를 탐색하는 도구를 만들어 보았습니다.
최근 KS 관련 취약점 트렌드를 보면, 공개되지 않은 숨겨진 속성(Undocumented Property)에서 문제가 발생하는 경우가 많습니다. 남들이 잘 보지 않는 공격 표면을 탐색하는 과정이야말로 취약점 분석의 효율을 높이는 지름길이 아닐까 싶습니다!
긴 글 읽어주셔서 감사합니다. 다음에 만나요~!