스펙터의 1년: V8 관점
2018년 1월 3일, 구글 프로젝트 제로와 다른 연구 팀은 스펙터 및 멜트다운으로 불리는 새로운 종류의 취약성을 처음 공개했습니다. 이는 추론 실행(speculative execution)을 수행하는 CPU에 영향을 미칩니다. CPU의 추론 실행 메커니즘을 사용하여 공격자는 메모리에서 승인되지 않은 데이터를 읽는 것을 방지하는 코드의 암묵적 및 명시적 안전 검사들을 임시적으로 우회할 수 있습니다. 프로세서 추론은 구조적 레벨에서 보이지 않는 미세 구조적 세부사항으로 설계되었지만, 정교하게 만들어진 프로그램은 추론 중에 승인되지 않은 정보를 읽고 프로그램 분절의 실행 시간을 포함한 부작용 채널을 통해 이를 공개할 수 있습니다.
자바스크립트가 스펙터 공격을 수행하는 데 사용될 수 있다는 것이 밝혀지자, V8 팀은 문제 해결에 관여하게 되었습니다. 우리는 긴급 대응 팀을 구성하고 구글 내 다른 팀, 다른 브라우저 공급업체의 파트너 및 하드웨어 파트너들과 긴밀하게 협력했습니다. 이들과 협력하여 공격적인 연구(프로토타입 도구 구축)와 방어적 연구(잠재적 공격에 대한 완화)를 적극적으로 수행했습니다.
스펙터 공격은 두 가지 부분으로 구성됩니다:
- 접근 불가능한 데이터를 숨겨진 CPU 상태로 누출. 알려진 스펙터 공격은 모두 추론을 사용하여 접근 불가능한 데이터의 일부를 CPU 캐시에 누출합니다.
- 숨겨진 상태를 추출하여 접근 불가능한 데이터를 복구. 이 작업을 위해 공격자는 충분한 정밀도의 클럭이 필요합니다. (특히 엣지 임계처리와 같은 기술로 인해 놀랍게도 낮은 해상도의 클럭도 충분할 수 있습니다.)
이론적으로 공격의 두 구성 요소 중 하나만 물리치면 충분합니다. 하지만 우리는 각 요소를 완벽하게 물리칠 방법을 모르기 때문에, CPU 캐시에 누출되는 정보의 양을 크게 줄이는 완화 조치와 숨겨진 상태를 복구하기 어렵게 만드는 완화 조치를 설계하고 배포했습니다.
고정밀 타이머
추론 실행으로 인해 생길 수 있는 작은 상태 변화는 대응하는 불가능할 정도로 작은 타이밍 차이를 발생시킵니다 — 대략 10억분의 1초 정도의 수준입니다. 개별적인 이러한 차이를 직접 탐지하려면 공격 프로그램은 고정밀 타이머가 필요합니다. CPU는 이러한 타이머를 제공하지만 웹 플랫폼은 이를 노출하지 않습니다. 웹 플랫폼에서 가장 정밀한 타이머는 performance.now()
였으며, 이는 단위 마이크로초의 해상도를 제공했지만 처음에는 이 목적에 적합하지 않다고 생각되었습니다. 그러나 2년 전, 미세 구조적 공격을 전문으로 하는 학술 연구 팀이 웹 플랫폼의 타이머 활용 가능성을 연구한 논문을 발표했습니다. 이들은 동시 변동 가능한 공유 메모리와 다양한 해상도 복구 기술이 나노초 수준의 타이머를 구성할 수 있다는 결론을 내렸습니다. 이러한 타이머는 개별 L1 캐시 적중 및 실패를 감지할 만큼 정밀하며, 이는 일반적으로 스펙터 도구가 정보를 누출하는 방식입니다.
타이머 완화 조치
작은 타이밍 차이를 감지하는 능력을 방해하기 위해 브라우저 공급업체는 다방면에서 접근법을 채택했습니다. 모든 브라우저에서 performance.now()
의 해상도가 감소되었고(크롬에서는 5 마이크로초에서 100 마이크로초로), 해상도 복구를 방지하기 위해 랜덤 균일 지터가 도입되었습니다. 모든 공급업체 간 협의 후, 우리는 스펙터 공격을 위한 나노초 타이머 구성을 방지하기 위해 SharedArrayBuffer
API를 전례 없이 즉시 및 소급하여 모든 브라우저에서 비활성화하기로 결정했습니다.
증폭
공격적인 연구 초기 단계에서 타이머 완화 조치만으로는 충분하지 않다는 것이 명확해졌습니다. 그 이유 중 하나는 공격자가 단순히 도구를 반복적으로 실행하여 누적된 시간 차이가 단일 캐시 적중 또는 실패보다 훨씬 커지게 할 수 있기 때문입니다. 우리는 한 번에 여러 캐시 라인을 사용하는 신뢰할 수 있는 도구를 엔지니어링했으며, 이는 최대 캐시 용량까지 시간 차이가 600 마이크로초로 증가했습니다. 이후 우리는 캐시 용량에 제한을 받지 않는 임의 증폭 기술을 발견했습니다. 이러한 증폭 기술은 비밀 데이터를 여러 번 시도하여 읽는 데 의존합니다.
JIT 완화 조치
Spectre를 사용해 접근 불가능한 데이터를 읽으려면, 공격자는 CPU를 속여 정상적으로 접근할 수 없는 데이터를 읽고 이를 캐시로 인코딩하도록 투기적으로 코드를 실행하게 만듭니다. 공격은 두 가지 방법으로 방지할 수 있습니다:
- 코드의 투기적 실행을 방지합니다.
- 접근 불가능한 데이터를 읽는 투기적 실행을 방지합니다.
우리는 (1)을 실험해 보았으며, 모든 중요한 조건 분기에 Intel의 LFENCE
와 같은 권장된 투기적 장벽 명령어를 삽입하고, 간접 분기에는 retpolines를 사용하는 방법을 적용했습니다. 그러나 이러한 지나친 완화 조치는 성능을 크게 저하시킵니다(Octane 벤치마크 기준 2–3배 느려짐). 대신, 우리는 접근 불가능한 데이터를 잘못된 투기적 실행을 통해 읽는 것을 방지하는 완화를 삽입하는 방법인 (2)를 선택했습니다. 다음 코드 스니펫을 통해 기술을 설명하겠습니다:
if (condition) {
return a[i];
}
간단히 하기 위해 condition이 0
또는 1
이라고 가정하겠습니다. 위 코드에서는 i
가 범위를 초과했을 때 CPU가 a[i]
를 투기적으로 읽음으로써 정상적으로 접근 불가능한 데이터를 읽는 경우 취약합니다. 중요한 점은 이러한 경우에 투기적 실행이 condition
이 0
일 때 a[i]
를 읽으려 한다는 것입니다. 우리의 완화책은 이 프로그램을 다시 작성하여 원래 프로그램처럼 작동하지만 투기적으로 로드된 데이터를 누출하지 않도록 합니다.
우리는 하나의 CPU 레지스터를 예약하여 코드가 잘못된 분기를 실행 중인지 추적하는 포이즌(poison) 레지스터라고 부릅니다. 포이즌 레지스터는 생성된 코드에서 모든 분기 및 호출을 통해 유지되며, 잘못된 분기는 포이즌 레지스터를 0
으로 변경합니다. 그런 다음 모든 메모리 접근을 도구화하여 현재 포이즌 레지스터 값을 사용해 모든 로드 결과를 일률적으로 마스킹합니다. 이는 프로세서가 분기를 예측하거나 잘못된 예측을 하는 것을 방지하지 않지만, 잘못된 분기로 인해 로드된 값의 정보를 파괴합니다(잠재적인 범위 초과값 포함). 도구화된 코드는 다음과 같습니다 (a
가 숫자 배열이라고 가정).
let poison = 1;
// …
if (condition) {
poison *= condition;
return a[i] * poison;
}
추가된 코드는 프로그램의 정상적인(건축적으로 정의된) 동작에는 아무런 영향을 미치지 않습니다. 투기적 실행을 하는 CPU에서만 미세 아키텍처 상태에 영향을 미칩니다. 프로그램이 소스 수준에서 도구화된 경우 현대 컴파일러의 고급 최적화가 이러한 도구화를 제거할 수 있습니다. V8에서는 컴파일의 가장 늦은 단계에서 완화를 삽입하여 컴파일러가 이를 제거하지 못하도록 합니다.
우리는 또한 인터프리터의 바이트코드 디스패치 루프와 JavaScript 함수 호출 순서에서 잘못 예측된 간접 분기에서 누출을 방지하기 위해 포이즌 기술을 사용합니다. 인터프리터에서는 바이트코드 핸들러(즉, 단일 바이트코드를 해석하는 머신 코드 시퀀스)가 현재 바이트코드와 일치하지 않으면 포이즌을 0
으로 설정합니다. JavaScript 호출에 대해서는 타겟 함수를 매개변수(레지스터로)로 전달하고, 들어오는 타겟 함수가 현재 함수와 일치하지 않으면 각 함수 시작 시 포이즌을 0
으로 설정합니다. 이러한 포이즌 완화 조치가 적용되면 Octane 벤치마크에서 20% 이하의 성능 저하만을 경험할 수 있습니다.
WebAssembly에 대한 완화는 더 간단합니다. 주요 안전 체크는 메모리 접근이 범위 내에 있는지 확인하는 것입니다. 32비트 플랫폼의 경우 정상적인 범위 검사 외에도 모든 메모리를 다음 2의 거듭제곱으로 패딩하고 사용자 제공 메모리 인덱스의 상위 비트를 일률적으로 마스킹합니다. 64비트 플랫폼은 범위 체크를 위해 가상 메모리 보호를 사용하므로 이러한 완화가 필요 없습니다. 우리는 switch/case 문을 잠재적으로 취약한 간접 분기를 사용하는 대신 이진 검색 코드로 컴파일하는 것을 실험해봤지만, 일부 워크로드에서는 비용이 너무 많이 발생합니다. 간접 호출은 retpolines로 보호됩니다.
소프트웨어 완화는 지속 가능하지 않은 경로입니다
다행인지 불행인지, 우리의 공격 연구가 방어 연구보다 훨씬 빠르게 발전했으며, Spectre로 인한 모든 가능성 있는 누출을 소프트웨어로 완전히 차단하는 것이 실행 불가능하다는 것을 빠르게 발견했습니다. 이는 여러 가지 이유 때문입니다. 첫째, Spectre에 대처하기 위해 투입된 엔지니어링 노력은 위협 수준에 비해 과도했습니다. V8에서는 일반적인 버그로 인한 직접적인 경계 밖 읽기(속도가 빠르고 Spectre보다 직접적인) 및 경계 밖 쓰기(Spectre로는 불가능하지만 더 심각함), 그리고 잠재적인 원격 코드 실행(Spectre로는 불가능하며 훨씬 더 심각함) 등 훨씬 더 심각한 많은 보안 위협에 직면합니다. 둘째, 우리가 설계하고 구현한 점점 더 복잡한 완화 조치는 상당한 복잡성을 초래했으며, 이는 기술적 부채일 뿐만 아니라 실제로 공격 표면을 증가시킬 수도 있고 성능 오버헤드를 유발하기도 했습니다. 셋째, 마이크로아키텍처 누출 완화 조치를 테스트하고 유지 관리하는 것은 가젯 자체를 설계하는 것보다도 더 까다롭습니다. 완화 조치가 설계된 대로 계속 작동하는지 확인하기 어렵기 때문입니다. 최소한 한 번, 중요한 완화 조치가 이후 컴파일러 최적화로 인해 사실상 무효화된 적이 있었습니다. 넷째, Apple의 JIT 컴파일러에서 문제를 해결하기 위한 우리의 파트너들의 영웅적 노력에도 불구하고, Spectre의 일부 변종, 특히 변종 4를 소프트웨어로 효과적으로 완화하는 것은 단순히 실행 불가능하다는 것을 발견했습니다.
사이트 격리
우리의 연구는 원칙적으로 신뢰할 수 없는 코드가 Spectre와 부채널을 사용하여 프로세스의 전체 주소 공간을 읽을 수 있다는 결론에 도달했습니다. 소프트웨어 완화 조치는 많은 잠재적 가젯의 효과를 줄여주지만 효율적이거나 포괄적이지는 않습니다. 유일한 효과적인 완화 방법은 프로세스의 주소 공간에서 민감한 데이터를 이동하는 것입니다. 다행히도 Chrome은 오래전부터 일반적인 취약성으로 인한 공격 표면을 줄이기 위해 사이트를 서로 다른 프로세스로 분리하는 작업을 진행해왔습니다. 이러한 투자는 결실을 맺었으며, 2018년 5월까지 가능한 한 많은 플랫폼에 대해 사이트 격리를 생산화하고 배포했습니다. 따라서 Chrome의 보안 모델은 더 이상 렌더러 프로세스 내에서 언어로 강제되는 기밀성을 가정하지 않습니다.
Spectre는 길고도 많은 산업과 학계의 협력을 강조하는 여정을 보여주었습니다. 현재까지 흰 모자 해커가 검은 모자 해커를 앞서고 있는 것으로 보입니다. 호기심 많은 실험자들과 개념 증명 가젯을 개발하는 전문 연구자를 제외하고, 현재까지 실사용 환경에서의 공격 사례를 알지 못합니다. 이러한 취약점의 새로운 변종은 계속해서 나타나고 있으며, 앞으로도 한동안 지속될 수 있습니다. 우리는 이러한 위협을 계속 추적하고 진지하게 받아들이고 있습니다.
프로그래밍 언어와 그 구현에 대한 배경이 있는 많은 사람들처럼, 안전한 언어가 적절한 추상화 경계를 강제하여 잘 형식화된 프로그램이 임의의 메모리를 읽지 못하도록 한다는 아이디어는 우리의 정신 모델의 기반을 이루는 보증이었습니다. 우리의 모델이 잘못되었고 — 오늘날의 하드웨어에서는 이 보장이 사실이 아니라는 결론은 매우 우울합니다. 물론, 우리는 여전히 안전한 언어가 훌륭한 엔지니어링 이점을 제공한다고 믿으며 미래의 기반이 될 것이라고 생각합니다. 하지만… 오늘날의 하드웨어에서는 약간 누출이 있습니다.
관심 있는 독자는 우리의 백서를 통해 더 많은 세부사항을 탐구할 수 있습니다.