Make sure you validate your React props
XSS via a spoofed React element

In late February 2015, I reported an XSS vulnerability in
HackerOne itself. This one took
advantage of the way the arguments passed to React functions were being
validated, tricking React into thinking it was rendering a React
element instead of the string that was expected.
At the request of HackerOne, the report was publicly disclosed
today.
Show me the bug report!
Sure, it's right
here
Cast of Characters
HackerOne is a "Security
Response and Bug Bounty Platform" that was "[built] from the ground
up with security as [a] top priority". The team that built it
includes several security engineers and penetration testers, so I'm
particularly excited when I'm able to find a vulnerability in their
site.
React is a
popular javascript library used to built user interfaces.
I'm a developer at Trello and I
run Trello's bug bounty
program on HackerOne. Occasionally I'll try to find security
issues in the applications that I use.
Background
This issue follows a different XSS vulnerability, a pending, currently
undisclosed report, and a Content-Security-Policy
bypass that I'd previously submitted against HackerOne.
Sending Unexpected JSON Types
When I'm looking for security issues, I spend a lot of time
trying to create situations that are "unexpected". A site asks for
my name; I give it some HTML. They ask for a URL, I give them one
that contains a mess of quotes, newlines, null characters, etc. You
get the idea.
While poking around HackerOne, I took a look at some of the AJAX
calls being made, and noticed that some of them were sending
complex JSON objects as the request body, things like
{
    state: "open",
    substate: "triaged",
    report_ids: [ 49652, 46916 ],
    reply_action: "change-state",
    reference: "http://danlec.com"
}

I tried making similar calls, setting some of the fields to
types that I'd expect to be invalid; e.g. sending a string value
like "danlec" where a number was expected, or an array
where I should normally be sending a string, things like
{
    state: { "foo": "danlec", "bar": 42 },
    substate: 3.2,
    report_ids: [ "xyzzy", 46916 ],
    reply_action: [2, "change-state"],
    reference: { "a": 1, b: ["2"] }
}

Not surprisingly, these values were almost universally rejected,
or else converted into a value of the appropriate type (e.g. a
value like { 'foo': 'bar' } might get converted to the
string '{ "foo" => "bar" }').
However, there were a couple places where the wrong-typed values
weren't being rejected outright and were coming back from the
server; i.e. it appeared that the values with the wrong types were
being stored and returned in response to subsequent API calls.
The two cases I found were the reference field used
when triaging a report and the data fields associated
with the criteria for a trigger.
Unfortunately, while this was definitely strange, the
wrong-typed values actually got rendered safely, and I couldn't
immediately think of a way to exploit this behavior. I added it to
my list of bugs that had some potential and moved on.
Nearly giving up
Over the course of the next week, I kept coming back to this
bug. I tried several different values, and while I couldn't get
anything bad to happen, I did notice that some of the values were
getting rendered strangely.
Normally, string values like "foo" would get
rendered as something like
<span>foo</span>

… and I noticed that when the value was instead an array, like
["foo", "bar"], it would be rendered as something
like
<span><span>foo</span><span>bar</span></span>

Even more exciting, when using an object like { foo: "bar"
} the key foo was getting included in the
react-id for the span element when it was getting
rendered, e.g.
<span react-id="…1.2.3.foo.…">bar</span>

I was definitely having some influence on what was
ending up in the DOM, but the rendering code was doing a good job
of sanitizing everything, and I still couldn't get it to
do anything bad.
I got to the point where I was just going to submit it as a
"weird thing that's probably a bug but doesn't have any security
implications", but decided I'd give it one last try and see if I
could find anything by stepping through the client code that was
rendering the elements.
Stepping through minified JS
After setting a breakpoint where my wrong-typed value was
getting rendered, I set out stepping through all of the code to see
if there was anything interesting I could take advantage of. Most
of the symbols were minified, so it wasn't always clear exactly
what was going on, but when I saw my wrong-typed value get passed
to this:
l.isValidElement = function(e) {
    var t = !(!e || !e._isReactElement);
    return t
}

… I started to get excited. (This is the really fun part of
security research, where you know you've found something bad and
now you just have to figure out how bad it actually
is)
I'd demonstrated to myself that I could cause e to
have any JSON value I wanted … so it was no trick to make it into
an object that had a key _isReactElement set to
true … which would seem to tell the client that the
object I'd created was a "valid element".
Once I added _isReactElement, and then a few other
keys (_store, type, props),
my object went down an entirely different render path, one that
eventually included a check for a
dangerouslySetInnerHTML property. Now there's a
property that looked like it'd be fun to set!
Clearly the name was trying to warn me that including raw HTML
was … dangerous … but of course, I had no
qualms including a dangerouslySetInnerHTML on my fake
React element. Once I had that set, the render method spit out the
exact HTML that I gave it, allowing arbitrary HTML injection, XSS,
etc.
Exploit achieved!
So … What was actually happening here?
So once I'd gotten the XSS to work, I took a step back to figure
out what was actually happening. HackerOne uses React, and it turns out
React's createElement method has one argument that
expects a React
Node that can be a string (for simple text content) … or an
array, or a React element.
Remember how I noticed that sometimes my inputs would be
rendered as multiple spans?
I tried running some React methods from the console and it
became clear what was actually happening:
> React.renderToString(React.createElement("span", null, "abc"))

"<span data-reactid=".7" data-react-checksum="-876606633">abc</span>"

> React.renderToString(React.createElement("span", null, ["abc"]))

"<span data-reactid=".8" data-react-checksum="-171174425">
 <span data-reactid=".8.0">abc</span></span>"

> React.renderToString(React.createElement("span", null,
  { foo: "bar" }))

"<span data-reactid=".a" data-react-checksum="1979389930">
 <span data-reactid=".a.$foo:0">bar</span></span>"

HackerOne's client was assuming that the value that it was
passing was always a string, but I was able to get it to pass my
specially crafted JSON object instead, and by setting the right
attributes, React thought that it was rendering an element.
dangerouslySetHTML was a 
special non-DOM attribute used to "Provides the ability to
insert raw HTML" into the element, which is exactly what an XSSer
like me is looking for.
The full object I used, instead of the string HackerOne
expected, was something like
{
  _isReactElement: true,
  _store: {},
  type: "body",
  props: {
    dangerouslySetInnerHTML: {
      __html:
        "<h1>Arbitrary HTML</h1>
        <script>alert('No CSP Support :(')</script>
        <a href='http://danlec.com'>link</a>"
    }
  }
}

Rendering that one on the console:
> React.renderToString(React.createElement("span", null,
  { _isReactElement: true, …}))

"<span data-reactid=".9" data-react-checksum="-1151650166">
 <body data-reactid=".9.0"><h1>Arbitrary HTML</h1>
 <script>alert('No CSP Support :(')</script>
 <a href='http://danlec.com'>link</a></body></span>"

HackerOne's response
I submitted the
issue on a Saturday morning, and the HackerOne team had the
vulnerability mitigated within a few
hours.
After their initial fix, I identified another field that was
potentially vulnerable (the inverse field on a trigger
criterion) and reported that as well.
Since the issue was mitigated, HackerOne had time to audit the
rest of their code, so I had to wait a while. (I don't have access
to all of HackerOne's features, so it was possible that there were
other fields with the same vulnerability that would be impossible
for me to find)
Once they finished that, they requested public disclosure, which
is why you're reading about this now :)
