목적
이번엔 커널레벨에서 프로세스 목록을 가져와 디버깅 메시지로 출력하는 동작을 수행해보려고 한다.
조건이 몇가지 있는데
- 오프셋 하드코딩 하지 말것
- 프로세스별로 오프셋 n바이트 위치에 있는 값을 가져오지 않게
- 특정할 수 있는 기준 오프셋을 항상 동일하게 뽑을수 있는 경우를 상정하기
- 호환성 준수할것
- OS 버전에 따라 사용하는 구조체나 함수가 최대한 달라지지 않을 것
- 다른 OS 커널 버전에서 사용하지 않는 구조의 사용은 피할 것
- 최대한 알려진 구조를 사용할 것
- 공식 문서에 나와있는 구조체와 함수 사용
아래 웹사이트에서 커널 버전별로 문서화되지 않은 커널 구조들에 대해 찾아볼 수 있다.
https://www.vergiliusproject.com
이를 참조하면 2번과 3번 조건의 만족이 좀 더 용이할 듯.
이러한 조건들을 만족하면서 프로세스 목록을 띄워보자.
프로세스 구조 - Windbg
프로세스 목록을 띄우려면 일단 프로세스가 메모리에 올라갔을 때 어떻게 보이는지 부터 알아놔야 어떤 값을 찾아올지를 결정해볼 수 있을 것 같다.
!process
- 프로세스의 정보 조회하는 명령어
- !process 0 0 으로 모든 프로세스 목록 조회 가능
0: kd> !process 0 0
**** NT ACTIVE PROCESS DUMP ****
PROCESS ffff958f0f6aa040
SessionId: none Cid: 0004 Peb: 00000000 ParentCid: 0000
DirBase: 001ad002 ObjectTable: ffffab0ab4e47e40 HandleCount: 2335.
Image: System
PROCESS ffff958f0f6db080
SessionId: none Cid: 005c Peb: 00000000 ParentCid: 0004
DirBase: 012f2002 ObjectTable: ffffab0ab4e80cc0 HandleCount: 0.
Image: Registry
PROCESS ffff958f11633080
SessionId: none Cid: 0150 Peb: 3d94db9000 ParentCid: 0004
DirBase: 02b9f002 ObjectTable: ffffab0ab57a7180 HandleCount: 53.
Image: smss.exe
PROCESS ffff958f116c3140
SessionId: 0 Cid: 01a4 Peb: fdad173000 ParentCid: 019c
DirBase: 234b0e002 ObjectTable: ffffab0ab8692b00 HandleCount: 496.
Image: csrss.exe
PROCESS ffff958f12233080
SessionId: 0 Cid: 01f0 Peb: ddd242c000 ParentCid: 019c
DirBase: 22fa33002 ObjectTable: ffffab0ab8694480 HandleCount: 169.
Image: wininit.exe
- 프로세스 주소, PID, PEB, 부모 PID, 이미지, 오브젝트 테이블 등의 정보를 조회할 수 있다.
- 이중에 Image 가 프로세스 이름이 들어있는 부분
- 프로세스 주소를 입력하면 해당 프로세스 내 쓰레드 정보도 조회할 수 있다.
- dt _EPROCESS 프로세스 주소 명령어를 입력해 프로세스 구조체를 조회할 수 있다.
+0x440 UniqueProcessId : 0x00000000`00000004 Void
+0x5a8 ImageFileName : [15] "System"
뽑아올 내용은 UniqueProcessId 와 ImageFileName.
구조체상의 저 두가지 내용을 다른 함수나 구조체에 명시된(혹은 반환하는) 오프셋을 통해 동적으로 접근 가능한 방법을 찾아야한다.
ZwQuerySystemInformation - 예제코드
//프로세스 목록 가져오는 함수 - ZwQuerySystemInformation 함수
void getProcessList() {
NTSTATUS retStatus = STATUS_SUCCESS;
ULONG BufferSize, BackwardPID = 0;
//전달받을 데이터 크기 구함
retStatus = ZwQuerySystemInformation((SYSTEM_INFORMATION_CLASS)SystemProcessInformation, NULL, 0, &BufferSize);
DbgPrintEx(DPFLTR_IHVDRIVER_ID, 0, "===== Sysinfo Class : %d ====== \n", (SYSTEM_INFORMATION_CLASS)SystemProcessInformation);
DbgPrintEx(DPFLTR_IHVDRIVER_ID, 0, "===== BufferSize : %ld ====== \n", BufferSize);
DbgPrintEx(DPFLTR_IHVDRIVER_ID, 0, "===== NT RETURN : %x ====== \n", retStatus);
//반환값 체크
if (retStatus == STATUS_INFO_LENGTH_MISMATCH) {
dmsg("Get Buffer Size Complete!");
if (BufferSize) {
//커널 메모리 동적할당
PVOID PoolMem = ExAllocatePoolWithTag(PagedPool, BufferSize, POOL_TAG);
dmsg("Pool Memory Complete !");
if (PoolMem) {
//데이터 전달받음
retStatus = ZwQuerySystemInformation((SYSTEM_INFORMATION_CLASS)SystemProcessInformation, PoolMem, BufferSize, &BufferSize);
if (NT_SUCCESS(retStatus)) {
dmsg("Get Process Info Complete!");
//할당한 메모리 공간 SYSTEM_PROCESS_INFORMATION 구조체에 대입
SYSTEM_PROCINFO *ProcessEntry = (SYSTEM_PROCINFO*)PoolMem;
do {
ULONG TargetPID = 0;
if (ProcessEntry->ImageName.Length) {
TargetPID = (ULONG)ProcessEntry->UniqueProcessId;
DbgPrintEx(DPFLTR_IHVDRIVER_ID, 0, "===== DRIVER MESSAGE : PNAME - %ws | PID - %lx ====== \n",(ProcessEntry->ImageName.Buffer), TargetPID);
}
ProcessEntry = (SYSTEM_PROCINFO *)((BYTE*)ProcessEntry + ProcessEntry->NextEntryOffset);
BackwardPID = TargetPID;
} while (ProcessEntry->NextEntryOffset);
}
}
}
}
}
윈도우 8 이전까지 사용하던 함수로, 지정된 시스템 정보를 찾아 출력하는 역할을 한다.
NTSTATUS WINAPI ZwQuerySystemInformation(
_In_ SYSTEM_INFORMATION_CLASS SystemInformationClass,
_Inout_ PVOID SystemInformation,
_In_ ULONG SystemInformationLength,
_Out_opt_ PULONG ReturnLength
);
해당 함수의 SystemInformationClass 인자로 5가 넘어올 경우 SYSTEM_PROCESS_INFORMATION 을 조회하며 프로세스 목록을 구조체 형식의 버퍼로 전달한다.
위 예제코드가 어떤 원리로 프로세스 목록을 가져오는지를 분석해보자.
if (ZwQuerySystemInformation(SystemProcessInformation, NULL, 0, &bufferSize) == STATUS_INFO_LENGTH_MISMATCH) {
- ZwQuerySystemInformation 함수를 호출하지만 두번째, 세번째 인자가 각각 NULL과 0임
- 네 번째 인자인 ReturnLength 는 선택적으로 전달됨
- 요청된 내용에 대해 함수가 실제로 데이터를 write할 영역의 크기를 가지고 있다.
- 해당 값이 세번째 인자인 SystemInformationLength 보다 작거나 같으면 복사가 가능하다는 의미 → 두번째 인자로 전달된 SystemInformation 에 정보를 복사한다.
- 해당 값이 세번째 인자보다 크면 복사가 불가능하다는 의미 → NTSTATUS 에러코드를 반환하고, ReturnLength 인자에 실제 요청된 데이터의 크기를 복사한다.
- 즉, 해당 코드는 실제 복사할 데이터의 크기를 모르는 상태에서 이를 얻기위해 실행된다.
- 결과값이 STATUS_INFO_LENGTH_MISMATCH 인 경우라면 ReturnLength 인자에 실제 데이터의 크기가 복사되었을 것.
if (bufferSize) {
PVOID memory = ExAllocatePoolWithTag(PagedPool, bufferSize, POOL_TAG);
- 그렇게 buffersize가 구해진 경우에 ExAllocatePoolWithTag() 함수를 실행함
- 커널 메모리를 동적으로 할당한다.
- PagedPool - 할당될 메모리의 pool 유형
- 위에서 구한 데이터의 크기만큼 메모리를 동적으로 할당한다.
- 성공적으로 종료되면 할당된 메모리의 주소를 반환함.
if (memory) {
ntstatus = ZwQuerySystemInformation(SystemProcessInformation, memory, bufferSize, &bufferSize);
if (NT_SUCCESS(ntstatus)) {
PSYSTEM_PROCESSES processEntry = memory;
- 주소가 할당된 경우 ZwQuerySystemInformation() 함수를 다시 한번 호출
- 세번째 인자와 네번째 인자에 같은값이 전달되었으므로 데이터 복사가 진행 될 것.
- 동적으로 할당된 메모리 영역에 데이터를 복사한다.
- 복사 성공한 경우 memory의 포인터를 ProcessEntry로 복사한다.
- SYSTEM_PROCESS_INFORMATION 구조체처럼 사용할 수 있음.
//할당한 메모리 공간 SYSTEM_PROCESS_INFORMATION 구조체에 대입
SYSTEM_PROCINFO *ProcessEntry = (SYSTEM_PROCINFO*)PoolMem;
do {
ULONG TargetPID = 0;
if (ProcessEntry->ImageName.Length) {
TargetPID = (ULONG)ProcessEntry->UniqueProcessId;
DbgPrintEx(DPFLTR_IHVDRIVER_ID, 0, "===== DRIVER MESSAGE : PNAME - %ws | PID - %lx ====== \n",(ProcessEntry->ImageName.Buffer), TargetPID);
}
ProcessEntry = (SYSTEM_PROCINFO *)((BYTE*)ProcessEntry + ProcessEntry->NextEntryOffset);
BackwardPID = TargetPID;
} while (ProcessEntry->NextEntryOffset);
- 반복문을 돌면서 복사한 데이터를 ProcessEntry를 통해 구조체처럼 사용함
- 유효한 ProcessName을 가진 경우
- RtlStringCbPrintfA() 함수 실행 - 문자열 구성
- 구조체를 참조해 ProcessName과 ProcessId를 가져와 문자열 구성하여 String에 저장
- 문자열 구성 성공한 경우
- RtlStringCbLengthA() 함수 실행 - 문자열 길이 구함
- 구성한 string 문자열의 길이를 결정해 length 변수에 저장
- strlen과 동일한 역할 수행
- 문자열 길이 구한 경우
- ZwWriteFile() 함수 실행 - 파일에 문자열 쓰기 수행
- 파일 핸들, 상태블록, 문자열, 길이가 인자로 전달
- 지정 파일에 문자열 쓰기 작업을 수행한다.
- 유효한 ProcessName을 가진 경우
- processEntry->NextEntryDelta 멤버에는 다음 systemprocess 주소의 오프셋이 담겨있다.
- 해당 값을 참조하여 다음 프로세스 구조체 시작위치의 오프셋을 구한 후 연산하여 다음 프로세스에 접근한다.
- 해당 값이 존재하지 않는다면 반복문을 종료한다.
ExFreePoolWithTag(memory, POOL_TAG);
- ExFreePoolWithTag() 함수는 동적할당한 메모리를 할당 해제한다.
정리하면, 예제코드는 ZwQuerySystemInformation() 함수를 통해 프로세스 목록을 가져오고, 각 프로세스 구조체에 들어있는 프로세스 id와 이름을 알아낸 다음 , 다음순서 프로세스 구조체로 연속해 넘어가는 형태로 동작하여 전체 프로세스의 정보를 얻어낸다.
구현 방법
NtQuerySystemInformation 함수를 사용하면 예제코드와 동일하게 동작을 구현 가능하다.- 뒤에 설명
- Process Hide 기법들이 이런 프로세스 목록을 조회하는 함수를 후킹하거나 구조체를 조작해 프로세스를 숨기곤 하므로, 이런 기법들 위주로 찾으면 여러 함수나 구조체를 찾아볼 수 있을것같다.
- PsGetCurrentProcess() 매크로 함수를 사용하여 현재 쓰레드의 프로세스 목록을 가져와 EPROCESS 포인터를 획득할 수 있다.
- EPROCESS와 그 안에 들어있는 KPROCESS 포인터는 커널 빌드에 따라 구성 형태가 바뀔 수 있어 오프셋을 바로 사용하는 방법은 바람직하지 않다.
- 그러나 해당 구조체들의 특정 오프셋에 바로 접근하는 함수를 사용할 수만 있으면 기준점을 잡아 충분히 사용할 수 있다. 커널 빌드가 바뀌어 포인터 오프셋이 바뀌면 해당 함수들이 접근하는 오프셋도 바뀔 것이기 때문.
- PsGetCurrentProcess() 매크로 함수를 사용하여 현재 쓰레드의 프로세스 목록을 가져와 EPROCESS 포인터를 획득할 수 있다.
- ntOSKernel
- 현재 버전 OS 커널빌드에서 사용하는 구조체나 함수등은 system32/ntoskrnl.exe에 선언되어있고, 해당 라이브러리를 분석하면 어떤 키워드를 사용할 수 있는지를 알 수 있다.
- 디버거를 통해 함수 구현부를 자세히 분석하면,
- 내부적으로 호출하는 함수나 특정 오프셋에 접근하는 형태등을 알 수 있고,
- 오브젝트나 구조체 내부 오프셋을 타고 넘어가면서 어떤 값들이 참조되고 인자로 넘어가는지의 연속적인 흐름을 알 수 있다.
- 따라서, 어떤 함수를 사용하고, 이 함수를 통해 얻은 결과물의 어떤 부분을 어떻게 참조하여 어떤 값을 얻어낼 수 있는지를 미리 정해 어떤 함수를 사용할지 결정할 수 있게 된다.
- 디버거를 통해 함수 구현부를 자세히 분석하면,
- 현재 버전 OS 커널빌드에서 사용하는 구조체나 함수등은 system32/ntoskrnl.exe에 선언되어있고, 해당 라이브러리를 분석하면 어떤 키워드를 사용할 수 있는지를 알 수 있다.
- 헤더선언때문에 발생하는 문제가 있다. ntddk와 winternl 은 같은 이름을 가진 선언들을 꽤 많이 공유하고 있어서, 두 헤더를 같이 사용하면 재정의 오류에 더불어 좀 많은 에러들이 발생한다.
- 따라서, 현재 winternl 에서 필요로 하는 _SYSTEM_INFORMATION_CLASS 구조체와 _SYSTEM_PROCESS_INFORMATION 구조체는 직접 선언해서 쓴다.
- NtQuerySystemInformation 함수는 ntkerel(ntoskrnl.exe) 에 포함된 export 함수이므로 함수 포인터만 선언하고 원형은 직접 불러와서 사용하자.
- 유저모드에서는 GetModuleHandle() 이나 GetProcAddress() 함수를 통해 이런 형태의 동작을 구현할 수 있다.
- MmGetSystemRoutineAddress() 함수를 사용해 GetProcAddress()와 동일한 동작을 구현할 수 있다.
- GetProcAddress() 와는 살짝 다른게, 특정 DLL을 지정하지 않고 이름만 가지고 NtosKrnl에서 export 하는 함수를 바로 가져온다.
RtlInitUnicodeString(&NtQuerySystemInformationName, L"NtQuerySystemInformation"); NtQuerySystemInformation = (NtQuerySystemInformation_t) MmGetSystemRoutineAddress(&NtQuerySystemInformationName);
정리 - 구현
- ZwQuerySystemInformation 함수 이용
- ntOSKernel.exe 의 Export 함수 이용
- 함수 포인터를 MmGetSystemRoutineAddress() 실행하여 가져옴
- 데이터 길이 계산
- 실제 데이터 받아옴
- ntOSKernel.exe 의 Export 함수 이용
- SYSTEM_PROCESS_INFORMATION 구조 이용
- ImageName과 ProcessId 갖고 있음
- NextEntryOffset 에 다음 프로세스까지의 오프셋 저장되어 있음
- 해당 오프셋 가져와 현재 프로세스의 주소값과 더하면 다음 프로세스의 주소값 나옴
- 연달아서 다음 프로세스 참조하는 형식으로 진행
- IOCTL 통한 호출
- 유저모드 프로세스에서 드라이버로 IOCTL 코드 전송하여 동작 트리거
실습
- 코드에서 가지고 온 주소값이 실제 함수의 주소인지 확인
- 서로 일치하는것 확인된다.
- NtQuerySystemInformation 함수가 정상작동하지 않는걸로 확인되어 값이 바뀔 BufferSize와 ntstatus를 확인
-
- BufferSize 는 0으로, 함수가 정상 실행되지 않은것으로 보임
- NT Return 이 0xc0000005 → STATUS_ACCESS_VIOLATION 오류.
- Nt 접두사가 붙은 함수들은 인자들이 유저모드 메모리 공간에서 넘어오는지를 검증하고, 그렇지 않으면 에러를 뱉어낸다.
- 즉, 커널모드에서 함수를 쓸때는 Nt~ 계열이 아닌 Zw~ 계열 함수를 사용해야 하는 것.
- 최종적으로 ZwQuerySystemInformation() 함수를 사용하는 방식으로 변경.
- ProcessEntry->ImageName 은 구조체 형식을 가지고 있고, .Buffer 멤버에 접근해야 WHAR 자료형 Imagename 텍스트를 가져올 수 있다.
DbgPrintEx(DPFLTR_IHVDRIVER_ID, 0, "===== DRIVER MESSAGE : PNAME - %ws | PID - %ld ====== \n",(ProcessEntry->ImageName.Buffer), (long int)ProcessEntry->UniqueProcessId);
실습 완료
Github
https://github.com/synod2/Kernel_Hooking/tree/main/Window%20Device%20Driver/MyDriver3
참고 문서
https://www.vergiliusproject.com
https://github.com/danielkrupinski/KernelProcessList
https://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/sysinfo/process.htm?ts=0,1000
https://t0rchwo0d.github.io/windows/Windows-Anti-Reversing-Technique-Hide-Process/ - Process Hide
http://alter.org.ua/docs/nt_kernel/procaddr/ - Using NT KernlMode Export
https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-mmgetsystemroutineaddress - MmGetSystemRoutineAddress
오 쓰고보니 100번째 글