Adding a test for partial paths in exec functions

When using functions like subprocess.Popen etc to launch an
external executable, the full path should be given. This prevents
an attacker from manipulting the search path or placing a bogus
executable that will be launched instead of the intended one.

Change-Id: I4a11f988bc3e954331ab0f0902ea849c6ec31888
This commit is contained in:
Tim Kelsey 2015-06-30 18:00:34 +01:00
parent 36e28b331a
commit 055598028a
13 changed files with 129 additions and 47 deletions

View File

@ -40,6 +40,7 @@ profiles:
- any_other_function_with_shell_equals_true
- start_process_with_a_shell
- start_process_with_no_shell
- start_process_with_partial_path
exclude:
SqlInjection:

View File

@ -14,6 +14,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import ast
import bandit
from bandit.core.test_properties import checks
from bandit.core.test_properties import takes_config
@ -82,3 +84,27 @@ def start_process_with_no_shell(context, config):
confidence=bandit.MEDIUM,
text="Starting a process without a shell."
)
@takes_config('shell_injection')
@checks('Call')
def start_process_with_partial_path(context, config):
if config and len(context.call_args):
if(context.call_function_name_qual in config['subprocess'] or
context.call_function_name_qual in config['shell'] or
context.call_function_name_qual in config['no_shell']):
delims = ['/', '\\', '.']
node = context.node.args[0]
# some calls take an arg list, check the first part
if isinstance(node, ast.List):
node = node.elts[0]
# make sure the param is a string literal and not a var name
if(isinstance(node, ast.Str) and node.s[0] not in delims):
return bandit.Issue(
severity=bandit.LOW,
confidence=bandit.HIGH,
text=("Starting a process with a partial executable path"
" %s" % context.call_args_string)
)

41
docs/partial_paths.md Normal file
View File

@ -0,0 +1,41 @@
Avoid spawning subprocess with partial paths
=====================
When launching a subprocess from within Python, care should be taken over
executable paths. The search path, normally the 'PATH' environment variable,
will be used to discover a executable binary if a fully qualified path is not
given. This can allow an attacker to manipulate the search path, or place a
similarly named executable at an early point, such that it will be executed in
preference to the expected executable.
Paths should be given either fully qualified from the filesystem root, or
relative to the running processes working directory. If it is desirable to use
unqualified executable names for the perpose of location independent deployments
then consider using paths relative to the deployment directory or deducing the
paths using mechanisms such as `os.cwd()`
### Correct
Fully qualified paths, or relative paths:
```python
os.Popen('/bin/ls -l', shell=False)
os.Popen(['/bin/ls', '-l'], shell=False)
os.Popen(['../ls', '-l'], shell=False)
```
### Incorrect
Unqualified executable names:
```python
os.Popen('ls -l', shell=False)
os.Popen(['ls', '-l'], shell=False)
```
## Consequences
The following consequences may arise from the use of unqualified paths
* Unintended execution of malicious binaries
## References
* https://cwe.mitre.org/data/definitions/426.html

View File

@ -6,7 +6,7 @@ import hashlib as hhhh
from pickle import loads as lp
import pickle as p
pop('gcc --version', shell=True)
pop('/bin/gcc --version', shell=True)
h.md5('1')
hh.md5('2')

View File

@ -1,6 +1,5 @@
import os
os.startfile('foo.docx')
os.startfile('bad.exe')
os.startfile('text.txt')
os.startfile('/bin/foo.docx')
os.startfile('/bin/bad.exe')
os.startfile('/bin/text.txt')

View File

@ -1,3 +1,3 @@
import os
os.system('echo hi')
os.system('/bin/echo hi')

View File

@ -0,0 +1,10 @@
from subprocess import Popen as pop
pop('gcc --version', shell=False)
pop('/bin/gcc --version', shell=False)
pop(var, shell=False)
pop(['ls', '-l'], shell=False)
pop(['/bin/ls', '-l'], shell=False)
pop('../ls -l', shell=False)

View File

@ -2,14 +2,14 @@ import commands
import popen2
print(commands.getstatusoutput('echo / | xargs ls'))
print(commands.getoutput('echo / | xargs ls'))
print(commands.getstatusoutput('/bin/echo / | xargs ls'))
print(commands.getoutput('/bin/echo / | xargs ls'))
# This one is safe.
print(commands.getstatus('echo / | xargs ls'))
print(commands.getstatus('/bin/echo / | xargs ls'))
print(popen2.popen2('echo / | xargs ls')[0].read())
print(popen2.popen3('echo / | xargs ls')[0].read())
print(popen2.popen4('echo / | xargs ls')[0].read())
print(popen2.Popen3('echo / | xargs ls').fromchild.read())
print(popen2.Popen4('echo / | xargs ls').fromchild.read())
print(popen2.popen2('/bin/echo / | xargs ls')[0].read())
print(popen2.popen3('/bin/echo / | xargs ls')[0].read())
print(popen2.popen4('/bin/echo / | xargs ls')[0].read())
print(popen2.Popen3('/bin/echo / | xargs ls').fromchild.read())
print(popen2.Popen4('/bin/echo / | xargs ls').fromchild.read())

View File

@ -1,7 +1,7 @@
subprocess.call(["ls", "-l"])
subprocess.call(["ls", "-l"]) #noqa
subprocess.call(["ls", "-l"]) # noqa
subprocess.call(["ls", "-l"]) # nosec
subprocess.call(["ls", "-l"])
subprocess.call(["ls", "-l"]) #nosec
subprocess.call(["ls", "-l"])
subprocess.call(["/bin/ls", "-l"])
subprocess.call(["/bin/ls", "-l"]) #noqa
subprocess.call(["/bin/ls", "-l"]) # noqa
subprocess.call(["/bin/ls", "-l"]) # nosec
subprocess.call(["/bin/ls", "-l"])
subprocess.call(["/bin/ls", "-l"]) #nosec
subprocess.call(["/bin/ls", "-l"])

View File

@ -5,20 +5,20 @@ from subprocess import Popen as pop
def Popen(*args, **kwargs):
print('hi')
pop('gcc --version', shell=True)
Popen('gcc --version', shell=True)
pop('/bin/gcc --version', shell=True)
Popen('/bin/gcc --version', shell=True)
subprocess.Popen('gcc --version', shell=True)
subprocess.Popen(['gcc', '--version'], shell=False)
subprocess.Popen(['gcc', '--version'])
subprocess.Popen('/bin/gcc --version', shell=True)
subprocess.Popen(['/bin/gcc', '--version'], shell=False)
subprocess.Popen(['/bin/gcc', '--version'])
subprocess.call(["ls",
subprocess.call(["/bin/ls",
"-l"
])
subprocess.call('ls -l', shell=True)
subprocess.call('/bin/ls -l', shell=True)
subprocess.check_call(['ls', '-l'], shell=False)
subprocess.check_call('ls -l', shell=True)
subprocess.check_call(['/bin/ls', '-l'], shell=False)
subprocess.check_call('/bin/ls -l', shell=True)
subprocess.check_output(['ls', '-l'])
subprocess.check_output('ls -l', shell=True)
subprocess.check_output(['/bin/ls', '-l'])
subprocess.check_output('/bin/ls -l', shell=True)

View File

@ -1,10 +1,8 @@
import utils
import utils as u
u.execute('gcc --version', shell=True)
utils.execute('gcc --version', shell=True)
u.execute_with_timeout('gcc --version', shell=True)
utils.execute_with_timeout('gcc --version', shell=True)
utils.execute_with_timeout(['gcc', '--version'], shell=False)
u.execute('/bin/gcc --version', shell=True)
utils.execute('/bin/gcc --version', shell=True)
u.execute_with_timeout('/bin/gcc --version', shell=True)
utils.execute_with_timeout('/bin/gcc --version', shell=True)
utils.execute_with_timeout(['/bin/gcc', '--version'], shell=False)

View File

@ -2,15 +2,15 @@ import os as o
import subprocess as subp
# Vulnerable to wildcard injection
o.system("tar xvzf *")
o.system('chown *')
o.popen2('chmod *')
subp.Popen('chown *', shell=True)
o.system("/bin/tar xvzf *")
o.system('/bin/chown *')
o.popen2('/bin/chmod *')
subp.Popen('/bin/chown *', shell=True)
# Not vulnerable to wildcard injection
subp.Popen('rsync *')
subp.Popen("chmod *")
subp.Popen(['chown', '*'])
subp.Popen(["chmod", sys.argv[1], "*"],
subp.Popen('/bin/rsync *')
subp.Popen("/bin/chmod *")
subp.Popen(['/bin/chown', '*'])
subp.Popen(["/bin/chmod", sys.argv[1], "*"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE)
o.spawnvp(os.P_WAIT, 'tar', ['tar', 'xvzf', '*'])

View File

@ -372,6 +372,13 @@ class FunctionalTests(unittest.TestCase):
'CONFIDENCE': {'HIGH': 2}}
self.check_example('paramiko_injection.py', expect)
def test_partial_path(self):
'''Test process spawning with partial file paths.'''
expect = {'SEVERITY': {'LOW': 9},
'CONFIDENCE': {'HIGH': 9}}
self.check_example('partial_path_process.py', expect)
def test_multiline_code(self):
'''Test issues in multiline statements return code as expected.'''
self.run_example('multiline-str.py')