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 자바스크립트!

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

JavaScript에서 free_result를 호출하면 결과적으로 표준 C 함수 free를 호출하여 이 메모리를 향후 할당에 사용할 수 있게 됩니다. 즉, Uint8Array 이후 Wasm을 호출하여 임의의 데이터로 덮어쓸 수 있습니다.

또는 free의 일부 구현에서 확보한 메모리를 즉시 0으로 채우기로 결정할 수도 있습니다. 이 Emscripten에서 사용하는 free는 그렇게 하지 않지만, 여기서 구현 세부정보를 활용하고 있습니다. 보장될 수 없습니다

또는 포인터 뒤에 있는 메모리가 보존되더라도 새 할당은 WebAssembly 메모리 JavaScript API를 통해 또는 상응하는 API를 통해 WebAssembly.Memory가 성장하는 경우 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을 다시 명시적으로 호출하지 않더라도 어느 시점에는 코덱에 멀티스레딩 지원을 추가할 수 있습니다. 이 경우 우리가 클론하기 직전에 데이터를 덮어쓰는 완전히 다른 스레드일 수 있습니다.

메모리 버그 찾는 중

혹시 모를 경우에 대비하여 이 코드에 실제로 문제가 나타나는지 확인해 보기로 했습니다. 새로운 Emscripten 새니타이저를 사용해 볼 수 있는 절호의 기회 같네요. 지난해 추가된 지원 Chrome Dev Summit의 WebAssembly 강연에서 발표된 내용을 확인하실 수 있습니다.

이 경우에는 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은 모든 메모리가 해제되었는지 자동으로 검증할 수 있습니다.

대신 이러한 경우 LeakSanitizer (AddressSanitizer에 포함됨)는 __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++가 아닌 JavaScript 측은 모든 범위가 올바르게 적용되었는지 확인하는 데 종료되고 모든 임시 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

다음은 훨씬 보기 좋습니다.

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

스택 트레이스의 일부는 Emscripten 내부 요소를 가리키므로 여전히 모호해 보이지만 RawImage에서 '전선 유형'으로 변환하여 누수가 발생한다고 알림 (JavaScript 값으로) 엠바인드. 실제로 코드를 살펴보면 RawImage C++ 인스턴스가 어느 쪽에서도 해제되지 않습니다.

현재 자바스크립트와 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_mem_dest 함수로, JPEG의 기존 값을 다시 사용합니다. outbufferoutsize이(가) 있는 경우 0이 아닌 값:

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) {
  // …
}

그중 일부가 첫 실행에서 느리게 초기화되었다가 향후 부적절하게 재사용되면 어떻게 될까요? 무엇인가요? 그러면 새니타이저를 한 번만 호출하면 문제가 된다고 보고하지 않습니다.

이미지를 여러 품질 수준에서 무작위로 클릭하여 여러 번 처리해 보겠습니다. 사용할 수 있습니다. 이제 다음과 같은 보고서가 표시됩니다.

메시지 스크린샷

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);
}

그 메모리 버그를 하나씩 계속 찾아낼 수도 있겠지만, 메모리 관리에 대한 현재의 접근 방식은 몇 가지 해로운 체계적인 문제를 야기합니다.

일부는 소독제에 즉시 잡힐 수 있습니다. 복잡한 속임수를 사냥해 잡을 수 있는 경우도 있습니다. 마지막으로, 게시물 시작 부분과 같은 문제가 있습니다. 로그에서 볼 수 있듯이 소독제로 전혀 잡히지 않습니다. 그 이유는 실제 오용이 네트워크상에서 새니타이저에 가시성이 없는 JavaScript 측. 이러한 문제는 코드를 수정할 수 있어야 합니다.

안전한 래퍼 빌드

몇 단계 전으로 돌아가서 코드를 재구성하여 이러한 문제를 모두 해결해 보겠습니다. 제공합니다 ImageQuant 래퍼를 다시 예시로 사용하겠지만 유사한 리팩터링 규칙이 적용됩니다. 모든 코덱 및 기타 유사한 코드베이스에 적용됩니다.

먼저 이 게시물의 시작 부분에서 use-after-free 문제를 해결해 보겠습니다. 이를 위해서는 를 사용하여 WebAssembly 지원 보기에서 데이터를 복제한 다음 JavaScript 측에서 데이터를 무료로 표시합니다.

  // 

  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;
}

이제 호출 간에 전역 변수의 상태를 공유하지 않도록 해 보겠습니다. 이 둘 다 이미 확인한 문제를 해결할 뿐만 아니라 코덱에 포함될 예정입니다.

이를 위해, 함수에 대한 각 호출이 자체 API를 관리하도록 하도록 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를 사용하여 자바스크립트와 상호작용하고 있으므로 C++ 메모리 관리 세부 정보를 완전히 숨겨 API를 훨씬 더 안전하게 만듭니다!

이를 위해 다음을 사용하여 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 래퍼의 코드를 약간 더 개선하고 다른 코덱에 대해 유사한 메모리 관리 수정사항을 복제했습니다. 더 자세히 알아보려면 그 결과로 생성된 PR을 확인할 수 있습니다. C++ 메모리 수정 코덱.

요약

이 리팩터링을 통해 어떤 교훈을 얻고 다른 코드베이스에도 적용할 수 있을까요?

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