tmxklab
I/O 관련 작업(Device Driver, I/O Manager) 본문
여기서 다루는 내용은 Device Driver와 I/O Manager에 중점을 둔다.
다음 그림은 유저단에서 WriteFile() API 함수를 호출했을 때 I/O Manager와 Device Drvier가 처리하는 과정이다.
syscall(x64) 또는 sysenter(x86) 요청을 발생하면 커널 익스큐티브(ntoskrnl.exe)에서 NtWriteFile() 시스템 서비스 루틴을 호출한 후 요청을 I/O Manager에게 전달한다. I/O Manager는 요청한 연산을 처리하는 드라이버를 파악하여 해당 드라이버에게 IRP(I/O Request Packet)을 전달한다. (IRP는 데이터 구조체로 I/O연산에 수행되어야 하는 연산과 필요한 버퍼의 정보를 포함한다.) Device Driver는 IRP를 읽고 확인 후 요청받은 연산을 완료하고 I/O Manager는 유저 애플리케이션에게 요청 작업에 대한 상태와 데이터를 반환한다.
+) Device Driver
일반적으로 Device Driver는 단일 장치 또는 여러 장치를 생성하고 해당 장치를 다룰 수 있는 연산의 유형을 지정하고 이들 연산을 처리하는 루틴의 주소를 지정한다. (이들 루틴은 I/O Manager가 Device Driver에 대한 디스패치 루틴 또는 IRP 핸들러에서 호출한다. 이 때, IRP 구조에 대한 포인터를 전달) 장비를 생성한 후 드라이버는 장치를 유저 모드 애플리케이션에서 접근할 수 있도록 알린다.
추가 참고자료)
DriverEntry(), DRIVER_OBJECT, DEVICE_OBJECT
DriverEntry()는 디바이스 드라이버가 처음 시작되는 루틴이다.(마치, main()함수와 같이)
드라이버를 메모리에 로드할 때 I/O Manager는 드라이버 객체(DRIVER_OBJECT 구조체)를 생성한다.
typedef struct _DRIVER_OBJECT {
CSHORT Type;
CSHORT Size;
PDEVICE_OBJECT DeviceObject;
ULONG Flags;
PVOID DriverStart;
ULONG DriverSize;
PVOID DriverSection;
PDRIVER_EXTENSION DriverExtension;
UNICODE_STRING DriverName;
PUNICODE_STRING HardwareDatabase;
PFAST_IO_DISPATCH FastIoDispatch;
PDRIVER_INITIALIZE DriverInit;
PDRIVER_STARTIO DriverStartIo;
PDRIVER_UNLOAD DriverUnload;
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
} DRIVER_OBJECT, *PDRIVER_OBJECT;
- DriverInit : I/O Manager가 지정한 DriverEntry루틴에 대한 포인터
- DriverUnload : 드라이버 언로드 루틴 진입점
- MajorFunction : 드라이버의 Dispatch루틴에 대한 진입점 배열(디스패치 테이블)
I/O Manager가 드라이버 객체(DRIVER_OBJECT)를 생성하고 드라이버 초기화 루틴인 DriverEntry()를 호출한다. 이 때, 파라미터로 드라이버 객체(DRIVER_OBJECT)에 대한 포인터를 전달한다. 이 DriverEntry()에서는 전달받은 드라이버 객체를 초기화해주는 작업을 진행한다.(초기화 작업은 드라이버의 I/O 요청에 의한 IRP를 처리해주는 다양한 엔트리 포인트로 채우는 것을 말함)
DRIVER_INITIALIZE DriverInitialize;
NTSTATUS DriverInitialize(
_DRIVER_OBJECT *DriverObject,
PUNICODE_STRING RegistryPath
)
{...}
일반적으로 DriverEntry루틴에서 IoCreateDevice() 또는 IoCreateDeviceSecure API함수를 사용하여 논리적 또는 물리적 장치를 나타내는 장치 객체(DEVICE_OBJECT 구조체)를 생성한다.
NTSTATUS IoCreateDevice(
PDRIVER_OBJECT DriverObject,
ULONG DeviceExtensionSize,
PUNICODE_STRING DeviceName,
DEVICE_TYPE DeviceType,
ULONG DeviceCharacteristics,
BOOLEAN Exclusive,
PDEVICE_OBJECT *DeviceObject
);
장치 객체(DEVICE_OBJECT)를 생성할 때 선택적으로 장치에 이름을 부여하고 여러 장치를 만들 수 있으며 장치 객체가 생성된 후 첫 번째로 생성한 장치의 포인트는 드라이버(DRIVER_OBJECT)에 업데이트 된다.
다음은 cdfs.sys 드라이버에 대한 hexlay 화면이다.
또한, 앞서 말했듯이 MajorFunction배열(Dispatch Table)에 드라이버의 I/O요청에 대한 IRP를 처리해주는 Dispatch루틴에 대한 진입점을 채워준다.
이제 디버깅을 통해서 커널 모듈 드라이버를 확인해보자(windbg + livekd)
다음 그림은 Null Device Driver에 대한 정보를 나타낸다.
- !drvobj명령어를 이용하여 Null Device Driver에 대한 객체 정보를 확인할 수 있다.(여기서 Null Device Driver는 리눅스에서 /dev/null과 동일한 기능)
- Driver Object 주소 : 0xffffdb864d4bed80
- Device Object 주소 : 0xffffdb864a3e0a70
다음 그림은 Null Driver Object(DRIVER_OBJECT)에 대한 정보를 나타낸다.
- DeviceObject : 드라이버(null.sys)에서 생성한 Device Object(DEVICE_OBJECT)에 대한 포인터
- DriverName : Driver Object 이름("\Driver\Null")
- DriverInit : 드라이버 초기화 루틴(DriverEntry)의 포인터
- DriverUnload : 드라이버 언로드 루틴의 포인터
- MajorFunction : 디스패치 테이블
다음 그림은 Null Device Object(DEVICE_OBJECT)에 대한 정보를 나타낸다.
- DriverObject : Driver Object(DRIVER_OBJECT)에 대한 포인터이며 다시 Driver Object를 가리킨다. 이는 Device Object를 통해서 연관된 Driver Object를 파악할 수 있다.
- NextDevice : 다음 device에 대한 객체를 가리킨다. null.sys 드라이버는 하나의 장치만 만들었기 때문에 null로 세팅되었다.
위 내용을 토대로 그림으로 나타내면 다음과 같다.
각각의 Device Object는 연관된 Driver Object를 가지고 있고 NextDevice를 통해서 다음 Device Object를 구할 수 있다.
(DeviceTree 또는 WinObj와 같은 GUI 툴을 사용해 드라이버에 대한 정보를 확인할 수 있음)
심볼릭 링크
유저단에서 I/O연산을 하기 위해 직접적으로 디바이스에 대해 접근할 수 없다. 예를 들면 CreateFile()함수에서 인자로 "/dev/null"과 같이 파라미터를 줄 수 없다. 따라서, 유저단에서 디바이스에 대해 접근하기 위해서는 드라이버가 해당 디바이스를 공개해야 한다. 이를 위해서 드라이버는 커널 API인 IoCreateSymbolicLink()를 통해 심볼릭 링크를 만든다. 해당 디바이스에 대한 심볼릭 링크가 만들어지면 객체 관리자 네임스페이스의 \GLOBAL?? 디렉터리에서 찾을 수 있다.
NTSTATUS IoCreateSymbolicLink(
PUNICODE_STRING SymbolicLinkName,
PUNICODE_STRING DeviceName
);
- SymbolicLinkName : 사용자가 볼 수 있는 이름
- DeviceName : 드라이버가 생성한 Device Object의 이름
유저단의 애플리케이션은 단순히 심볼릭 링크의 이름을 이용해(format : \\.\<심볼릭링크 이름>) 디바이스에 대한 핸들을 열 수 있다. 예를 들면 \Device\Null에 대한 핸들을 얻고자 CreateFile()의 파라미터에 "\\.\Nul"을 줄 수 있다. 즉, 객체 관리자의 디렉터리 GLOBAL??안의 심볼릭 링크를 통해 핸들을 구할 수 있다.
다음은 WinObj 툴을 사용해 GLOBAL?? 디렉터리를 확인한 결과이다.
위 그림에서 C:볼륨은 \Device\HarddiskVolume3의 심볼릭 링크 네임이다. 근데 궁금한 점이 파일을 생성하거나 파일 I/O와 같은 작업을 할 때 관련 API함수의 파라미터로 "\\.\C:"이런식으로 주지는 않았다. 예를 들면 CreateFile()의 파라미터로 "C:\test.txt"이런식으로 주지 "\\.\C:test.txt"이렇게 주지는 않았다. ms에서 확인한 결과
요렇다고 한다. 앞에 접두사("\\?\")는 생략하는 것 같다.
정리)
- 드라이버는 초기화 중(DriverEntry루틴)에 장치 객체를 만들고 심볼릭 링크를 통해 유저 애플리케이션에서 사용할 수 있도록 공개한다.
- 윈도우에서는 I/O연산은 가상 파일(네임 스페이스에 등록된 심볼릭 링크)을 사용하여 작업을 수행한다.
그럼 이제 문제는 유저 애플리케이션이 I/O 연산 요청을 했을 때 각 I/O 유형에 따라 따라 다르게 처리해야 한다.
그러기 위해서는 드라이버가 I/O Manager에게 어떤 유형의 연산(읽기, 쓰기 등)을 장치에 지원하는지 알려야 한다.
앞서 말했듯이 드라이버가 초기화하면서 DRIVER_OBJECT 구조체에 있는 MajorFunction[28](디스패치 배열)에 디스패치 루틴의 주소를 업데이트한다고 하였다. 28개의 함수 포인터 배열을 가지며 각각의 인덱스 값은 특정 작업을 나타낸다. 예를 들어 IRP_MJ_CREATE는 인덱스 값이 0이며 Device에 대한 핸들을 열기 위해 사용된다.
정리하면 애플리케이션이 파일 또는 디바이스에 대한 핸들을 열기 위한 요청을 I/O Manager에게 보내면 I/O Manager는 DRIVER_OBJECT의 Major Function에서 IRP_MJ_CREATE(0)를 인덱스로 사용하여 해당 요청을 처리할 디스패치 루틴의 주소를 찾는다.
[ IRP Major Function Code ]
다음 그림을 통해 null.sys드라이버가 채운 디스패치 루틴 배열을 확인할 수 있다.
위 그림에서 드라이버가 지원하지 않는 연산은 ntoskrnl.exe의 IopInvalidDeviceRequest가 가리키므로 IopInvalidDeviceRequest가 아닌 IRP Major Function Code들은 모두 사용할 수 있다.
다음 DeviceTree GUI툴을 사용해서 지원가능한 IRP Major Function Code들을 확인할 수 있다.
정리)
① 유저 애플리케이션에서 I/O 요청
② I/O Manager는 해당 드라이버를 찾고 I/O 요청을 설명하는 IRP(I/O 요청 패킷)을 만든다.
(I/O Manager가 생성한 IRP는 드라이버가 디바이스에서 읽는 데이터 또는 디바이스가 데이터를 저장할 때 사용하는 커널 메모리 버퍼도 포함한다.)
③ I/O Manager가 생성한 IRP는 해당 드라이버의 디스패치 루틴으로 전달된다.
④ 드라이버는 I/O연산을 설명하는 주요 함수코드(IRP_MJ_XX)를 포함한 IRP를 수신한다.
⑤ I/O연산이 초기화된 후 정상인지 확인하고자 점검한다.(읽기 또는 쓰기에서 제공된 버퍼의 크기가 충분한지)
⑥ I/O연산같은 경우 일반적으로 드라이버는 HAL 루틴을 통해 전달된다.
⑦작업이 완료돼면 드라이버는 IRP를 I/O Manager에게 반환해 연산이 완료되었음을 알려준다.
⑧ I/O Manager는 IRP를 해제한다.
⑨ 작업이 완료되면 I/O Manager는 상태와 데이터를 유저 애플리케이션에게 반환한다.
이제 IrpTracker 툴을 사용해서 유저 컴포넌트와 커널 컴포넌트간의 상호작용을 확인해보자
(참고로, Window 10은 지원안해주는 것 같다.)
관리자 권한으로 실행시키고 "Ctrl + R"을 누르고 왼쪽 입력란에 "null"을 입력하고 OK버튼을 누르자. 아 그리고 상단에Options에서 "Display NTAPI Callls"를 눌러서 활성화시키자
그리고 cmd창을 켜서 출력 값을 /Device/Null로 리다이렉시키자
디바이스는 가상 파일처럼 사용되고 디바이스에 작성하기 전에 먼저 CreateFile()을 통해서 디바이스 핸들을 오픈한다. (아마도 위에서 처음 빨간색 박스, Major Function에서 CREATE라고 뜬 부분인 듯, 여기서 IRP_MJ_CREATE Major Function Code에 해당하는 디스패치 루틴을 호출)
그리고 WriteFile()을 통해서 쓰기 연산을 수행하는데 WriteFile()은 최종적으로 ntoskrnl.exe의 NtWriteFile()을 호출해 I/O Manager에게 요청을 보낸다. I/O Manager는 IRP_MJ_WRITE에 해당하는 디스패치 루틴을 호출한다.
Device Driver와 통신 - DeviceIoControl()
유저 애플리케이션에서 DeviceIoControl()를 사용해 커널 모드 디바이스 드라이버와 직접적으로 통신할 수 있다. DeviceIoControl()는 유저 애플리케이션과 디바이스와 통신을할 수 있는 양방향성을 가지는 함수이다.(단방향성을 가지는 함수는 ReadFile(), WriteFile()과 같은 것)
BOOL DeviceIoControl(
HANDLE hDevice,
DWORD dwIoControlCode,
LPVOID lpInBuffer,
DWORD nInBufferSize,
LPVOID lpOutBuffer,
DWORD nOutBufferSize,
LPDWORD lpBytesReturned,
LPOVERLAPPED lpOverlapped
);
- hDevice : device에 대한 핸들
- dwIoControlCode : IOCTL값, 수행할 특정 작업과 수행할 디바이스 유형을 식별
유저 애플리케이션은 디바이스에 대한 핸들을 CreateFile()을 통해 열고 DeviceIoControl()을 호출해 윈도우에서 제공하는 표준 제어 코드를 전달하는 식으로 디바이스와 통신을 수행할 수 있다. 디바이스 드라이버는 해당 디바이스에 특화된 Control Code를 정의할 수 있는데 루트킷의 유저모드 컴포넌트가 이를 이용해 DeviceIoControl API로 드라이버와 통신을 수행할 수 있다.
[ 내부 흐름 ]
유저모드 컴포넌트가 IOCTL값을 전달해 DeviceIoControl()을 호출할 때 ntdll.dll의 NtDeviceIoControlFile()을 호출해 스레드를 커널 모드로 전환하고 ntoskrnl.exe의 시스템 서비스 루틴 NtDeviceIoControlFile()을 호출한다. 이후에 I/O Manager를 호출하고 I/O Manager는 IOCTL코드를 포함한 IRP 패킷을 만들어 IRP_MJ_DEVICE_CONTROL로 식별된 디스패치 루틴에 전달된다.
디바이스 트리
다음 그림은 I/O 요청이 하드웨어 장치에 도달하기 전에 여러 개의 드라이버를 통과하는 과정을 보여준다.
위 과정을 자세히 살펴보기 위해 디버깅을 통해 확인해보자
먼저, netstat.exe를 사용해 출력 내용을 리다이렉션시켜보자
C:\WINDOWS\system32>netstat -an -t 60 > C:\test.txt
그리고 Process Explorer툴을 관리자 권한으로 실행시키자
netstat.exe가 "C:\net.txt"를 오픈하면 I/O Manager는 파일 객체(FILE_OBJECT 구조체)를 만드는데 위에 Object Address에서 0xFFFFDB86660897D0이 C:\net.txt의 파일객체 주소를 나타낸다. 또한, 핸들을 반환하기 전에 파일 객체 내부에 있는 디바이스 객체의 포인터를 저장하게 된다.
위 파일 객체 주소 값을 통해 net.txt의 파일 객체 정보를 확인해보자
DeviceObject에 디바이스 객체(DEVICE_OBJECT)의 포인터 값을 확인할 수 있다.
다시 디바이스 객체 주소 값을 통해 디바이스 객체 정보를 확인해보자
디바이스 이름(HarddiskVolume3)과 관련 드라이버(\Driver\volmgr)을 확인할 수 있다. AttachedDevice는 연결된 디바이스 객체에 대한 포인터로 디바이스 스택에서 디바이스 객체 HarddiskVolume3의 상위에 존재하는 fvevol.sys드라이버이다.
I/O 요청이 통과하는 드라이버 계층을 파악하기 위해 !devstack 명령어를 사용해보자
위 그림을 통해 volmgr드라이버가 소유한 HarddiskVolume3와 관련된 다바이스 스택을 확인할 수 있다. 아까 위에서 봤듯이 HarddiskVolume3객체 위에 "\Driver\fvevol"드라이버를 확인할 수 있다. 이는 I/O Manager가 I/O 요청을 먼저 volsnap에게 전달하고 volsnap는 IRP 요청을 처리한 뒤 해당 요청을 스택에 있는 드라이버들에게 아래쪽으로 차례차례 전달하여 최종적으로 volmgr에 도달하게 된다.
DeviceTree툴을 사용해서 쉽게 Device Tree를 확인해볼 수 있다.
(루트킷은 드라이버가 타겟 Device의 스택 아래 또는 위에 추가해 IRP를 수신할 수 있음, 이를 통해 루트킷 드라이버는 IRP를 정상 드라이버에 전달되기 전에 로깅하거나 수정할 수 있게 됨)
참고자료)
'OS > 03 Windows' 카테고리의 다른 글
윈도우 프로세스(프로세스 관련) (0) | 2021.04.28 |
---|---|
Window API 호출 흐름 (0) | 2021.04.19 |