Emscripten을 사용하여 WebAssembly의 메모리 누수 디버깅

JavaScript는 스스로 정리하는 것을 상당히 용인하지만 정적 언어는 절대로 그렇지 않습니다...

Squoosh.app은 서로 다른 이미지 코덱과 설정이 품질에 큰 영향을 미치지 않고 이미지 파일 크기를 얼마나 개선할 수 있는지 보여주는 PWA입니다. 그러나 C++ 또는 Rust로 작성된 라이브러리를 가져와 웹으로 가져오는 방법을 보여주는 기술 데모이기도 합니다.

기존 생태계에서 코드를 포팅할 수 있다는 것은 대단히 가치가 있지만 이러한 정적 언어와 JavaScript에는 몇 가지 주요 차이점이 있습니다. 그 중 하나는 메모리 관리에 대한 다른 접근 방식입니다.

JavaScript는 자체적으로 정리하는 것을 상당히 용인하지만, 이러한 정적 언어는 절대로 정리되지 않습니다. 새로 할당된 메모리를 명시적으로 요청해야 하며 나중에 다시 사용하도록 하고 다시 사용하지 않아야 합니다. 그렇지 않으면 유출이 발생하며... 실제로는 주기적으로 발생합니다. 이러한 메모리 누수를 디버그하는 방법과 더 나아가 다음 번에 이러한 문제를 방지하기 위해 코드를 설계하는 방법을 살펴보겠습니다.

의심스러운 패턴

최근 Squoosh 작업을 시작하면서 C++ 코덱 래퍼에서 흥미로운 패턴을 발견하게 되었습니다. ImageQuant 래퍼를 예로 살펴보겠습니다 (객체 생성 및 할당 해제 부분만 표시하도록 축소됨).

liq_attr* attr;
liq_image* image;
liq_result* res;
uint8_t* result;

RawImage quantize(std::string rawimage,
                  int image_width,
                  int image_height,
                  int num_colors,
                  float dithering) {
  const uint8_t* image_buffer = (uint8_t*)rawimage.c_str();
  int size = image_width * image_height;

  attr = liq_attr_create();
  image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_set_max_colors(attr, num_colors);
  liq_image_quantize(image, attr, &res);
  liq_set_dithering_level(res, dithering);
  uint8_t* image8bit = (uint8_t*)malloc(size);
  result = (uint8_t*)malloc(size * 4);

  // …

  free(image8bit);
  liq_result_destroy(res);
  liq_image_destroy(image);
  liq_attr_destroy(attr);

  return {
    val(typed_memory_view(image_width * image_height * 4, result)),
    image_width,
    image_height
  };
}

void free_result() {
  free(result);
}

JavaScript (물론 TypeScript):

export async function process(data: ImageData, opts: QuantizeOptions) {
  if (!emscriptenModule) {
    emscriptenModule = initEmscriptenModule(imagequant, wasmUrl);
  }
  const module = await emscriptenModule;

  const result = module.quantize(/* … */);

  module.free_result();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

문제를 발견하셨나요? 힌트: 이 기능은 use-after-free를 지원하지만, JavaScript에서 지원됩니다.

Emscripten에서 typed_memory_view는 WebAssembly (Wasm) 메모리 버퍼가 지원하는 JavaScript Uint8Array를 반환하며 byteOffsetbyteLength는 주어진 포인터와 길이로 설정됩니다. 요점은 이 데이터가 JavaScript 소유 데이터 사본이 아니라 WebAssembly 메모리 버퍼의 TypedArray 라는 것입니다.

JavaScript에서 free_result를 호출하면 표준 C 함수 free를 호출하여 이 메모리를 향후 할당에 사용 가능한 것으로 표시합니다. 즉, Uint8Array 뷰가 가리키는 데이터를 향후 Wasm 호출로 임의의 데이터로 덮어쓸 수 있습니다.

또는 free의 일부 구현에서 확보된 메모리를 즉시 0으로 채우도록 결정할 수도 있습니다. Emscripten에서 사용하는 free는 이러한 작업을 하지 않지만 여기서는 보장될 수 없는 구현 세부정보에 의존합니다.

또는 포인터 뒤에 있는 메모리가 보존되더라도 새 할당에서는 WebAssembly 메모리를 늘려야 할 수 있습니다. WebAssembly.Memory가 JavaScript API 또는 상응하는 memory.grow 명령어를 통해 확장되면 기존 ArrayBuffer와 이 속성이 지원하는 모든 뷰를 전이적으로 무효화합니다.

DevTools (또는 Node.js) 콘솔을 사용하여 이 동작을 시연해 보겠습니다.

> memory = new WebAssembly.Memory({ initial: 1 })
Memory {}

> view = new Uint8Array(memory.buffer, 42, 10)
Uint8Array(10) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// ^ all good, we got a 10 bytes long view at address 42

> view.buffer
ArrayBuffer(65536) {}
// ^ its buffer is the same as the one used for WebAssembly memory
//   (the size of the buffer is 1 WebAssembly "page" == 64KB)

> memory.grow(1)
1
// ^ let's say we grow Wasm memory by +1 page to fit some new data

> view
Uint8Array []
// ^ our original view is no longer valid and looks empty!

> view.buffer
ArrayBuffer(0) {}
// ^ its buffer got invalidated as well and turned into an empty one

마지막으로 free_resultnew Uint8ClampedArray 사이에 Wasm을 다시 명시적으로 호출하지 않더라도 언젠가는 코덱에 멀티스레딩 지원을 추가할 수 있습니다. 이 경우 클론하기 직전에 데이터를 덮어쓰는 완전히 다른 스레드일 수 있습니다.

메모리 버그 찾기

만약을 위해 더 나아가 이 코드에서 실제로 문제가 발생하는지 확인하기로 했습니다. 작년에 추가되었으며 Chrome Dev Summit의 WebAssembly 강연에서 소개된 새로운 Emscripten 새니타이저 지원을 사용해 볼 수 있는 완벽한 기회로 보입니다.

여기서는 다양한 포인터 및 메모리 관련 문제를 감지할 수 있는 AddressSanitizer에 관심이 있습니다. 사용하려면 -fsanitize=address로 코덱을 다시 컴파일해야 합니다.

emcc \
  --bind \
  ${OPTIMIZE} \
  --closure 1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s MODULARIZE=1 \
  -s 'EXPORT_NAME="imagequant"' \
  -I node_modules/libimagequant \
  -o ./imagequant.js \
  --std=c++11 \
  imagequant.cpp \
  -fsanitize=address \
  node_modules/libimagequant/libimagequant.a

이렇게 하면 포인터 안전 확인이 자동으로 사용 설정되지만 잠재적인 메모리 누수도 찾고자 합니다. ImageQuant를 프로그램이 아닌 라이브러리로 사용하고 있으므로 Emscripten이 모든 메모리가 해제되었음을 자동으로 확인할 수 있는 '종료 지점'이 없습니다.

대신 이러한 경우 AddressSanitizer에 포함된 LeakSanitizer는 __lsan_do_leak_check__lsan_do_recoverable_leak_check 함수를 제공합니다. 이 함수는 모든 메모리가 해제될 것으로 예상하고 해당 가정을 검증하려고 할 때마다 수동으로 호출할 수 있습니다. __lsan_do_leak_check는 누수가 감지될 경우 프로세스를 취소하려는 경우 실행 중인 애플리케이션 종료 시 사용되는 반면, __lsan_do_recoverable_leak_check는 누수를 콘솔에 출력하되 그와 관계없이 애플리케이션을 계속 실행하려는 경우와 같은 라이브러리 사용 사례에 더 적합합니다.

언제든지 JavaScript에서 호출할 수 있도록 Embind를 통해 두 번째 도우미를 노출해 보겠습니다.

#include <sanitizer/lsan_interface.h>

// …

void free_result() {
  free(result);
}

EMSCRIPTEN_BINDINGS(my_module) {
  function("zx_quantize", &zx_quantize);
  function("version", &version);
  function("free_result", &free_result);
  function("doLeakCheck", &__lsan_do_recoverable_leak_check);
}

이미지 작업이 완료되면 JavaScript 측에서 호출합니다. C++가 아닌 자바스크립트 측에서 이렇게 하면 검사를 실행할 때 모든 범위가 종료되고 모든 임시 C++ 객체가 해제되도록 할 수 있습니다.

  // …

  const result = opts.zx
    ? module.zx_quantize(data.data, data.width, data.height, opts.dither)
    : module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);

  module.free_result();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

그러면 콘솔에 다음과 같은 보고서가 표시됩니다.

메시지의 스크린샷

약간의 누수가 있지만 모든 함수 이름이 손상되므로 스택 트레이스는 그다지 유용하지 않습니다. 기본 디버깅 정보로 다시 컴파일하여 이러한 정보를 유지해 보겠습니다.

emcc \
  --bind \
  ${OPTIMIZE} \
  --closure 1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s MODULARIZE=1 \
  -s 'EXPORT_NAME="imagequant"' \
  -I node_modules/libimagequant \
  -o ./imagequant.js \
  --std=c++11 \
  imagequant.cpp \
  -fsanitize=address \
  -g2 \
  node_modules/libimagequant/libimagequant.a

이렇게 하면 훨씬 더 보기 좋습니다.

GenericBindingType RawImage ::toWireType 함수에서 &#39;12바이트 직접 유출&#39;이라는 메시지의 스크린샷

스택 트레이스의 일부는 Emscripten 내부를 가리키므로 여전히 모호해 보입니다. 하지만 Embind에서 RawImage를 '와이어 유형' (자바스크립트 값으로) 변환하여 누출이 발생한 것을 알 수 있습니다. 실제로 코드를 살펴보면 RawImage C++ 인스턴스가 자바스크립트에 반환되는 것을 알 수 있지만 어느 쪽에서든 해제되지 않습니다.

JavaScript와 WebAssembly는 개발 중이지만 현재는 JavaScript와 WebAssembly 간에 가비지 컬렉션을 통합할 수 없습니다. 대신 객체 작업을 완료한 후에는 자바스크립트 측에서 수동으로 메모리를 해제하고 소멸자를 호출해야 합니다. 특히 Embind의 경우 공식 문서에서 노출된 C++ 클래스에서 .delete() 메서드를 호출하는 것을 제안합니다.

JavaScript 코드는 수신한 C++ 객체 핸들을 명시적으로 삭제해야 합니다. 그렇지 않으면 Emscripten 힙이 무한히 늘어납니다.

var x = new Module.MyClass;
x.method();
x.delete();

실제로 클래스의 JavaScript에서 이를 수행하면 다음과 같이 됩니다.

  // …

  const result = opts.zx
    ? module.zx_quantize(data.data, data.width, data.height, opts.dither)
    : module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);

  module.free_result();
  result.delete();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

누수가 정상적으로 없어집니다.

새니타이저 관련 더 많은 문제 발견

새니타이저를 사용하여 다른 Squoosh 코덱을 빌드하면 유사한 문제뿐만 아니라 새로운 문제도 드러납니다. 예를 들어 MozJPEG 바인딩에 다음 오류가 있습니다.

메시지의 스크린샷

여기서는 누수가 아니라 할당된 경계 밖에 있는 메모리에 씁니다. 😱

MozJPEG의 코드를 자세히 살펴보면 여기서 문제는 JPEG용 메모리 대상을 할당하는 데 사용하는 함수인 jpeg_mem_dest0이 아닌 경우 outbufferoutsize의 기존 값을 재사용한다는 것을 발견했습니다.

if (*outbuffer == NULL || *outsize == 0) {
  /* Allocate initial buffer */
  dest->newbuffer = *outbuffer = (unsigned char *) malloc(OUTPUT_BUF_SIZE);
  if (dest->newbuffer == NULL)
    ERREXIT1(cinfo, JERR_OUT_OF_MEMORY, 10);
  *outsize = OUTPUT_BUF_SIZE;
}

그러나 이러한 변수를 초기화하지 않고 이 함수를 호출합니다. 즉, MozJPEG는 호출 시 이러한 변수에 저장된 임의의 잠재적인 메모리 주소에 결과를 작성합니다.

uint8_t* output;
unsigned long size;
// …
jpeg_mem_dest(&cinfo, &output, &size);

호출 전에 두 변수를 모두 0으로 초기화하면 이 문제가 해결되며, 이제 코드가 메모리 누수 검사에 도달합니다. 다행히 검사가 성공적으로 통과되어 이 코덱에 유출이 없음을 나타냅니다.

공유 상태 관련 문제

...아니면

코덱 결합은 일부 상태를 저장하고 전역 정적 변수를 생성한다는 것을 알고 있으며, MozJPEG에는 특히 복잡한 구조가 있습니다.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

val encode(std::string image_in, int image_width, int image_height, MozJpegOptions opts) {
  // …
}

그중 일부가 처음 실행 시 지연 초기화되고 이후 실행에서 부적절하게 재사용되면 어떻게 될까요? 그러면 새니타이저를 사용한 한 번의 호출에서도 문제가 있는 것으로 보고되지 않습니다.

UI에서 여러 품질 수준을 무작위로 클릭하여 이미지를 몇 번 처리해 보겠습니다. 이제 다음과 같은 보고서를 얻을 수 있습니다.

메시지의 스크린샷

262,144바이트 - 전체 샘플 이미지가 jpeg_finish_compress에서 유출된 것으로 보입니다.

문서와 공식 예를 확인한 결과 jpeg_finish_compress는 이전 jpeg_mem_dest 호출에서 할당한 메모리를 해제하지 않는 것으로 확인되었습니다. 압축 구조가 이미 메모리 대상에 관해 알고 있더라도 압축 구조만 해제합니다.

free_result 함수에서 데이터를 수동으로 해제하여 이 문제를 해결할 수 있습니다.

void free_result() {
  /* This is an important step since it will release a good deal of memory. */
  free(last_result);
  jpeg_destroy_compress(&cinfo);
}

계속해서 메모리 버그를 하나씩 추적할 수 있지만, 지금은 메모리 관리에 대한 현재의 접근 방식이 심각한 시스템 문제로 이어질 만큼 분명하다고 생각합니다.

일부는 새니타이저에 바로 찰 수 있습니다. 잡기 위해 복잡한 속임수를 요구하는 공격도 있습니다. 마지막으로, 로그에서 볼 수 있듯이 새니타이저에 전혀 포착되지 않는 게시물의 시작 부분과 같은 문제가 있습니다. 새니타이저가 확인할 수 없는 자바스크립트 측에서 실제로 오용이 발생하기 때문입니다. 이러한 문제는 프로덕션 단계에서 또는 향후 관련이 없어 보이는 코드가 변경된 후에만 나타납니다.

안전한 래퍼 빌드

몇 단계 뒤로 가서 코드를 더 안전한 방식으로 재구성하여 이러한 문제를 모두 해결해 보겠습니다. 다시 ImageQuant 래퍼를 예로 들었지만 모든 코덱은 물론 기타 유사한 코드베이스에 비슷한 리팩터링 규칙이 적용됩니다.

먼저 게시물 시작 부분부터 use-after-free 문제를 해결해 보겠습니다. 이렇게 하려면 JavaScript 측에서 무료 뷰로 표시하기 전에 WebAssembly 지원 뷰에서 데이터를 클론해야 합니다.

  // …

  const result = /* … */;

  const imgData = new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );

  module.free_result();
  result.delete();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
  return imgData;
}

이제 호출 간에 전역 변수에서 어떤 상태도 공유하지 않도록 하겠습니다. 이렇게 하면 이미 확인한 일부 문제가 해결되고 향후 멀티스레드 환경에서 코덱을 더 쉽게 사용할 수 있습니다.

이를 위해 C++ 래퍼를 리팩터링하여 각 함수 호출이 로컬 변수를 사용하여 자체 데이터를 관리하도록 합니다. 그런 다음 포인터를 다시 허용하도록 free_result 함수의 서명을 변경할 수 있습니다.

liq_attr* attr;
liq_image* image;
liq_result* res;
uint8_t* result;

RawImage quantize(std::string rawimage,
                  int image_width,
                  int image_height,
                  int num_colors,
                  float dithering) {
  const uint8_t* image_buffer = (uint8_t*)rawimage.c_str();
  int size = image_width * image_height;

  attr = liq_attr_create();
  image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_attr* attr = liq_attr_create();
  liq_image* image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_set_max_colors(attr, num_colors);
  liq_result* res = nullptr;
  liq_image_quantize(image, attr, &res);
  liq_set_dithering_level(res, dithering);
  uint8_t* image8bit = (uint8_t*)malloc(size);
  result = (uint8_t*)malloc(size * 4);
  uint8_t* result = (uint8_t*)malloc(size * 4);

  // …
}

void free_result() {
void free_result(uint8_t *result) {
  free(result);
}

하지만 이미 Emscripten의 Embind를 사용하여 JavaScript와 상호작용하고 있으므로 C++ 메모리 관리 세부정보를 완전히 숨겨 API를 더 안전하게 만들 수도 있습니다.

이를 위해 Embind를 사용하여 new Uint8ClampedArray(…) 부분을 JavaScript에서 C++ 쪽으로 이동해 보겠습니다. 그런 다음 함수에서 반환되기 전에 이 함수를 사용하여 데이터를 자바스크립트 메모리에 클론할 수 있습니다.

class RawImage {
 public:
  val buffer;
  int width;
  int height;

  RawImage(val b, int w, int h) : buffer(b), width(w), height(h) {}
};
thread_local const val Uint8ClampedArray = val::global("Uint8ClampedArray");

RawImage quantize(/* … */) {
val quantize(/* … */) {
  // …
  return {
    val(typed_memory_view(image_width * image_height * 4, result)),
    image_width,
    image_height
  };
  val js_result = Uint8ClampedArray.new_(typed_memory_view(
    image_width * image_height * 4,
    result
  ));
  free(result);
  return js_result;
}

참고로, 한 가지 변경을 통해 결과 바이트 배열이 JavaScript에 의해 소유되고 WebAssembly 메모리에서 지원되지 않도록 할 수 있으며 그리고 이전에 누수된 RawImage 래퍼도 삭제합니다.

이제 JavaScript는 더 이상 데이터 해제에 대해 걱정할 필요가 없으며 다른 가비지로 수집된 객체와 마찬가지로 결과를 사용할 수 있습니다.

  // …

  const result = /* … */;

  const imgData = new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );

  module.free_result();
  result.delete();
  // module.doLeakCheck();

  return imgData;
  return new ImageData(result, result.width, result.height);
}

즉, C++ 측에서 더 이상 맞춤 free_result 바인딩이 필요하지 않습니다.

void free_result(uint8_t* result) {
  free(result);
}

EMSCRIPTEN_BINDINGS(my_module) {
  class_<RawImage>("RawImage")
      .property("buffer", &RawImage::buffer)
      .property("width", &RawImage::width)
      .property("height", &RawImage::height);

  function("quantize", &quantize);
  function("zx_quantize", &zx_quantize);
  function("version", &version);
  function("free_result", &free_result, allow_raw_pointers());
}

무엇보다도 래퍼 코드는 더 깔끔하고 안전해졌습니다.

이후 ImageQuant 래퍼 코드를 약간 개선하고 다른 코덱에도 유사한 메모리 관리 수정사항을 복제했습니다. 자세한 내용은 C++ 코덱의 메모리 수정사항에서 결과 PR을 참고하세요.

테이크어웨이

이 리팩터링에서 배우고 다른 코드베이스에 적용할 수 있는 교훈에는 어떤 것이 있을까요?

  • 빌드에 사용된 언어에 관계없이 단일 호출을 넘어서는 WebAssembly에서 지원되는 메모리 뷰를 사용하지 마세요. 이보다 더 오래 살아남는 것에 의존할 수 없고 기존 방법으로는 이러한 버그를 포착할 수 없으므로 나중에 사용하기 위해 데이터를 저장해야 하는 경우 JavaScript 측에 복사하여 저장합니다.
  • 가능하다면 원시 포인터에서 직접 작업하는 대신 안전한 메모리 관리 언어 또는 적어도 안전 유형 래퍼를 사용하세요. 그렇다고 해서 JavaScript 위의 WebAssembly 경계에서 발생하는 버그를 줄일 수는 없지만, 적어도 정적 언어 코드에 의해 자체 포함된 버그의 노출 영역을 줄일 수 있습니다.
  • 어떤 언어를 사용하든 개발 중에 새니타이저를 사용하여 코드를 실행하면 정적 언어 코드의 문제뿐 아니라 .delete() 호출을 잊거나 자바스크립트 측에서 무효한 포인터를 전달하는 등 자바스크립트 ▸ WebAssembly 경계 전반에 걸쳐 발생하는 몇 가지 문제도 포착할 수 있습니다.
  • 가능한 경우 WebAssembly에서 관리되지 않는 데이터 및 객체를 JavaScript로 완전히 노출시키지 않는 것이 좋습니다. JavaScript는 가비지로 수집된 언어이므로 수동 메모리 관리가 일반적이지 않습니다. 이는 WebAssembly가 빌드된 언어의 메모리 모델의 추상화 누수로 간주될 수 있으며, JavaScript 코드베이스에서 잘못된 관리는 간과하기 쉽습니다.
  • 이는 명확할 수 있지만, 다른 코드베이스와 마찬가지로 전역 변수에 변경 가능한 상태를 저장하지 않는 것이 좋습니다. 다양한 호출 또는 스레드 간에 재사용 시 발생하는 문제는 디버그하지 않으려고 하므로 가능한 한 독립적인 상태로 유지하는 것이 가장 좋습니다.