Skip to main content

Electron과 V8 메모리 케이지

· 13 min read

Electron 21부터 V8 메모리 케이지가 활성화되며, 이로 인해 일부 네이티브 모듈에 영향을 미칠 수 있다.


업데이트 (2022/11/01)

Electron 21 이상에서 네이티브 모듈 사용에 대한 논의를 확인하려면 electron/electron#35801을 참고하라.

Electron 21에서는 Chrome 103에서와 마찬가지로 V8 샌드박스 포인터를 활성화할 예정이다. 이로 인해 네이티브 모듈에 일부 영향을 미칠 것이다. 또한, Electron 14에서 포인터 압축이라는 관련 기술을 이미 활성화한 바 있다. 당시에는 자세히 언급하지 않았지만, 포인터 압축은 V8 힙의 최대 크기에 영향을 미친다.

이 두 기술은 활성화될 경우 보안, 성능, 메모리 사용 측면에서 상당한 이점을 제공한다. 그러나 단점도 존재한다.

샌드박스 포인터를 활성화할 때의 주요 단점은 외부("오프 힙") 메모리를 가리키는 ArrayBuffer가 더 이상 허용되지 않는다는 점이다. 이는 V8에서 이 기능에 의존하는 네이티브 모듈이 Electron 20 이상에서 동작하려면 리팩토링이 필요함을 의미한다.

포인터 압축을 활성화할 때의 주요 단점은 V8 힙의 최대 크기가 4GB로 제한된다는 점이다. 이에 대한 세부 사항은 다소 복잡하다. 예를 들어, ArrayBuffer는 V8 힙과 별도로 계산되지만 자체적인 제한이 있다.

Electron 업그레이드 워킹 그룹은 포인터 압축과 V8 메모리 케이지의 이점이 단점을 상쇄할 만큼 크다고 판단했다. 이 결정에는 세 가지 주요 이유가 있다:

  1. Electron을 Chromium에 더 가깝게 유지한다. V8 설정과 같은 복잡한 내부 세부 사항에서 Electron이 Chromium과 차이가 적을수록 버그나 보안 취약점이 발생할 가능성이 줄어든다. Chromium의 보안 팀은 매우 강력하며, 그들의 작업을 최대한 활용하는 것이 중요하다. 또한, Chromium에서 사용되지 않는 설정에만 영향을 미치는 버그는 Chromium 팀이 우선적으로 수정할 가능성이 낮다.
  2. 성능이 개선된다. 포인터 압축은 V8 힙 크기를 최대 40% 줄이고 CPU 및 GC 성능을 5%~10% 향상시킨다. 4GB 힙 크기 제한에 도달하지 않고 외부 버퍼를 필요로 하는 네이티브 모듈을 사용하지 않는 대부분의 Electron 애플리케이션에게는 이는 상당한 성능 이점이다.
  3. 보안이 강화된다. 일부 Electron 앱은 신뢰할 수 없는 JavaScript를 실행하며(바라건대 보안 권장사항을 따르기를 바란다!), 이러한 앱의 경우 V8 메모리 케이지가 활성화되면 다양한 V8 취약점으로부터 보호받을 수 있다.

마지막으로, 더 큰 힙 크기가 필요한 앱을 위한 해결책도 있다. 예를 들어, 포인터 압축이 비활성화된 Node.js를 앱에 포함시키고 메모리 집약적인 작업을 자식 프로세스로 이동할 수 있다. 다소 복잡하지만, 특정 사용 사례에 맞게 포인터 압축이 비활성화된 커스텀 버전의 Electron을 빌드하는 것도 가능하다. 또한, 머지않아 wasm64가 도입되면 웹과 Electron에서 WebAssembly로 빌드된 앱이 4GB 이상의 메모리를 사용할 수 있게 될 것이다.


자주 묻는 질문

이 변경 사항이 내 앱에 영향을 미치는지 어떻게 알 수 있나요?

Electron 20 이상 버전에서는 외부 메모리를 ArrayBuffer로 감싸려고 하면 런타임 중에 충돌이 발생합니다.

앱에서 네이티브 Node 모듈을 사용하지 않는다면 안전합니다. 순수 JavaScript로는 이 충돌을 유발할 방법이 없기 때문입니다. 이 변경 사항은 V8 힙 외부에 메모리를 할당하고(예: malloc 또는 new 사용) 그 외부 메모리를 ArrayBuffer로 감싸는 네이티브 Node 모듈에만 영향을 미칩니다. 이는 상당히 드문 사용 사례이지만, 일부 모듈은 이 기법을 사용하며, Electron 20 이상 버전과 호환되려면 이러한 모듈을 리팩토링해야 합니다.

V8 힙 메모리 사용량을 측정해 4GB 제한에 근접했는지 확인하는 방법

렌더러 프로세스에서는 performance.memory.usedJSHeapSize를 사용해 V8 힙 사용량을 바이트 단위로 확인할 수 있다. 메인 프로세스에서는 process.memoryUsage().heapUsed를 사용해 비슷한 정보를 얻을 수 있다.

V8 메모리 케이지란 무엇인가?

일부 문서에서는 이를 "V8 샌드박스"라고 부르기도 하지만, 이 용어는 크로미움에서 사용되는 다른 종류의 샌드박싱과 혼동하기 쉽기 때문에 여기서는 "메모리 케이지"라는 용어를 사용하겠다.

V8에서 자주 발생하는 공격 유형은 다음과 같다:

  1. V8의 JIT 엔진에서 버그를 찾는다. JIT 엔진은 코드를 분석해 느린 런타임 타입 검사를 생략하고 빠른 기계어 코드를 생성한다. 때로는 논리 오류로 인해 이 분석이 잘못되어 필요한 타입 검사를 생략할 수 있다. 예를 들어, x가 문자열이라고 생각하지만 실제로는 객체인 경우가 있다.
  2. 이 혼란을 악용해 V8 힙 내부의 메모리 일부를 덮어쓴다. 예를 들어, ArrayBuffer의 시작 포인터를 덮어쓸 수 있다.
  3. 이제 ArrayBuffer가 원하는 위치를 가리키게 되어, 프로세스 내의 모든 메모리를 읽고 쓸 수 있게 된다. 심지어 V8이 일반적으로 접근할 수 없는 메모리도 조작할 수 있다.

V8 메모리 케이지는 이러한 종류의 공격을 근본적으로 방지하기 위한 기술이다. 이를 위해 V8 힙 내부에 어떤 포인터도 저장하지 않는다. 대신, V8 힙 내부의 다른 메모리를 참조할 때는 예약된 영역의 시작점에서의 오프셋으로 저장한다. 이렇게 하면 공격자가 V8의 타입 혼동 오류를 악용해 ArrayBuffer의 기본 주소를 손상시키더라도, 최악의 경우 케이지 내부의 메모리만 읽고 쓸 수 있다. 이는 이미 할 수 있었던 일일 가능성이 높다.

V8 메모리 케이지가 어떻게 동작하는지에 대해 더 많은 정보가 있지만, 여기서는 더 자세히 다루지 않겠다. 가장 좋은 시작점은 크로미움 팀의 고수준 설계 문서를 참고하는 것이다.

Electron 21+에서 네이티브 모듈을 리팩터링하려면 V8 메모리 케이지를 지원하도록 코드를 수정해야 한다. 이를 위한 두 가지 주요 방법이 있다.

첫 번째 방법은 외부에서 생성된 버퍼를 JavaScript로 전달하기 전에 V8 메모리 케이지로 복사하는 것이다. 이 방법은 일반적으로 간단하지만, 버퍼가 클 경우 속도가 느려질 수 있다. 두 번째 방법은 JavaScript로 전달할 메모리를 V8의 메모리 할당자를 사용해 할당하는 것이다. 이 방법은 더 복잡하지만, 복사를 피할 수 있어 대용량 버퍼에서 성능이 향상된다.

구체적인 예시로, 외부 배열 버퍼를 사용하는 N-API 모듈을 살펴보자.

// 외부에서 할당된 버퍼 생성
// |create_external_resource|는 malloc()을 통해 메모리를 할당한다.
size_t length = 0;
void* data = create_external_resource(&length);
// 버퍼로 감싸기. 메모리 케이지가 활성화되면 실패한다!
napi_value result;
napi_create_external_buffer(
env, length, data,
finalize_external_resource, NULL, &result);

이 코드는 메모리 케이지가 활성화된 상태에서 충돌을 일으킨다. 데이터가 케이지 외부에서 할당되었기 때문이다. 데이터를 케이지로 복사하도록 리팩터링하면 다음과 같다.

size_t length = 0;
void* data = create_external_resource(&length);
// V8이 할당한 메모리로 데이터를 복사해 새 버퍼 생성
napi_value result;
void* copied_data = NULL;
napi_create_buffer_copy(env, length, data, &copied_data, &result);
// 새로 복사된 데이터에 접근해야 한다면 |copied_data|를 사용한다.

이 코드는 데이터를 V8 메모리 케이지 내부의 새로 할당된 영역으로 복사한다. 필요에 따라 N-API는 새로 복사된 데이터에 대한 포인터를 제공할 수도 있다.

V8의 메모리 할당자를 사용하도록 리팩터링하는 방법은 조금 더 복잡하다. create_external_resource 함수를 수정해 malloc 대신 V8이 할당한 메모리를 사용해야 하기 때문이다. 이는 create_external_resource의 정의를 수정할 수 있는지 여부에 따라 달라진다. 먼저 napi_create_buffer를 사용해 버퍼를 생성한 후, V8이 할당한 메모리에 리소스를 초기화한다. 리소스의 수명 동안 Buffer 객체에 대한 napi_ref를 유지하는 것이 중요하다. 그렇지 않으면 V8이 Buffer를 가비지 컬렉션할 수 있으며, 이는 use-after-free 오류를 유발할 수 있다.