Improving Kernel Object Type Implementation (Part 4)
커널 객체 유형 구현 개선하기(4부)

In part 3 we implemented the bulk of what makes a DataStack – push, pop and clear operations. We noted a few remaining deficiencies that need to be taken care of. Let’s begin.
3부에서는 데이터스택을 구성하는 푸시, 팝, 지우기 작업의 대부분을 구현했습니다. 아직 해결해야 할 몇 가지 미비점을 발견했습니다. 시작하겠습니다.

Object Destruction 오브젝트 파괴

A DataStack object is deallocated when the last reference to it removed (typically all handles are closed). Any other cleanup must be done explicitly. The DeleteProcedure member of the OBJECT_TYPE_INITIALIZER is an optional callback we can set to be called just before the structure is freed:
데이터스택 객체에 대한 마지막 참조가 제거되면(일반적으로 모든 핸들이 닫힘) 객체의 할당이 해제됩니다. 다른 모든 정리는 명시적으로 수행해야 합니다. 객체 유형 초기화기의 DeleteProcedure 멤버는 구조가 해제되기 직전에 호출되도록 설정할 수 있는 선택적 콜백입니다:

init.DeleteProcedure = OnDataStackDelete;

The callback is simple – it’s called with the object about to be destroyed. We can use the cleanup support from part 3 to free the dynamic state of the stacked items:
콜백은 간단합니다. 소멸하려는 객체와 함께 호출됩니다. 3부의 정리 지원을 사용하여 스택된 항목의 동적 상태를 해제할 수 있습니다:

void OnDataStackDelete(_In_ PVOID Object) {
    auto ds = (DataStack*)Object;
    DsClearDataStack(ds);
}

Querying Information 정보 조회

The native API provides many functions starting with NtQueryInformation* with an object type like process, thread, file, etc. We’ll add a similar function for querying information about DataStack objects. A few declarations are in order, mimicking similar declarations used by other query APIs:
네이티브 API는 프로세스, 스레드, 파일 등과 같은 객체 유형으로 NtQueryInformation*으로 시작하는 많은 함수를 제공합니다. 데이터스택 객체에 대한 정보를 쿼리하기 위한 유사한 함수를 추가하겠습니다. 다른 쿼리 API에서 사용하는 유사한 선언을 모방한 몇 가지 선언이 순서대로 나열되어 있습니다:

typedef struct _DATA_STACK_CONFIGURATION {
    ULONG MaxItemSize;
    ULONG MaxItemCount;
    ULONG_PTR MaxSize;
} DATA_STACK_CONFIGURATION;
 
typedef enum _DataStackInformationClass {
    DataStackItemCount,
    DataStackTotalSize,
    DataStackConfiguration,
} DataStackInformationClass;

The query API itself mimics all the other Query APIs in the native API:
쿼리 API 자체는 네이티브 API의 다른 모든 쿼리 API를 모방합니다:

NTSTATUS NTAPI NtQueryInformationDataStack(
    _In_ HANDLE DataStackHandle,
    _In_ DataStackInformationClass InformationClass,
    _Out_ PVOID Buffer,
    _In_ ULONG BufferSize,
    _Out_opt_ PULONG ReturnLength);

The implementation (in kernel mode) is not complicated, just verbose. As with other APIs, we’ll start by getting the object itself from the handle, asking for DATA_STACK_QUERY access mask:
(커널 모드에서) 구현은 복잡하지 않고 장황할 뿐입니다. 다른 API와 마찬가지로 핸들에서 오브젝트 자체를 가져와서 DATA_STACK_QUERY 액세스 마스크를 요청하는 것으로 시작합니다:

NTSTATUS NTAPI NtQueryInformationDataStack(_In_ HANDLE DataStackHandle,
    _In_ DataStackInformationClass InformationClass,
    _Out_ PVOID Buffer, _In_ ULONG BufferSize,
    _Out_opt_ PULONG ReturnLength) {
    DataStack* ds;
    auto status = ObReferenceObjectByHandleWithTag(DataStackHandle,
        DATA_STACK_QUERY, g_DataStackType,
        ExGetPreviousMode(), DataStackTag, (PVOID*)&ds, nullptr);
    if (!NT_SUCCESS(status))
        return status;

Next, we check parameters:
다음으로 매개변수를 확인합니다:

// if no buffer provided then ReturnLength must be
// non-NULL and buffer size must be zero
//
if (!ARGUMENT_PRESENT(Buffer) && (!ARGUMENT_PRESENT(ReturnLength) || BufferSize != 0))
    return STATUS_INVALID_PARAMETER;
//
// if buffer provided, then size must be non-zero
//
if (ARGUMENT_PRESENT(Buffer) && BufferSize == 0)
    return STATUS_INVALID_PARAMETER;

The rest is pretty standard. Let’s look at one information class:
나머지는 매우 표준적입니다. 한 가지 정보 클래스를 살펴보겠습니다:

ULONG len = 0;
switch (InformationClass) {
    case DataStackItemCount:
        len = sizeof(ULONG); break;
    case DataStackTotalSize:
        len = sizeof(ULONG_PTR); break;
    case DataStackConfiguration:
        len = sizeof(DATA_STACK_CONFIGURATION); break;
    default:
        return STATUS_INVALID_INFO_CLASS;
}
 
if (BufferSize < len) {
    status = STATUS_BUFFER_TOO_SMALL;
}
else {
    if (ExGetPreviousMode() != KernelMode) {
        __try {
            if (ARGUMENT_PRESENT(Buffer))
                ProbeForWrite(Buffer, BufferSize, 1);
            if (ARGUMENT_PRESENT(ReturnLength))
                ProbeForWrite(ReturnLength, sizeof(ULONG), 1);
        }
        __except (EXCEPTION_EXECUTE_HANDLER) {
            return GetExceptionCode();
        }
    }
 
    switch (InformationClass) {
        case DataStackItemCount:
        {
            ExAcquireFastMutex(&ds->Lock);
            auto count = ds->Count;
            ExReleaseFastMutex(&ds->Lock);
 
            if (ExGetPreviousMode() != KernelMode) {
                __try {
                    *(ULONG*)Buffer = count;
                }
                __except (EXCEPTION_EXECUTE_HANDLER) {
                    return GetExceptionCode();
                }
            }
            else {
                *(ULONG*)Buffer = count;
            }
            break;
        }
//...
//
// set returned bytes if requested
//
if (ARGUMENT_PRESENT(ReturnLength)) {
    if (ExGetPreviousMode() != KernelMode) {
        __try {
            *ReturnLength = len;
        }
        __except (EXCEPTION_EXECUTE_HANDLER) {
            return GetExceptionCode();
        }
    }
    else {
        *ReturnLength = len;
    }
}
 
ObDereferenceObjectWithTag(ds, DataStackTag);
return status;

You can find the other information classes implemented in the source code in a similar fashion.
소스 코드에서 구현된 다른 정보 클래스도 비슷한 방식으로 찾을 수 있습니다.

To round it up, we’ll add Win32-like APIs that call the native APIs. The Native APIs call the driver in a similar way as the other native API user-mode implementations.
마지막으로 네이티브 API를 호출하는 Win32와 유사한 API를 추가하겠습니다. 네이티브 API는 다른 네이티브 API 사용자 모드 구현과 비슷한 방식으로 드라이버를 호출합니다.

BOOL WINAPI GetDataStackSize(HANDLE hDataStack, ULONG_PTR* pSize) {
    auto status = NtQueryInformationDataStack(hDataStack,
        DataStackTotalSize, pSize, sizeof(ULONG_PTR), nullptr);
    if (!NT_SUCCESS(status))
        SetLastError(RtlNtStatusToDosError(status));
    return NT_SUCCESS(status);
}
 
BOOL WINAPI GetDataStackItemCount(HANDLE hDataStack, ULONG* pCount) {
    auto status = NtQueryInformationDataStack(hDataStack,
        DataStackItemCount, pCount, sizeof(ULONG), nullptr);
    if (!NT_SUCCESS(status))
        SetLastError(RtlNtStatusToDosError(status));
    return NT_SUCCESS(status);
}
 
BOOL WINAPI GetDataStackConfig(HANDLE hDataStack, DATA_STACK_CONFIG* pConfig) {
    auto status = NtQueryInformationDataStack(hDataStack,
        DataStackConfiguration, pConfig,
        sizeof(DATA_STACK_CONFIG), nullptr);
    if (!NT_SUCCESS(status))
        SetLastError(RtlNtStatusToDosError(status));
    return NT_SUCCESS(status);
}

Waitable Objects 대기 가능한 개체

Waitable objects, also called Dispatcher objects, maintain a state called Signaled or Non-Signaled, where the meaning of “signaled” depends on the object type. For example, process objects are signaled when terminated. Same for thread objects.
디스패처 객체라고도 하는 대기 가능 객체는 시그널링 또는 비 시그널링 상태를 유지하며, 여기서 "시그널링"의 의미는 객체 유형에 따라 달라집니다. 예를 들어 프로세스 객체는 종료될 때 시그널링됩니다. 스레드 객체도 마찬가지입니다.

Job objects are signaled when all processes in the job terminate. And so on.
작업의 모든 프로세스가 종료되면 작업 개체에 신호가 전송됩니다. 등등.

Waitable objects can be waited on with WaitForSingleObject / WaitForMultipleObjects and friends in the Windows API, which call native APIs like NtWaitForSingleObject / NtWaitForMultipleObjects, which eventually get to the kernel and call ObWaitForSingleObject / ObWaitForMultipleObjects which finally invoke KeWaitForSingleObject / KeWaitForMultipleObjects (both documented in the WDK).
기다릴 수 있는 객체는 WaitForSingleObject / WaitForMultipleObjects와 Windows API의 친구로 기다릴 수 있으며, 이 친구는 NtWaitForSingleObject / NtWaitForMultipleObjects와 같은 네이티브 API를 호출합니다, 결국 커널에 도달하여 ObWaitForSingleObject / ObWaitForMultipleObjects를 호출하고, 이 호출은 최종적으로 KeWaitForSingleObject / KeWaitForMultipleObjects ( 둘 다 WDK에 문서화되어 있음)를 호출합니다.

It would be nice if DataStack objects would be dispatcher objects, where “signaled” would mean the data stack is not empty, and vice-versa. The first thing to do is make sure that the SYNCHRONIZE access mask is valid for the object type. This is the default, so nothing special to do here. GENERIC_READ also adds SYNCHRONIZE for convenience.
데이터 스택 객체가 디스패처 객체이면 좋겠는데, 여기서 "신호"는 데이터 스택이 비어 있지 않다는 것을 의미하며 그 반대의 경우도 마찬가지입니다. 가장 먼저 해야 할 일은 동기화 액세스 마스크가 해당 객체 유형에 유효한지 확인하는 것입니다. 이것은 기본값이므로 여기서 특별히 할 일은 없습니다. GENERIC_READ는 편의를 위해 SYNCHRONIZE도 추가합니다.

In order to be a dispatcher object, the structure managing the object must start with a DISPATCHER_HEADER structure (which is provided by the WDK headers). For example, KPROCESS and KTHREAD start with DISPATCHER_HEADER. Same for all other dispatcher objects – well, almost. If we look at an EJOB (using symbols), we’ll see the following:
디스패처 객체가 되려면 객체를 관리하는 구조체가 (WDK 헤더에서 제공하는) DISPATCHER_HEADER 구조체로 시작해야 합니다. 예를 들어 KPROCESS와 KTHREAD는 DISPATCHER_HEADER로 시작합니다. 다른 모든 디스패처 객체도 마찬가지입니다. (기호를 사용하여) EJOB을 살펴보면 다음과 같은 내용을 볼 수 있습니다:

kd> dt nt!_EJOB
   +0x000 Event            : _KEVENT
   +0x018 JobLinks         : _LIST_ENTRY
   +0x028 ProcessListHead  : _LIST_ENTRY
   +0x038 JobLock          : _ERESOURCE
...

The DISPATCHER_HEADER is in the KEVENT. In fact, a KEVENT is just a glorified DISPATCHER_HEADER:
DISPATCHER_HEADER는 KEVENT에 있습니다. 사실 KEVENT는 미화된 DISPATCHER_HEADER일 뿐입니다:

typedef struct _KEVENT {
    DISPATCHER_HEADER Header;
} KEVENT, *PKEVENT, *PRKEVENT;

The advantage of using a KEVENT is that the event API is available – this is taken advantage of by the Job implementation. For processes and threads, the work of signaling is done internally by the Process and Thread APIs.
KEVENT 사용의 장점은 이벤트 API를 사용할 수 있다는 점이며, 이는 Job 구현에서 활용됩니다. 프로세스와 스레드의 경우 시그널링 작업은 내부적으로 Process 및 Thread API에 의해 수행됩니다.

For the DataStack implementation, we’ll take the Job approach, as the scheduler APIs are internal and undocumented. The DataStack now looks like this:
스케줄러 API는 내부적이고 문서화되어 있지 않으므로 DataStack 구현에서는 Job 접근 방식을 취하겠습니다. 이제 DataStack은 다음과 같이 보입니다:

struct DataStack {
    KEVENT Event;
    LIST_ENTRY Head;
    FAST_MUTEX Lock;
    ULONG Count;
    ULONG MaxItemCount;
    ULONG_PTR Size;
    ULONG MaxItemSize;
    ULONG_PTR MaxSize;
};

In addition, we have to initialize the event as well as the other members:
또한 다른 멤버들과 마찬가지로 이벤트를 초기화해야 합니다:

1
2
3
4
5
void DsInitializeDataStack(DataStack* DataStack, ...) {
//...
    KeInitializeEvent(&DataStack->Event, NotificationEvent, FALSE);
//...
}

The event is initialized as a Notification Event (Manual Reset in user mode terminology). Why? This is just a choice. We could extend the DataStack creation API to allow choosing Notification (manual reset) vs. Synchronization (auto reset) – I’ll leave that for interested coder.
이벤트는 알림 이벤트(사용자 모드 용어로는 수동 재설정)로 초기화됩니다. 왜 그럴까요? 이것은 단지 선택 사항일 뿐입니다. 알림(수동 재설정)과 동기화(자동 재설정)를 선택할 수 있도록 데이터스택 생성 API를 확장할 수 있지만, 이는 관심 있는 코더에게 맡기겠습니다.

Next, we need to set or reset the event when appropriate. It starts in the non-signaled state (the FALSE in KeInitializeEvent), since the data stack starts empty. In the implementation of DsPushDataStack we signal the event if the count is incremented from zero to 1:
다음으로 적절한 경우 이벤트를 설정하거나 재설정해야 합니다. 데이터 스택이 비어 있기 때문에 신호가 없는 상태( KeInitializeEvent의 FALSE )에서 시작합니다. DsPushDataStack의 구현에서는 카운트가 0에서 1로 증가하면 이벤트에 신호를 보냅니다:

NTSTATUS DsPushDataStack(DataStack* ds, PVOID Item, ULONG ItemSize) {
//...
    if (NT_SUCCESS(status)) {
        InsertTailList(&ds->Head, &buffer->Link);
        ds->Count++;
        ds->Size += ItemSize;
        if(ds->Count == 1)
            KeSetEvent(&ds->Event, EVENT_INCREMENT, FALSE);
    }
//...

In the pop implementation, we clear (reset) the event if the item count drops to zero:
팝 구현에서는 아이템 수가 0으로 떨어지면 이벤트를 지웁니다(초기화):

NTSTATUS DsPopDataStack(DataStack* ds, PVOID buffer, ULONG inputSize, ULONG* itemSize) {
//...
memcpy(buffer, item->Data, item->Size);
ds->Count--;
ds->Size -= item->Size;
ExFreePool(item);
if (ds->Count == 0)
    KeClearEvent(&ds->Event);
return STATUS_SUCCESS;
//...

These operations are performed under the protection of the fast mutex, of course.
물론 이러한 작업은 빠른 뮤텍스의 보호 아래 수행됩니다.

Testing 테스트

Here is one way to amend the test application to use WaitForSingleObject:
다음은 테스트 애플리케이션을 수정하여 WaitForSingleObject를 사용하는 한 가지 방법입니다:

// wait 5 seconds at most for data to appear
while (WaitForSingleObject(h, 5000) == WAIT_OBJECT_0) {
    DWORD size = sizeof(buffer);
    if (!PopDataStack(h, buffer, &size) && GetLastError() != ERROR_NO_DATA) {
        printf("Error in PopDataStack (%u)\n", GetLastError());
        break;
    }
//...
    DWORD count;
    DWORD_PTR total;
    if (GetDataStackItemCount(h, &count) && GetDataStackSize(h, &total))
        printf("Data stack Item count: %u Size: %zu\n", count, total);
}

Refer to the project source code for the full sample.
전체 샘플은 프로젝트 소스 코드를 참조하세요.

Summary 요약

This four-part series demonstrated creating a new kernel object type and using only exported functions to implement it. I hope this sheds more light on certain mechanisms used by the Windows kernel.
4부로 구성된 이 시리즈에서는 새로운 커널 객체 유형을 생성하고 내보낸 함수만 사용하여 이를 구현하는 방법을 보여드렸습니다. 이를 통해 Windows 커널에서 사용하는 특정 메커니즘에 대해 더 자세히 알아볼 수 있기를 바랍니다.

Published by 게시자

Pavel Yosifovich 파벨 요시포비치

Developer, trainer, author and speaker. Loves all things software
개발자, 트레이너, 작가, 연사. 소프트웨어의 모든 것을 사랑합니다

Leave a comment  댓글 남기기