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 span s?

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 :)