본문 바로가기
정상을향해/OS·Kernel Driver·Rootkit

3. 응용프로그램과의 통신

by 사이테일 2015. 3. 9.


1. 디바이스 스택 (Device Stack)

윈도우는 하나의 디바이스를 여러 개의 디바이스로 나누어서 생각하려 한다이 때 여러 개의 디바이스들은 그들끼리 상하 관계를 유지하는 모습으로 스택을 구성하고 있다이를 디바이스 스택(Device Stack)’이라고 부른다.

디바이스 스택은 최소한 2개의 계층을 가지도록 설계되는데, 아래 그림에서 보게되는 물리층기능층이다.


모든 추상적인 디바이스는 항상 최소한 이렇게 물리층(PDO)’기능층(FDO)’으로 구분된다. 이렇게 나누는 이유는 모든 디바이스는 CPU에 소개되기 위해서는 반드시 버스에 연결되어야 한다는 사실 때문이다.

따라서 버스에 연결되는 특징으로 인해 디바이스 자체가 동작하려면 우선 버스로부터 해당 디바이스가 동작하도록 허용되는 과정과 디바이스 자체에 대한 프로그래밍 과정으로 나뉜다.


장치관리자를 통해 살펴보면, ‘HID 규격 마우스라는 장치 드라이버를 찾는데까지 많은 경로를 거치게 된다. 거치는 경로마다 드라이버(*.sys) 파일이 존재한다. 예를 들어 ‘PCI 버스라는 장치는 PCI.sys 라는 파일이 곧 드라이버 자체가 된다. 그러므로 ‘HID 규격 마우스 드라이버가 윈도우 상에 인식되기 위해서는 그 상위의 드라이버들이 모두 설치가 되어 있어야 한다. 그리고 디바이스 스택은 앞서 언급한 것과 같은 구조(물리층, 기능층 등)를 가지며, 이러한 스택을 구성하는 것을 디바이스 드라이버 제작이라고 할 수 있다.

- 물리층 (Physical Device Object)

Physical Device Object(이하 PDO)는 버스 드라이버(특정 버스에 attach된 디바이스들에 대한 열거 및 관리를 하는 드라이버)가 사용하는 Device Object라고 할 수 있다. PDO는 개발자가 직접 작성하는 것이 아니라 드라이버를 작성하고자 하는 디바이스의 상위 디바이스가 생성해준다.

예를 들어, PCI Interface를 사용하는 디바이스에 대한 드라이버를 작성하고자 한다면, 작성하고자 하는 드라이버에 대한 PDO는 상위개념인 PCI Manager(PCI.SYS)가 만들어 줄 것이다. 또한 이 PCI에 대한 PDO는 그 상위 드라이버가 만들어 준다.

-기능층 (Functional Device Object)

Functional Device Object(이하 FDO)는 기능을 담당하는 Device Object이다. 실제 드라이버를 설치하게 되면 DriverEntry 루틴에서 처음 설치를 하게 될 때 AddDevice라는 루틴으로 가라고 명시해 주게 되고, AddDevice 루틴에서는 IOCreateDevice() 함수를 사용해서 FDO를 생성해주는데 이 때부터 다른 모든 루틴에서 이 FDO를 컨트롤하며 작업을 하게 된다. 실제로 PDO를 받아서 쓰는 루틴은 DriverEntryAddDevice 루틴 밖에 없다.


2. 디바이스 드라이버와 대화하기 위한 함수

응용 프로그램은 특정 API 함수를 사용해서 디바이스 드라이버와 대화를 한다. 모든 대화는 CreateFile() 함수로부터 시작해서, CloseHandle() 함수로 마무리된다.

- Win32 API CreateFile()

응용 프로그램은 CreateFile() 함수를 사용해서 디바이스 드라이버와 대화를 시도한다. 이 중에서 첫 번째 파라미터에는 접근할 심볼릭 이름을 사용한다. 응용 프로그램은 심볼릭 이름을 첫 번째 파라미터에 사용하여 간단하게 디바이스 스택으로 열기 명령(CREATE 명령)을 보낼 수 있다. 주의할 점은, 이 함수는 디바이스 스택상으로 열기 명령이 실제 전달되는 기능을 수행하기 때문에 디바이스 스택상에 존재하는 디바이스 드라이버가 이 명령에 대해서 거부하면 이 함수는 에러를 반환한다는 사실이다.

위 예시에서 5번째 파라미터를 보면, ‘OPEN_EXISTING’ 상수를 사용하고 있다. 하지만 이는 실제 어떤 파이을 생성하기 위해 CreateFile() 함수를 사용하는 것이 아니라, 이미 있는 디바이스 스택에 접근하고자 이 함수를 사용하는 것이다.

- Win32 API ReadFile()

응용 프로그램은 디바이스 스택으로부터 일련의 데이터를 수신하고 싶을 때 ReadFile() 함수를 사용한다. 두 번째 파라미터는 수신할 데이터를 보관할 버퍼이며, 세 번째 파라미터는 수신할 버퍼의 크기를 의미한다. 이 때 수신되는 데이터는 그 크기가 요구되는 버퍼 크기보다 클 수 없으며, 더 작은 크기의 데이터가 수신되는 경우도 가능하므로 네 번재 파라미터를 통해서 실제로 수신된 데이터의 크기를 알 수 있게 한다. 마지막 파라미터는 중첩 파일 입출력 핸들을 사용하는 경우에 한해서 사용되는 필드다. 사용자가 CreateFile() 함수를 사용할 때, 중첩 파일 입출력 핸들을 얻도록 요청했다면 ReadFile()의 마지막 파라미터는 특별한 용도로 사용될 수 있다.

- Win32 API WriteFile()

ReadFile()과 파라미터는 비슷한 의미를 가진다. , 사용 용도는 그 반대로서, 응용 프로그램 측에서 일정 양의 데이터를 드라이버 측으로 보내는 용도로 사용된다는 점이 다르다. 마지막 파라미터는 역시 중첩 파일 입출력 핸들을 사용하는 경우에 사용되는 필드다.

- Win32 API DeviceIoControl()

앞에서 살펴본 ReadFile() 함수와 WriteFile() 함수는 모두 파라미터로서 각각 하나의 버퍼만 사용이 가능한 함수였다. 결국 ReadFile() 함수는 드라이버 측에서 응용 프로그램 측으로, WriteFile() 함수는 응용 프로그램 측에서 드라이버 측으로 무엇인가 데이터를 전달하고자 하는 용도로 사용되는 함수다. , 단방향 함수라고 볼 수 있다.

이번에 살펴볼 DeviceIoControl() 함수는 버퍼의 전달 방향이 양방향성을 가지는 함수로, 버퍼를 2개 사용하는 것이 특징이다.

DeviceIoControl() 함수는 2개의 버퍼를 제공하는 특징만 가진 것이 아니라, dwIoControlCode와 같은 IoControlCode를 사용하는 것도 특징이다. 이 코드는 응용 프로그램이 사용하는 코드값이 그대로 드라이버에게 전달되기 때문에 응용 프로그램과 드라이버는 이 코드값을 사용하면 얼마든지 그들만의 대화를 만들어낼 수 있다. 이 코드를 만드는 방법은 그냥 1, 2, 3, 4와 같은 숫자를 사용하는 것이 아니라 ‘CTL_CODE’ 매크로를 사용해야 한다. 아래 그림은 이 매크로에 대한 설명이다. 아래의 코드는 DDK‘DEVIOCTL.H’ 파일이나 SDK‘WINIOCTL.H’ 파일에서 발견할 수 있다.



다음은 DDK에서 정의하는 CTL_CODE 매크로이다.

IoControlCode는 크게 4가지 영역으로 나뉘어진다.

 - DeviceType : IoControlCode가 사용되는 디바이스 장치의 유형을 정의한다.

 - Access : 해당하는 IoControlCode와 같이 사용되는 버퍼의 방향을 명시한다.

 - Function : 구체적인 수행 코드를 구분한다.

 - Method : 사용되는 버퍼의 사용 전략을 명시한다.

 

DeviceType 필드는 IoControlCode 명령을 받게 될 디바이스 스택을 구성하는 PDEVICE_OBJECT 중 가장 상위에 위치한 PDEVICE_OBJECTDeviceType과 일치한다. 이 부분은 중요한 부분은 아니지만, 호환성을 생각해 고려해두는 것이 좋다.

Access 필드는 보통 FILE_ANY_ACCESS를 사용하는 경우가 대부분이다.

Function 필드는 구체적인 수행 코드를 명시하는데, 현재 마이크로소프트에서 0~2047까지의 수를 예약해서 사용하므로 드라이버 개발자는 2048부터 4095까지의 수만 사용해야 한다.

Method 필드는 DeviceIoControl() 함수에서 사용하는 2개의 버퍼와 관련된 중요한 의미를 띄게 된다.

앞서 IoControlCode는 값을 CTL_CODE 매크로로 결정한다고 했는데, DDK 도움말에서 볼 수 있는 많은 양의 IOCTL 값은 결국 이미 결정되어 있다는 의미가 된다. 이 중에서 CTL_CODE의 마지막 부분인 Method 역시 이미 결정되었다는 의미도 된다.


-- IoControlCode의 Method

DeviceIoControl() 함수는 InBuffer와 OutBuffer 두 가지를 모두 사용하는 함수다. InBuffer는 버퍼의 내용이 전달되는 방향이 응용 프로그램 측에서 드라이버 측으로, OutBuffer는 방향이 드라이버 측에서 응 프로그램 측으로 향한다는 의미이다. 이런 두 가지 버퍼는 드라이버에 전달될 때 Method 방식에 따라 몇 가지 방향으로 그 모습이 바뀐다.

 - METHOD_BUFFERED

 - METHOD_IN_DIRECT

 - METHOD_OUT_DIRECT

 - METHOD_NEITHER

응용 프로그램 측면에서는 어떤 방식을 사용하더라도 DeviceIoControl() 함수를 사용하는 코드의 모습이 눈에 띄게 달라지지 않는다. 그러나 어떤 방식이 사용되는가에 따라 드라이버에서 버퍼를 처리한느 방식은 크게 바뀐다.

METHOD_BUFFERED 방식의 특징은 '버퍼링'이다. 운영체제는 응용 프로그램이 제공하는 버퍼를 보호하기 위해서, 응용 프로그램의 버퍼와 똑같은 크기의 버퍼를 새로 할당한다. 디바이스 드라이버는 이렇게 새로 할당된 시스템 버퍼를 사용한다. 이 때 실제 응용 프로그램의 버퍼와 시스템 버퍼 간의 동기화 문제는 운영체제가 관여하여 해결한다.

(이하 타 방식의 특징 설명 생략)


-- DeviceIoControl()의 버퍼 사용

현재 Win32 API DeviceIoControl() 함수는 2개의 버퍼, InBufferOutBuffer가 있다. 2개의 버퍼를 사용하는 데 있어서, 앞서 언급한 각각의 버퍼 전략을 사용하는 경우 버퍼는 드라이버 측으로 어떻게 설명될까.

METHOD_BUFFERED 방식은 시스템 버퍼를 사용한다고 했다. 하지만 그렇다고 해서 InBufferOutBuffer 모두 시스템 버퍼를 새로 할당해서 운용된다는 뜻은 아니다. 실제로 시스템 버퍼는 InBufferOutBuffer 중에 가장 큰 버퍼만큼의 크기로 할당된 하나의 버퍼를 사용한다.

운영체제는 우선 InBuffer의 내용을 시스템 버퍼로 복사한다. 그런 다음, 드라이버에 시스템 버퍼를 전달한다. 드라이버는 시스템 버퍼에 들어있는 내용을 모두 읽는다. 이 내용은 결국 InBuffer에 담겨진 내용과 같다. 이제, 드라이버는 응용 프로그램에 전달하고자 하는 내용을 오히려 시스템 버퍼에 기록한다. 이로 인해 InBuffer의 내용을 담고 있던 시스템 버퍼의 내용은 무의미해졌다. 드라이버에 의해서 시스템 버퍼의 내용이 갱신되면 운영체제는 오히려 반대로 시스템 버퍼의 내용을 응용 프로그램의 OutBuffer로 복사한다. 이런 방식을 사용하므로 하나의 시스템 버퍼만 사용해도 충분하다.