Meltdown

이동화·2025년 7월 27일
post-thumbnail

Meltdown 취약점은 SW에서 발생하는 것이 아닌 CPU에서 발생하는 취약점으로, 유저 모드 프로그램이 커널 주소공간의 메모리를 읽을 수 있는 취약점이다. CPU는 성능 향상을 위해 분기나 메모리 접근 시 이 경로로 실행될 수 있다는 가능성을 바탕으로 미리 명령어를 실행해보는데, 이 때 권한을 확인하지도 않고 데이터를 불러와 cache에 저장하기도 한다.

Out-of-Order

커널의 가상 주소를 읽으려고 하면 일반적으로는 예외가 발생해야 하는데, 해당 주소를 투기적으로 읽는(Speculative Execution) 경우 때문에 실행 도중 cache에 흔적이 남게된다. 현대의 CPU에서의 pipelining은 fetch, decode, rename, dispatch, execute, writeback to ROB, retire 단계로 진행된다.

해당 개념을 Out-of-Order라고 하며, 메모리 지연이나 분기 판정과 같은 느린 작업들을 기다리는 동안 CPU cycle이 낭비되지 않도록 한다. 한 번에 명령어 여러개를 명령어 윈도우 버퍼로 받아, 각 명령어의 제어 의존성을 분석하여 지금 바로 실행할 수 있는 것과 기다려야 할 것들을 구분한다. 즉, 같은 레지스터를 사용하더라도 독립적인 명령어들을 구분한다.

retire(=commit) 단계는 과거 CPU에서 메모리나 레지스터에 바로 값을 썼던 writeback 단계와는 다르게 execute 이후 곧바로 결과를 레지스터에 쓰지 않는다. ROB(Reorder Buffer) 에 먼저 기록한 이후, 검증 과정을 거쳐 정상적으로 반영된 상태를 의미하는 architectural state인 레지스터에 최종 반영하게 된다. 검사에 실패하게 되면 execute 결과는 전부 squash(취소)되고 pipeline을 flush하여 레지스터 등 architectural state값들과 ROB의 값들을 전부 지운다. ROB와 같은 성능을 높이기 위한 내부 구조는 Microarchitectural state라고 한다.

여기서 execute 단계까지는 투기적으로 실행될 수 있다는 것인데, 일반적으로는 투기적으로 실행되어도 cache line에는 결과가 남는다. execute 단계에서 '커널 메모리를 읽는 load 명령'의 결과를 ROB에 쓰도록 예약하면, cache는 성능을 위해 line 단위 (64byte)로 미리 해당 커널 메모리를 읽고 cache에 저장해둔다. 검증에 실패하여 ROB나 레지스터를 flush해도 cache line은 flush하지 않는다. 캐시를 롤백하려면 엄청난 성능 부하가 걸려 cache의 존재와는 부합하지 않기 때문이다.

해당 cache line을 직접적으로 읽어오는 명령어는 없기 때문에, cache hit과 cache miss로 발생하는 접근 속도를 통해 간접적으로 cache를 읽어야 한다. 특정 캐시 라인을 clflush로 비우고, reload 시점의 접근 속도로 히트 여부를 확인하는 flush-reload 방법이나, cache의 특정 line을 비워두고 각 슬롯을 probe하여 어떤 것이 덮였는지 확인하는 prime-probe 기법을 통해 확인할 수 있다.

flush-reload

flush-reload 원리는 다음과 같다. 현대의 CPU는 논리적 연산에 사용하는 실제 레지스터 (architectural)와 물리적 레지스터 (microarchitectural)를 엄격히 구분하는데, 여기서 물리적 레지스터는 CPU가 내부적으로 따로 보유하고 있는 저장소이다. mov al, <kernel_addr> 을 실행하면, al 결과를 담을 물리적 레지스터 pn(가칭)를 ROB 내에 할당하고 renaming table에 al-pn을 매핑 정보를 저장한다. 투기적 실행에 의해 pn에 커널 주소의 값이 저장되고, 검증과정까지 끝나면 논리적 레지스터인 al에 실제로 값이 저장되는 구조이다. 그런데 검증 과정이 마무리되기 전에도 CPU는 pipeline으로 다음 명령어들을 순차적으로 fetch-decode-execute를 진행한다. 즉 후속 명령이 al 정보를 요구하면, renaming table에 저장된 정보에 따라 pn에 저장된 al의 값을 넘겨받을 수 있다는 것이다. ROB에 저장된 값을 후속 pipeline 단계로 넘기는 작업을 Forwarding 이라고 하며, 포워딩에 의해 architectural register인 al에 접근하지 않고도 pn의 값을 연산에 활용할 수 있다.

위 사실을 바탕으로 meltdown으로 커널 메모리의 정보를 다음과 같이 꺼내올 수 있다. 사전에 256 x 4096 크기로probe_array를 정의하여, index 별로 서로 다른 cache line이 대응할 수 있도록 할당해 둔다. 4096를 곱하는 이유는, 4096마다 page 하나가 할당되기 때문에 probe_array + 4096 * i가 서로 다른 페이지의 시작 주소가 되어 각 4096 byte마다 서로 다른 cache line이 매칭되기 때문이다.

uint8_t *probe_array = mmap(NULL, 256*4096,
                            PROT_READ|PROT_WRITE,
                            MAP_PRIVATE|MAP_ANONYMOUS,
                            -1, 0);

다음으론 probe_array를 flush하여 cache에서 제거함과 동시에 초기화 해둔다. 해당 과정에서 모든 probe_array의 메모리는 cache에서 삭제된다. 이후 포워딩된 al(=pn)값을 shift << 12 연산을 통해 페이지 크기를 맞추어 준다. 이때 al 값을 통해 mov rbx, [probe_array + rax] 연산을 한다면, probe_array의 하나의 슬롯에 cache line이 접근할 것이다. 이렇게 되면 256개의 페이지 중 하나에는 접근할 것이고, cache에 올라갈 것이다.

이후 측정(reload) 단계에서 256개의 페이지에 대해 __rdtsc() 함수나 __rdtscp() 함수를 통해 timestamp를 측정하여, cache에 올라간 페이지와 cache에 올라가지 않은 페이지를hit/miss 차이로 구별할 수 있다. 그리고 hit된 n번째 페이지가 al에 의해 접근된 페이지이며, <kernel_addr>에 존재했던 byte는 n이되는 것이다.

prime-probe

물리 메모리에서 각 블록은 하나의 cache set에 매핑된다. 실제 물리 주소에 의해 set 번호가 결정되며, cache는 각 set마다 n개의 슬롯(way)를 두어 한 블록이 cache에 올라갈 때 블록에 해당되는 set의 n개의 슬롯 중 하나에 저장되게 된다. 이 구조를 set-associative라고 하며, cache miss 확률이 줄어 latency(메모리 대기)에 의한 성능 저하가 완화된다. 특정 주소에 대한 load 명령이 실행되면 해당 set에서 cache hit가 된 경우 바로 포워딩이 이루어지고, miss 시에는 해당 주소를 cache set에 저장하기 위해 replacement policy에 따라 한 슬롯을 비우고 (evict) 새 라인을 저장한다.

prime-probe는 이런 set-associative 구조를 사용한다. 목표 set을 포화시키기 위해 같은 set에 대응하는 모든 물리 주소에 연속적인 load 명령을 주입하여 set의 모든 슬롯에 공격자 제어 데이터를 채워 넣는다. 결과적으로 cache set에는 빈 슬롯이 남지 않아 어떤 load도 miss만을 발생시킨다.

이후 커널 메모리에 접근하는 투기적 실행이 발생하면, 동일 set을 공유하는 커널 주소에 대해 load가 발생하여, cache miss가 발생할 것이다. 그리고 해당 cache line에는 공격자의 데이터로 채워지지 않은, 커널의 데이터가 들어가게 된다. 같은 set에 매핑된 주소를 골라낼 수 있는 원리는 가상 주소의 하위 bit는 물리 주소의 하위 bit와 일치하기 때문인데, 하위 12bit는 가상 주소와 물리 주소에서 동일하기 때문에 유저 공간의 array와 kernel page가 동일한 하위 비트를 가지도록 가상 주소를 잘 선택하면, 같은 set에 매핑되도록 유도할 수 있다.

마지막으로 다시 해당 set에 대응하는 물리 주소들에 전부 load 명령을 실행하여, hit/miss 여부를 hit/miss 시간 차이로 어떤 슬롯이 evict되었는지 알 수 있고, 이를 통해 해당 cache set에 속하는 addr이 load되었는지 알 수 있다. 이를 바탕으로 간접적으로 커널 메모리의 값을 읽을 수 있게 된다.

prime-probe는 대부분의 환경에서 clflush 명령을 제한하기에 flush-reload 기법을 쓸 수 없기 때문에 등장한 기법이다. 또한, flush-reload에 비해 탐지하기가 더 어렵다. ![]

profile
notion이 나은듯

0개의 댓글