Debugging mit Sanitizern

Undefined Behaviour Sanitizer

Clangs Undefined Behavior Sanitizer (UBSan) kann mit Emscripten verwendet werden. Dies erleichtert das Auffinden von Fehlern in Ihrem Code erheblich.

Um UBSan zu verwenden, übergeben Sie einfach -fsanitize=undefined an emcc oder em++. Beachten Sie, dass Sie dies sowohl während der Kompilierungs- als auch der Linkphase übergeben müssen, da es sowohl die Codegenerierung als auch die Systembibliotheken beeinflusst.

Null-Dereferenzierung abfangen

Standardmäßig führt das Dereferenzieren eines Nullzeigers bei Emscripten nicht sofort zu einem Segmentierungsfehler, anders als bei herkömmlichen Plattformen, da 0 in einem WebAssembly-Speicher nur eine normale Adresse ist. 0 ist auch eine normale Position in einem JavaScript Typed Array, was ein Problem im JavaScript neben dem WebAssembly (Laufzeit-Support-Code, JS-Bibliotheksmethoden, EM_ASM/EM_JS usw.) und auch für den kompilierten Code ist, wenn Sie mit -sWASM=0 bauen.

In Builds mit aktivierten ASSERTIONS wird am Ende der Programmausführung ein magischer Cookie, der an Adresse 0 gespeichert ist, überprüft. Das heißt, es wird Sie benachrichtigen, wenn während der Programmausführung etwas an diese Stelle geschrieben wurde. Dies erkennt nur Schreibvorgänge, keine Lesevorgänge, und hilft nicht dabei, die tatsächliche Stelle des fehlerhaften Schreibvorgangs zu finden.

Betrachten Sie das folgende Programm, null-assign.c

int main(void) {
    int *a = 0;
    *a = 0;
}

Ohne UBSan erhalten Sie einen Fehler, wenn das Programm beendet wird

$ emcc null-assign.c
$ node a.out.js
Runtime error: The application has corrupted its heap memory area (address zero)!

Mit UBSan erhalten Sie die genaue Zeilennummer, wo dies geschah

$ emcc -fsanitize=undefined null-assign.c
$ node a.out.js
null-assign.c:3:5: runtime error: store to null pointer of type 'int'
Runtime error: The application has corrupted its heap memory area (address zero)!

Betrachten Sie das folgende Programm, null-read.c

int main(void) {
    int *a = 0, b;
    b = *a;
}

Ohne UBSan gibt es keine Rückmeldung

$ emcc null-read.c
$ node a.out.js
$

Mit UBSan erhalten Sie die genaue Zeilennummer, wo dies geschah

$ emcc -fsanitize=undefined null-assign.c
$ node a.out.js
null-read.c:3:9: runtime error: load of null pointer of type 'int'

Minimale Laufzeit

Die Laufzeit von UBSan ist nicht trivial, und ihre Verwendung kann die Angriffsfläche unnötig vergrößern. Aus diesem Grund gibt es eine minimale UBSan-Laufzeit, die für den Produktionseinsatz konzipiert ist.

Die minimale Laufzeit wird von Emscripten unterstützt. Um sie zu verwenden, übergeben Sie zusätzlich zu Ihrem Flag -fsanitize das Flag -fsanitize-minimal-runtime.

$ emcc -fsanitize=null -fsanitize-minimal-runtime null-read.c
$ node a.out.js
ubsan: type-mismatch
$ emcc -fsanitize=null -fsanitize-minimal-runtime null-assign.c
$ node a.out.js
ubsan: type-mismatch
Runtime error: The application has corrupted its heap memory area (address zero)!

Address Sanitizer

Clangs Address Sanitizer (ASan) kann ebenfalls mit Emscripten verwendet werden. Dies erleichtert das Auffinden von Pufferüberläufen, Speicherlecks und anderen verwandten Fehlern in Ihrem Code erheblich.

Um ASan zu verwenden, übergeben Sie einfach -fsanitize=address an emcc oder em++. Wie bei UBSan müssen Sie dies sowohl in der Kompilierungs- als auch in der Linkphase übergeben, da es sowohl die Codegenerierung als auch die Systembibliotheken beeinflusst.

Sie müssen wahrscheinlich INITIAL_MEMORY auf mindestens 64 MB erhöhen oder ALLOW_MEMORY_GROWTH setzen, damit ASan genügend Speicher zum Starten hat. Andernfalls erhalten Sie eine Fehlermeldung, die ungefähr so aussieht:

Cannot enlarge memory arrays to size 55152640 bytes (OOM). Either (1) compile with -sINITIAL_MEMORY=X with X higher than the current value 50331648, (2) compile with -sALLOW_MEMORY_GROWTH which allows increasing the size at runtime, or (3) if you want malloc to return NULL (0) instead of this abort, compile with -sABORTING_MALLOC=0

ASan unterstützt vollständig Multithread-Umgebungen. ASan arbeitet auch mit dem JS-Support-Code, d.h., wenn JS versucht, von einer ungültigen Speicheradresse zu lesen, wird dies abgefangen, genau wie wenn dieser Zugriff von Wasm aus erfolgte.

Beispiele

Hier sind einige Beispiele, wie AddressSanitizer zum Auffinden von Fehlern verwendet werden kann.

Pufferüberlauf

Betrachten Sie buffer_overflow.c

#include <string.h>

int main(void) {
  char x[10];
  memset(x, 0, 11);
}
$ emcc -gsource-map -fsanitize=address -sALLOW_MEMORY_GROWTH buffer_overflow.c
$ node a.out.js
=================================================================
==42==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x02965e5a at pc 0x000015f0 bp 0x02965a30 sp 0x02965a30
WRITE of size 11 at 0x02965e5a thread T0
    #0 0x15f0 in __asan_memset+0x15f0 (a.out.wasm+0x15f0)
    #1 0xc46 in __original_main stack_buffer_overflow.c:5:3
    #2 0xcbc in main+0xcbc (a.out.wasm+0xcbc)
    #3 0x800019bc in Object.Module._main a.out.js:6588:32
    #4 0x80001aeb in Object.callMain a.out.js:6891:30
    #5 0x80001b25 in doRun a.out.js:6949:60
    #6 0x80001b33 in run a.out.js:6963:5
    #7 0x80001ad6 in runCaller a.out.js:6870:29

Address 0x02965e5a is located in stack of thread T0 at offset 26 in frame
    #0 0x11  (a.out.wasm+0x11)

  This frame has 1 object(s):
    [16, 26) 'x' (line 4) <== Memory access at offset 26 overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (a.out.wasm+0x15ef)
...

Verwendung nach Freigabe

Betrachten Sie use_after_free.cpp

int main() {
  int *array = new int[100];
  delete [] array;
  return array[0];
}
$ em++ -gsource-map -fsanitize=address -sALLOW_MEMORY_GROWTH use_after_free.cpp
$ node a.out.js
=================================================================
==42==ERROR: AddressSanitizer: heap-use-after-free on address 0x03203e40 at pc 0x00000c1b bp 0x02965e70 sp 0x02965e7c
READ of size 4 at 0x03203e40 thread T0
    #0 0xc1b in __original_main use_after_free.cpp:4:10
    #1 0xc48 in main+0xc48 (a.out.wasm+0xc48)

0x03203e40 is located 0 bytes inside of 400-byte region [0x03203e40,0x03203fd0)
freed by thread T0 here:
    #0 0x5fe8 in operator delete[](void*)+0x5fe8 (a.out.wasm+0x5fe8)
    #1 0xb76 in __original_main use_after_free.cpp:3:3
    #2 0xc48 in main+0xc48 (a.out.wasm+0xc48)
    #3 0x800019b5 in Object.Module._main a.out.js:6581:32
    #4 0x80001ade in Object.callMain a.out.js:6878:30
    #5 0x80001b18 in doRun a.out.js:6936:60
    #6 0x80001b26 in run a.out.js:6950:5
    #7 0x80001ac9 in runCaller a.out.js:6857:29

previously allocated by thread T0 here:
    #0 0x5db4 in operator new[](unsigned long)+0x5db4 (a.out.wasm+0x5db4)
    #1 0xb41 in __original_main use_after_free.cpp:2:16
    #2 0xc48 in main+0xc48 (a.out.wasm+0xc48)
    #3 0x800019b5 in Object.Module._main a.out.js:6581:32
    #4 0x80001ade in Object.callMain a.out.js:6878:30
    #5 0x80001b18 in doRun a.out.js:6936:60
    #6 0x80001b26 in run a.out.js:6950:5
    #7 0x80001ac9 in runCaller a.out.js:6857:29

SUMMARY: AddressSanitizer: heap-use-after-free (a.out.wasm+0xc1a)
...

Speicherlecks

Betrachten Sie leak.cpp

int main() {
  new int[10];
}
$ em++ -gsource-map -fsanitize=address -sALLOW_MEMORY_GROWTH -sEXIT_RUNTIME leak.cpp
$ node a.out.js

=================================================================
==42==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 40 byte(s) in 1 object(s) allocated from:
    #0 0x5ce5 in operator new[](unsigned long)+0x5ce5 (a.out.wasm+0x5ce5)
    #1 0xb24 in __original_main leak.cpp:2:3
    #2 0xb3a in main+0xb3a (a.out.wasm+0xb3a)
    #3 0x800019b8 in Object.Module._main a.out.js:6584:32
    #4 0x80001ae1 in Object.callMain a.out.js:6881:30
    #5 0x80001b1b in doRun a.out.js:6939:60
    #6 0x80001b29 in run a.out.js:6953:5
    #7 0x80001acc in runCaller a.out.js:6860:29

SUMMARY: AddressSanitizer: 40 byte(s) leaked in 1 allocation(s).

Beachten Sie, dass, da Leckprüfungen beim Programmende stattfinden, Sie -sEXIT_RUNTIME verwenden oder __lsan_do_leak_check oder __lsan_do_recoverable_leak_check manuell aufrufen müssen.

Sie können erkennen, dass AddressSanitizer aktiviert ist und __lsan_do_leak_check ausgeführt wird, indem Sie Folgendes tun:

#include <sanitizer/lsan_interface.h>

#if defined(__has_feature)
#if __has_feature(address_sanitizer)
  // code for ASan-enabled builds
  __lsan_do_leak_check();
#endif
#endif

Dies ist fatal, wenn Speicherlecks vorhanden sind. Um Speicherlecks zu überprüfen und den Prozess weiterlaufen zu lassen, verwenden Sie __lsan_do_recoverable_leak_check.

Wenn Sie nur nach Speicherlecks suchen möchten, können Sie auch -fsanitize=leak anstelle von -fsanitize=address verwenden. -fsanitize=leak instrumentiert nicht alle Speicherzugriffe und ist daher viel schneller als -fsanitize=address.

Verwendung nach Rückkehr

Betrachten Sie use_after_return.c

#include <stdio.h>

const char *__asan_default_options() {
  return "detect_stack_use_after_return=1";
}

int *f() {
  int buf[10];
  return buf;
}

int main() {
  *f() = 1;
}

Beachten Sie, dass Sie für diese Prüfung die ASan-Option detect_stack_use_after_return verwenden müssen. Sie können diese Option aktivieren, indem Sie eine Funktion namens __asan_default_options wie im Beispiel deklarieren, oder Sie können Module['ASAN_OPTIONS'] = 'detect_stack_use_after_return=1' im generierten JavaScript definieren. --pre-js ist hier hilfreich.

Diese Option ist ziemlich kostspielig, da sie Stack-Zuweisungen in Heap-Zuweisungen umwandelt, und diese Zuweisungen werden nicht wiederverwendet, so dass zukünftige Zugriffe zu Traps führen können. Daher ist sie standardmäßig nicht aktiviert.

$ emcc -gsource-map -fsanitize=address -sALLOW_MEMORY_GROWTH use_after_return.c
$ node a.out.js
=================================================================
==42==ERROR: AddressSanitizer: stack-use-after-return on address 0x02a95010 at pc 0x00000d90 bp 0x02965f70 sp 0x02965f7c
WRITE of size 4 at 0x02a95010 thread T0
    #0 0xd90 in __original_main use_after_return.c:13:10
    #1 0xe0a in main+0xe0a (a.out.wasm+0xe0a)

Address 0x02a95010 is located in stack of thread T0 at offset 16 in frame
    #0 0x11  (a.out.wasm+0x11)

  This frame has 1 object(s):
    [16, 56) 'buf' (line 8) <== Memory access at offset 16 is inside this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-use-after-return (a.out.wasm+0xd8f)
...

Konfiguration

ASan kann über eine --pre-js-Datei konfiguriert werden

Module.ASAN_OPTIONS = 'option1=a:option2=b';

Platzieren Sie beispielsweise das obige Snippet mit Ihren Optionen in asan_options.js und kompilieren Sie mit --pre-js asan_options.js.

Für eigenständiges LSan verwenden Sie stattdessen Module.LSAN_OPTIONS.

Für ein detailliertes Verständnis der Flags siehe die ASan-Dokumentation. Bitte beachten Sie, dass die meisten Flag-Kombinationen nicht getestet sind und möglicherweise funktionieren oder auch nicht.

Deaktivierung von malloc/free Stack Traces

In einem Programm, das malloc/free (oder deren C++-Äquivalent, operator new/operator delete) sehr häufig verwendet, kann das Erstellen eines Stack Traces bei allen Aufrufen von malloc/free sehr teuer sein. Wenn Ihr Programm bei Verwendung von ASan sehr langsam ist, können Sie versuchen, die Option malloc_context_size=0 wie folgt zu verwenden:

Module.ASAN_OPTIONS = 'malloc_context_size=0';

Dies verhindert, dass ASan den Speicherort von Speicherlecks meldet oder Einblicke in die Herkunft des Speichers für einen Heap-basierten Speicherfehler gibt, kann aber enorme Geschwindigkeitsvorteile bieten.

Vergleich mit SAFE_HEAP

Emscripten bietet einen SAFE_HEAP-Modus, der durch Ausführen von emcc mit -sSAFE_HEAP aktiviert werden kann. Dies bewirkt verschiedene Dinge, von denen einige mit Sanitizern überlappen.

Im Allgemeinen konzentriert sich SAFE_HEAP auf die spezifischen Probleme, die beim Anzielen von Wasm auftreten. Die Sanitizer hingegen konzentrieren sich auf die spezifischen Probleme, die mit der Verwendung von Sprachen wie C/C++ verbunden sind. Diese beiden Bereiche überlappen sich, sind aber nicht identisch. Welche Sie verwenden sollten, hängt davon ab, welche Art von Problemen Sie suchen. Sie möchten möglicherweise mit allen Sanitizern und mit SAFE_HEAP testen, um maximale Abdeckung zu erzielen, aber Sie müssen möglicherweise für jeden Modus separat bauen, da nicht alle Sanitizer miteinander kompatibel sind und nicht alle von ihnen mit SAFE_HEAP kompatibel sind (weil die Sanitizer einige ziemlich radikale Dinge tun!). Sie erhalten einen Compilerfehler, wenn es ein Problem mit den von Ihnen übergebenen Flags gibt. Eine sinnvolle Reihe von separaten Test-Builds könnte sein: ASan, UBsan und SAFE_HEAP.

Die spezifischen Dinge, bei denen SAFE_HEAP Fehler meldet, umfassen

  • NULL-Zeiger (Adresse 0) Lese- oder Schreibvorgänge. Wie bereits erwähnt, ist dies in WebAssembly und JavaScript ärgerlich, da 0 nur eine normale Adresse ist, sodass Sie keinen sofortigen Segmentierungsfehler erhalten, was verwirrend sein kann.

  • Nicht ausgerichtete Lese- oder Schreibvorgänge. Diese funktionieren in WebAssembly, aber auf einigen Plattformen kann ein falsch ausgerichteter Lese- oder Schreibvorgang viel langsamer sein, und mit wasm2js (WASM=0) ist er falsch, da JavaScript Typed Arrays keine nicht ausgerichteten Operationen zulassen.

  • Lese- oder Schreibzugriffe über den oberen Bereich des gültigen Speichers hinaus, wie er von sbrk() verwaltet wird, d.h. Speicher, der nicht ordnungsgemäß von malloc() zugewiesen wurde. Dies ist nicht spezifisch für Wasm, jedoch wird in JavaScript, wenn die Adresse groß genug ist, um außerhalb des Typed Arrays zu liegen, undefined zurückgegeben, was sehr verwirrend sein kann, weshalb dies hinzugefügt wurde (in Wasm wird zumindest ein Fehler ausgelöst; SAFE_HEAP hilft jedoch weiterhin mit Wasm, indem es den Bereich zwischen dem oberen Ende des sbrk()-Speichers und dem Ende des Wasm-Speichers überprüft).

SAFE_HEAP führt diese Prüfungen durch, indem es jeden einzelnen Lade- und Speichervorgang instrumentiert. Das hat den Preis, dass die Dinge langsamer werden, aber es bietet eine einfache Garantie, *alle* solchen Probleme zu finden. Es kann auch nach der Kompilierung an einem beliebigen Wasm-Binärprogramm durchgeführt werden, während die Sanitizer beim Kompilieren aus der Quelle erfolgen müssen.

Im Vergleich dazu kann UBSan auch Nullzeiger-Lese- und Schreibvorgänge finden. Es instrumentiert jedoch nicht jeden einzelnen Lade- und Speichervorgang, da dies während der Kompilierung des Quellcodes geschieht, sodass die Prüfungen dort hinzugefügt werden, wo clang weiß, dass sie benötigt werden. Dies ist viel effizienter, aber es besteht das Risiko, dass die Codegenerierung und Optimierungen etwas ändern oder clang einen bestimmten Ort übersieht.

ASan kann Lese- oder Schreibvorgänge von nicht zugewiesenem Speicher finden, einschließlich Adressen oberhalb des von sbrk() verwalteten Speichers. Es kann in einigen Fällen effizienter sein als SAFE_HEAP: Während es auch jeden Lade- und Speichervorgang überprüft, wird der LLVM-Optimierer ausgeführt, nachdem es diese Prüfungen hinzugefügt hat, was einige davon entfernen kann.