C++에 시간적 메모리 안전성 레트로피팅
참고: 이 게시물은 원래 Google Security Blog에 게시되었습니다.
Chrome의 메모리 안전성은 사용자를 보호하기 위해 끊임없이 노력 중입니다. 우리는 악의적 행위자를 앞서기 위해 다양한 기술을 끊임없이 실험하고 있습니다. 이러한 정신으로, 이번 게시물은 C++의 메모리 안전성을 향상시키기 위해 힙 스캐닝 기술을 사용하는 우리의 여정에 관한 것입니다.
그러나 시작부터 이야기해 보겠습니다. 애플리케이션의 수명 동안 상태는 일반적으로 메모리에 표현됩니다. 시간적 메모리 안전성은 항상 메모리가 구조와 유형에 대한 최신 정보를 통해 접근된다는 것을 보장하는 문제를 의미합니다. 불행히도 C++은 이러한 보장을 제공하지 않습니다. C++보다 더 강력한 메모리 안전성을 보장하는 다른 언어에 대한 욕구는 있으나, Chromium과 같은 대규모 코드베이스는 가까운 미래에도 C++을 계속 사용할 것입니다.
auto* foo = new Foo();
delete foo;
// foo가 가리키는 메모리 위치는 더 이상 Foo 객체를 나타내지 않습니다.
// 객체가 삭제(해제)되었기 때문입니다.
foo->Process();
위 예제에서는 foo
가 해당 메모리가 시스템에 반환된 후에 사용됩니다. 오래된 포인터는 댕글링 포인터라고 불리며 이를 통해 접근하면 use-after-free (UAF) 접근이 발생합니다. 최악의 경우 이러한 오류는 명확히 정의된 충돌을 초래하며, 최악의 경우 악의적 행위자가 이용할 수 있는 미묘한 손상을 초래합니다.
UAF는 객체의 소유권이 다양한 구성 요소 간에 전송되는 더 큰 코드베이스에서 자주 발견되기 어렵습니다. 일반적인 문제는 매우 광범위하여 오늘날까지도 산업계와 학계에서 정기적으로 완화 전략을 제공합니다. 여러 가지 예가 있습니다: C++ 스마트 포인터는 애플리케이션 수준에서 소유권을 더 잘 정의하고 관리하기 위해 모든 종류로 사용됩니다. 정적 분석기는 처음부터 문제가 있는 코드를 컴파일하지 않도록 도와줍니다. 정적 분석이 실패할 경우, C++ sanitizers와 같은 동적 도구가 특정 실행 중 문제를 가로채고 잡아낼 수 있습니다.
Chrome에서의 C++ 사용 또한 다르지 않으며, 고위험 보안 버그의 대다수는 UAF 문제입니다. 문제가 제품에 도달하기 전에 잡아내기 위해, 앞서 언급한 모든 기술이 사용됩니다. 정기적인 테스트 외에도, 퍼저는 동적 도구가 작업할 수 있는 새로운 입력을 지속적으로 제공합니다. Chrome은 한 걸음 더 나아가 Oilpan이라는 C++ 가비지 컬렉터를 도입했으며, 이는 일반 C++ 구문과는 다르지만 사용되는 곳에서는 시간적 메모리 안전성을 제공합니다. 이러한 편차가 합리적이지 않은 경우, 최근에는 MiraclePtr이라는 새로운 종류의 스마트 포인터가 도입되어 사용될 때 댕글링 포인터에 대한 접근에서 결정적으로 충돌을 일으킵니다. Oilpan, MiraclePtr, 스마트 포인터 기반 솔루션은 애플리케이션 코드에서 상당한 채택을 요구합니다.
지난 10년 동안 다른 접근법이 어느 정도 성공을 거두었습니다: 메모리 격리. 기본 아이디어는 명시적으로 해제된 메모리를 격리 상태로 두고 특정 안전 조건에 도달했을 때만 사용 가능하도록 만드는 것입니다. Microsoft는 브라우저에 이러한 방식을 구현한 버전을 도입했습니다: Internet Explorer에서 2014년에 MemoryProtector와 2015년에 (프리-크로미움) Edge에서 MemGC. Linux 커널에서는 메모리가 결국 재활용될 때까지 확률적 접근 방식이 사용되었습니다. 그리고 이 접근 방식은 MarkUs 논문으로 최근 몇 년 동안 학계에서 주목을 받았습니다. 이 글의 나머지 부분은 Chrome에서 격리와 힙 스캐닝을 사용한 실험 여정을 요약한 것입니다.
(이 시점에서 메모리 태그가 이 그림에서 어디에 맞는지 물을 수 있습니다 – 계속 읽으세요!)
격리 및 힙 스캐닝, 기본 사항
격리 및 힙 스캐닝을 통해 시간적 안전성을 보장하는 주요 아이디어는 더 이상 해당 메모리를 참조하는 포인터(허상 포인터 포함)가 없음을 증명하기 전까지 메모리를 재사용하지 않는 것입니다. C++ 사용자 코드나 그 의미론을 변경하지 않기 위해, new
및 delete
를 제공하는 메모리 할당자를 가로챕니다.
delete
를 호출하면 해당 메모리는 실제로 격리 공간으로 옮겨지며, 애플리케이션의 후속 new
호출에서 재사용할 수 없게 됩니다. 어떤 시점에서 힙 스캔이 트리거되며, 이 스캔은 가비지 수집기처럼 전체 힙을 스캔하여 격리된 메모리 블록의 참조를 찾습니다. 일반 애플리케이션 메모리에서 들어오는 참조가 없는 블록은 재분배를 위해 할당자에게 다시 전달되어 후속 할당에 사용됩니다.
여러 성능 비용이 수반되는 강화 옵션이 있습니다:
- 격리된 메모리를 특정 값(예: 0)으로 덮어쓰기;
- 스캔이 실행되는 동안 모든 애플리케이션 스레드 중지 또는 힙을 동시 스캔;
- 메모리 쓰기를 가로챔(예: 페이지 보호를 통해)으로 포인터 업데이트 탐지;
- 메모리를 워드 단위로 스캔하여 가능한 포인터를 찾거나(보수적 처리) 객체에 대해 기술자를 제공(정밀 처리);
- 안전 및 비안전 파티션으로 애플리케이션 메모리를 분리하여 성능에 민감하거나 스킵해도 안전하다고 정적으로 증명된 특정 객체 제외;
- 힙 메모리 스캔 외에도 실행 스택 스캔;
이 알고리즘의 다양한 버전을 우리는 StarScan [스타 스캔] 또는 약어로 *Scan이라 부릅니다.
현실 점검
우리는 관리되지 않는 렌더러 프로세스 부분에 *Scan을 적용하고 Speedometer2를 사용하여 성능 영향을 평가합니다.
*Scan의 다양한 버전을 실험했습니다. 그러나 성능 오버헤드를 가능한 최소화하기 위해, 힙을 스캔하는 별도 스레드를 사용하는 구성과 delete
호출 시 격리된 메모리를 즉시 지우지 않고 *Scan 실행 시 격리된 메모리를 지우는 구성을 평가합니다. 첫 번째 구현에서 간단히 하기 위해 new
를 사용하여 할당된 모든 메모리를 옵트인하며, 할당 사이트 및 유형 간 구별을 하지 않습니다.
제안된 *Scan 버전이 완벽하지 않다는 점에 유의하십시오. 구체적으로, 악의적인 행위자가 스캔되지 않은 메모리 영역에서 이미 스캔된 메모리 영역으로 허상 포인터를 이동시켜 스캔 스레드와의 경쟁 상태를 악용할 수 있습니다. 이 경쟁 상태를 해결하려면, 예를 들어 메모리 보호 메커니즘을 사용하여 해당 접근을 가로채거나, 안전 지점에서 모든 애플리케이션 스레드를 중지하여 객체 그래프 변화를 완전히 막아야 합니다. 어쨌든, 이 문제를 해결하는 데는 성능 비용이 수반되며 흥미로운 성능-보안 트레이드오프를 보여줍니다. 참고로 이 공격은 일반적이지 않으며 모든 UAF(Use-After-Free)에 대해 작동하지 않습니다. 소개에서 보여준 문제와 같은 경우에는 허상 포인터가 옮겨지지 않으므로 이러한 공격에 취약하지 않습니다.
이러한 안전 지점의 세분성에 따라 보안상의 이점이 크게 달라지고, 우리는 가능한 가장 빠른 버전을 실험하려 하므로, 안전 지점을 완전히 비활성화했습니다.
Speedometer2에서 기본 버전을 실행한 결과 총 점수가 8% 하락합니다. 실망스럽네요...
이 모든 오버헤드가 어디에서 오는 걸까요? 예상대로, 힙 스캐닝은 메모리 바운드이며, 스캔 스레드가 참조를 확인하기 위해 전체 사용자 메모리를 조사해야 하므로 상당히 비용이 많이 듭니다.
회귀를 줄이기 위해 원시 스캔 속도를 개선하는 다양한 최적화를 구현했습니다. 당연히, 메모리를 스캔하지 않는 것이 가장 빠른 방법이므로 힙을 두 가지 범주로 나눴습니다: 포인터를 포함할 수 있는 메모리와 포인터를 포함할 수 없음을 정적으로 증명할 수 있는 메모리(예: 문자열). 포인터를 포함할 수 없는 메모리는 스캔을 피합니다. 이러한 메모리도 여전히 격리 공간의 일부로 간주되지만, 스캔만 하지 않습니다.
이 메커니즘을 확장하여 다른 할당자를 지원하는 백업 메모리로 제공되는 할당(예: 최적화된 자바스크립트 컴파일러를 위해 V8에서 관리하는 영역 메모리)도 포함했습니다. 이러한 영역은 항상 한 번에 폐기되며(영역 기반 메모리 관리 참조), V8에서 다른 수단을 통해 시간적 안전성이 보장됩니다.
추가적으로, 여러 마이크로 최적화를 적용하여 속도를 높이고 계산을 제거했습니다: 포인터 필터링을 위한 도우미 테이블을 사용; 메모리 바운드된 스캔 루프에 SIMD 활용; 페치 및 락 접두가 붙은 명령어 수를 최소화.
우리는 특정 제한에 도달했을 때 힙 스캔을 시작하도록 하는 초기 스케줄링 알고리즘을 개선하여, 실제 애플리케이션 코드 실행과 비교하여 스캔에 소비하는 시간을 조정합니다(참조: 가비지 수집 관련 문헌에서 변이 사용률).
결국 알고리즘은 여전히 메모리에 의존하고, 스캔은 여전히 눈에 띄게 비용이 많이 드는 절차로 남아 있습니다. 최적화를 통해 Speedometer2의 회귀를 8%에서 2%로 줄였습니다.
원시 스캔 시간을 개선했음에도 불구하고 메모리가 검역 상태에 있다는 사실이 프로세스의 전체 작업 집합을 증가시킵니다. 이러한 오버헤드를 추가로 빛내기 위해 우리는 Chrome의 실제 브라우징 벤치마크의 선택된 세트를 사용하여 메모리 소비를 측정합니다. *렌더러 프로세스의 스캔은 메모리 소모를 약 12% 증가시킵니다. 이 작업 집합의 증가는 애플리케이션의 빠른 경로에서 더 많은 메모리가 페이징되는 결과를 만듭니다.
하드웨어 메모리 태그로 문제 해결
MTE(메모리 태그 확장)는 ARM v8.5A 아키텍처의 새로운 확장 기능으로, 소프트웨어 메모리 사용 오류를 감지하는 데 도움이 됩니다. 이러한 오류는 공간 오류(예: 경계 밖 액세스) 또는 시간 오류(사용 후 해제)일 수 있습니다. 확장은 다음과 같이 작동합니다. 메모리의 16바이트마다 4비트 태그가 할당됩니다. 포인터 또한 4비트 태그가 할당됩니다. 할당자는 할당된 메모리와 동일한 태그를 가진 포인터를 반환해야 합니다. 로드 및 저장 명령은 포인터와 메모리 태그가 일치하는지 확인합니다. 메모리 위치와 포인터의 태그가 일치하지 않으면 하드웨어 예외가 발생합니다.
MTE는 사용 후 해제에 대해 결정론적인 보호를 제공하지 않습니다. 태그 비트의 수가 제한적이기 때문에 메모리의 태그와 포인터가 오버플로로 인해 일치할 가능성이 있습니다. 4비트의 태그에서는 태그가 일치하는 데 16번의 재할당만 필요합니다. 악의적인 사용자는 대기하다가 포인터의 태그가 메모리와 다시 일치할 때까지 기다림으로써 사용 후 해제를 악용할 수 있습니다.
*스캔은 이 문제의 측면 사례를 해결하는 데 사용할 수 있습니다. 각 delete
호출 시 기본 메모리 블록의 태그가 MTE 메커니즘에 의해 증가됩니다. 대부분의 경우 블록은 4비트 범위 내에서 태그를 증가시켜 재할당 가능하게 됩니다. 오래된 포인터는 이전 태그를 참조하므로 역참조 시 신뢰할 수 있게 충돌합니다. 태그가 오버플로되면 해당 객체는 격리 검역 상태로 이동하고 *스캔에 의해 처리됩니다. 스캔이 이 메모리 블록에 대한 더 이상 걸려 있는 포인터가 없음을 확인하면 할당자로 되돌립니다. 이를 통해 스캔 횟수와 이에 수반되는 비용을 약 16배 줄일 수 있습니다.
다음 그림은 이 메커니즘을 보여줍니다. foo
에 대한 포인터는 처음에 0x0E
의 태그를 가지고 있어 bar
를 할당하기 위해 한 번 더 증가할 수 있습니다. bar
에 대해 delete
를 호출하면 태그가 오버플로되어 실제로 *스캔의 검역 상태로 이동됩니다.
우리는 실제 MTE를 지원하는 하드웨어를 확보하고 렌더러 프로세스에서 실험을 다시 진행했습니다. 결과는 유망하며 Speedometer에서의 회귀가 소음 범위 내에 있었고 Chrome의 실제 브라우징 스토리에서 메모리 발자국이 약 1% 증가한 것으로 나타났습니다.
이것이 실제로 어떤 공짜 점심인가요? 사실 MTE는 이미 비용이 지불된 몇 가지 비용을 수반합니다. 특히 Chrome의 기본 할당자인 PartitionAlloc은 모든 MTE 지원 장치에 대해 기본적으로 태그 관리 작업을 수행합니다. 또한 보안상의 이유로 메모리는 정말로 적극적으로 0으로 설정해야 합니다. 이러한 비용을 측정하기 위해 우리는 여러 구성에서 MTE를 지원하는 초기 하드웨어 프로토타입으로 실험을 진행했습니다:
A. MTE 비활성화 및 메모리 미초기화; B. MTE 비활성화 및 메모리 초기화; C. MTE 활성화 및 *스캔 미사용; D. MTE 활성화 및 *스캔 사용;
(우리는 또한 동기 및 비동기 MTE가 있으며 이것이 결정론 및 성능에도 영향을 미친다는 사실을 알고 있습니다. 이 실험을 위해 우리는 계속해서 비동기 모드를 사용했습니다.)
결과는 MTE와 메모리 초기화가 Speedometer2에서 약 2%의 비용이 발생한다는 것을 보여줍니다. PartitionAlloc이나 하드웨어가 아직 이러한 시나리오에 최적화되지 않았다는 점에 유의하십시오. 또한 MTE 위에 *스캔을 추가해도 측정 가능한 비용이 발생하지 않는다는 것이 실험을 통해 나타났습니다.