V8 샌드박스
약 3년간 초기 설계 문서 및 그동안의 수백개의 CL 이후, V8 샌드박스 — V8를 위한 경량의 프로세스 내 샌드박스 — 는 이제 더 이상 실험적 보안 기능으로 간주되지 않을 만큼 발전했습니다. 오늘부터 V8 샌드박스는 Chrome's Vulnerability Reward Program (VRP)에 포함되었습니다. 강력한 보안 경계를 형성하기 전에 해결해야 할 문제들이 아직 남아 있지만, VRP 포함은 해당 방향으로 중요한 단계입니다. 따라서 Chrome 123은 샌드박스의 일종의 "베타" 릴리스로 간주될 수 있습니다. 이 블로그 게시물은 샌드박스의 동기를 논의하고, V8에서의 메모리 손상이 호스트 프로세스 내에서 확산되는 것을 어떻게 방지하는지 보여주며, 궁극적으로 왜 메모리 안전을 향한 필수 단계인지 설명하는 기회로 사용됩니다.
메모리 안전은 여전히 중요한 문제입니다: 지난 3년 동안 실제로 발견된 모든 크롬 익스플로잇 (2021 – 2023)은 크롬 렌더러 프로세스에서 원격 코드 실행 (RCE)용으로 악용된 메모리 손상 취약점으로 시작되었습니다. 이 중 60%는 V8의 취약점이었습니다. 그러나 한 가지 문제가 있습니다: V8의 취약점은 드물게 "클래식" 메모리 손상 버그(사용 후 해제, 경계 초과 액세스 등)이며 대신 미묘한 논리 문제로, 메모리 손상을 발생시키는 데 악용될 수 있습니다. 따라서 대부분의 경우 기존 메모리 안전 솔루션은 V8에 적용할 수 없습니다. 특히 Rust와 같은 메모리 안전 언어로 전환하거나 현재 및 미래의 하드웨어 메모리 안전 기능(예: 메모리 태그)을 사용하는 것이 오늘날 V8이 직면한 보안 과제에 도움이 되지 않습니다.
왜 이러한 점이 문제가 되는지 이해하려면, 매우 단순한 가상의 자바스크립트 엔진 취약점을 살펴보겠습니다: JSArray::fizzbuzz()
의 구현은 배열의 값 중 3으로 나누어 떨어지는 값을 "fizz"로, 5로 나누어 떨어지는 값을 "buzz"로, 그리고 3과 5로 모두 나누어 떨어지는 값을 "fizzbuzz"로 교체합니다. 아래는 C++로 작성된 해당 함수의 구현입니다. JSArray::buffer_
는 JSValue*
, 즉 자바스크립트 값 배열에 대한 포인터로 생각할 수 있으며, JSArray::length_
는 해당 버퍼의 현재 크기를 포함합니다.
1. for (int index = 0; index < length_; index++) {
2. JSValue js_value = buffer_[index];
3. int value = ToNumber(js_value).int_value();
4. if (value % 15 == 0)
5. buffer_[index] = JSString("fizzbuzz");
6. else if (value % 5 == 0)
7. buffer_[index] = JSString("buzz");
8. else if (value % 3 == 0)
9. buffer_[index] = JSString("fizz");
10. }
간단해 보이나요? 그러나 여기에는 약간 미묘한 버그가 있습니다: 3번 행의 ToNumber
변환은 사용자 정의 자바스크립트 콜백을 호출할 수 있으므로 부작용을 가질 수 있습니다. 이러한 콜백은 배열을 축소하여 이후의 경계 초과 쓰기를 발생시킬 수 있습니다. 아래의 자바스크립트 코드는 메모리 손상을 유발할 가능성이 높습니다:
let array = new Array(100);
let evil = { [Symbol.toPrimitive]() { array.length = 1; return 15; } };
array.push(evil);
// 위 코드의 3번 행에서는 |evil|의 @@toPrimitive 콜백이 호출되면서 배열의
// 길이를 1로 축소하고 백업 버퍼를 재할당합니다. 이후 쓰기(5번 행)는
// 경계 초과로 이어집니다.
array.fizzbuzz();
이 취약점은 수작업으로 작성된 런타임 코드(위의 예와 같은 경우) 또는 JIT(Just-In-Time) 컴파일러가 런타임에 생성한 머신 코드에서 발생할 수 있습니다. 첫 번째 경우, 프로그래머는 저장 작업에 대한 명시적 경계 검사가 필요하지 않다고 결론지을 수 있습니다. 두 번째 경우, 컴파일러가 최적화 과정 중 하나에서 동일한 잘못된 결론을 내릴 수 있습니다(예: 부분 중복 제거 또는 경계 검사 제거). 컴파일러가 ToNumber()
의 부작용을 올바르게 모델링하지 않는 경우에서 말입니다.
이 버그는 인위적으로 단순화된 버그입니다(구체적인 버그 패턴은 현재 퍼저의 개선, 개발자의 인식 증가, 연구자의 관심 덕분에 거의 자취를 감추었습니다). 그러나 현대 자바스크립트 엔진의 취약점을 일반적인 방식으로 완화하기 어려운 이유를 이해하는 데 여전히 유용합니다. 컴파일러가 메모리 안전을 보장하는 것이 책임인 Rust와 같은 메모리 안전 언어를 사용하는 접근 방식을 생각해 보십시오. 위 사례에서는 메모리 안전 언어는 인터프리터가 사용하는 수동 작성 런타임 코드에서 이 버그를 방지할 가능성이 큽니다. 그러나 JIT 컴파일러에서 발생하는 논리적 문제로 인한 버그를 방지하지는 못할 것입니다. 실제로 메모리 손상을 일으키는 것은 컴파일러가 생성한 코드입니다. 본질적으로, 컴파일러가 공격 표면에 직접 포함되어 있을 경우 메모리 안전은 컴파일러에 의해 보장될 수 없습니다.
마찬가지로, JIT 컴파일러를 비활성화하는 것도 부분적인 해결책일 뿐입니다. 역사적으로, V8에서 발견되고 악용된 버그의 절반 정도는 컴파일러에 영향을 미쳤으며, 나머지는 런타임 함수, 인터프리터, 가비지 컬렉터 또는 파서와 같은 다른 구성 요소에 영향을 미쳤습니다. 이러한 구성 요소에 대해 메모리 안전 언어를 사용하고 JIT 컴파일러를 제거하는 것이 작동할 수는 있지만, 엔진 성능을 상당히 저하시키게 될 것입니다(작업 부하의 유형에 따라 계산 집약적인 작업에서 성능이 1.5~10배 이상 감소할 수 있음).
이제 특히 메모리 태깅을 포함한 인기 있는 하드웨어 보안 메커니즘을 고려해 봅시다. 메모리 태깅도 효과적인 솔루션이 되지 않는 여러 이유가 있습니다. 예를 들어, CPU 측 채널 공격은 자바스크립트에서 쉽게 악용될 수 있으며, 태그 값을 유출하여 공격자가 완화를 우회할 수 있습니다. 또한, 포인터 압축 때문에 현재 V8의 포인터에 태그 비트를 위한 공간이 없습니다. 따라서 전체 힙 영역에 동일한 태그를 적용해야 하며, 이로 인해 객체 간 손상을 탐지할 수 없게 됩니다. 따라서 메모리 태깅은 특정 공격 표면에서 매우 효과적일 수 있지만, 자바스크립트 엔진의 경우 공격자에게 큰 장애물이 될 가능성은 낮습니다.
요약하면, 현대 자바스크립트 엔진에는 강력한 익스플로잇 원시 기능을 제공하는 복잡한 2차 논리 버그가 포함되어 있는 경향이 있습니다. 이러한 버그는 일반적인 메모리 손상 취약점을 보호하기 위해 사용되는 동일한 기술로 효과적으로 보호할 수 없습니다. 그러나 오늘날 V8에서 발견되고 악용된 거의 모든 취약점에는 하나의 공통점이 있습니다: 결국 메모리 손상은 반드시 V8 힙 내부에서 발생합니다. 이는 컴파일러와 런타임이 (거의) 독점적으로 V8 HeapObject
인스턴스에서 작동하기 때문입니다. 여기서 샌드박스가 등장합니다.
V8 (힙) 샌드박스
샌드박스의 기본 아이디어는 V8의 (힙) 메모리를 격리하여 해당 메모리 내에서 발생하는 메모리 손상이 프로세스의 다른 부분으로 "확산"되지 않도록 하는 것입니다.
샌드박스 설계를 위한 동기 부여 예로서 현대 운영 체제에서 사용자 및 커널 공간의 분리를 고려하십시오. 역사적으로 모든 응용 프로그램과 운영 체제의 커널은 동일한 (물리적) 메모리 주소 공간을 공유했습니다. 따라서 사용자 응용 프로그램의 메모리 오류는 커널 메모리를 손상시켜 전체 시스템을 붕괴시킬 수 있었습니다. 반면 현대 운영 체제에서는 각 사용자 공간 응용 프로그램이 자체 전용 (가상) 주소 공간을 가집니다. 따라서 메모리 오류는 응용 프로그램 자체에 국한되며, 시스템의 나머지 부분은 보호됩니다. 즉, 결함이 있는 응용 프로그램은 자체적으로 충돌할 수는 있지만 시스템의 나머지 부분에는 영향을 미치지 않습니다. 이와 유사하게, V8 샌드박스는 V8이 실행하는 신뢰할 수 없는 자바스크립트/웹어셈블리 코드가 V8의 버그로 인해 호스팅 프로세스의 나머지 부분에 영향을 미치지 않도록 격리하려고 합니다.
원칙적으로, 샌드박스는 하드웨어 지원을 통해 구현할 수 있습니다: 사용자 공간-커널 분할과 유사하게, V8은 샌드박스 코드에 들어가거나 나올 때 일부 모드 전환 명령어를 실행하여 CPU가 샌드박스 외부 메모리에 액세스할 수 없게 만들 것입니다. 실제로 오늘날에는 적합한 하드웨어 기능이 없으며, 현재 샌드박스는 순수하게 소프트웨어로 구현됩니다.
소프트웨어 기반 샌드박스의 기본 아이디어는 샌드박스 외부 메모리에 액세스할 수 있는 모든 데이터 유형을 "샌드박스 호환" 대안으로 대체하는 것입니다. 특히, V8 힙 또는 메모리 내 다른 위치의 객체를 가리키는 모든 포인터와 64비트 크기를 제거해야 합니다. 공격자가 이를 손상시켜 프로세스 내 다른 메모리에 접근할 수 있기 때문입니다. 이는 스택과 같은 메모리 영역이 샌드박스 내부에 있을 수 없다는 것을 암시합니다. 하드웨어 및 OS 제약으로 인해 스택에는 (예를 들어 반환 주소가 포함된) 포인터가 있어야 하기 때문입니다. 따라서 소프트웨어 기반 샌드박스를 사용하는 경우, 오직 V8 힙만 샌드박스 내부에 있고, 전체적인 구조는 웹어셈블리가 사용하는 샌드박스 모델과 크게 다르지 않습니다.
이 기능이 실제로 어떻게 작동하는지 이해하려면 메모리를 손상시킨 후 익스플로잇이 수행해야 하는 단계들을 살펴보는 것이 유용합니다. RCE 익스플로잇의 목표는 일반적으로 권한 상승 공격을 수행하는 것으로, 예를 들어 쉘코드를 실행하거나 반환 지향 프로그래밍(ROP) 스타일 공격을 수행하는 형태일 것입니다. 이러한 경우 익스플로잇은 프로세스에서 임의 메모리를 읽고 쓸 수 있는 기능을 먼저 원할 것입니다. 예를 들어 함수 포인터를 손상시키거나 메모리 어딘가에 ROP 페이로드를 배치하고 이를 피벗하기 위해서입니다. V8 힙에서 메모리를 손상시키는 버그를 고려할 때, 공격자는 다음과 같은 객체를 찾을 것입니다:
class JSArrayBuffer: public JSObject {
private:
byte* buffer_;
size_t size_;
};
이로 인해 공격자는 임의 읽기/쓰기 기본기를 생성하기 위해 버퍼 포인터나 크기 값을 손상시키고자 할 것입니다. 이는 샌드박스가 방지하려는 단계입니다. 특히 샌드박스가 활성화된 경우, 참조된 버퍼가 샌드박스 내부에 위치해 있다고 가정하면 위의 객체는 다음과 같이 변경됩니다:
class JSArrayBuffer: public JSObject {
private:
sandbox_ptr_t buffer_;
sandbox_size_t size_;
};
여기서 sandbox_ptr_t
는 샌드박스의 기본값에서 40비트 오프셋(1TB 샌드박스의 경우)입니다. 유사하게, sandbox_size_t
는 "샌드박스 호환" 크기이며, 현재는 32GB로 제한됩니다.
다른 경우, 참조된 버퍼가 샌드박스 외부에 위치해 있는 경우 객체는 다음과 같이 변경됩니다:
class JSArrayBuffer: public JSObject {
private:
external_ptr_t buffer_;
};
여기서 external_ptr_t
는 유닉스 커널의 파일 디스크립터 테이블 또는 WebAssembly.Table과 비슷한 방식으로포인터 테이블 간접 참조를 통해 버퍼(및 해당 크기)를 참조하며 메모리 안전성을 보장합니다.
두 경우 모두에서 공격자는 샌드박스 밖으로 "도달"하여 주소 공간의 다른 부분에 접근할 수 없게 됩니다. 대신, 그들은 추가적인 취약성, 즉 V8 샌드박스 우회가 필요하게 됩니다. 다음 그림은 상위 수준의 설계를 요약한 것이며, 관심 있는 독자는 src/sandbox/README.md
에 링크된 설계 문서에서 샌드박스에 대한 더 많은 기술적 세부 정보를 찾을 수 있습니다.
포인터와 크기를 다른 표현으로 변환하는 것만으로는 V8과 같이 복잡한 애플리케이션에서 충분하지 않으며, 수정해야 할 다른 여러 문제들 이 남아 있습니다. 예를 들어, 샌드박스가 도입됨에 따라 다음과 같은 코드가 갑자기 문제가 될 수 있습니다:
std::vector<std::string> JSObject::GetPropertyNames() {
int num_properties = TotalNumberOfProperties();
std::vector<std::string> properties(num_properties);
for (int i = 0; i < NumberOfInObjectProperties(); i++) {
properties[i] = GetNameOfInObjectProperty(i);
}
// 다른 유형의 속성들을 처리
// ...
이 코드는 JSObject에 직접 저장된 속성의 수가 해당 객체의 총 속성 수보다 작아야 한다는 (합리적인) 가정을 합니다. 그러나 이러한 숫자가 JSObject 어딘가에 단순히 정수로 저장되어 있다고 가정하면, 공격자는 이러한 불변성을 깨뜨리기 위해 하나를 손상시킬 수 있습니다. 그 결과 (샌드박스 외부) std::vector
로의 접근이 범위를 초과하게 됩니다. SBXCHECK
와 같은 명시적인 경계 검사를 추가하면 이를 수정할 수 있습니다.
긍정적인 점은 지금까지 발견된 거의 모든 "샌드박스 위반"이 이와 같은 유형이라는 점입니다: 경계 검사의 부족으로 인한 use-after-free 또는 범위 초과 액세스와 같은 1차 메모리 손상 버그입니다. 일반적으로 V8에서 발견되는 2차 취약성과는 달리 이러한 샌드박스 버그는 앞에서 논의된 접근방식에 의해 실제로 방지되거나 완화될 수 있습니다. 실제로, 위의 특정 버그는 이미 Chrome의 libc++ 강화 덕분에 오늘날 완화될 것입니다. 따라서 장기적으로 샌드박스가 V8보다 더 방어 가능한 보안 경계가 되길 기대하고 있습니다. 현재 사용 가능한 샌드박스 버그 데이터 세트는 매우 제한적이지만 오늘 출시된 VRP 통합이 샌드박스 공격 표면에서 발생하는 취약성 유형에 대한 더 명확한 그림을 제공하기를 바랍니다.
성능
이 접근 방식의 주요 장점 중 하나는 근본적으로 비용이 저렴하다는 점입니다: 샌드박스로 인한 오버헤드는 주로 외부 객체에 대한 포인터 테이블 간접 참조(대략 추가 메모리 로드 1회 비용)와 원시 포인터 대신 오프셋을 사용하는 것(주로 시프트+더하기 연산만 소모하며, 매우 저렴합니다)에서 비롯됩니다. 따라서 현재 샌드박스의 오버헤드는 Speedometer 및 JetStream 벤치마크 모음으로 측정한 일반적인 워크로드에서 대략 1% 이하입니다. 이는 V8 샌드박스를 호환 가능한 플랫폼에서 기본적으로 활성화할 수 있게 해줍니다.
테스트
어떤 보안 경계에서 바람직한 기능은 테스트 가능성입니다: 약속된 보안 보장이 실제로 구현되는지 수동 및 자동으로 테스트하는 능력입니다. 이를 위해서는 명확한 공격자 모델, 공격자를 "모방"하는 방법, 그리고 이상적으로는 보안 경계가 실패했을 때 이를 자동으로 결정하는 방법이 필요합니다. V8 샌드박스는 이러한 요구 사항을 모두 충족합니다:
- 명확한 공격자 모델: 공격자가 V8 샌드박스 내부에서 임의로 읽고 쓰기가 가능하다고 가정합니다. 목표는 샌드박스 외부에서 메모리 손상을 방지하는 것입니다.
- 공격자를 모방하는 방법: V8은
v8_enable_memory_corruption_api = true
플래그로 빌드될 때 "메모리 손상 API"를 제공합니다. 이는 일반적인 V8 취약점에서 얻은 원시 동작을 모방하며 특히 샌드박스 내부에서 전체 읽기 및 쓰기 접근을 제공합니다. - "샌드박스 위반"을 감지하는 방법: V8은
--sandbox-testing
또는--sandbox-fuzzing
플래그를 통해 활성화되는 "샌드박스 테스트" 모드를 제공합니다. 이 모드는 신호 핸들러를 설치하여SIGSEGV
와 같은 신호가 샌드박스의 보안 보장을 위반했는지 여부를 결정합니다.
결국 샌드박스는 Chrome의 VRP 프로그램과 통합되어 전문적인 퍼저들에 의해 퍼징될 수 있게 됩니다.
사용법
V8 샌드박스는 v8_enable_sandbox
빌드 플래그를 사용하여 빌드 시에 활성화/비활성화해야 합니다. 기술적 이유로 런타임에 샌드박스를 활성화/비활성화하는 것은 불가능합니다. V8 샌드박스는 많은 양의 가상 주소 공간(현재는 1테라바이트)을 예약해야 하므로 64비트 시스템이 필요합니다.
V8 샌드박스는 이미 약 2년 동안 Android, ChromeOS, Linux, macOS 및 Windows의 64비트(구체적으로는 x64 및 arm64) 버전의 Chrome에서 기본적으로 활성화되었습니다. 샌드박스가 기능적으로 완전하지 않음에도 불구하고, 안정성 문제를 유발하지 않도록 보장하고 실세계 성능 통계를 수집하기 위해 주로 활성화되었습니다. 그러므로 최근 V8 익스플로잇들은 이미 샌드박스를 우회해야 했으며, 이는 보안 특성에 대한 유용한 초기 피드백을 제공했습니다.
결론
V8 샌드박스는 프로세스 내 다른 메모리에 영향을 미치는 V8의 메모리 손상을 방지하기 위해 설계된 새로운 보안 메커니즘입니다. 샌드박스는 현재 메모리 안전 기술이 JavaScript 엔진 최적화에 크게 적용되지 않는다는 사실에 기반합니다. 이러한 기술은 V8 자체의 메모리 손상을 방지하는 데 실패하지만 실제로 V8 샌드박스의 공격 표면을 보호할 수 있습니다. 따라서 샌드박스는 메모리 안전으로 가는 필수적인 단계입니다.