Avoiding Shell Injection in Ruby, Python and PHP.

I recently found a shell injection bug in some Ruby-gem I use.

Shell injections have scared me for a long time and I usually prefer to whitelist certain characters/patterns rather than to blacklist. This means that the system fails to the save side. Unfortunally it usually does fail – my whitelisting is to rigorous and data that would not cause any problems gets rejected. So I decided to take the opportunity to investigate how to prevent shell injection in my favorite scripting language (Python), the language I found the problem in and finally the language that I can not avoid (PHP).

Ruby

In Ruby you can use exec and system in a shell injection save way. If you pass a single string to them they will expand all shell characters, but if you pass the command and arguments separately, they won’t be interpreted by the shell:

irb(main):011:0> system("echo -e $(seq 5)")
1 2 3 4 5
=> true
irb(main):012:0> system("echo", "-e", "$(seq 5)")
$(seq 5)
=> true

Unfortunally IO.popen and the backticks don’t have such a feature. Ruby really leaves you in the rain here (and that triggers bugs like the one mentioned in the intro).

UPDATE: As 13k mentioned below there is a stdlib method now: shellescape.

Here the escape-library jumps in:

irb(main):001:0> require 'escape'
=> true
irb(main):002:0> s=`#{Escape.shell_command(["echo", "$(seq)", "\"'`seq` && ||"])}`
=> "$(seq) \"'`seq` && ||\n"

Something like that should really become part of the ruby standard library.

Python

Since version 2.4 Python has the subprocess-module which claims to handle arguments securly:

Unlike some other popen functions, this implementation will never call /bin/sh implicitly. This means that all characters, including shell metacharacters, can safely be passed to child processes.

By default shell characters will not be expanded.

>>> import subprocess
>>> subprocess.call(["ls", "*.c"])
ls: *.c: No such file or directory

However, you can tell it, to do so by setting shell=True:

>>> import subprocess
>>> subprocess.call("ls *.c",shell=True)
crack_rsa_in_qudratic_time.c

Did you notice, that the syntax slightly changed? I passed "ls *.c" instead of ["ls","*.c"].
The latter wont work without shell=True:

>>> subprocess.call(["ls", "*.c"],shell=True)
a_short_proof_of_fermats_last_theorem.tex  loveletter.tex 
crack_rsa_in_qudratic_time.c               showargs.sh
0

In fact it just calls “ls”. It took me quite some time to figure out, what’s going on.
Digging through the subprocess source I found:

            if shell:
                args = ["/bin/sh", "-c"] + args

            if executable is None:
                executable = args[0]
           ....
                       if env is None:
                        os.execvp(executable, args)
                    else:
                        os.execvpe(executable, args, env)

So calling subprocess.call(["ls", "*.c"],shell=True) is (roughly) equivalent to os.execvp("/bin/sh", ["-c", "ls", "-l").
And the man page of bash explains:

       -c string If  the  -c  option  is  present, then commands are read from
                 string.  If there are arguments after the  string,  they  are
                 assigned to the positional parameters, starting with $0.

Reading this carfully you might guess that sh -c in fact does not “call” the program after the -c parameter but invokes the “shell-script” and sets the positional parameters $0, $1, $2, ... according to the following arguments. The following should clarify what’s going on:

>>> import subprocess
>>> subprocess.call(['echo $0 $1 $2 $3', 'arg0', 'arg1', 'arg2', 'arg3'], shell=True)
arg0 arg1 arg2 arg3
0

This is a bit sad, since it implies that using shell=True, changes the syntax of of call() and Popen(). If you really want to use shell expansion, it is probably best to put the whole command in one string.

Read more about subprocess.

PHP

PHP offers the usual commands:
system(), exec(), passthru() and backticks. None of them protects you from shell injection by default. You have to use escapeshellarg() or escapeshellcmd().