written by hartmannsyg
Preexisting writeups
This challenge had very few solves (4 solves), so finding preexisting writeups on it was hard. There was the “official writeup”:
1 | <script> |
Roughly, the flow involves conducting a buffer overflow to modify the pointer from “is_dangerous” to point to the “escape_attr” function. With this, DomPurify will only sanitize “ and ‘ characters, allowing us to freely use < and > for performing XSS (Cross-Site Scripting).
but I wanted to dig deeper. The only other source I could find was this writeup in Japanese by BunkyoWesterns which was in japanese. And I don’t know japanese. So I painfully tried to read the writeup, one google-translated sentence at a time.
The Challenge
I think I downloaded the wrong DOMPurify.
We are given a app and an admin bot (so basically an XSS challenge).
Admin bot
1 | const puppeteer = require('puppeteer') const flag = process.env.FLAG || 'MAPNA{test-flag}'; async function visit(url){ let browser; if(!/^https?:\/\//.test(url)){ return; } try{ browser = await puppeteer.launch({ pipe: true, args: [ "--no-sandbox", "--disable-setuid-sandbox", "--ignore-certificate-errors", ], executablePath: "/usr/bin/google-chrome-stable", headless: 'new' }); let page = await browser.newPage(); |
It sets a cookie named “flag” which contains the flag, with domain: 'web'
(???????), httpOnly: fals
(means we can simply steal it using document.cookie
), and sameSite: 'Lax'
.
Apparently, web
is an internal domain:
1 | version: "3.9" services: bot: build: ./bot/ restart: always ports: - "8001:8000" environment: - "FLAG=MAPNA{test-flag}" - "CAPTCHA_SECRET=" |
App website
Now on the actual app website, we have:
1 | window.onmessage = e=>{ list.innerHTML += ` <li>From ${e.origin}: ${window.DOMPurify.sanitize(e.data.toString())}</li> ` } setTimeout(_=>window.postMessage("hi",'*'),1000) |
In window.onmessage
, it does not check for the message origin. This meant that you can send a message from the attacker’s webpage using an iframe or using window.open
. However, since the cookie has sameSite: 'Lax'
, window.open
must be used, as that is considered top-level navigation (TL;DR: it must change the url in your address bar) while iframe is not.
It seems like we would have to do the insurmountable task of overcoming the real DOMPurify. However, window.DOMPurify is overwritten by:
1 | async function init() { window.wasm = (await WebAssembly.instantiateStreaming( fetch('./purify.wasm') )).instance.exports } function sanitize(dirty) { |
The wasm has three functions used: set_mode
, add_char
and get_char
.
Custom sanitization implementation
We are given the source code for the wasm:
1 | // clang --target=wasm32 -emit-llvm -c -S ./purify.c && llc -march=wasm32 -filetype=obj ./purify.ll && wasm-ld --no-entry --export-all -o purify.wasm purify.o struct globalVars { unsigned int len; unsigned int len_r; char buf[0x1000]; int (*is_dangerous)(char c); } g; int escape_tag(char c){ if(c == '<' || c == '>'){ return 1; } else { return 0; } } int escape_attr(char c){ if(c == '\'' || c == '"'){ return 1; } else { return 0; } } int hex_escape(char c,char *dest){ dest[0] = '&'; dest[1] = '#'; dest[2] = 'x'; dest[3] = "0123456789abcdef"[(c&0xf0)>>4]; dest[4] = "0123456789abcdef"[c&0xf]; dest[5] = ';'; return 6; } void add_char(char c) { if(g.is_dangerous(c)){ g.len += hex_escape(c,&g.buf[g.len]); } else { g.buf[g.len++] = c; } } int get_char(char f) { if(g.len_r < g.len){ return g.buf[g.len_r++]; } return '\0'; } void set_mode(int mode) { if(mode == 1){ g.is_dangerous = escape_attr; } else { g.is_dangerous = escape_tag; } } |
set_mode
firsts sets whether the is_dangerous
is:
escape_attr
(only removes single and double quotes, backticks ``` are still allowed, able to xss)
orescape_tag
(removes angled brackets, impossible to xss)
However, set_mode
is originally set to 0, which corresponds to escape_tag
. So what do we do?
Buffer Overflow
we see in the add_char
code that g.buf
is vulnerable to a buffer overflow:
35 | void add_char(char c) { if(g.is_dangerous(c)){ g.len += hex_escape(c,&g.buf[g.len]); } else { |
It can arbitrarily write past the length of g.buf
(0x1000).
Since is_dangerous
is located after buf
in g
, we could potentially overwrite it with a buffer overflow:
2 | struct globalVars { unsigned int len; unsigned int len_r; |
wasm
from the BunkyoWesterns writeup apparently there is a feature in the Chrome Dev Tools: Sources → purify.wasm which contains the wasm instructions
1 | (func $add_char (;4;) (export "add_char") (param $var0 i32) (local $var1 i32) (local $var2 i32) global.get $__stack_pointer i32.const 16 i32.sub local.tee $var1 global.set $__stack_pointer local.get $var1 local.get $var0 i32.store8 offset=15 block $label1 block $label0 local.get $var1 i32.load8_u offset=15 i32.const 24 i32.shl i32.const 24 i32.shr_s i32.const 0 i32.load offset=5148 |
is_dangerous