NodeJS Command Injection:
Examples and Prevention

stackhawk

StackHawk|September 12, 2021

In this post, we'll learn about command injection vulnerabilities when working with shell command functions in NodeJS.

Modern websites can be complex pieces of software. They can have multiple moving parts spread across many environments. If your application isn't secured effectively, then each of these environments can pose as a unique attack surface for exploiting command injection vulnerabilities.

In this post, we'll learn about command injection vulnerabilities when working with shell command functions in NodeJS. We'll also explore a few techniques we can use to better protect ourselves from these types of attacks.

Let's start by learning a little bit more about command injection vulnerabilities.

What Is a Command Injection Vulnerability?

These vulnerabilities can happen when an application accepts unsafe user input and uses it as a parameter for operating system commands. The goal of a command injection attack is to manipulate a legitimate command so that the attacker can run arbitrary commands against the operating system. This input can come from any user-modifiable source, such as forms, cookies, HTTP headers, and so on.

Note that a command injection is different from a code injection attack. That type of attack is mainly concerned with subverting the application context itself. For example, a front-end JavaScript code injection attack might have the browser execute some arbitrary code on the user's browser. But the attack would (usually) be limited to the scope of the browser.

In a command injection attack, on the other hand, the underlying OS is targeted. So, a command injection can potentially be a lot more damaging.

A Quick Breakdown

NodeJS Command Injection: Examples and Prevention - Picture 1 image

Command injection flow.

Have a look at the image above. The expected flow of the system accepts user input and uses it as part of a system command. Assuming the input is valid and the user isn't malicious, the system just returns the output of the system command back at the user.

However, if a malicious user suspects there's a vulnerability, they can overload the application's system command by appending their own command at the end.

For example, in a DOS command shell on Windows, you can use the & (ampersand) character to append a command. In Linux systems, you can use the ; (semicolon) command for the same behavior. The application would execute both commands and return the output to the user.

Also, the attacker doesn't even need to view the output of the command. If for some reason the application doesn't return the output, the attacker can send a command like curl and have it ping a server under the attacker's control to confirm that the command injection is working. This makes it much easier to automate this type of attack and find applications or websites that are vulnerable to command injection.

Let's have a look at an actual example to learn more about this vulnerability.

Example of a Vulnerable Website

We're going to create a quick-and-dirty website to demonstrate the vulnerability. Let's imagine our website is part of a series of websites that teach you shell commands and help you learn how to list the contents of a directory. It will allow the user to enter the full path of a directory, and then it'll return the contents of that folder.

Let's start the setup.

Setting Up

Make sure you have a supported version of NodeJS and npm running. If you don't, you should install that first.

Next, run the following commands to initialize your project. The example assumes that you're running the commands in a Mac or Linux environment or that you have Windows WSL2 running.

mkdir nodejs-command-injection
cd nodejs-command-injection
npm init -y
npm install express
npm install pug

These commands will create the project folder and install Express and Pug. We use Express for web server functionality so that you can load the website. And we use Pug for some quick templating functionality.

Adding Functionality

Now, let's create a dead-simple page for the user to enter the directory path. Create the file nodejs-command-injection/server.js and put in the following code:

const express = require('express');
const {exec} = require('child_process');
const app = express();
const port = 3000;
const pug = require('pug');

// Listen in on root
app.get('/', (req, res) => {
  const folder = req.query.folder;
  if (folder) {
    // Run the command with the parameter the user gives us
    exec(`ls -l ${folder}`, (error, stdout, stderr) => {
      let output = stdout;
      if (error) {
        // If there are any errors, show that
        output = error; 
      }
      res.send(
        pug.renderFile('./pages/index.pug', {output: output, folder: folder})
      );
    });
  } else {
    res.send(pug.renderFile('./pages/index.pug', {}));
  }
});

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`);
});

Next, add the template file to display the form. Create the file nodejs-command-injection/pages/index.pug, and add the following template code:

html 
  head 
    title="NodeJS Command Injection Example"

  body 
    h1="List Folders"  

    form(action="/" method="GET") 
      input(type="text" name="folder" value=folder) 
      button(type="submit") Submit

    code 
      pre #{output}

This is fairly straightforward. We're just creating a simple HTML page using Pug's concise markup style.

You can now try starting the application by running the command: node server.js. If everything's set up correctly, you should see the page below when you browse to http://localhost:3000/ on your browser.

NodeJS Command Injection: Examples and Prevention - Picture 2 image

The landing page of our website.

If things are as they should be, let's look at the vulnerability in detail.

Exploring the Vulnerability

Just a reminder, we're assuming that you're running this website on a Mac, Linux, or Windows WSL2 based environment. This is important because of the commands and folders we'll be using for the examples.

Now that's out of the way, let's test out our website. Type in /usr and click the Submit button. You should now see a list of folders as the output.

NodeJS Command Injection: Examples and Prevention - Picture 3 image

List of folders.

OK, so that seems to be working and is showing the expected result. If you type in a different command, like ps, into the input box, it won't work. So, it looks like it lists folders only. But is that true? Try typing /usr; ps into the input.

NodeJS Command Injection: Examples and Prevention - Picture 4 image

Lists system users.

Well, that's troubling! We just listed all the users that exist on the server that's hosting our website. This is possible because we're using the semicolon character as a command separator. This tells the shell command to run two separate commands.

If you look at the code below, you can see that the ls command is run first, concatenated with the user's input. The code expects there to be only one command. But if the user subverts that flow, then it lets the user run any command they want on the server.

...
exec(`ls -l ${folder}`, (error, stdout, stderr) => {
....

So how can we improve our approach here? We'll go through a few options available to us in the next section.

Defending Against Command Injections

As discussed above, the reason this vulnerability exists is because we're leaving the door open for malicious users. Ideally, you shouldn't be passing in arbitrary user input into shell commands. This is just very bad practice and should definitely be avoided.

Having said that, let's say you have that one-in-a-million use case that requires this functionality. What do you do?

Validate Your Inputs

This is more of a general case rather than just something that applies to our example website: Never trust user input. It should always be sanitized and handled with care so that nobody can use it as a delivery method for damaging commands.

At a minimum, you should be doing the following:

  1. Use an allowlist to make sure that only allowed commands and parameters are coming through into your system.

  2. Validate the input, and make sure that special break characters are not allowed into the system. Only allow characters that you expect to be valid.

Use execFile() Instead of exec()

NodeJS has a handy method called execFile that allows you to call an executable directly instead of using raw shell access. This makes the process a little safer because the user can't run any additional commands and the arguments are passed in a structured way.

If we updated our code, it would look like this:

   const {exec, execFile} = require('child_process');
    ...
    app.get('/', (req, res) => {
      const folder = req.query.folder;
      ...
      execFile(`/usr/bin/ls`, ['-l', folder], (error, stdout, stderr) => {
         ...
      }
    }

Substitute Shell Commands With Programming Language Level Commands

One of the safest ways to avoid command injection vulnerabilities is to replace system shell level commands with commands from your specific programming environment—in this case, node. NodeJS already has directory listing functions built in. We just need to import the file system module in order to use these functions.

The code below shows an example using the fs module.

 const fs = require('fs');
  const folder = req.query.folder;
  if (folder) {
    // Read the files in the folder using the fs module
    fs.readdir(folder, function (err, files) {
      //handling error
      if (err) {
        return console.log('Unable to scan directory: ' + err);
      }
      let fileOutput = '';
      files.forEach(function (file) {
        fileOutput += `${file}\n`;
      });
      res.send(
        pug.renderFile('./pages/index.pug', {
          output: fileOutput,
          folder: folder,
        })
      );
    });
  }

This method is vastly more secure than what we started with. It's always a good idea to explore what your programming environment provides before going straight for the nuclear option!

Find and Fix Security Vulnerabilities

Summary and Next Steps

In this blog post, we created an example website with a command injection vulnerability built into it. We also looked at options available to us in order to better protect ourselves from these types of attacks.

Where do we go from here? A good place to start would be to learn how command injection vulnerabilities affect other environments, such as Rust and Java. Another good point of interest would be the SQL injection guide for NodeJS.

Shell access is a powerful tool that can be a double-edged sword. Always make sure you absolutely need it before you go the shell command route. After all, why pick up a jackhammer when a chisel is what you actually need?

Check out StackHawk for more information. You can create a free account or search the blog for topics that interest you.

This post was written by John Pereira. John is a technology enthusiast who's passionate about his work and all forms of technology. With over 15 years in the technology space, his area of expertise lies in API and large scale web application development, and its related constellation of technologies and processes.


StackHawk  |  September 12, 2021