MeltDown 및 Spectre Variant 1의 원리분석과 그 대응방법,
MeltDown과 멀티테넌트, MeltDown 패치와 성능 하락 이슈까지
일단 쓰기에도 캐쉬가 적용되는 건 맞기 때문에, 단순한 쓰기만으로는 캐쉬가 탈락되지 않는다. 하지만 Prime 공격이 기존과 다른 점은 Prime+Probe를 사용한다는 것 외에 하나가 더 있는데, 그건 공격자와 공격대상이 다른 코어에 위치해야 한다는 점이다. 즉, 같은 코어에서 공격자와 공격대상 프로세스가 작동하고 있다면 이 방법은 통하지 않는다. 또 양쪽의 공격법을 설명하면서 모두 ‘캐쉬’라고 뭉뚱그려서 얘기했지만, 정확하게 말하자면 최근의 인텔 CPU(스카이레이크 이후)를 가정할 때 Prime 공격에서 주로 얘기하는 건 각 코어에서 독립적으로 사용하는 L1, L2 캐쉬이며 모든 코어가 공유하는 L3 캐쉬는 해당 사항이 없다. 참고로 기존 공격 방식에서는 어떤 캐쉬든 상관없이 존재하기만 하면 데이터를 유출할 수 있다.
위 그림에서 오른쪽이 스카이레이크의 캐쉬 구조이다. L3는 공유되지만 L2는 각 코어에 종속된 것을 확인할 수 있다.
각 코어가 독립적으로 캐쉬를 갖는다는 건 하나의 CPU 내 동일한 메모리에 대한 캐쉬 사본이 복수 개 존재할 수 있다는 것인데, 만약 코어 1이 메모리 X에 대한 캐쉬를 가지고 있고 코어 2도 동일한 메모리 X에 대한 캐쉬를 가지고 있다면 반드시 이 두 캐쉬는 같은 내용이어야 한다. 당연하지만 이 두개 캐쉬의 내용이 달라진다면 멀티코어 시스템은 제대로 작동할 수 없다. 이것을 캐쉬 일관성(cache coherence)이라 한다. 그렇기 때문에 각 코어는 메모리에 쓰기 전에 자신이 해당 메모리의 쓰기 권한을 획득하고자 한다는 것을 브로드캐스트하고, 이 메시지를 받은 코어들은 대상이 되는 메모리의 캐쉬를 가지고 있을 경우 이를 무효화(invalidate)해 버린다. 이후 실제 메모리에 쓰면서 해당 코어의 캐쉬는 업데이트 되지만, 그외 코어의 캐쉬는 모두 무효화되었기 때문에 이 캐쉬는 CPU내의 유일한 캐쉬가 되고 일관성은 지켜진다. 이것이 쓰기 요청으로 인한 캐쉬 탈락이다.
위 그림은 캐쉬 일관성을 유지하기 위한 프로토콜인 MESI 시뮬레이터를 보여준다. CPU 0이 read a0, CPU 1이 write a0을 한 직후의 상태. CPU 0의 a0에 대한 캐쉬는 I(Invalid) 상태가 된 것을 확인할 수 있다.
그러면 Prime 공격이 기존 공격 방식과 비교하여 무슨 차이가 있는가에 대한 생각을 해보지 않을 수가 없는데, 이 공격은 기존의 MeltDown이나 Spectre를 성능 면에서 좀 더 최적화한 것이라 볼 수 있다. 캐쉬와 메모리는 수십 배 이상 속도 차이가 나기 때문에 캐쉬에 메모리의 내용이 없는 경우 이를 읽어서 적재하려면 캐쉬에서 바로 읽는 것보다 훨씬 많은 시간이 걸리게 된다. 그런데 어떤 종류의 메모리 쓰기 작업도 일단 캐쉬에 쓴 후 실메모리로 전달되므로(write back, 현세대 CPU는 모두 write back을 사용한다), 메모리에 쓰는 작업은 캐쉬에 쓰는 것만으로 끝나고, 캐쉬가 넘치는 상황이 발생하지 않는 이상 읽는 작업보다 항상 빠르다. 즉 추측실행에서 ‘읽기’를 하는 것보다 ‘쓰기’를 하는 것이 더 빠르기 때문에, 매우 짧은 misspeculation window 내에 공격을 마쳐야 하는 공격자 입장에서는 쓰기가 더 효율적인 공격 방식이라는 결론을 내릴 수 있는 것이다.
실제로 해당 논문에서 테스트한 결과를 보면 동일한 하드웨어에서 Spectre가 97.9% 확률로 공격에 성공한데 비해 SpectrePrime은 99.95%로 상당히 성공률이 개선되었다는 사실을 확인할 수 있다. 물론 공격자와 공격대상이 각각 다른 코어에 pin돼야 한다는 추가적인 조건이 있기 때문에 필드에서의 범용성은 좀 떨어질 것이다. 하지만 이런 식으로 계속 공격 효율이 향상된다면, 향후 이 취약점들에 대해 안전하다고 알려진 CPU들에 대해서도 공격에 성공하는 사례가 나올지도 모른다.
MeltDown
이제 이번 회의 본 주제 중 하나인 MeltDown에 대한 얘기를 해보자. 전회에 설명한 대로 이번에 발견된 취약점들은 CPU의 추측실행과 비순차실행 구조 자체를 공격하는 것으로, 원리는 유사하나 구현방법에서 차이 있는 공격 세 가지가 존재한다. 그리고 논문이 공개된 이후 다시 ARM Cortex-A15, Cortex-A57, Cortex-A72에서 작동하는 마지막 공격의 변형이 하나 발견되었다.
Variant 1: Bounds Check Bypass, CVE-2017-5753 (Spectre)
Variant 2: Branch Target Injection, CVE-2017-5715 (Spectre)
Variant 3: Rogue Data Cache Load, CVE-2017-5754 (MeltDown)
SubVariant 3a : Privileged register reads from unprivileged code (MeltDown)
잘 알려진 대로 Variant 1인 Bounds Check Bypass와 Variant 2인 Branch Target Injection을 Spectre, Variant 3인 Rogue Data Cache Load를 MeltDown이라 하며, 나중에 발견된 Privileged register reads from unprivileged code도 Variant 3과 비슷한 성격으로 보아 SubVariant 3a인 MeltDown으로 분류하고 있다. Variant 3a의 경우 Variant 3과 많은 부분이 유사하지만 공격 대상이 메모리가 아니라 레지스터라는 점에서 차이가 있는 정도이며, 상대적으로 다른 취약점에 비해 공격의 영향은 작다. 앞으로 이 원고에서는 Variant 3에 한정해서 MeltDown이라는 용어를 사용하고 설명하도록 하겠다.
MeltDown은 이름에서 알 수 있듯이 일반과 특권의 영역경계가 무너져서 일반 유저 권한 프로세스가 커널의 보호되는 메모리 영역을 무제한적으로 읽을 수 있는 취약점으로, 인텔과 ARM 계열 일부 CPU에서만 공격 성공이 확인됐다. AMD나 다른 ARM 프로세서, PowerPC 등에서 이 공격을 성공시키지 못한 것은 구현의 차이에 따른 것으로 생각되지만 실제 CPU 내부의 마이크로코드가 어떤 식으로 작동하는지는 확인할 수 없으므로 추측만 할 뿐이다. 앞서 얘기한 대로 이 공격이 성공하려면 추측실행 이후 취소되기 전까지(즉 misspeculation window 동안) 데이터 유출이 가능하게끔 코드가 실행돼야 한다. MeltDown 공격은 대략 다음과 같은 식으로 구현할 수 있다. 참고로 아래 내용은 원리를 설명하는데 주안점을 둔 방식이며 실제 공격 시에는 이렇게 비효율적으로 1비트씩 읽지 않고 배열을 사용하여 1바이트의 내용을 한 번에 확인한다. 캐쉬도 한 번에 읽는 단위(캐쉬라인)가 있어서 코드를 작성할 때 그 부분도 고려해주어야 한다.
1) 메모리 a0과 a1을 Flush한다(캐쉬를 소거).
2) 레지스터 R에 보호된 메모리 영역 ap(공격대상)를 읽어 들인다. ap는 커널 메모리에 존재하므로 여기서 보호비트에 의해 예외가 발생하고 아래의 3) 4)는 실행되지 않아야 한다.
3) R의 0번 비트가 0이면 a0, 1이면 a1을 메모리 ax에 대입한다.
4) ax의 메모리 주소 내용을 읽는다. 즉 a0 또는 a1이 읽혀지고 캐쉬에 남는다.
5) CPU는 최대한 파이프라인을 쉬지 않게 실행하려고 하므로 3) 4)는 같이 실행됐다가 취소될 수 있다. 그래도 캐쉬에는 a0 또는 a1이 남아 있다.
6) 캐쉬에 직접 접근하는 건 불가능하지만, a0과 a1를 각각 읽으면서 시간을 측정하면 캐쉬에 존재하는 데이터가 더 빨리 읽힌다. 이제 ap의 0번 비트가 0인지 1인지를 알 수 있다.
7) 같은 원리로 ap의 다른 비트들도 읽을 수 있고, 반복하면 커널 메모리를 처음부터 모두 덤프할 수 있다.
일반적으로 커널 메모리에는 보호비트가 설정돼 있어서 일반 모드 애플리케이션이 읽으려고 하면 예외가 발생하면서 거기서 실행이 멈춘다. 따라서 순차적으로 명령을 실행한다면 2)에서 프로그램은 멈춰야 하고 그 이하의 명령들은 실행되지 않아야 한다. 하지만 현대적 CPU는 수퍼스칼라 구조에 의해 복수 개 파이프라인에 한꺼번에 명령을 적재하므로 이미 명령 3) 4)까지가 Fetch-Decode를 거쳐서 실행유닛에 올라와 있을 수 있고, 이 경우 2)에서 예외가 발생하기 전에 ap의 값이 전달돼 3) 4)까지 실행이 된다면 a0 또는 a1 둘 중의 한 메모리가 캐쉬에 남게 된다. 그 후 예외가 발생하면 3) 4)가 파이프라인에서 삭제되면서 명령의 실행은 취소되지만 이미 읽은 메모리에 대한 캐쉬까지 원래대로 복구되지는 않으므로 이 데이터를 통해 간접적으로 보호된 메모리의 내용을 확인할 수 있는 것이다. 여기서 중요한 건 역시 속도로, 2)의 명령에 의해 예외가 발생하기 전에 최소 3) 4)까지 실행이 완료돼야 공격이 성공하게 된다.
특히 인텔 CPU의 경우 인텔 고유의 TSX(Transactional Synchronization Extensions) 명령을 같이 사용하면 커널 메모리에 접근하면서 발생하는 예외 자체를 억누를 수 있기 때문에 더욱 효율적인 공격이 가능하다. AMD CPU도 동일한 방식으로 공격할 수 있지만 지금까지 성공했다는 소식은 들려오지 않는데 이로부터 AMD CPU는 같은 공격에 대해서 예외가 발생하면 인텔보다 더 빨리 실행을 취소할 것이라는 추정이 가능하다. 또 초기에 ARM에서는 MeltDown 버그가 없다고 알려졌지만 이후 일부 CPU군에서 공격에 성공했다는 점을 생각하면 이 공격의 성공 여부는 얼마나 최적화된 공격코드를 실행하는가에 달려 있다는 사실도 알 수 있다. 다시 말해 인텔 CPU는 추측실행 시 일반 애플리케이션이 커널 메모리에 접근할 수 있게 돼 있으나 AMD CPU나 ARM은 그렇지 않다, 가 아니다. 모든 CPU에서 일반 애플리케이션이 보호비트가 설정된 커널 메모리에 접근할 때는 예외가 발생하며, 이는 어떤 CPU라도 비슷하게 적용된다. 하지만 공격이 너무 느리다든가, 구조적인 차이가 있다든가, 하는 여러 가지 이유들로 인해 성공하기도 하고 그렇지 않게 되기도 하는 것뿐이다. 논문에서도 AMD CPU나 ARM CPU에서 MeltDown이 성공하지 못한 이유에 대해 ‘공격이 너무 느렸던 것’일 수 있으며, 좀 더 최적화된 버전이라면 성공할지도 모른다는 의견을 기술하고 있다. 취약점이 발표된 지 한 달 이상 지난 지금까지 MeltDown PoC를 성공한 CPU는 여전히 인텔 계열과 ARM 일부에 국한돼 있기는 하지만, 앞서 설명한 Prime 공격처럼 더 최적화된 변형공격들이 나온다면 이후 어떻게 될지는 여전히 알 수 없는 상황이다.
MeltDown의 대응
MeltDown은 일단 1) exploit을 대상 시스템에 업로드할 수 있어야 하고 2) 해당 exploit을 실행할 수 있어야 한다라는 두 가지 조건만 충족하면 공격이 성립한다. 즉 예전에 문제가 되었던 SMB attack처럼 네트워크에 접속만 하면 감염되는 그런 종류는 아니고 local exploit에 해당한다고 할 수 있다. 하지만 다른 종류의 공격을 통해 쉘(예를 들어 웹쉘)을 얻으면 앞서의 두 가지 조건은 아주 간단하게 맞춰지므로 네트워크로부터의 공격에 완전히 안전하다고 말할 수도 없다. 심각한 점은 이 취약점의 원리가 대단히 심오한데 비해 공격 방법이 간단하고 조건을 맞추기가 까다롭지 않기 때문에 패치가 되지 않은 시스템에 대한 공격은 아주 높은 확률로 성공한다는 것이다. 암호화된 비밀도 결국은 사용하기 위해 복호화해 메모리에 올려야 하는데, 이 부분을 아무런 흔적도 남기지 않고 쉽게 읽을 수 있다는데 MeltDown의 심각성이 있다.
또한 이 경우 1차적인 공격대상이 되는 건 커널 메모리이지만 커널 메모리에는 프로세스의 페이지 테이블이 저장돼 있다. 잘 알려진 대로 현대적인 CPU에서는 메모리를 유연하게 사용하기 위해 모든 주소를 가상화하여 사용하며 이 가상화된 메모리와 실제 물리 메모리를 맵핑하는 데이터를 갖는 것이 페이지 테이블이다. 즉 이 페이지 테이블이 없이는 메모리로부터 1바이트도 읽을 수 없는데 커널 메모리에 제한 없이 접근할 수 있다는 것은 곧 다른 프로세스의 메모리도 동일하게 읽어낼 수 있다는 사실을 의미하는 것이다.
따라서 위의 두 가지 조건을 충족하거나 그럴 가능성이 있는 시스템들은 패치를 해야 하며, 커널에 대한 공격이므로 OS에 대한 패치가 이 취약점에 대한 대응방법이 된다. 패치의 내용은 간단하게 커널의 페이지 테이블과 애플리케이션의 페이지 테이블을 분리하여 실제로 보호된 메모리의 내용은 커널이 아니면 읽지 못하게 하는 것이다. 리눅스에서는 이미 KPTI(Kernel Page Table Isolation)이라는 이름으로 적용돼 있으며, 윈도우의 패치도 페이지풀을 복사하는 유사한 내용이다. 지금까지는 효율을 위해 모든 애플리케이션의 페이지 테이블에 커널의 페이지 테이블을 복사해서 포함시켜 두었다. 결국 애플리케이션이 시스템콜을 하게 되면 커널의 주소공간에 위치한 API 함수들에 접근해야 하기 때문이다. 이번 패치는 애플리케이션과 커널이 컨텍스트 스위칭을 할 때 양쪽의 페이지 테이블을 그때마다 복사해서 교체하므로 애플리케이션에서 MeltDown 취약점을 이용해 커널주소의 내용을 읽으려고 해도 그에 해당하는 페이지 테이블이 없어서 실제 내용을 읽지 못하게 하는 것이다.
위 그림은 KPTI의 간단한 모식도다. 보라색 부분이 Kernel Space에 대한 페이지 테이블인데 이를 오른쪽처럼 커널 모드와 사용자 모드로 분리하는 것이 KPTI이다. 패치 후 공격자는 하얀색의 비어있는 부분은 읽을 수 없고 API 호출을 위한 최소한의 영역인 보라색 부분에만 접근할 수 있다.
멀티테넌트와 MeltDown
멀티테넌트 시스템, 즉 많은 고객(테넌트)이 하나의 서비스나 시스템을 공유하는 경우 MeltDown은 매우 위협적이라 말할 수 있는데, MeltDown의 공격 대상인 커널을 공유하는 시스템이라면 상호간에 공격으로 인한 정보유출이 가능해지기 때문이다. 웹호스팅이나 컨테이너, 반가상화(Para-Virtualization) 기술을 사용하는 VM등이 대표적으로 커널을 공유하는 서비스인데 이 서비스들은 모두 파일을 업로드하기 위한 공간과 작업을 하기 위한 쉘접속을 제공하므로 앞서 언급한 MeltDown 공격이 성립하기 위한 조건 두 가지를 간단하게 충족해버린다. 즉 웹호스팅 계정을 하나 신청해서 쉘을 얻으면 그 서버에 접속하는 모든 다른 고객들의 정보를 얻을 수 있는 가능성이 생긴다. 따라서 이런 종류 서비스나 시스템이라면 성능 패널티를 감수하고라도 KPTI 패치는 필수적으로 해야 하는 것이다.
또 이런 멀티테넌트의 대표적인 서비스로 인프라 클라우드(IaaS)가 있는데, IaaS의 경우 멀티테넌트는 맞지만 일부 초기 버전 서비스를 제외한 대부분의 업체들이 하드웨어적으로 지원되는 전가상화(Full-Virtualization) 기술을 사용하고 있기 때문에 각 VM의 OS 커널과 호스트의 커널은 모두 분리돼 있다. 이 경우 VM에서 exploit을 실행하더라도 읽을 수 있는 것은 VM에서 실행되는 OS 커널의 메모리이고 따라서 이 취약점을 이용해 호스트의 메모리를 읽거나 하는 일(VM escape)은 일어날 수 없다.
하지만 VM의 OS를 패치하지 않을 경우 외부에서 공격하여 VM의 커널을 읽는 공격은 가능하고, 그렇게 되면 VM 내의 정보는 전부 유출가능하기 때문에 일단 성능저하를 감수하고라도 VM의 OS를 패치하는 것이 바람직하다. 만약 부득이 패치를 못할 상황이라면 앞서 언급한 두 가지 공격 성립 조건이 충족되지 않도록 각별하게 보안에 신경 써 주어야 한다. 외부에서 파일을 업로드하지 못하게 하거나, 파일이 업로드 되더라도 실행 권한을 막거나 하는 조치들이 필요하다.
호스트의 경우는 VM의 공격으로 인해 침해되지는 않지만 호스트가 직접 공격당하면 동일한 상황이 되므로 역시 OS패치는 권장사항이다. 하지만 장기적으로 봐서는 VM이나 호스트 양쪽 모두 패치를 하는 편이 안전하다. 지금의 해커들은 MeltDown 취약점만 사용하는 것이 아니라 공격하기 위해서 필요한 모든 방법을 복합적으로 다 동원하므로 어디 한 군데가 뚫리게 되면 남아 있는 MeltDown 취약점은 매우 크리티컬한 요소가 될 것이기 때문이다.
성능 하락
사용자들이 가장 관심을 갖는 내용 중 하나는 이 MeltDown 패치에 의한 성능 하락이다. 보안적으로 문제가 있다고 하니 패치를 하기는 해야겠는데 성능 하락이 우려된다. 인텔이나 MS 등 글로벌 기업은 성능 하락이 있지만 전반적으로 큰 영향은 없다고 얘기하고 있으나 어떤 테스트에서는 30% 이상 성능이 낮아졌다는 얘기도 있고. 게임이나 일반 애플리케이션은 거의 영향이 없다는 소리도 들린다. 어떤 업체는 데이터센터에서 테스트한 결과 60%라는 경악할만한 성능하락을 기록했다고 하기도 한다. 도대체 어떤 얘기가 맞는 걸까?
결론적으로 얘기한다면, 적절한 환경에서 테스트했을 경우 모든 테스트 결과가 가능하다고 말할 수 있다. 어떻게 거의 영향이 없는 수준에서 60%까지 성능저하가 있을 수 있는가에 대해 의아하게 생각할 수도 있는데, 사실 이 MeltDown 패치에 의한 성능하락은 하드웨어 구성, OS 환경, 워크로드에 따라 달라질 뿐만 아니라, 극단적으로는 같은 항목을 테스트하더라도 벤치마크 프로그램에 따라 아주 다른 결과를 보여줄 수도 있다. 테스트하는 환경에 따라 결과가 이 정도로 크게 변한다는 것 자체가 매우 당황스러운 일일 수 있는데 이 패치는 지난 20여 년간 당연하게 생각돼온 부분을 고친 것이라는 점을 잊어서는 안 된다. 벤치마크 프로그램들도 CPU의 취약점을 막기 위해 모든 시스템콜 시 페이지 테이블을 복사해야 하는 상황까지 고려해서 작성되진 않았기 때문에 이런 들쭉날쭉한 결과가 나오게 되는 것이다.
최근에 Netflix에서 근무하는 Brendan Gregg이라는 엔지니어가 MeltDown 패치에 대한 매우 흥미로운 테스트 결과를 공개했는데 필자가 판단하기에는 이 내용이 지금까지의 테스트 중 패치 이후의 실제 상황을 가장 잘 반영하고 있다고 보인다. 잘 알려진 대로 Netflix는 모든 인프라를 AWS 위에서 구동하고 있기 때문에 이 테스트 결과는 AWS를 많이 쓰는 고객들에게 특히 유의미한 자료가 될 것이다.
Brendan의 자료에 의하면 MeltDown 패치 이후 성능에 영향을 주는 요소는 크게 다음의 다섯 가지로 볼 수 있다. 이외에 여기에 포함되지 않은 다른 영향을 주는 요소가 있을 수 있지만 이 정도면 가장 중요한 내용들은 다 들어가 있다고 보인다.
1) 시스템콜의 호출 숫자(Syscall rate)
시스/템콜을 할 때마다 커널과 애플리케이션 사이에서 페이지 테이블을 복사해야 하므로, 그만큼 오버헤드가 늘어난다. Brendan의 테스트에 의하면 초당 5만 번 호출까지는 성능 저하가 2%에 불과했다고 한다. 그러나 이 숫자가 올라가면 성능저하는 400%를 넘어설 수도 있다. Netflix의 경우 데이터베이스 등 일부 높은 부하를 갖는 시스템을 제외한 대부분의 서버들은 초당 1만 번 미만의 시스템콜을 하므로 이로 인한 성능 저하는 0.5% 미만으로 예상할 수 있다. 필자 생각건대 일반적으로는 이 요소가 가장 성능에 높은 영향을 미치게 될 것이다.
위 그림은 KPTI 후 시스템콜 숫자에 따른 성능저하 그래프다. X축이 초당 시스템콜 숫자(천 단위), Y축이 성능저하 비율이다.
2) 컨텍스트 스위칭(Context switching)
컨텍스트 스위칭이 발생하면 시스템콜과 마찬가지로 페이지 테이블을 복사해야 한다. 특별히 컨텍스트 스위칭이 많이 발생하는 상황, 즉 프로세스나 쓰레드의 숫자가 일반적인 것보다 훨씬 많은 경우가 아니라면 성능에 크게 영향을 주지는 않는다.
3) 페이지 폴트 숫자(Page fault rate)
페이지 폴트가 발생하는 건 메모리를 할당받지 못했다는 것이고 따라서 커널모드로 전환하여 페이지를 할당해야 한다. 이 숫자가 높으면 커널로의 컨텍스트 스위칭이 많이 일어나게 되며 1) 2)와 동일한 원리로 페이지 테이블 복사의 오버헤드가 늘어난다. 단기간에 급격하게 많은 메모리 할당을 요구하는 애플리케이션이라면 성능이 그만큼 하락한다.
4) 작업에 사용하는 데이터의 크기(Working set size)
실제 메모리로 읽어 들여서 작업할 데이터가 크면 TLB(Translation Lookaside Buffer)가 Flush되면서 성능이 하락한다. TLB는 느린 메모리인 페이지 테이블의 페이지를 저장하는 일종의 캐쉬이다.
5) 캐쉬에 접근하는 패턴(Cache access pattern)
애플리케이션이 시스템 캐쉬를 얼마나 잘 활용하느냐에 따라서도 성능이 최대 1~10%까지 추가적으로 하락할 수 있다.
MeltDown 패치로 인한 성능 저하는 피할 수 없겠으나 어떤 애플리케이션을 운영하는가에 따라 성능 저하 폭에는 차이가 있을 수 있다. 인텔이나 MS, AWS 등에서 ‘성능 저하 폭은 크지 않다’라고 얘기하는 것은 일반적인 워크로드 기준이겠지만, DBMS나 게임서버처럼 매우 높은 부하를 처리해야 하는 시스템의 경우는 좀 더 성능이 낮아질 것을 예상한다. 각 사용자마다 상황이 다르고 시스템의 구성이 다르기 때문에 뭐라고 말하기 어렵지만 일단 위의 다섯 가지 항목에 대해서 현재의 상태를 체크해보면 대강 패치 후 성능 하락의 정도를 짐작할 수 있을 것이다. 그리고 벤치마크 프로그램은 시스템의 부하를 한계까지 올려서 테스트하는 것이므로 실제의 워크로드보다는 성능저하가 더 크게 나올 수밖에 없다. 더불어 벤치마크 프로그램들에서 시간을 측정하기 위해 수시로 호출하는 gettimeofday()나 clock_get_time() 등의 시스템콜도 성능에는 부정적 영향을 미친다. 한 번의 성능 테스트를 위해서는 이 API들이 정말로 많이 호출 된다. 그러니까 벤치마크 프로그램의 결과에 너무 경도될 필요는 없으나 그렇다고 아예 무시할 필요도 없다. 중요한 것은 실제 운영되는 워크로드를 올려서 테스트해보는 일이다.
만약 상당한 수준의 성능 저하가 예상된다면 미리 시스템을 업그레이드하거나 일부 애플리케이션을 변경하는 작업까지도 필요하다. 모든 시스템을 교체하지 못하는 이상 당분간 우리는 이 취약점과 같이 살아야 하고, KPTI를 적용하지 않을 수는 없으므로 정도에 차이가 있을지언정 성능 하락은 피할 수 없기 때문이다.
Spectre Variant 1
이제 Spectre에 대한 얘기를 해보자. 앞서 얘기한 대로 Spectre는 Variant 1과 Variant 2가 있는데 기본적인 원리는 MeltDown과 비슷하다. 다만 MeltDown이 비순차실행을 통해 보호된 커널 메모리를 읽는 취약점이라고 한다면 Spectre는 비슷하게 시큐어 코딩된 부분을 파훼하거나 또는 공격자의 코드를 실행하게끔 하여 애플리케이션의 중요한 정보를 얻어내지만 주로 추측실행에 의존하여 공격한다는 점에서 차이가 있다.
Spectre Variant 1은 Bounds Check Bypass라고 하며, 애플리케이션에서 내부적으로 사용하는 배열의 인덱스를 넘어선 영역을 읽게 하여 내부정보를 유출하게 하는 기법이다. 이것과 가장 비슷한 성격의 취약점이라고 한다면 buffer overflow를 들 수 있다. 너무 잘 알려진 buffer overflow는 정해진 크기의 buffer를 넘치게 하여 대상을 필요한 대로 조작하는 취약점이었는데 buffer의 사이즈보다 크게 쓰기를 수행할 수 있으면 성립한다. 이 경우도 경계 확인(Bounds Check)를 잘 하지 않아서 생기는 취약점이었는데, 이런 코드가 문제 된다는 것이 너무 잘 알려져 있어서 최근에 이 취약점을 갖고 있는 소스는 좀처럼 보기 힘들다. 시큐어 코딩이 많이 일반화되기도 했고, 소스의 문제를 체크해주는 code analyzer도 있어서 대중들이 많이 쓰는 애플리케이션이라면 이런 이슈는 거의 해결해서 배포되는 것이 보통이다. 그런데 Spectre Variant 1은 개발자가 아무리 Bounds Check를 잘 해두었다고 하더라도 이를 통과해서 배열 밖의 내용을 읽을 수 있게 하는 공격기법이다. 구체적으로는 구글의 논문에서 예시로 든 다음과 같은 코드가 공격대상이 될 수 있다.
if (x < array1_size)
y = array2[array1[x] * 256];
여기서 x값을 공격자가 조작할 수 있다고 해보자. x가 array1의 크기보다 작은 경우에만 y값을 읽게 돼있으므로 이 코드는 배열의 경계확인을 하는 보안적으로 문제가 없는 코드이다. x를 array1_size보다 작은 범위 내 둔 상태에서 이 코드를 반복실행하면 CPU는 이 조건분기의 결과는 항상 참이며, 그러므로 즉 항상 y값을 읽어야 한다고 예측한다. 그러면 몇 번의 반복 후 이 코드는 항상 조건분기에서 y값을 읽는 방향으로 추측실행을 하게 될 것이다. 그런데 이렇게 학습된 상태에서 갑자기 array1_size보다 큰 x를 지정하게 되면 이미 추측실행에 의해 파이프라인에는 y값을 읽는 명령이 적재돼 있을 것이고 이 실행은 바로 취소되나 array2의 읽은 값이 캐쉬에 저장되는 것을 막을 수 없다. 이제 MeltDown과 동일하게 Side Channel Attack을 하면 캐쉬로부터 array1의 배열 밖 메모리에 저장된 값을 확인할 수 있는 것이다. 이렇게 취소되는 명령으로부터 데이터를 얻는 것이므로 개발자가 배열 경계범위를 아무리 잘 체크해도 소용이 없다. 그러니까 하드웨어적인 취약점 때문에 시큐어코딩을 아무리 잘해도 이를 Bypass 할 수 있으므로 소용이 없어지는 결과가 나오게 되는 것인데 개발자 입장에서는 황당하면서도 무력해지지 않을 수 없다.
위 내용으로부터 우리는 외부로부터 인자를 받아서 배열을 사용하는 모든 코드가 잠재적으로 Spectre Variant 1의 공격대상이 될 수 있다는 사실을 알 수 있다. 그리고 이 취약점은 MeltDown처럼 특정 CPU군에만 존재하는 것이 아니라 현대적인 추측실행을 하는 대부분의 CPU들에 존재하기 때문에 공격할 수 있는 애플리케이션이나 시스템의 숫자는 매우 많아진다.
Spectre Variant 1의 공격 조건
지금까지 얘기만 들으면 Spectre Variant 1도 대단히 위험한 취약점처럼 보인다. 하지만 사실은 앞서 설명한 MeltDown만큼 위험하지는 않다. 왜냐면 MeltDown은 exploit을 올려서 실행할 수만 있으면 커널의 메모리를 무제한으로 읽을 수 있지만 Spectre는 Variant 1과 Variant 2 모두 정해진 몇 가지의 꽤 까다로운 조건을 충족해야 공격이 성립하기 때문이다.
Spectre Variant 1은 작동하는데 두 가지 조건을 요구한다. 하나는 공격을 위해 배열에 접근하기 위한 인자 값을 공격자가 조작할 수 있어야 한다는 것이며, 다른 하나는 이 조작에 의해 실제 캐쉬에 데이터를 남기는 코드조각(gadget이라 한다)이 기존 코드 내에 존재하고 있거나 또는 삽입 가능해야 한다는 것이다. 그러니까 배열의 경계를 넘는 건 언제나 가능하지만 캐쉬로부터 데이터를 읽으려면 추측실행으로 실행되는 코드가 공격자가 접근 가능한 메모리로부터 데이터를 읽어줘야 한다.
이 경우 가장 먼저 공격 대상이 될 수 있는 건 커널 API로, 이 API들 중 1) 숫자로 인자를 받으며 2) 내부적으로 이 인자를 통해 배열에 접근하고 3) 인자를 통해 경계를 넘었을 때 해당 코드가 공격자가 접근 가능한 메모리에 대해서 읽거나 쓰거나 할 수 있는 API를 찾아내야 하는 것이다. 그러니까 내부적으로 배열을 사용하고 이 배열의 인덱스를 인자로 받는 API는 적지 않겠지만 이 중에서 공격이 실제 가능한(gadget이 존재하는) API를 골라내서 시나리오를 만들어야 하는데 이는 쉽지 않은 일이다. 방어하는 입장에서도 어떤 API가 잠재적으로 공격대상이 될 수 있는지 일일이 확인하여 소스를 수정하는 일이 간단하지는 않은데 뒤에서 설명하겠지만 이 경우는 좀 더 간단한 해결책이 있다.
또 다른 하나 유력한 공격대상은 인터프리터나 JIT로, API 호출보다 훨씬 공격이 간단하다. 공격자가 입력한 코드를 공격대상의 주소공간에서 실행하게 되므로 gadget을 찾지 않고 직접 코드로 작성하면 되기 때문이다. 이 경우는 훨씬 공격이 쉽고 간단하므로 공격자 입장에서는 API 호출보다는 이쪽을 선호할 수밖에 없을 것으로 생각한다. 실제 Spectre 논문에서도 JavaScript를 통한 웹브라우저 공격코드 예제를 제시했으며, 현대적인 브라우저들은 대부분 JavaScript를 JIT로 컴파일해 기계어로 실행하므로 공격하기에 충분한 속도를 보장한다. 최근의 장비나 애플리케이션에서는 서비스를 좀 더 유연하게 운영할 수 있도록 lua나 python, JavaScript 등의 플러그인을 두고 있는데 이런 시스템들도 역시 잠재적으로 모두 공격대상이 될 수 있다.
Spectre Variant 1의 대응
Spectre Variant 1에 대해서는 MeltDown처럼 전체 환경에 적용하는 패치가 존재하지 않는다. 현재 Linux나 MS에서 내놓은 패치는 단지 OS의 커널 API를 Spectre Variant 1에서 보호하기 위한 것이며, 즉 이 패치를 하게 되면 커널 API를 호출할 때 Spectre Variant 1 공격으로 데이터가 유출되는 일을 원천적으로 막을 수 있게 된다. 하지만 apache web server라든가, java 라든가, 또는 웹브라우저 등 이 취약점으로 공격할 수 있는 대상은 매우 많다. JIT를 가지고 있거나 외부의 코드조각을 플러그인식으로 실행할 수 있는 애플리케이션은 모두 공격 대상이 될 수 있다. 만약 자주 쓰는 애플리케이션이 그런 타입이라면 Spectre Variant 1에 대한 적절한 패치가 나와 있는지 확인할 필요가 있다고 보인다. 즉 이 취약점은 OS를 포함하여 개별 애플리케이션 각각에 대해 개별적으로 대응해주어야 하는 것이다.
구체적으로 보자면, Spectre Variant 1에 대해 인텔에서 권고하는 대응안은 LFENCE, MFENCE, SFENCE 같은 직렬화 명령을 사용하는 것이다. 인텔은 그중에서도 가장 성능 면에서 낫다는 이유로 LFENCE를 적절하게 코드에 삽입할 것을 권고하고 있는데, 이는 특별한 CPU 명령으로 이 명령 뒤에 오는 명령들은 실행유닛에 적재될 수는 있으나 LFENCE가 수행되기 전까지 병행실행되지 않는다. 따라서 그 뒤의 명령을 추측실행하여 공격하는 경우에 대한 효과적인 대비책이 될 수 있다. 추측실행으로 인한 성능향상을 다소 포기하기 때문에 성능이 낮아질 수 있지만 심각한 수준은 아닐 것으로 보인다. 실제로 많은 테스트의 결과를 보면 Spectre Variant 1 패치로 인한 성능하락은 거의 없다는 것이 공통된 결론이다.
위 그림은 RedHat이 FOSDEM 2018에서 발표한 슬라이드의 일부를 발췌한 것이다. 조건분기문 결과블록 첫줄에 직렬화 명령이 들어가서 이 위치까지 실행이 완료돼야 다음 명령이 실행될 수 있음을 명시한다.
앞서 언급한 대로 리눅스 진영에서는 이미 이 패치를 적용한 커널이 릴리즈 됐으며, 윈도우의 Spectre Variant 1에 대한 패치도 이 LFENCE를 적극 사용한 것이다. 실제 윈도우의 패치 내용을 살펴보면 어지간한 시스템 콜 진입점에 모두 LFENCE를 넣어둔 것을 확인할 수 있는데 이를 통해 커널에 대한 공격을 막으려는 의도를 확인할 수 있다. 문제는 LFENCE 명령을 방어해야 하는 포인트마다 넣어서 다시 빌드를 해야 하는 것이라 개별 애플리케이션 단위로 밖에 대응되지 않는다는 것이다. OS에 대한 보호는 배포된 패치 설치로 해결할 수 있지만 사용하는 애플리케이션들에 대해서도 각각 이 취약점에 개별 대응할 수밖에 없다. 아마도 많은 사람들이 사용하는 애플리케이션부터 패치되지 않을까 생각하는데 앞서 언급한대로 이 취약점은 공격 자체가 매우 까다롭기 때문에 먼저 설명한 MeltDown 만큼 위협적이지는 않다. 경계확인을 통과할 수 있는 건 맞지만 그걸로 무엇을 할 수 있는가는 애플리케이션의 코드에 따라 다르고, 다시 말해 어떤 gadget을 찾아낼 수 있는가, 가 문제가 되며, 대부분은 통과할 수 있다 정도이지 실제로 위협이 될 수 있을 정도로 중요한 데이터를 읽는 건 쉽지 않다. 공격이 되지 않는다는 건 아니지만 공격 성립이 되는 조건이 아주 까다롭기 때문에 실제 의미 있는 데이터 유출이 가능하려면 매우 잘 구성된 정교한 설계가 필요할 것으로 보인다.
공격이 쉽지는 않으나 만약 본인이 사용하는 시스템에 attack surface가 존재한다고 판단한다면 침해 가능성이 없는지 한번 확인할 필요는 있다. 구글 논문에서 예를 든 것은 웹브라우저의 JavaScript(JIT)와 커널 모듈을 이용해 공격하는 방법(API 호출)인데 앞서 언급한 대로 접근성 면에서는 전자가 좀 더 범용적일 것이다. 해커가 공격용 JavaScript를 어딘가의 웹사이트에 숨겨놓고 실행을 유도한다면 Spectre에 대한 대비가 돼있지 않은 브라우저를 쓰는 경우 샌드박스를 부수고 웹브라우저에 저장된 민감 정보에 접근할 수 있게 된다. 또한 JavaScript는 공격코드 작성이 간단하고 실행을 유도하기가 쉬운 편이라 향후 피해규모가 엄청나게 커질 가능성이 있는데 이런 이유로 크롬이나 파이어폭스 등 많이 사용되는 브라우저에는 이미 해당 취약점에 대한 패치가 완료돼 있다. 이 패치의 주된 내용 중 하나는 JavaScript에서 사용하는 타이머의 정밀도를 떨어뜨리는 것으로 이렇게 하면 캐쉬가 남더라도 읽는 속도차로 데이터를 유출하는 일이 불가능해지기 때문이다. 일반적인 JavaScript 애플리케이션이라면 이 정도 정밀도 저하로 실행에 문제가 되지는 않으므로 해당 패치로 인한 오동작은 염려하지 않아도 좋다. 따라서 브라우저를 최신 버전으로 업데이트하고 계속 유지하고 있다면 웹브라우저를 경유한 이 취약점 공격에 대해서는 크게 우려하지 않아도 될 것이다.
커널 모듈이나 윈도우의 DLL을 통해 공격할 수도 있겠지만 이는 애플리케이션이나 모듈의 명시적인 설치를 요구하므로 인가 받지 않은 프로그램이나 출처가 불분명한 파일을 실행하지 않는다면 문제될 부분은 없다. 지금까지 원칙을 세워 보안이슈들을 잘 관리해왔다면 Spectre 에 대해서도 동일하게 잘 대응할 수 있을 것이다.
두 번째 연재를 마치며
이렇게 해서 총 3회 연재의 두 번째 기고가 끝이 났다. 이번 기고는 특히 길고 확인할 내용이 많아서 힘들었는데 독자들에게 의도한 내용이 잘 전달됐는지 모르겠다. 다음 회에서는 CPU 취약점의 마지막 주제인 Spectre Variant 2에 대해서 얘기해보도록 하겠다. 끝까지 이 원고가 많은 사람들에게 도움이 될 수 있기를 기대한다.
[글_ 노규남 가비아 클라우드사업부장 겸 최고기술책임자(ngn@gabia.com)]
■ CPU 취약점 종합보고서 연재순서
① 취약점의 기본원리
② MeltDown과 Spectre Variant 1
③ Spectre Variant 2
필자 소개_ 노규남은 가비아 클라우드사업부장 겸 최고기술책임자(CTO)로서, IT 업계에서 수십 년간 쌓은 경험을 바탕으로 가비아의 인프라를 고도화하고 발전시키는 역할을 맡고 있다. 최근에는 내부에 쌓이는 서비스 관련 데이터를 신경망으로 처리해 운영을 자동화하고 보안과 클라우드 인프라를 고도화하는 것에 관심을 갖고 있다. 가치투자사이트 밸류스타 기술이사, 영상보안서비스 에버뷰 대표이사를 역임했다.
[오다인 기자(boan2@boannews.com)]
<저작권자: 보안뉴스(www.boannews.com) 무단전재-재배포금지>
※ 본 기사는 가비아 노규남 CTO가 보안뉴스에 특별기고한 내용을 정리한 것입니다.