1. 개요 #
커널은 시스템의 모든 부분에 대한 궁극적인 제어 권한을 가지고 있는 운영 체제의 핵심 부분이기 때문에 커널을 대상으로 하는 공격은 지속적으로 발생하고 있다. 이러한 공격의 원인이 되는 커널 취약점은 디바이스 드라이버 및 각종 서브시스템 등 다양한 부분에서 발생할 수 있다. 특히 디바이스 드라이버는 커널 모드에서 실행되는 경우가 많아 주요 공격 대상이 된다. 본 보고서에서는 디바이스 드라이버로 인해 발생하는 취약점 유형과 해당 취약점을 방지하기 위한 주요 데스크탑 운영체제들(Windows, Linux)의 보호 기법에 대해 살펴보겠다.
2. 커널의 종류 #
커널의 종류에는 모놀리틱 커널(monolithic kernel), 마이크로커널(micro kernel), 혼합형 커널(hybrid kernel), 엑소커널(exokernel) 등이 존재한다. 이 중 모놀리틱 커널, 마이크로커널, 혼합형 커널이 널리 사용된다.
2.1 모놀리틱 커널 #
모놀리틱 커널은 단일 커널이라고도 불리며 그 이름처럼 커널의 핵심 기능을 커널 내부에서 실행하는 구조이다. 운영체제의 핵심 기능인 파일 시스템, 네트워킹, 드라이버, 프로세스 관리 등이 커널 내부에서 이루어지기 때문에 다른 방식의 커널보다 빠르고 성능이 뛰어나다는 장점이 있으며, 시스템의 동작이 간단하고 효율적이다. 성능 향상을 위해 드라이버 역시 커널 모드에서 실행되는데, 하드웨어에 대한 직접적인 액세스와 운영 체제의 핵심 기능들을 빠르게 사용하기 위함이다. 그러나 이렇게 통합된 구조로 인해 한 부분에서 발생한 문제가 시스템 전체에 영향을 줄 수 있고, 구성요소들 간의 의존성이 높아 디버깅이 어렵다.
2.2 마이크로커널 #
마이크로커널 또한 이름에서 그 구조를 떠올릴 수 있다. 커널에서 제공할 수 있는 다양한 서비스들을 한 덩어리로 묶은 커널인 모놀리틱 커널과 대비되게, 마이크로커널은 OS에 추가되어야 하는 다양한 매커니즘을 최소한으로 제공하는 초소형 커널이다. 마이크로커널에서는 드라이버를 비롯한 대부분의 서비스들이 유저 모드에서 실행되기 때문에, 시스템의 낮은 수준 자원에 대한 무제한적인 접근이 제한된다. 이로 인해, 어떤 한 부분에서 발생한 문제가 시스템 전체로 확산될 가능성이 줄어드는 장점이 있다. 하지만 이 구조는 파일 서비스 등에서 빈번한 프로세스 문맥 교환과 메시지 전송을 요구하게 되며, 이는 모놀리틱 커널에 비해 성능 면에서 불리할 수 있다.
2.3 혼합형 커널 #
모놀리틱 커널과 마이크로커널의 개념을 합친 것이 바로 혼합형 커널이다. 성능 향상을 위해 추가적인 코드를 커널 공간에 넣은 점을 제외하면 많은 부분이 순수 마이크로커널과 유사하여 수정 마이크로커널이라고도 한다. 디바이스 드라이버의 경우 시스템의 안정성과 보안을 고려하여 필요한 경우에만 유저 모드에서 실행되고, 대부분 성능을 위해 커널 모드에서 실행된다.
2.4 주요 데스크탑 OS가 채택한 커널 구조 #
전 세계 데스크탑 OS 시장에서는 Windows가 가장 높은 점유율을 차지하고 있으며, 그 뒤를 macOS와 Linux가 따르고 있다. 반면 서버 OS 시장에서는 Linux가 선두를 달리고 있으며, Windows가 그 뒤를 잇고 있다. 이 중 Windows (Windows NT)1와 Linux는 각각 혼합형 커널, 모놀리틱 커널 구조를 채택하고 있다. 앞서 언급한 바와 같이, 이들 커널 구조 모두 디바이스 드라이버를 커널 모드에서 실행시키므로 커널 취약점 발생 가능성이 증가하게 된다.
3. 커널 익스플로잇의 목적 #
커널 익스플로잇의 주요 목적은 권한 상승이다. 높은 수준의 권한을 획득한다면 시스템 설정을 임의로 변경하거나 중요 데이터를 탈취 및 조작할 수 있으며 결국에는 시스템 전체를 제어할 수 있기 때문이다. Windows와 Linux는 시스템 전체가 상이한 구조를 가지고 있는데, 이는 권한 관리 구조도 마찬가지이다. 주요 취약점 유형과 보호 기법을 살펴보기 앞서, 각 OS의 권한 관리 구조와 권한 상승 공격 흐름를 간략하게 짚고 넘어가겠다.
3.1 Windows의 권한 관리 #
Windows에서 접근 권한에 대한 정보는 Access Token에 들어있고, Access Token은 커널 메모리에 있는 EPROCESS(Executive Process) 구조체에서 접근할 수 있다.
3.1.1 EPROCESS 구조체
#
프로세스가 처음 실행될 때 이 구조체가 생성되므로 모든 프로세스가 EPROCESS 구조체를 가진다.
이 구조체는 각 프로세스를 관리하는데 필요한 모든 정보를 가지고 있는데, 이 정보에 Access Token이 포함된다.
dt nt!_eprocess
Windows 디버거인 WinDbg에서 위와 같은 명령어를 사용해 EPROCESS 구조체의 멤버를 조회할 수 있다.
0: kd> dt nt!_eprocess
+0x000 Pcb : _KPROCESS
...
+0x4b8 Token : _EX_FAST_REF
...
실제로 해당 명령어를 WinDbg에서 사용해보면 EPROCESS 구조체 안에 Token 멤버가 존재하는 것을 확인할 수 있다.
3.1.2 Token Overwrite #
3.1.1 EPROCESS 구조체에서 설명했듯이 모든 프로세스는 EPROCESS 구조체를 가지고, 이는 시스템 프로세스2에도 해당된다. 또한 Windows OS는 Access Token으로 사용자의 권한을 식별하므로 사용자 프로세스의 Access Token을 시스템 프로세스의 Access Token으로 덮어쓴다면 권한 상승이 가능해진다.
시스템 프로세스 중에서 lsass.exe3의 Access Token에 어떻게 접근할 수 있는지 WinDbg를 사용하여 살펴보겠다.
0: kd> !process 0 0 lsass.exe
PROCESS ffffad08231af080
SessionId: 0 Cid: 02c0 Peb: 77b03a5000 ParentCid: 0214
DirBase: 101901000 ObjectTable: ffffd3098618b800 HandleCount: 1287.
Image: lsass.exe
먼저 !process 0 0 명령을 사용해 대상 프로세스에 대한 정보를 얻어야 한다. 여기서 PROCESS 뒤에 나오는 주소가 EPROCESS 구조체의 시작 주소이다. (여기서는 ffffad08231af080)
0: kd> !process ffffad08231af080 7
PROCESS ffffad08231af080
SessionId: 0 Cid: 02c0 Peb: 77b03a5000 ParentCid: 0214
DirBase: 101901000 ObjectTable: ffffd3098618b800 HandleCount: 1287.
Image: lsass.exe
VadRoot ffffad0824b55150 Vads 152 Clone 0 Private 1351. Modified 394. Locked 3.
DeviceMap ffffd3098245d1e0
Token ffffd30983f4b220
ElapsedTime 00:09:02.496
UserTime 00:00:00.078
KernelTime 00:00:00.046
...
다음으로 해당 주소를 인자로 주어 상세 정보를 출력하도록 하면 Token 멤버의 주소를 구할 수 있다.
0: kd> !token ffffd30983f4b220
_TOKEN 0xffffd30983f4b220
TS Session ID: 0
User: S-1-5-18
User Groups:
00 S-1-5-32-544
Attributes - Default Enabled Owner
01 S-1-1-0
Attributes - Mandatory Default Enabled
02 S-1-5-11
Attributes - Mandatory Default Enabled
03 S-1-16-16384
Attributes - GroupIntegrity GroupIntegrityEnabled
Primary Group: S-1-5-18
Privs:
02 0x000000002 SeCreateTokenPrivilege Attributes - Enabled
03 0x000000003 SeAssignPrimaryTokenPrivilege Attributes -
04 0x000000004 SeLockMemoryPrivilege Attributes - Enabled Default
05 0x000000005 SeIncreaseQuotaPrivilege Attributes -
07 0x000000007 SeTcbPrivilege Attributes - Enabled Default
08 0x000000008 SeSecurityPrivilege Attributes -
09 0x000000009 SeTakeOwnershipPrivilege Attributes -
...
구한 Access Token의 주소를 인자로 !token 명령을 실행하면 Access Token의 속성과 권한 등 자세한 정보를 조회할 수 있다. 이 Access Token을 사용자 프로세스의 Token 멤버에 쓰면 해당 프로세스는 lsass.exe 프로세스의 권한으로 실행되어 권한 상승이 가능해진다. (실제 익스플로잇을 할 때는 이 과정을 소스 코드로 작성하여 자동화한다.)
사용자 프로세스의 Access Token을 시스템 프로세스의 Access Token으로 덮어썼다면 사용자 프로세스는 nt authority\system 권한을 가지게 된다.4 nt authority\system는 시스템의 대부분 자원에 접근할 수 있는 가장 높은 수준의 권한이다.
3.2 Linux의 권한 관리 #
Linux에서 접근 권한에 대한 정보는 cred 구조체에 들어있고, cred 구조체는 커널 메모리에 있는 task_struct5 구조체에서 접근할 수 있다.
3.2.1 task_struct 구조체와 cred 구조체
#
struct task_struct {
...
/* Process credentials: */
/* Tracer's credentials at attach: */
const struct cred __rcu *ptracer_cred;
/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;
/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;
...
}
프로세스가 처음 실행될 때와 새 스레드가 생성될 때 이 구조체가 생성된다.
cred 구조체 또한 프로세스가 처음 실행될 때 생성되는데, task_struct 구조체와 달리 새 스레드를 생성할 때는 생성되지 않는다. 대신 이미 생성된 cred 구조체는 해당 프로세스 내의 모든 스레드와 공유된다.
struct cred {
atomic_long_t usage;
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
unsigned securebits; /* SUID-less security management */
...
} __randomize_layout;
cred 구조체의 주요 멤버들은 위와 같다. 접근 권한에 대한 정보를 담고 있는 구조체이므로 uid, euid 등의 멤버가 존재하는 것을 볼 수 있다.
3.2.2 prepare_kernel_cred()와 commit_creds(), 그리고 init_cred 구조체
#
prepare_kernel_cred()와 commit_creds()는 프로세스의 권한을 수정하기 위해 커널에서 사용하는 함수들이다.
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
...
if (daemon)
old = get_task_cred(daemon);
else
old = get_cred(&init_cred);
...
}
prepare_kernel_cred()는 원하는 신원 정보의 cred 구조체를 생성하는 함수이다. 이 함수는 인자로 NULL을 받게 될 경우 get_cred(&init_cred)를 호출하여 init_cred에 정의된 권한을 가지게 된다.
struct cred init_cred = {
.usage = ATOMIC_INIT(4),
.uid = GLOBAL_ROOT_UID,
.gid = GLOBAL_ROOT_GID,
.suid = GLOBAL_ROOT_UID,
.sgid = GLOBAL_ROOT_GID,
.euid = GLOBAL_ROOT_UID,
.egid = GLOBAL_ROOT_GID,
.fsuid = GLOBAL_ROOT_UID,
.fsgid = GLOBAL_ROOT_GID,
...
};
init_cred에 정의된 권한은 바로 root 사용자의 권한이다. 즉, 최고 권한이다. 따라서 prepare_kernel_cred(NULL)와 같이 호출하면 root 사용자 권한이 적용된 cred 구조체를 얻을 수 있다. 물론 함수 이름 그대로 프로세스의 권한 정보가 담긴 구조체를 생성하여 실제 권한 적용을 준비하는 것이기 때문에 prepare_kernel_cred(NULL)를 호출한다고 즉시 root 권한을 얻을 수 있는 것은 아니다.
int commit_creds(struct cred *new)
{
struct task_struct *task = current;
const struct cred *old = task->real_cred;
...
if (new->user != old->user || new->user_ns != old->user_ns)
inc_rlimit_ucounts(new->ucounts, UCOUNT_RLIMIT_NPROC, 1);
rcu_assign_pointer(task->real_cred, new);
rcu_assign_pointer(task->cred, new);
...
}
실제로 프로세스의 권한을 변경하는 함수가 바로 commit_creds()이다.
여기서 rcu_assign_pointer(task->real_cred, new), rcu_assign_pointer(task->cred, new)가 바로 프로세스의 권한을 설정하는 핵심 부분이다. 프로세스 구조체인 task의 real_cred와 cred를 인자로 받은 cred 구조체인 new로 설정하여 권한이 변경된다.
commit_creds(prepare_kernel_cred(NULL));
결론적으로, prepare_kernel_cred(NULL)으로 root 사용자 권한이 적용된 cred 구조체를 얻은 뒤, commit_creds()의 인자로 주어 실행하면 권한 상승이 가능해진다. 그러나 이는 리눅스 커널의 버전이 6.2 미만일때만 가능한 방법이다. 6.2 버전부터는 prepare_kernel_cred()의 인자로 NULL을 전달해도 get_cred(&init_cred)가 호출되지 않는다.
commit_creds(&init_cred)
하지만 init_cred는 여전히 존재하므로 commit_creds()에 init_cred를 인자로 주어 호출하면 권한 상승이 가능하다.
4. 취약점 유형 #
4.1 Buffer Overflow #
Buffer Overflow는 입력 데이터가 메모리 경계를 초과하여 데이터를 덮어쓰게 되는 취약점으로, 사용자 공간뿐만 아니라 커널 공간에서도 치명적이다. 이제 Buffer Overflow 취약점을 유발할 수 있는 함수와 매크로들을 살펴보겠다.
4.1.1 Windows #
void RtlCopyMemory(
void* Destination,
const void* Source,
size_t Length
);
RtlCopyMemory()는 원본 메모리 블록의 내용을 대상 메모리 블록에 복사하는 매크로다. (사용자 메모리의 값을 커널 메모리에 복사하는 것이 가능하고, 그 반대도 가능하다.)
// wdm.h
#define RtlCopyMemory(Destination,Source,Length) memcpy((Destination),(Source),(Length))
RtlCopyMemory()의 선언부를 보면 memcpy()를 래핑한 함수6라는 것을 볼 수 있는데, memcpy()는 버퍼의 크기에 대한 검사를 수행하지 않고 단순 복사만 수행하기 때문에 취약점이 발생할 수 있다.
RtlCopyMemory(KernelBuffer, UserBuffer, Size);
Buffer Overflow에 취약한 코드의 예시이다. 버퍼 크기를 지정하는 세 번째 인자 Size를 사용자로부터 입력받고 크기에 대한 검증을 거치지 않아 취약하다.
RtlCopyMemory(KernelBuffer, UserBuffer, sizeof(KernelBuffer));
세 번째 인자를 복사 대상 버퍼의 크기로 지정하면 안전하게 메모리의 값을 복사할 수 있다.
4.1.2 Linux #
unsigned long _copy_from_user(
void *to,
const void __user *from,
unsigned long n
)
_copy_from_user()는 이름 그대로 사용자 메모리 공간에서 커널 메모리 공간으로 데이터를 복사하는 함수다. 4.1.1 Windows에서 살펴보았던 RtlCopyMemory()와 마찬가지로 세 번째 인자 즉, 복사 크기에 대한 검사를 하지 않기 때문에 Buffer Overflow 취약점이 발생할 수 있다.
static __always_inline unsigned long __must_check
copy_from_user(void *to, const void __user *from, unsigned long n)
{
if (check_copy_size(to, n, false))
n = _copy_from_user(to, from, n);
return n;
}
Linux에서는 Buffer Overflow 취약점을 방지하기 위해 _copy_from_user()를 래핑한 copy_from_user()를 제공한다. 소스 코드를 보면 _copy_from_user()를 호출하기 전에 check_copy_size()를 호출하여 복사 대상 버퍼와 복사할 크기를 검사하는 것을 확인할 수 있다.
4.2 Use After Free #
Use After Free는 프로그램이 이미 할당 해제(Free)된 메모리를 계속해서 사용하는 경우 발생하는 취약점이다.
4.2.1 Windows #
typedef struct _USE_AFTER_FREE_NON_PAGED_POOL
{
FunctionPointer Callback;
CHAR Buffer[0x54];
} USE_AFTER_FREE_NON_PAGED_POOL, *PUSE_AFTER_FREE_NON_PAGED_POOL;
typedef struct _FAKE_OBJECT_NON_PAGED_POOL
{
CHAR Buffer[0x54 + sizeof(PVOID)];
} FAKE_OBJECT_NON_PAGED_POOL, *PFAKE_OBJECT_NON_PAGED_POOL;
PUSE_AFTER_FREE_NON_PAGED_POOL g_UseAfterFreeObjectNonPagedPool = NULL;
NTSTATUS AllocateUaFObjectNonPagedPool(VOID)
{
UseAfterFree = ExAllocatePoolWithTag(
NonPagedPool,
sizeof(USE_AFTER_FREE_NON_PAGED_POOL),
(ULONG)POOL_TAG
);
UseAfterFree->Callback = &UaFObjectCallbackNonPagedPool;
g_UseAfterFreeObjectNonPagedPool = UseAfterFree;
...
}
NTSTATUS FreeUaFObjectNonPagedPool(VOID)
{
if (g_UseAfterFreeObjectNonPagedPool)
{
ExFreePoolWithTag((PVOID)g_UseAfterFreeObjectNonPagedPool, (ULONG)POOL_TAG);
}
...
}
NTSTATUS UseUaFObjectNonPagedPool(VOID)
{
if (g_UseAfterFreeObjectNonPagedPool->Callback)
{
g_UseAfterFreeObjectNonPagedPool->Callback();
}
...
}
NTSTATUS AllocateFakeObjectNonPagedPool(PFAKE_OBJECT_NON_PAGED_POOL UserFakeObject)
{
KernelFakeObject = (PFAKE_OBJECT_NON_PAGED_POOL)ExAllocatePoolWithTag(
NonPagedPool,
sizeof(FAKE_OBJECT_NON_PAGED_POOL),
(ULONG)POOL_TAG
);
RtlCopyMemory(
(PVOID)KernelFakeObject,
(PVOID)UserFakeObject,
sizeof(FAKE_OBJECT_NON_PAGED_POOL)
);
...
}
Use After Free 취약점이 발생하는 예제 코드이다. 여기에는 ExAllocatePoolWithTag()7로 풀 메모리8를 할당받는 AllocateUaFObjectNonPagedPool(), ExFreePoolWithTag()로 풀 메모리를 할당 해제(Free)하는 FreeUaFObjectNonPagedPool(), 풀 메모리 포인터 g_UseAfterFreeObjectNonPagedPool를 역참조하여 Callback을 호출하는 UseUaFObjectNonPagedPool()가 존재한다.
풀 메모리를 할당 받은 후, FreeUaFObjectNonPagedPool()에서 해당 메모리를 해제하더라도 전역 변수인 g_UseAfterFreeObjectNonPagedPool는 여전히 메모리의 주소를 가리키고 있다. 따라서 메모리 해제 후 UseUaFObjectNonPagedPool()를 실행해도 g_UseAfterFreeObjectNonPagedPool->Callback()을 실행할 수 있다. 즉, g_UseAfterFreeObjectNonPagedPool->Callback()를 조작하여 셸 코드의 주소로 만들면 권한 상승이 가능해진다.
이를 위해서 AllocateFakeObjectNonPagedPool()가 필요하다. AllocateFakeObjectNonPagedPool()에서 호출하는 ExAllocatePoolWithTag()가 앞서 FreeUaFObjectNonPagedPool()로 해제했던 메모리의 주소 즉, g_UseAfterFreeObjectNonPagedPool가 가리키는 주소를 할당한다면 RtlCopyMemory()로 해당 구조체를 사용자가 원하는 값으로 덮어쓸 수 있다. 이후 UseUaFObjectNonPagedPool()를 호출하면 최종적으로 사용자가 원하는 함수를 실행시킬 수 있다. 공격 흐름을 정리하면 다음과 같다:
ALLOCATE_UAF_OBJECTFREE_UAF_OBJECTALLOCATE_FAKE_OBJECTUSE_UAF_OBJECT
중요한 부분은 메모리를 해제한 후 할당받을 때 같은 주소에 할당이 되어야 한다는 것이다. Windows 메모리 할당자는 할당 해제된 메모리의 크기와 할당할 메모리의 크기가 같다면 같은 주소를 할당한다. 하지만 풀 메모리를 할당 해제하는 순간 메모리 단편화 방지를 위해 인접한 할당 해제 상태의 메모리끼리 합쳐지기 때문에 같은 주소에 메모리가 할당될 가능성은 낮다. 따라서 Heap에 수많은 데이터를 삽입하는 Heap Spray 공격 기법을 활용해야 한다. 이 공격은 뿌려지는 데이터가 예측 가능한 메모리 배치와 할당 패턴을 가지는 것이 중요하다. 이 때문에 Windows Kernel에서 Heap Spray 공격을 시도할 경우 IoCompletionReserve9 구조체를 사용하는 경우가 많다.
85407000 size: 60 previous size : 0 (Allocated)IoCo(Protected)
85407060 size : 60 previous size : 60 (Free)IoCo
85407100 size : 60 previous size : 60 (Allocated)IoCo(Protected)
85407160 size : 60 previous size : 60 (Free)IoCo
854071c0 size : 60 previous size : 60 (Allocated)IoCo(Protected)
85407220 size : 60 previous size : 60 (Free)IoCo
85407280 size : 60 previous size : 60 (Allocated)IoCo(Protected)
854072e0 size : 60 previous size : 60 (Free)IoCo
85407340 size : 60 previous size : 60 (Allocated)IoCo(Protected)
854073a0 size : 60 previous size : 60 (Free)IoCo
85407400 size : 60 previous size : 60 (Allocated)IoCo(Protected)
85407460 size : 60 previous size : 60 (Free)IoCo
854074c0 size : 60 previous size : 60 (Allocated)IoCo(Protected)
85407520 size : 60 previous size : 60 (Free)IoCo
...
해당 구조체를 메모리에 다수 할당한 뒤, 사이사이의 구조체만 할당 해제시킨다면 위와 같은 구조를 가지게 된다. 이 상태에서 ExAllocatePoolWithTag()를 호출하면 할당 해제되어 있는 메모리 중 하나가 할당 되어 g_UseAfterFreeObjectNonPagedPool는 해당 주소를 가리키게 될 것이다. 이후 다시 메모리를 할당 해제시키면 인접한 메모리에 할당 해제 상태의 메모리가 없으므로 합쳐지지 않는다. 따라서 AllocateFakeObjectNonPagedPool()를 계속 실행시키면 같은 주소에 메모리를 할당 받을 수 있고 권한 상승 공격이 가능해진다.
ExFreePoolWithTag((PVOID)g_UseAfterFreeObjectNonPagedPool, (ULONG)POOL_TAG);
g_UseAfterFreeObjectNonPagedPool = NULL;
이 취약점의 근본적인 원인은 메모리를 할당 해제한 후에도 포인터가 여전히 해당 메모리를 가리키고 있는 것이다. 메모리를 할당 해제한 후에 포인터를 NULL로 설정하면, 이후에 해당 포인터를 통해 해제된 메모리에 접근할 수 없게 되어 Use After Free를 방지할 수 있다.
4.2.2 Linux #
Linux 역시 전반적인 공격 흐름은 같다. 여기서는 Linux의 특성으로 인해 발생하는 Use After Free에 대해서 알아보겠다.
g_buf = kzalloc(BUFFER_SIZE, GFP_KERNEL);
static int module_close(struct inode *inode, struct file *file)
{
printk(KERN_INFO "module_close called\n");
kfree(g_buf);
return 0;
}
Linux에서는 파일 디스크립터로 디바이스 드라이버에 접근하고, 파일 디스크립터를 닫을 때 module_close()가 호출된다. 위 예제 코드에서는 파일 디스크립터를 닫을 때 kfree()로 할당받은 메모리를 해제하는 것을 볼 수 있다.
int fd1 = open("/dev/driver", O_RDWR);
int fd2 = open("/dev/driver", O_RDWR);
close(fd1);
write(fd2, "Hello", 5);
Linux에서는 여러 개의 파일 디스크립터가 디바이스 드라이버를 열 수 있기 때문에 하나의 파일 디스크립터가 닫히더라도 다른 파일 디스크립터는 여전히 전역 변수에 접근하여 읽기 / 쓰기가 가능하다. 이로 인해 할당 해제된 메모리를 가리키는 전역변수 g_buf를 조작할 수 있게 되어 Use After Free가 발생한다. Linux도 Windows와 같은 이유로 Heap Spray 공격이 필요한데, /dev/ptmx10를 사용하는 경우가 많다. IoCompletionReserve 구조체와 마찬가지로 예측 가능한 메모리 배치와 할당 패턴을 가지기 때문이다.
static int module_close(struct inode *inode, struct file *file)
{
printk(KERN_INFO "module_close called\n");
kfree(g_buf);
g_buf = NULL;
return 0;
}
Windows와 마찬가지로 메모리를 할당 해제한 후에 포인터를 NULL로 설정하면 Use After Free를 방지할 수 있다.
4.3 NULL Pointer Dereference #
NULL Pointer Dereference는 이름 그대로 NULL을 가리키고 있는 포인터를 역참조할 때 발생하는 에러이다. 현재는 이 에러를 직접적으로 활용해 익스플로잇을 하는 것이 불가능하다. 하지만 과거에는 가능했는데, 커널이 사용자 영역 메모리에 제한 없이 접근할 수 있었고 사용자 영역 프로그램이 제로 페이지11를 매핑할 수 있었기 때문이다. 이제 각 OS에서 제로 페이지를 매핑하는데 사용되었던 함수들을 살펴보겠다.
4.3.1 Windows #
과거 Windows에서 메모리 매핑에 사용되는 함수들이었던 VirtualAlloc(), VirtualAllocEx()는 0x1000보다 작은 주소에 메모리를 할당할 수 없었다. 그러나 당시 문서화되지 않은 함수였던 NtAllocateVirtualMemory()에는 주소에 대한 제한이 없었다. 공격자들은 NtAllocateVirtualMemory()를 사용해 제로 페이지를 매핑한 다음 셸코드를 저장하고, 이를 커널 공간에서 실행하는 방식으로 NULL Pointer Dereference를 활용해왔다.
NTSYSCALLAPI NTSTATUS NtAllocateVirtualMemory(
HANDLE ProcessHandle,
PVOID *BaseAddress,
ULONG_PTR ZeroBits,
SIZE_T RegionSize,
ULONG AllocationType,
ULONG Protect
);
NtAllocateVirtualMemory()는 커널 모드에서 유저 모드의 가상 주소 공간에 메모리를 할당하는 함수로, 현재는 문서화되어 있다. 또한 MSDN(Microsoft Developer Network)의 함수 설명을 보면 할당할 페이지 영역의 베이스 주소를 의미하는 인자 BaseAddress가 NULL Pointer가 아니어야 한다는 조건이 명시되어 있다.
4.3.2 Linux #
Linux 또한 mmap()을 호출해 제로 페이지를 매핑하는 것이 가능했다.
static inline unsigned long round_hint_to_min(unsigned long hint)
{
hint &= PAGE_MASK;
if (((void *)hint != NULL) &&
(hint < mmap_min_addr))
return PAGE_ALIGN(mmap_min_addr);
return hint;
}
현재는 mmap()을 호출하면 내부에서 round_hint_to_min()가 호출12되는데, 이 함수가 mmap_min_addr이라는 변수와 인자로 받은 주소를 비교하여 제로 페이지 매핑을 방지한다.
$ sysctl vm.mmap_min_addr
vm.mmap_min_addr = 65536
mmap_min_addr는 최소 메모리 매핑 주소를 설정하는 변수로, sysctl13로 해당 변수의 값을 확인할 수 있다. 대부분의 시스템에서 mmap_min_addr의 기본값은 65536으로 설정되어 있으며, 이는 64 KB 이상인 주소부터 메모리 매핑이 가능함을 의미한다.
앞서 살펴봤듯이 현재 사용되는 메모리 매핑 함수들은 기본적으로 제로 페이지 매핑을 허용하지 않으며, 설령 허용되더라도 SMEP와 SMAP로 인해 NULL Pointer Dereference가 직접적인 취약점이 되지 않는다. 하지만 NULL Pointer Dereference로 인해 간접적인 취약점이 발생할 위험은 여전히 존재한다. 예를 들어, Linux 커널에서 NULL Pointer Dereference가 발생하면 커널 Oops가 생성되는데 이는 DoS 공격을 수행하는 데 이용될 수 있다. 이 외에도 설계상의 오류로 인해 취약점이 발생할 수 있기 때문에, 포인터를 사용하기 전에 NULL을 가리키고 있지 않은지 항상 확인하는 것이 중요하다.
4.4 Double Fetch #
Double Fetch는 Race Condition14중에서도 TOCTOU15의 일종으로, 커널 모드와 사용자 모드 간에 발생한다.
일반적으로 Race Condition은 주로 같은 공간의 코드(커널 공간 내에서 커널 코드 간, 또는 유저 공간 내에서 사용자 코드 간)를 동시에 실행했을 때 발생하지만, Double Fetch는 서로 다른 공간(커널 공간과 유저 공간) 간의 데이터 전송 과정에서 발생한다는 차이점이 있다. 이제 어떤 상황에서 Double Fetch 취약점이 발생하는지 살펴보겠다.
Double Fetch 취약점은 커널 공간에서 사용자 공간의 데이터를 두 번 이상 가져올 경우 발생한다. 위 그림에서 커널 함수가 사용자 데이터 확인 및 검증을 위해 한 번, 실제로 사용하기 위해 한 번 데이터를 가져오는 것을 볼 수 있다. 만약 데이터를 가져오는 두 번의 과정 사이에 사용자 스레드가 데이터를 수정한다면, 커널 함수가 두 번째로 데이터에 접근할 때 계산 결과가 달라질 뿐만 아니라 Buffer Overflow, Out of Bounds와 같은 취약점이 연달아 발생할 수 있다.
UserBuffer = UserDoubleFetch->Buffer;
ProbeForRead(UserBuffer, sizeof(KernelBuffer), (ULONG)__alignof(UCHAR));
if (UserDoubleFetch->Size > sizeof(KernelBuffer))
{
Status = STATUS_INVALID_PARAMETER;
return Status;
}
RtlCopyMemory((PVOID)KernelBuffer, UserBuffer, UserDoubleFetch->Size);
해당 취약점이 발생하는 코드의 예시는 위와 같다. RtlCopyMemory()로 사용자 메모리의 값을 커널 메모리에 복사하기 전에, 사용자로부터 받은 UserDoubleFetch->Size의 크기를 커널 버퍼와 비교하는 것을 볼 수 있다. 얼핏 보면 안전해보이지만, UserDoubleFetch->Size를 검증하고 사용하는 과정 사이에 UserDoubleFetch->Size를 변조하면 Buffer Overflow 취약점이 발생한다. 따라서 값을 변조할 때까지 무한히 동작하는 두 개의 사용자 스레드를 만들어 실행16하는 식으로 익스플로잇이 가능하다. 이 때, 한 스레드는 계속해서 공유된 사용자 공간의 데이터(여기서는 UserDoubleFetch->Size)를 바꾸고 한 스레드는 계속해서 디바이스 드라이버로 사용자 공간의 데이터를 전송해야 한다.
UserBuffer = UserDoubleFetch->Buffer;
UserBufferSize = UserDoubleFetch->Size;
if (UserBufferSize > sizeof(KernelBuffer))
{
Status = STATUS_INVALID_PARAMETER;
return Status;
}
RtlCopyMemory((PVOID)KernelBuffer, UserBuffer, UserBufferSize);
이 취약점이 발생하는 근본적인 원인은 사용자 공간의 데이터를 두 번 이상 역참조하는 데에 있다. 이를 막을 수 있는 방법 중 하나는 정수 상수를 그대로 전달하는 것이다. 하지만, 성능을 비롯한 여러가지 이유로 대부분의 디바이스 드라이버는 구조체로 데이터를 전달하므로 포인터 연산을 완전히 배제하는 것은 현실적으로 어렵다. 해결책은 간단한데, 역참조를 한 번만 하는 것이다. 역참조를 한 뒤 값을 커널 지역 변수에 저장하면 사용자 공간에서 해당 값을 변조할 수 없으므로 값의 무결성이 보장된다.
5. 보호 기법 #
Windows와 Linux는 유사한 커널 구조를 채택하고 있어서 공통적인 커널 보호 기법이 많다. (본 보고서에서는 커널 고유의 보호 기법에 초점을 맞추므로, 디바이스 드라이버에서도 사용되는 Stack Canary, ASLR (Address Space Layout Randomization)과 같은 보안 메커니즘은 별도로 다루지 않겠다.)
5.1 SMEP (Supervisor Mode Execution Prevention) #
SMEP는 커널 공간에서 사용자 공간의 코드를 실행하는 것을 금지하는 보호 기법이다. 따라서 SMEP가 활성화된 상태에서 사용자 공간에 저장된 셸코드를 실행시키려고 하면 커널 패닉17이 발생하게 된다. 이는 특정 공간에서의 코드 실행을 방지하는 NX(No Execute) / DEP(Data Execution Prevention)와 유사한 보호 기법으로 볼 수 있다. 일반적으로 커널 공간에서 사용자 공간으로의 접근 및 실행 제한은 하드웨어 또는 에뮬레이션을 통해 이루어지는데, 본 보고서에서는 하드웨어 기반의 방법을 다룰 것이다.
5.1.1 CR4 Register #
SMEP와 이후 다룰 SMAP는 x86 / x86-64 아키텍쳐를 지원하는 CPU18에서 제공하는 보호 기법19들이다. 대부분의 CPU는 RIP, RAX와 같은 프로그램 실행을 위한 레지스터 외에도 시스템 관련 설정을 관리하기 위한 레지스터를 가지고 있다. 이러한 레지스터는 컨트롤 레지스터(Control Register)라고 불린다.
SMEP와 SMAP는 컨트롤 레지스터 중에서도 CR4 레지스터를 통해 제어된다. 그림에서 확인할 수 있듯이, SMEP는 CR4 레지스터의 20번째 비트 값을 통해 활성화되거나 비활성화된다. 이 비트가 1로 설정되어 있으면 SMEP가 활성화되며, 0으로 설정되어 있으면 비활성화된다.
5.1.2 SMEP 활성화 여부 확인 #
5.1.3 SMEP 우회에서 설명하겠지만, 소스 코드를 작성하여 컨트롤 레지스터의 값을 읽고 쓸 수 있다. 하지만 여기서는 디버거와 파일을 사용하여 간단하게 SMEP 활성화 여부를 확인하는 방법을 다루겠다.
5.1.2.1 Windows #
0: kd> r cr4
cr4=0000000000350ef8
0: kd> ? ((@cr4 >> 20) & 1)
Evaluate expression: 0 = 00000000`00000000
WinDbg를 사용하면 CR4 레지스터의 값을 직접 읽을 수 있다. 따라서 CR4 레지스터의 값을 읽은 뒤, 비트 연산을 통해 SMEP 활성화 여부를 파악할 수 있다.
5.1.2.2 Linux #
$ cat /proc/cpuinfo | grep smep
flags : ... smep bmi2 rdseed adx smap clflushopt ...
Linux는 CPU에 대한 다양한 정보를 제공하는 /proc/cpuinfo 파일을 제공한다. SMEP가 설정되어 있다면 해당 파일에서 smep 문자열을 찾을 수 있다.
5.1.3 SMEP 우회 #
Windows와 Linux 모두 커널 모드에서 컨트롤 레지스터에 접근할 수 있도록 허용한다. 이는 디바이스 드라이버나 커널 모듈이 CPU의 특정 기능을 제어할 수 있도록 하기 위함이다.
mov rax, 0xFFFEFFFFF
mov cr4,rax
ret
당연하게도, 컨트롤 레지스터인 CR4 레지스터도 제어가 가능하다. 위 명령어는 CR4 레지스터의 20번째 비트를 0으로 설정하여 SMEP를 비활성화시킨다. 위와 같은 커널 공간의 가젯들을 활용해 ROP(Return-Oriented Programming) / JOP(Jmp-Oriented Programming) Chain을 구성하면 SMEP 우회가 가능하다.
5.2 SMAP (Supervisor Mode Access Prevention) #
SMAP는 SMEP를 보완하기 위해 설계된 보호 기법이다. SMEP의 경우 커널 공간에서 사용자 공간의 코드를 실행하는 것만을 금지했다면, SMAP는 사용자 공간의 메모리를 읽고 쓰는 것을 막는다. SMAP를 사용함으로써, 보안 측면에서 여러가지 이점을 얻을 수 있다.
주요 이점은 사용자 공간을 활용한 Stack Pivoting 방지이다.
mov esp, 0x12345678
ret
앞서 SMEP가 활성화된 상태에서는 사용자 공간에 저장된 셸코드를 실행할 수 없다고 했다. 하지만 커널 공간에는 방대한 양의 명령어가 들어 있기 때문에 위와 같은 ROP 가젯이 반드시 존재한다. x64에서는 ESP에 입력된 값이 무엇이든 이 가젯이 실행되면 RSP는 해당 값으로 변경된다.20 이런 낮은 주소는 사용자 공간에서 확보가 가능21하기 때문에 SMEP가 활성화되어 있어도 RIP만 제어할 수 있다면 ROP / JOP Chain을 구성하여 권한을 상승시킬 수 있다. 만약 SMAP이 활성화되어 있다면 사용자 공간의 데이터 즉, ROP chain을 커널 공간에서 읽을 수 없기 때문에 커널 패닉이 발생한다. 이처럼 SMEP과 더불어 SMAP이 활성화되면 ROP에 의한 공격을 완화할 수 있다.
이쯤에서 사용자 공간의 메모리를 읽고 쓸 수 있어야 하는 디바이스 드라이버의 경우 SMAP가 활성화되어 있는 상황에서 어떻게 작업을 수행할 수 있는지 의문이 생길 수 있다. 이는 stac, clac 명령을 통해 이루어진다. stac / clac는 각각 set AC / clear AC를 의미하는데, 이름 그대로 AC(Alignment Check) 플래그를 활성화 / 비활성화하는 역할을 한다. AC 플래그는 EFLAGS 레지스터에 있는 플래그 중 하나로, 메모리 접근 시 데이터가 정렬된 주소를 사용하도록 강제하는 기능을 수행한다. 이는 커널 모드 코드가 사용자 공간 메모리에 접근할 때도 적용된다. AC 플래그가 설정되면, CPU는 SMAP가 활성화된 상태에서도 사용자 공간 메모리에 접근을 허용한다. (이 과정에서 CR4 레지스터의 SMAP 비트가 변경되는 것은 아니다.)
static __always_inline __must_check unsigned long
copy_user_generic(void *to, const void *from, unsigned long len)
{
stac();
/*
* If CPU has FSRM feature, use 'rep movs'.
* Otherwise, use rep_movs_alternative.
*/
asm volatile(
"1:\n\t"
ALTERNATIVE("rep movsb",
"call rep_movs_alternative", ALT_NOT(X86_FEATURE_FSRM))
"2:\n"
_ASM_EXTABLE_UA(1b, 2b)
:"+c" (len), "+D" (to), "+S" (from), ASM_CALL_CONSTRAINT
: : "memory", "rax");
clac();
return len;
}
4.1.2 Buffer Overflow (Linux)에서 살펴보았던 copy_from_user()도 내부에서 stac / clac 명령을 사용한다. 정확히는copy_from_user(), _copy_from_user(), raw_copy_from_user(), copy_user_generic() 순으로 호출이 되는데 copy_user_generic()에서 사용자 공간의 값을 복사하기 전에 stac()를, 함수를 종료하기 전에 clac()를 호출한다. (인라인 어셈블리가 아니라 함수로 선언된 이유는 SMAP 지원 여부를 확인하는 작업도 같이 이루어지기 때문이다.)
따라서 커널 공간에 정의된 copy_user_generic()이 호출하는 stac()를 사용해 ROP / JOP Chain을 구성하여 SMAP를 우회하는 것이 가능하다. SMAP 뿐만 아니라 다른 커널 보호 기법들도 이미 정의된 명령어를 ROP 가젯으로 활용해 우회하는 경우가 흔하다. 익스플로잇에 필요한 명령어들 대부분은 커널 함수들에게도 필요하기 때문이다. (5.1.1 CR4 Register에서 언급한 바와 같이 SMAP 역시 CR4 레지스터를 통해 제어된다. 활성화 여부 확인 및 CR4 레지스터 조작을 통한 우회 방법은 SMEP와 같으므로 별도로 다루지 않겠다.)
5.3 KASLR (Kernel Address Space Layout Randomization) #
사용자 공간에서는 주소를 무작위화하는 ASLR(Address Space Layout Randomization)이 존재했다. 이와 유사하게 커널 공간에서 주소를 무작위화하는 KASLR(Kernel ASLR)이라는 보호 기법도 존재한다. 물론 각 OS가 주소를 무작위화하는 요소들에는 차이가 있다. 먼저 Windows부터 살펴보겠다.
- 커널 이미지 (
ntoskrnl.exe) - 커널 모드 디바이스 드라이버
- 커널 힙
Linux는 다음과 같다.
- 커널 이미지
- 커널 모드 디바이스 드라이버 (커널 모듈)
- 커널 스택
- 커널 힙
KASLR은 시스템 부팅 시 한 번만 적용되기 때문에 커널 내의 함수나 데이터의 주소를 하나라도 유출시키면 베이스 주소를 알아낼 수 있다. (이러한 단점을 보완하기 위해 함수 단위로 주소를 무작위화 시키는 기법인 FGKASLR(Function Granular KASLR)이 개발되었지만, 아직 Linux 커널에 적용되지는 않았다.) 또한 커널 주소는 커널 공간에서 공통적으로 사용되므로 특정 디바이스 드라이버가 KASLR로 인해 익스플로잇이 불가능하더라도, 다른 디바이스 드라이버가 커널 주소를 유출시키면 익스플로잇이 가능하다. 이러한 커널의 특성 때문에 KASLR은 ASLR처럼 강력하지 않다. 특히 Linux의 경우 x64 환경에서 최대 9비트의 무작위성만 가질 수 있어 무차별 대입 공격(Brute Force Attack)에 취약하다. (Windows의 경우 18비트의 무작위성을 가진다.)
5.4 KPTI (Kernel Page-Table Isolation) #
KPTI22는 Intel CPU에서 발생하는 취약점인 Meltdown에 대응하기 위해 도입되었다. Meltdown은 커널 공간의 메모리를 사용자 권한으로 읽을 수 있는 취약점으로 악용할 경우 커널 메모리 유출, KASLR 우회 등의 공격이 가능하다. 본 보고서의 범위를 벗어나므로 Meltdown에 대한 구체적인 설명은 생략하겠다.
KPTI를 적용하지 않으면 기본적으로 유저 모드 프로세스의 페이지 테이블에는 커널 공간의 주소와 유저 공간의 주소가 모두 매핑된다. 이는 TLB Flushing23으로 인한 오버헤드를 줄이기 위함이다. 유저 모드 프로세스는 System Call, Context Switching으로 인해 자주 커널 모드로 전환되기 때문에 매핑을 유지하면 TLB Hit24로 이득을 볼 수 있다. 또한 OS는 특정 페이지의 권한을 설정할 수 있기 때문에 보안에도 문제가 없다.
그럼에도 불구하고, Intel CPU에서만 해당 취약점이 발생한 이유는 성능 향상을 위해 사용하는 비순차 실행(out-of-order execution)25 방식 때문이었다. 대부분의 CPU는 메모리 접근 권한을 검증한 후 데이터를 캐시에 로드하도록 설계되어 있는 반면, Intel CPU는 메모리 접근 권한을 확인하기 전에 데이터를 캐시에 로드한다. 이러한 차이로 인해 유저 모드에서 간접적으로 캐시에 들어있는 커널 메모리에 접근이 가능해진다.
이를 완화하기 위해 도입된 것이 KPTI다. KPTI는 이름 그대로 유저 모드와 커널 모드가 사용하는 페이지 테이블을 두 개로 분리한다. KPTI가 적용된 유저 모드 페이지 테이블 그림을 보면, 인터럽트 핸들러와 같이 반드시 필요한 부분을 제외하고 커널 공간의 매핑이 모두 제거된 것을 볼 수 있다. 따라서 모드를 전환할 때 페이지 테이블도 함꼐 전환해야 하며, 이는 TLB Flushing로 인한 오버헤드를 발생시켜 결과적으로 성능의 저하를 초래한다.
컨트롤 레지스터 중에서도 CR3 레지스터가 바로 페이지 테이블을 전환하는 역할을 한다. 페이지 테이블을 전환하는 과정은 커널 공간에서 ROP Chain을 구성해 익스플로잇을 할 때도 필요하다.
...
0xffffffff81800e7f: or rdi,0x1000
0xffffffff81800e86: mov cr3,rdi
0xffffffff81800e89: pop rax
0xffffffff81800e8a: pop rdi
0xffffffff81800e8b: swapgs
0xffffffff81800e8e: jmp 0xffffffff81800eb0
...
위 명령어들은 Linux 커널에서 사용자 모드로 전환할 때 사용하는 swapgs_restore_regs_and_return_to_usermode의 일부분이다. 매크로 안에 페이지 테이블을 전환하는 과정이 포함되어 있는 것을 볼 수 있다. 이처럼 커널에서 사용자 공간으로 돌아가는 프로세스에 페이지 테이블을 전환하는 과정은 반드시 포함되기 때문에 커널에 정의되어 있는 명령어를 ROP 가젯으로 사용하면 된다.
모든 CPU가 Meltdown에 취약한 것은 아니기 때문에 현재 KPTI는 취약점이 존재하는 CPU에서만 작동한다. KPTI는 Meltdown에 대한 완화책으로, 일반적인 취약점을 완화하는 데 큰 영향을 미치지 않지만 가능하면 해당 기능을 활성화하는 것이 좋다.
5.5 KADR (Kernel Address Display Restriction) #
KADR(Kernel Address Display Restriction)은 Linux 커널 심볼 및 주소 정보가 유출되는 것을 방지하여 커널의 내부 구조를 파악하지 못하도록 하는 보호 기법이다. KADR이 활성화 되면 일반 사용자는 /boot/vmlinuz*, /boot/System.map*, /sys/kernel/debug/, /proc/slabinfo, /proc/kallsyms와 같은 주요 폴더 및 파일 정보를 조회할 수 없다.
$ cat /proc/kallsyms | grep prepare_kernel_cred
0000000000000000 T prepare_kernel_cred
0000000000000000 t prepare_kernel_cred.cold
/proc/kallsyms 파일은 Linux 커널에서 제공하는 가상 파일26로, 커널의 모든 심볼(함수와 변수)의 이름과 주소를 포함한다. 사용자 권한으로 /proc/kallsyms을 조회하면 심볼의 주소가 0으로 출력된다.
$ sysctl -w kernel.kptr_restrict=0
$ sysctl -w kernel.perf_event_paranoid=0
sysctl27로 kptr_restrict28, perf_event_paranoid29의 값을 0으로 설정하면 KADR이 비활성화된다.
$ cat /proc/kallsyms | grep prepare_kernel_cred
ffffffffab12ae90 T prepare_kernel_cred
ffffffffabec1e78 t prepare_kernel_cred.cold
이후 다시 사용자 권한으로 /proc/kallsyms을 조회해보면 커널의 모든 심볼의 이름과 주소를 볼 수 있다. 이처럼 KADR이 비활성화되어 있으면 주소를 유출시킬 필요가 없기 때문에 공격자 입장에서 KADR 적용 여부는 중요하다.
5.6 Driver Signature Enforcement #
Driver Signature Enforcement는 디바이스 드라이버의 무결성을 확인하고 디바이스 드라이버를 제공하는 공급 업체의 신원을 확인하는 기능이다. (Windows Vista부터 모든 Windows 버전에서 기본적으로 활성화된다. (x64 OS만 해당)) 이는 디지털 서명을 통해 이루어지며, 새 디바이스 드라이버를 설치 및 실행할 때마다 아래 조건을 검사하여 하나라도 충족하지 못한다면 설치 및 실행이 차단되도록 한다:
- 드라이버가 유효한 코드 서명 인증서로 서명되었는지 여부
- Windows 하드웨어 개발자 센터에서 확인하고 서명했는지 여부
- Microsoft에서 서명했는지 여부
따라서 Driver Signature Enforcement를 사용하면 신뢰할 수 없는 드라이버가 설치 및 실행되는 것을 막을 수 있는 것은 물론이고, 무결성 검증을 통해 중간자 공격으로 인한 피해도 방지할 수 있다. 하지만 이 기능에도 취약점은 존재한다. 앞서 Windows 하드웨어 개발자 센터에서 확인하고 서명했는지 여부가 검사된다고 했는데, 이는 Windows 10 버전 1607부터 요구되는 정책이다. Microsoft는 전환 기간 동안 기존 드라이버를 수용하기 위해 세 가지 예외를 적용하였는데, 예외의 내용은 다음과 같다:
- Windows 10으로 업그레이드한 이전 버전의 Windows에 배포된 드라이버
- BIOS에서 보안 부팅이 비활성화된 상태에서 배포된 드라이버
- 2015년 7월 29일 이전에 유효한 사용자 인증서로 서명된 드라이버 (인증서가 Windows에서 신뢰하는 인증 기관에서 발급한 경우)
이 중 마지막 예외 사항이 바로 악용될 수 있는 취약점으로 작용할 수 있다. 예를 들어, 악성 디바이스 드라이버가 Windows에서 신뢰하는 인증 기관이 발급한 인증서로 서명되고, 그 서명에 타임 스탬프를 변경하여 인증서가 2015년 7월 29일 이전에 서명된 것처럼 보이게 만들어진다면 Driver Signature Enforcement를 우회할 수 있게 된다.
실제로, 해킹 그룹 LAPSUS$가 NVIDIA의 코드 서명 인증서를 탈취하여 유출한 사건이 있었다. 이 인증서로 서명된 첫 번째 멀웨어 샘플은 인증서가 유출된 지 단 하루 만에 등장하기 시작했을 정도로 해커들은 이를 적극적으로 활용했다. (탈취된 인증서는 2015년 7월 29일 이전에 만료되었기 때문에 서명 타임 스탬프를 변경할 필요조차 없었다.) 이는 곧 Driver Signature Enforcement가 해커들의 입장에서 매우 까다로운 기능임을 의미하기도 한다. 따라서 사용자들은 Driver Signature Enforcement를 활성화 상태로 유지하고, 유출된 인증서가 시스템에 있는지 주기적으로 확인함으로써 악성 디바이스 드라이버로부터 시스템을 보호할 수 있다.
6. 결론 #
주요 OS에서 커널과 밀접하게 연관된 디바이스 드라이버의 취약점은 치명적인 결과를 초래할 수 있다. 따라서 다양한 보호 기법을 결합하여 취약점을 최소화하고, 잠재적인 공격을 효과적으로 방어하는 것이 필수적이다. 디바이스 드라이버와 커널 보안의 발전은 지속적인 연구와 방어 기법의 개선을 통해 이루어져야 하며, 이는 시스템의 안정성과 신뢰성을 보장하는 데 중요한 역할을 할 것이다.
참고 문헌 #
- KernJC: Automated Vulnerable Environment Generation for Linux Kernel Vulnerabilities
- Desktop Operating System Market Share Worldwide | Statcounter Global Stats
- fortunebusinessinsights.com/ko/server-operating-system-market-106601
- Kernel (operating system) - Wikipedia
- Operating Systems for Dummies
- Difference between microkernel and monolithic kernel – IT Release
- Kernel in Operating System - GeeksforGeeks
- Hybrid kernel - Wikipedia
- Hybrid kernel | Microsoft Wiki | Fandom
- Why Is Linux a Monolithic Kernel? | Baeldung on Linux
- Access Tokens - Win32 apps | Microsoft Learn
- EPROCESS structure in Windows Kernel | by S12 - 0x12Dark Development | Medium
- sched.h source code [linux/include/linux/sched.h] - Codebrowser
- cred.h source code [linux/include/linux/cred.h] - Codebrowser
- cred.c source code [linux/kernel/cred.c] - Codebrowser
- prepare_kernel_cred(), commit_creds() 함수란?
- cred.c source code [linux/kernel/cred.c] - Codebrowser
- HackSysExtremeVulnerableDriver/Driver/HEVD/Windows/BufferOverflowStack.c at master · hacksysteam/HackSysExtremeVulnerableDriver
- RtlCopyMemory macro (wdm.h) - Windows drivers | Microsoft Learn
- RtlCopyMemory() Vs Memcpy() - NTDEV - OSR Developer Community
- usercopy.c source code [linux/lib/usercopy.c] - Codebrowser
- uaccess.h source code [linux/include/linux/uaccess.h] - Codebrowser
- ExFreePoolWithTag function (wdm.h) - Windows drivers | Microsoft Learn
- HEVD Windows Kernel Exploitation 6: Use-After-Free – Binary Exploitation
- Heap Spray - 기본 Heap Spra.. : 네이버블로그
- Holstein v3: Use-after-Freeの悪用 | PAWNYABLE!
- 05.Null pointer dereference(32bit & 64bit) - TechNote - Lazenca.0x0
- Zero page - Wikipedia
- FuzzySecurity | Windows ExploitDev: Part 12
- NtAllocateVirtualMemory function (ntifs.h) - Windows drivers | Microsoft Learn
- sys.c source code [linux/arch/arm64/kernel/sys.c] - Codebrowser
- mmap.c source code [linux/mm/mmap.c] - Codebrowser
- Linux kernel oops - Wikipedia
- sec17-wang.pdf
- HackSysExtremeVulnerableDriver/Driver/HEVD/Windows/DoubleFetch.c at master · hacksysteam/HackSysExtremeVulnerableDriver
- HEVD writeups - yuvaly0’s blog
- Double Fetch | PAWNYABLE!
- 레지스터 (Register)
- Control register - Wikipedia
- Linux kernel protection
- IA-32e 모드 전환 : 네이버 블로그
- PowerPoint Presentation - Windows SMEP bypass U equals S_0.pdf
- 메모리 보호기법 정리
- x64 Architecture - Windows drivers | Microsoft Learn
- VirtualAlloc function (memoryapi.h) - Win32 apps | Microsoft Learn
- mmap(2) - Linux manual page
- Supervisor Mode Access Prevention - Wikipedia
- CLAC — Clear AC Flag in EFLAGS Register
- STAC — Set AC Flag in EFLAGS Register
- uaccess_64.h source code [linux/arch/x86/include/asm/uaccess_64.h] - Codebrowser
- FGKASLR - CTF Wiki
- Kernel page-table isolation - Wikipedia
- [Linux Kernel] KPTI: Kernel Page-Table Isolation
- Paging in Operating System - GeeksforGeeks
- 레지스터 (Register)
- Segmentation과 Paging(3) - 페이징
- Linux Kernel PWN | 01 From Zero to One
- A Guide for Driver Signature Enforcement for Windows 7/10/11
- Driver Signing - Windows drivers | Microsoft Learn
- Stolen Nvidia certificates used to sign malware—here’s what to do - ThreatDown by Malwarebytes
- Hackers exploit Windows driver signature enforcement loophole for malware persistence | CSO Online
-
Windows NT 이전의 OS들에서는 단순한 구조를 채택하여 모놀리틱 커널에 가까운 구조를 가지고 있었다. ↩︎
-
시스템 프로세스는 OS가 직접 실행시키는 프로세스들이다. 이 프로세스들은 시스템의 시작과 함께 자동으로 생성되고, OS의 안정적인 운영과 관리를 담당하므로 높은 권한을 가진다. ↩︎
-
lsass.exe(Local Security Authority Subsystem Service, 로컬 보안 권한 서브시스템 서비스)는 사용자 로그인 정보와 보안 정책을 관리한다. ↩︎ -
대부분의 시스템 프로세스는
nt authority\system권한을 가지고 있다. ↩︎ -
태스크(Task)는 리눅스 커널에서 프로세스와 같은 개념으로 사용하는 용어이다. ↩︎
-
함수 래핑은 하나의 함수를 다른 함수로 감싸는 기술로, 특정 함수의 기능을 확장하거나 변경하고 싶을 때 사용된다. 이 경우는 특정 환경에서의 함수 사용을 표준화하기 위해 래핑한 것으로 보인다. ↩︎
-
ExAllocatePoolWithTag()는 Windows 10 버전 2004에서 더 이상 사용되지 않으며ExAllocatePool2()로 대체되었다. 하지만 Use After Free 취약점은 이러한 함수들의 구현 때문이 아니라, 메모리 관리의 논리적 오류로 인해 발생하므로 함수에 대한 자세한 설명은 생략하겠다. ↩︎ -
Windows OS에서는 커널 모드에서 다양한 유형의 메모리를 관리하기 위해 풀 메모리를 사용한다. 풀 메모리는 일반적으로 자주 사용되는 메모리 할당 및 해제 작업을 효율적으로 처리하기 위한 구조로, Nonpaged Pool (비페이징 풀)과 Paged Pool (페이징 풀)로 나뉜다. Nonpaged Pool은 물리 메모리에 항상 상주하는 메모리로, 언제든지 접근 가능하다. 반면 Paged Pool은 필요에 따라 물리 메모리와 페이지 파일 간에 스왑될 수 있으므로 항상 접근 가능하지 않을 수 있다. ↩︎
-
IoCompletionReserve구조체는 Windows에서 비동기 I/O 작업의 완료를 처리하기 위해 사용되는 데이터 구조이다. 이 구조체는 예측 가능하게 메모리 힙에 할당되며, 해제된IoCompletionReserve구조체의 메모리 블록은 이후의 메모리 할당 요청에서 재사용될 수 있다. 이는 요청된 크기와 정확히 일치하지 않는 경우에도 마찬가지이다. ↩︎ -
/dev/ptmx는 “pseudo-terminal master multiplexer"의 약자로, 가상 터미널 디바이스이다. 이 장치는 다중 터미널 장치를 관리하고, 실제 터미널 장치와 사용자 프로세스 간의 상호작용을 조정한다. ↩︎ -
페이지(Page)는 컴퓨터 메모리 관리에서 사용되는 용어로, 물리적 또는 가상 메모리를 일정 크기의 구획으로 나눈 것을 의미한다. 제로 페이지는 시작 주소가 0인 페이지를 의미한다. ↩︎
-
정확히는
SYSCALL_DEFINE6(),ksys_mmap_pgoff(),vm_mmap_pgoff(),do_mmap(),round_hint_to_min()순서로 호출된다. ↩︎ -
커널의 매개변수를 런타임에 조회 / 수정할 수 있는 도구이다. ↩︎
-
레이스 컨디션(Race Condition)은 둘 이상의 스레드 또는 프로세스가 동시에 공유 자원에 접근하고, 이로 인해 예상치 못한 동작이나 오류가 발생할 수 있는 상태를 의미한다. ↩︎
-
TOCTOU(Time of Check to Time of Use) 취약점은 프로그램이 상태를 확인하는 시점과 그 상태를 사용하는 시점 사이에 그 상태가 변경될 가능성 때문에 발생한다. 이는 주로 파일 시스템, 메모리, 권한 검증 등에서 발생하며, Race Condition의 한 종류로 간주된다. ↩︎
-
Windows에서는
CreateThread(), Linux에서는pthread_create()와 같은 함수로 스레드를 생성하는 것이 가능하다. ↩︎ -
커널 패닉은 운영 체제의 핵심 부분인 커널이 치명적인 오류를 만났을 때 발생하는 상황이다. 이러한 오류는 커널이 자체적으로 복구할 수 없거나, 계속해서 안정적으로 시스템을 운영할 수 없다고 판단될 때 일어난다. 리눅스나 유닉스 기반 시스템에서는 커널 패닉이 발생하면 “Kernel panic"이라는 메시지와 함께, 충돌의 원인에 대한 정보가 표시된다. 윈도우 시스템에서는 유사한 상황이 발생하면 “블루 스크린 오브 데스(BSOD)” 또는 “시스템 오류"와 같은 메시지가 나타난다. ↩︎
-
x86 및 x86-64 아키텍처를 지원하는 CPU를 생산하는 주요 제조업체로는 Intel과 AMD가 있다. ↩︎
-
ARM CPU에서는 SMEP/SMAP와 유사한 기능을 PXN/PAN이란 이름으로 제공하고 있다. ↩︎
-
x64에서는 32비트 레지스터에 대한 연산 결과가 64비트로 확장된다. ↩︎
-
Windows에서는
VirtualAlloc(), Linux에서는mmap()과 같은 함수로 확보가 가능하다. ↩︎ -
이전에는 KAISER(Kernel Address Isolation to have Side-channels Efficiently Removed)라고 불렸다. Windows에서는 유사한 기술을 KVA Shadow(Kernel Virtual Address Shadow)라고 부른다. ↩︎
-
TLB (Translation Lookaside Buffer)는 페이지 테이블을 참조하는 데 드는 시간을 줄여 메모리 접근 속도를 높이기 위해 존재하는 캐시이다. TLB는 Context Switching, System Call과 같이 다른 프로세스로 전환되거나 페이지 테이블이 변경될 때 저장된 모든 엔트리를 무효화하는 작업인 TLB Flushing을 수행한다. 이는 정확하고 안전한 메모리 접근을 위해서이다. ↩︎
-
가상 주소에 대한 변환 정보가 TLB에 있다면, CPU는 빠르게 물리 주소를 얻을 수 있다. 이를 TLB Hit라고 한다. 반대로 가상 주소에 대한 변환 정보가 TLB에 없다면, CPU는 페이지 테이블을 참조하여 변환 정보를 가져온다. 그런 다음 이 정보를 TLB에 저장하고, 물리 주소로 접근한다. 이를 TLB Miss라고 한다. ↩︎
-
비순차 실행은 CPU가 명령어를 순서대로 실행하지 않고, 가능한 여러 명령어를 동시에 실행하여 효율성을 극대화하는 기술이다. ↩︎
-
가상 파일은 실제로 디스크에 존재하지 않고, 커널이 메모리에서 동적으로 생성하여 제공하는 파일이다. 운영체제의 특정 상태나 정보를 실시간으로 보여주기 위해 존재한다. ↩︎
-
root 권한이 필요한 명령이므로 일반 사용자는
sudo명령과 함께 사용해야 한다. ↩︎ -
커널 주소 정보의 노출 수준을 제어하는 매개변수이다. ↩︎
-
비특권 사용자들이 통해 시스템 성능 데이터를 얼마나 접근할 수 있는지를 제어하는 매개변수이다. ↩︎