[Translation] Exploiting System Mechanic Driver Part 1
서론
중간고사 망한 L0ch입니다. 아직 3과목이나 남았지만 눈에 띄는 글이 있어 망한 김에 시험공부는 던지고 번역해봤습니다. 인생..
교수님 이제 그만 절 놓아주세요..
커널 드라이버 취약점에 대해 알기 쉽게 정리한 글이 있어서 파트 2로 나눠서 정리해봤습니다. 파트 1에서는 드라이버 구조에 대한 소개와 IOCTL, 드라이버 퍼징에 대한 내용을 다루며 파트 2에서는 본격적인 드라이버 취약점의 root cause 분석과 익스플로잇에 대해 다룹니다!
지난달 last&VoidSec은 NULLCON에서 Ashfaq Ansari(@HackSysTeam)의 Windows Kernel Exploitation Advanced 과정을 수강했다. 이 과정은 흥미로웠으며 core kernel 공간의 개념과 mitigation 우회 및 exploit에 대해 다루었다. 마지막 실습인 Write an exploit for System Mechanics
에서는 더 이상의 힌트는 제공되지 않았으며 우리는 새로 습득한 지식과 교육 자료에 대한 이해와 리버스 엔지니어링을 테스트하기 위해 도전했다. 이 실습을 어떻게 수행했는지, 그리고 사전 지식 없이 실제 드라이버를 어떻게 활용했는지에 대해 자세히 알아보도록 한다.
Windows drivers 101
드라이버를 리버스 엔지니어링하고 취약점을 찾기 전에 먼저 드라이버가 무엇이며 어떻게 작동하는지 살펴봐야 한다. Windows에서 드라이버는 기본적으로 특정 이벤트가 발생할 때 커널 컨텍스트에서 실행되는 코드를 포함하는 로드 가능한 모듈로 정의할 수 있다. 이벤트는 운영체제가 수행하는 인터럽트 또는 프로세스일 수 있으면 커널은 이러한 인터럽트를 처리하고 요청을 수행하기 위해 적절한 드라이버를 실행한다. 따라서 드라이버 = 커널의 DLL이라고 생각할 수 있다. 드라이버는 프로세스 탐색기에서 시스템 프로세스(PID 4가 있는 모듈) 내에서 로드된 모듈로 나열된다.
PID 4(System Process)에 로드된 모듈
Driver Entry
이제 드라이버의 구조를 살펴보겠다. 대부분의 코드와 마찬가지로 드라이버에는 DriverEntry
라는 일종의 main
함수가 존재한다. 이 기능은 다음과 같이 Microsoft 문서에 정의되어 있다.
NTSTATUS DriverEntry (
_In_ PDRIVER_OBJECT DriverObject,
_In_ PUNICODE_STRING RegistryPath
) ;
인수 앞의 _In_
은 인수가 DriverEntry
함수에 전달되는 입력값이어야 한다는 의미이다. DriverObject
인수는 드라이버에 대한 정보를 보유하는 DRIVER_OBJECT
데이터 구조에 대한 포인터를 나타내며 이후에 더 자세히 설명하도록 하겠다. RegistryPath
인수는 드라이버 이미지(커널이 드라이버 코드를 로드하는 .sys
파일)의 레지스트리 경로를 포함하는 UNICODE_STRING
구조체 (UTF-16 문자열 및 기타 제어정보를 포함하는 구조체)에 대한 포인터이다.
Devices & Symlinks
유저 모드에서 액세스 하려면 드라이버가 표준 유저 프로세스에 액세스할 수 있도록 장치와 심볼릭 링크(a.k.a symlink)를 만들어야 한다. 장치는 프로세스가 드라이버와 상호작용 할 수 있도록 하는 인터페이스이며 심볼릭 링크는 Win32
함수를 호출하는 동안 사용할 수 있는 장치 이름(alias-별칭)이다.
심볼릭 링크의 예: C:\
는 저장장치를 위한 심볼릭 링크에 불과하다. Sysinternals
의 WinObj 툴을 사용해 root namespace 아래의 GLOBAL??
디렉터리에서 C:
를 찾을 수 있다.
드라이버는 IoCreateDevice
및 IoCreateSymbolicLink
를 사용하여 장치 및 심볼릭 링크를 만든다. 드라이버를 리버스 엔지니어링 할 때 위 두 함수가 연속적으로 호출이 장치와 심볼릭 링크를 인스턴스화 하는 부분인 것을 확인할 수 있다. 대부분의 드라이버는 하나의 장치만 사용하므로 대부분 한 번만 발생하며 일반적으로 장치 이름은 \Device\VulnerableDevice
형식을 사용한다. 반면 심볼릭 링크는 \\.\VulnerableDeviceSymlink
형식과 유사하다. 이제 드라이버의 “frontend”에 대해 설명했으므로 “backend”인 디스패치 루틴에 대해 설명하겠다.
Dispatch Routines
드라이버는 장치에서 호출되는 함수에 따라 다른 작업(a.k.a 함수/루틴)을 실행한다. 드라이버는 WriteFile
, ReadFile
또는 DeviceIoControl
API가 해당 장치에서 호출될 때 각각 다르게 작동할 수 있다. 이 동작은 DriverObject
구조체의 함수 포인터 배열인 MajorFunctions
멤버를 통해 드라이버 개발자가 제어한다. WriteFile
, ReadFile
또는 DeviceIoControl
과 같은 API는 MajorFunctions
내에 해당 인덱스가 있어 관련 함수 포인터가 호출된다. 일부 매크로를 사용하여 관련 인덱스를 기억할 수 있으며 다음은 몇 가지 예다.
IRP_MJ_CREATE
:CreateFile
호출 후 호출되는 함수 포인터를 포함하는 인덱스IRP_MJ_READ
:ReadFile
과 같은 함수와 관련된 인덱스IRP_MJ_DEVICE_CONTROL
: DeviceIoControl에 해당하는 인덱스
드라이버 개발자가 MyDriverRead
라는 함수를 정의했으며 프로세스가 드라이버 장치에서 ReadFile
API를 호출할 때 호출되기를 원한다고 가정해본다. DriverEntry
내부 (또는 호출된 함수)에 다음 코드를 작성하면 된다.
DriverObject->MajorFunctions[IRP_MJ_READ] = MyDriverRead;
이와 같은 함수는 디스패치 루틴의 이름을 사용한다. 그러나 MajorFunctions
는 제한된 크기의 배열이므로 드라이버에 많은 디스패치 루틴을 할당하지 못한다. 이 문제점은 유저 모드 함수 DeviceIoControl
를 사용하는 이유가 된다.
DEVICEIOCONTROL & IOCTL Codes
IRP_MJ_DEVICE_CONTROL
로 정의된 MajorFunctions
내에는 특정 인덱스가 있다. 이 인덱스에는 드라이버 장치에서 DeviceIoControl
API 호출 후 호출되는 디스패치 루틴의 함수 포인터가 저장된다. 이 함수는 인수 중 하나가 IOCTL로 알려진 32 비트 정수이기 때문에 매우 중요하다. 이 I/O 코드는 드라이버에 전달되고 DeviceIoControl
을 통해 전달되는 IOCTL에 따라 각각의 작업을 수행한다. 인덱스 IRP_MJ_DEVICE_CONTROL
의 디스패치 루틴은 코드의 다음과 같은 스위치 케이스처럼 작동한다.
switch(IOCTL)
{
case 0xDEADBEEF:
DoThis();
break;
case 0xC0FFEE;
DoThat();
break;
case 0x600DBABE;
DoElse();
break;
}
이러한 방식으로 개발자는 프로세스에서 전송하는 IOCTL 코드에 따라 드라이버가 함수를 호출하도록 할 수 있다. 이러한 종류의 “코드 지문”은 드라이버를 리버스 엔지니어링 하는 동안 찾기가 매우 쉽기 때문에 매우 중요하다. 어떤 IOCTL이 어떤 코드로 연결되는지 알면 드라이버를 더 쉽게 분석하고 퍼징할 수 있다.
Reverse Engineering: Finding the IOCTL
드라이버 리버스 엔지니어링을 시작하기 전에 첫 번째로 해야 할 일은 통신에 사용하는 IOCTL 코드와 장치 이름 (symlink)을 찾는 것이다. 우리의 경우 대상 애플리케이션은 iolo – System Mechanic Pro v.15.5.0.61 (amp.sys)
이며 설치 시 WinObj
를 활용하여 다음과 같이 장치 이름과 권한을 복구했다.
이제 장치 이름 \Device\AMP
을 수집했으니 드라이버 amp.sys
를 디스어셈블러 (IDA 사용)에 로드하고 누락된 경우 다음과 같은 필요한 구조체를 추가한다.
DRIVER_OBJECT
IRP
IO_STACK_LOCATION
DriverEntry
함수에 도달하면 드라이버가 생각보다 조금 더 복잡하다는 것을 알 수 있다. Imports
섹션에서 IoDeviceControl
API를 xref하기로 결정했으며 그 결과는 SUB_2CFE0
(DriverCreateDevice
로 변경) 하나뿐이다.
DeviceName
이 인스턴스화 되고 DriverObject
가 전달되는 것을 볼 수 있으므로 올바른 함수에 도달했음을 알 수 있고 디컴파일을 진행했다.
MajorFunction[14] (offset 0x0e)
을 살펴보면 IRP_MJ_DEVICE_CONTROL 드라이버를 찾을 수 있다. 이는 시스템 정의 IOCTL 세트가 있는 경우 DispatchDeviceControl 루틴에서 드라이버가 지원해야 하는 요청이다. SUB_2C580
을 디컴파일하면 이 드라이버에 대한 IOCTL 코드에 도달할 수 있다. 아래의 디컴파일 코드를 보고 직접 IOCTL 코드를 찾아보자.
__int64 __fastcall sub_2C580(__int64 a1, IRP *a2)
{
BOOLEAN v3; // [rsp+20h] [rbp-38h]
ULONG v4; // [rsp+24h] [rbp-34h]
_IO_STACK_LOCATION *v5; // [rsp+28h] [rbp-30h]
unsigned int v6; // [rsp+30h] [rbp-28h]
PNAMED_PIPE_CREATE_PARAMETERS v7; // [rsp+38h] [rbp-20h]
a2->IoStatus.Information = 0i64;
v5 = a2->Tail.Overlay.CurrentStackLocation;
if ( v5->Parameters.Read.ByteOffset.LowPart == 2252803 )
{
v4 = v5->Parameters.Create.Options;
v7 = v5->Parameters.CreatePipe.Parameters;
v3 = IoIs32bitProcess(a2);
v6 = sub_166D0(v3, v7, v4);
}
else
{
v6 = -1073741808;
}
a2->IoStatus.Status = v6;
IofCompleteRequest(a2, 0);
return v6;
}
찾을 수 없거나 분석이 추가된 코드를 원한다면 다음 코드를 살펴보자.
__int64 __fastcall Driver_IRP_MJ_DEVICE_CONTROL(DEVICE_OBJECT *DeviceObject, IRP *Irp)
{
__int64 result; // rax
_BYTE Is32BitProcess; // [rsp+20h] [rbp-38h]
_DWORD bufferSize; // [rsp+24h] [rbp-34h]
_QWORD IoStackLocation; // [rsp+28h] [rbp-30h]
NTSTATUS status; // [rsp+30h] [rbp-28h]
_QWORD userBuffer; // [rsp+38h] [rbp-20h]
_QWORD; // [rsp+68h] [rbp+10h]
Irp->IoStatus.Information = 0i64;
IoStackLocation = Irp->Tail.Overlay.CurrentStackLocation;
if ( IoStackLocation->Parameters.Read.ByteOffset.LowPart == 0x226003 )// IOCTL Code
{
bufferSize = IoStackLocation->Parameters.Create.Options;
userBuffer = &IoStackLocation->Parameters.CreatePipe.Parameters->NamedPipeType;
Is32BitProcess = IoIs32bitProcess(Irp);
status = DriverVulnerableFunction(Is32BitProcess, userBuffer, bufferSize);
}
else
{
status = 0xC0000010; // STATUS_INVALID_DEVICE_REQUEST
}
Irp->IoStatus.Status = status;
IofCompleteRequest(Irp, 0);
return (unsigned int)status;
}
IOCTL 코드 0x226003
는 IOCTL 요청과 함께 전달된 데이터 버퍼에 액세스할 때 커널이 사용하는 방법을 이해하기 위해 추가로 디코딩될 수 있다. OSR 온라인 IOCTL 디코더 툴을 사용하여 다음 정보를 복구할 수 있다.
METHOD_NEITHER
는 IOCTL 요청과 함께 전달된 데이터 버퍼에 액세스하는 데 사용되는 가장 취약한 방식이다. 이를 사용할 때 I/O 관리자는 유저 데이터에 대해 어떤 종류의 유효성 검사도 수행하지 않고 원시 데이터를 드라이버에 그대로 전달한다. 유효성 검사 없이 유저 데이터를 관리하는 코드에 있는 버그 또는 취약점을 발견할 확률이 더 높다. 이제 IOCTL 코드 0x226003
와 장치 이름 \Device\AMP
을 구했으니 드라이버를 퍼징하고 취약점을 찾을 수 있다.
Fuzzing
IOCTL 코드를 검색한 후 ioctlbf로 드라이버를 퍼징했다. Ioctlbf 구문은 이해하기 매우 쉽다. 먼저 장치 이름 -d
를 지정한 다음 퍼징할 IOCTL 코드를 -i
로 지정한 뒤 -u
매개 변수를 사용하여 제공된 IOCTL 코드만 퍼징하도록 한다. (드라이버에 IOCTL 코드가 하나만 있다는 것을 이미 알고 있으므로 무차별 대입이 필요 없다.)
ioctlbf를 실행한 직후 다음 메시지 amp+6c8d
와 함께 디버기 머신에서 크래시가 발생했다.
Access violation - code c0000005 (!!! second chance !!!)
fffff801`3ae96c8d 488b0e mov rcx,qword ptr [rsi]
PROCESS_NAME: ioctlbf.EXE
READ_ADDRESS: 0000000000000000
ERROR_CODE: (NTSTATUS) 0xc0000005 - The instruction at 0x%p referenced memory at 0x%p. The memory could not be %s.
EXCEPTION_CODE_STR: c0000005
EXCEPTION_PARAMETER1: 0000000000000000
EXCEPTION_PARAMETER2: 0000000000000000
STACK_TEXT:
ffff9304`c35c66e0 ffffe60b`ecd87bb0 : 00000000`00000001 00000000`00000000 fffff801`35c23f8b ffff9304`c35c6700 : amp+0x6c8d
ffff9304`c35c66e8 00000000`00000001 : 00000000`00000000 fffff801`35c23f8b ffff9304`c35c6700 00000000`00000001 : 0xffffe60b`ecd87bb0
ffff9304`c35c66f0 00000000`00000000 : fffff801`35c23f8b ffff9304`c35c6700 00000000`00000001 ffffe60b`e5303c80 : 0x1
널 포인터 역 참조와 유사한 오류이다. 이제 드라이버를 리버스 엔지니어링해 access violation이 발생한 이유와 이를 악용할 수 있는 방법이 있는지 이해하기로 했다.
다음 파트는 취약점의 root cause 분석과 익스플로잇에 대한 번역으로 돌아오겠습니다!