Avoid input from untrusted sources to be used directly as part of an OS command and use specialized Python modules where possible instead.

Python can run shell commands either with an active shell=True where an actual shell is invoked to run a line of commands such /bin/bash -c "ls -la *.txt" or via non-interactive shell=False expecting a Python list object.

Using shell=False is recommended but is not going to prevent all attacks.

Examples of reduced functionality with shell=False:

  • Asterisks ls -1 *.txt get surrounded by single quotes ls -1 '*.txt' so that some Unix commands no longer work.
  • Piping commands ls -1 |grep *.txt is prohibited.
  • Escape sequences can be difficult to manage

Specialized Python modules, such as pathlib or shutil, provide a platform-independent solution for most needs and should generally be preferred.

Following table 00 provides a limited list of Unix shell commands to Python module mapping, see Python Module index for more.

Action Unix Python
Compress or decompress files gzip, unzip zlib, gzip, bz2, lzma
Filesystem operations find .
tree
ls -1 *.txt
test -d
test -f
cp
Path.rglob("*.txt")
Path.glob("*.txt")
Path.is_dir()
Path.is_file()
shutil.copy()
Access control operations chown
chmod
shutil.chown()
os.chmod()
stat
Environment variables export
set
os.getenv()
os.setenv()
Get user/group id id os.getuid()
os.setuid()
Get OS and/or kernel type and name uname -as os.uname()

table 00, example list of Unix commands and their Python equivalents.

Any variation of using input from a lesser trusted source as part of a command line program has a very high probability of resulting in a potential attack including the use of specialized modules. Consider:

Non-Compliant Code Example (Read Only)

This scenario demonstrates a potential remote command execution. The FileOperations.list_dir() method allows an attacker to inject commands into the string dirname such as head -1 /etc/passwd under Linux or net user under Windows. Older versions of Python < 3.9.12 allow to turn a non-interactive shell into an active shell in Windows by providing cmd.exe /C as an argument [python.org 3.12.5 - Subprocess management].

noncompliant01.py:

# SPDX-FileCopyrightText: OpenSSF project contributors
# SPDX-License-Identifier: MIT
""" Non-compliant Code Example """
from subprocess import Popen
import os


class FileOperations:
    """Helper class for file system operations"""

    def list_dir(self, dirname: str):
        """List the contents of a directory"""
        if "nt" in os.name:
            Popen("dir " + dirname, shell=True).communicate()
        if "posix" in os.name:
            Popen("ls " + dirname, shell=True).communicate()


#####################
# Trying to exploit above code example
#####################
if "nt" in os.name:
    FileOperations().list_dir("%HOMEPATH% & net user")
if "posix" in os.name:
    FileOperations().list_dir("/etc/shadow; head -1 /etc/passwd")

The code in noncompliant01.py prints the first line of /etc/passwd on Linux or starts net user under Windows. The FileOperations().list_dir() method allows an attacker to add commands via ; in Linux and & in Windows.

Compliant Solution (pathlib)

The compliant01.py code uses the cross-platform compatible pathlib module to check if the provided dirname is an actual directory, and if so, it iterates over the files inside. The pathlib.Path class will treat the string that you pass it as a literal file system path, preventing any command line injections. Instead of interpreting environment variables like %HOMEPATH% or & as a command separator, it will search for a folder by the literal name of the raw string (e.g %HOMEPATH% & net user in the windows example.).

The pathlib on its own will not prevent all attacks. It will not fully sanitize the string or make it safe for logging. These aspects have been skipped for brevity here and have been covered in rules such as pyscg-0044: Canonicalize Input Before Validating and the logging section of our guide.

compliant01.py:

# SPDX-FileCopyrightText: OpenSSF project contributors
# SPDX-License-Identifier: MIT
""" Compliant Code Example """
import os
from pathlib import Path

class FileOperations:
    """Helper class for file system operations"""

    def list_dir(self, dirname: str):
        """List the contents of a directory"""
        path = Path(dirname)
        # TODO: input sanitation
        # TODO: Add secure logging
        if path.is_dir():
            for item in path.iterdir():
                print(item)


#####################
# Trying to exploit above code example
#####################
if "nt" in os.name:
    FileOperations().list_dir("%HOMEPATH% & net user")
if "posix" in os.name:
    FileOperations().list_dir("/etc/shadow; head -1 /etc/passwd")

Non-Compliant Code Example (Read, Write)

The attack surface increases if a user is also allowed to upload or create files or folders.

The noncompliant02.py example demonstrates the injection via file or folder name that is created prior to using the list_dir() method. We assume here that an untrusted user managed to create files or folders, whose names contain shell or batch commands, using another compromised service, such as an upload area, submit form, or via a zip-bomb as described in pyscg-0012: Extract Archives Safely. Encoding issues as per pyscg-0044: Canonicalize Input Before Validating must also be considered. Those names could contain direct commands, such as & calc.exe or ;ps aux, or they could point to script files containing those commands.

The issue occurs when mixing shell commands with data from a lesser trusted source.

Some shell commands, such as find with -exec, allow running secondary commands via arguments [CTFOBins] [LOLBAS] that can be misused for shell injections if no shell is provided shell=False. The shlex.split() method is frequently used to split a string into a list for subprocess.run() in order to run a non-interactive shell such as ls -la into ["ls", "-la"] and plays a minor role in simplifying the attack. The noncompliant02.py code works on Linux, in this example calling a rather harmless uptime, and on Windows it opens the calculator application.

noncompliant02.py:

# SPDX-FileCopyrightText: OpenSSF project contributors
# SPDX-License-Identifier: MIT
""" Non-compliant Code Example """
import os
import shlex
from subprocess import run

IS_WINDOWS = "nt" in os.name
IS_LINUX = "posix" in os.name


def list_dir(dirname: str):
    """Lists folders and their subfolders using shell commands"""
    os.chdir(dirname)
    if IS_WINDOWS:
        cmd = "powershell -NoProfile -Command Get-ChildItem -Directory -Name | Sort-Object -Descending"
    elif IS_LINUX:
        cmd = "find . -maxdepth 1 -type d"
    else:
        raise NotImplementedError("Detected OS is not supported")

    result = run(shlex.split(cmd), check=True, capture_output=True)
    for subfolder in result.stdout.decode("utf-8").splitlines():
        if IS_WINDOWS:
            cmd = "powershell -NoProfile -Command Get-ChildItem " + subfolder
        elif IS_LINUX:
            cmd = "find " + subfolder + " -maxdepth 1 -type d"
        subresult = run(shlex.split(cmd), check=True, capture_output=True)
        for item in subresult.stdout.decode("utf-8").splitlines():
            print(item)


#####################
# Trying to exploit above code example
#####################
os.makedirs("temp", exist_ok=True)
print("Testing Corrupted Directory")
if IS_WINDOWS:
    os.makedirs("temp\\temp; Start-Process calc", exist_ok=True)
if IS_LINUX:
    with open("temp/toast.sh", "w", encoding="utf-8") as file_handle:
        file_handle.write("uptime\n")
    os.makedirs("temp/. -exec bash toast.sh {} +", exist_ok=True)
list_dir("temp")

When run on Linux, noncompliant02.py creates a toast.sh file that contains the commands to run. The attacker also creates a folder named . -exec bash toast.sh {} + that will later become part of the shell find command forming find . -exec bash toast.sh {} +.

The attack is similar on Windows, where noncompliant02.py instead injects a command to start the calc process, which is run by the list_dir() method.

The result is that list_dir(dirname) will run the OS-specific script file without the attacker needing execute rights. Such a file can contain harmful scripts of unlimited length and complexity.

Compliant Solution (using pathlib recursively)

The compliant02.py also uses pathlib, additionally restricting the filesystem area. Again, pathlib on its own will not sanitize the input or provide a secure logging system.

compliant02.py:

# SPDX-FileCopyrightText: OpenSSF project contributors
# SPDX-License-Identifier: MIT
""" Compliant Code Example """
import os
from pathlib import Path

IS_WINDOWS = "nt" in os.name
IS_LINUX = "posix" in os.name

def list_dir(dirname: str):
    """List the contents of a directory recursively

    Parameters:
        dirname (string): Directory name
    """
    path = Path(dirname)
    allowed_directory = Path.home()
    # TODO: input sanitation
    # TODO: Add secure logging
    if Path(
        allowed_directory.joinpath(dirname)
        .resolve()
        .relative_to(allowed_directory.resolve())
    ):
        for item in path.rglob("*"):
            print(item)


#####################
# Trying to exploit above code example
#####################
os.makedirs("temp", exist_ok=True)
print("Testing Corrupted Directory")
if IS_WINDOWS:
    os.makedirs("temp\\temp; Start-Process calc", exist_ok=True)
if IS_LINUX:
    with open("temp/toast.sh", "w", encoding="utf-8") as file_handle:
        file_handle.write("uptime\n")
    os.makedirs("temp/. -exec bash toast.sh {} +", exist_ok=True)
list_dir("temp")

The compliant02.py does not use data that originates from a lesser trusted source in order to form a shell command. It will throw an error for an attempt to list content outside of the allowed area. The code is actually not “neutralizing” data itself from an untrusted source as such, the attack is “neutralized” by no longer using subprocess or os to run find.

Automated Detection

Tool Version Checker Description
Pycharm 2022.3.3 Python 3.11.6 PR100 Calling subprocess.call, subprocess.run, or subprocess.Popen with shell=True can leave the host shell open to local code execution or remote code execution attacks
bandit 1.7.9 on python 3.11.4 B404 Consider possible security implications associated with these modules
bandit 1.7.9 on python 3.11.4 B602 Bsubprocess call with shell=True identified, security issue.bandit
bandit 1.7.9 on python 3.11.4 B603 subprocess call - check for execution of untrusted input.
bandit 1.7.9 on python 3.11.4 B604 Consider possible security implications associated with the subprocess module
bandit 1.7.9 on python 3.11.4 B605 Bandit seems to detect any use of os.system() whether sanitized or not.
   
MITRE CWE Pillar: CWE-707: Improper Neutralization
MITRE CWE Base: CWE-78, Improper Neutralization of Special Elements Used in an OS Command (“OS Command Injection”)
SEI CERT Coding Standard for Java IDS07-J. Sanitize untrusted data passed to the Runtime.exec() method
SEI CERT C Coding Standard ENV03-C. Sanitize the environment when invoking external programs
SEI CERT C Coding Standard ENV33-C. Do not call system()
SEI CERT C++ Coding Standard ENV03-CPP. Sanitize the environment when invoking external programs VOID ENV02-CPP. Do not call system() if you do not need a command processor
ISO/IEC TR 24772:2013 Injection [RST]

Bibliography

   
[Python docs] subprocess — Subprocess management — Python 3.10.4 documentation [online]. Available from: https://docs.python.org/3/library/subprocess.html, [accessed 1 November 2024]
[Python docs] os — Miscellaneous operating system interfaces — Python 3.10.4 documentation [online]. Available from: https://docs.python.org/3/library/os.html#os.system, [accessed 1 November 2024]
[CTFOBins] GTFOBins is a curated list of Unix binaries that can be used to bypass local security restrictions in misconfigured systems. [online]. Available from: https://gtfobins.github.io/, [accessed 1 November 2024]
[LOLBAS] LOLBAS Living Off The Land Binaries, Scripts and Libraries. [online]. Available from: https://lolbas-project.github.io/, [accessed 1 November 2024]