sábado, octubre 02, 2010

Ejecutando código Python de forma segura con eval()

Respuesta corta


Eval es maligno, no lo intentes.

Respuesta larga


Hace unos días publicaron en la lista de Python Argentina la pregunta de qué tan peligroso era usar la función eval para ejecutar código:

code = raw_input('Ingrese una entrada: ')
eval(code)

Sucede que si el usuario ingresa una expresion similar a:

Ingrese una entrada: __import__('os').system('ls /')

Pueden ocurrir cosas malas; en este ejemplo se esta listando los archivos del directorio raíz, pero ingresando: "rm -rf ~" se van a borrar todos los archivos de la carpeta personal del usuario.

Entonces, eval() parece bastante malo.

Una primera mejora a la fución eval() es asignarle dos parámetros adicionales para anular las funciones de importacion de módulos. Haciendo:

eval(code, {'__import__': None}, {})

El anterior ejemplo ya no funciona:

>>> eval('__import__("os")', {'__import__': None}, {})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<string>", line 1, in <module>
TypeError: 'NoneType' object is not callable

Pero hay otra forma de pasar por arriba la limitacion de __import__:

>>> eval('__builtins__["__import__"]("os")', {'__import__': None}, {})
<module 'os' from '/usr/lib/python2.6/os.pyc'>

Ya tenemos de vuelta la referencia a __import__. Entonces como bien comentaron en la lista, una solución sería anular la referencia a __builtins__:

>>> eval('__builtins__["__import__"]("os")', {'__import__': None, '__builtins__': None}, {})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<string>", line 1, in <module>
TypeError: 'NoneType' object is unsubscriptable

Y si queremos abrir un arhivo tampoco se puede:

>>> eval('open("/tmp/aa", "w")', {'__import__': None, '__builtins__': None}, {})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<string>", line 1, in <module>
NameError: name 'open' is not defined

Con la función 'file' tampoco funciona. El módulo __builtins__ es el módulo donde residen todas las funciones predefinidas de Python: file, open, list, dir, etc. Si queremos construir una lista tampoco se puede:

>>> eval('list()', {'__import__': None, '__builtins__': None}, {})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<string>", line 1, in <module>
NameError: name 'list' is not defined

Pero hay otras formas de pasar por arriba las limitaciones de no tener __builtins__. Solo se necesita tener una referencia a un objeto que referencie a una función que nos permita hacer cosas no permitidas.

Una forma es usar las operaciones de 'introspection' de Python. Dada una instancia de un objeto podemos saber su clase, la clase padre y las subclases. Por ejemplo:

>>> (1).__class__.__bases__[0].__subclasses__()
[<type 'type'>, <type 'weakref'>, <type 'weakcallableproxy'>, <type 'weakproxy'>, <type 'int'>, <type 'basestring'>, <type 'bytearray'>, ...]

En este caso estamos tomando la instancia del objeto int(1), preguntando su clase, luego la clase padre (object), y luego las subclases de ésta (¡un montón!).

Mirando atentamente la lista encontramos dos referencias interesantes:

  • <type 'file'>
  • <type 'zipimport.zipimporter'>

La primera es el tipo file(), la misma que se usa para leer y escribir archivos. La segunda es la clase zipimporter, que permite importar módulos dentro de un zip.

Lo interesante es que aún anulando la referencia al módulo __builtins__, las anteriores operaciones siguen funcionando:

>>> eval("""[x for x in (1).__class__.__bases__[0].__subclasses__() if x.__name__=='file'][0]("/proc/version")""", {'__import__': None, '__builtins__': None, }, {})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<string>", line 1, in <module>
IOError: file() constructor not accessible in restricted mode

Intentando usar la referencia a 'file' tenemos un problema: el IOError.

El mensaje de error IOError aparece porque dentro de la función file() hay un bloque de código que verifica que __builtins__ no sea None.

Años atrás, el equipo de Python intentó agregarle al lenguaje la funcionalidad de ejecución restringida de código, que permita ejecutar código no seguro de forma segura.

Luego de varios intentos desistieron, justamente porque no era posible contemplar todos los casos. Hoy en día esas funciones están deprecated y en Python 3 fueron completamente removidas del código fuente.

Entonces, en Python 3 es posible importar cualquier módulo y ejectar codigo malicioso fácilmente, aún anulando __builtins__:

>>> (eval('[x for x in (1).__class__.__bases__[0].__subclasses__() if x.__name__ == "ImpImporter"][0]().find_module("os").load_module("os").system("id")', {'__import__': None, '__builtins__': None}, {}))
uid=1000(alejo) gid=1000(alejo) groups=1000(alejo),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),44(video),46(plugdev)

En este caso, la clase ImpImporter es una subclase de object que abstrae todo el comportamiento de importación de módulos. Acceder a esa clase es algo que no se puede evitar usando las opciones de eval().

Quedó pendiente la instancia de zipimporter.

Importar modulos con esa clase no imposible, la única complicación es conocer la ruta de algún egg o zip dentro del sistema que nos ofrezca alguna referencia a __builtins__, __import__, al módulo 'os' o a otro módulo que nos interese.

Por ejemplo, hacer un zip que tenga dentro un modulo que importe el módulo 'os' es bastante trivial:

>>> eval("""[x for x in (1).__class__.__bases__[0].__subclasses__() if x.__name__=='zipimporter'][0]("/tmp/modulo1.zip").find_module("modulo1").load_module("modulo1").os.system("id")""", {'__import__': None, '__builtins__': None, }, {})
uid=1000(alejo) gid=1000(alejo) groups=1000(alejo),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),44(video),46(plugdev)

En resumen, no se confíen de eval() para ejecutar código potencialmente inseguro. En la wiki de Python.org hay una sección dedicada exclusivamente a la seguridad, aquellos que estén interesados no dejen de leerla.

7 comentarios:

Nuser dijo...

Hola Alejandro, muy bueno tu artículo sobre la seguridad de eval, pero con esta función permito ejecutar el código que yo deseo dentro de una determinada aplicación.
Es cierto que es un Eval limitado, pero funcionaría para la invocación de objetos que yo deseo y no deja que se ejecute nada de los ejemplos que ahí tienes.

def eval_wrapper(expression):
PYTHON_RESERVED_WORDS = ('and', 'assert', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except',
'exec', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'not', 'or',
'pass', 'print', 'raise', 'return', 'try', 'while')


if not type(expression) is str:
raise ValueError('The expression should be a str object')
for reserved_word in PYTHON_RESERVED_WORDS:
if reserved_word in expression:
raise PermissionError('You cannot execute the sentence: {0}'.format(expression))

return eval(expression)

alejolp dijo...

Nuser, tu solucion no funciona en general, estás filtrando casos de uso perfectamente validos, por ejemplo en un str:

>>> [x for x in dir("") if any([(y in x) for y in PYTHON_RESERVED_WORDS])]
['__class__', '__contains__', '__delattr__', '__format__', '__init__', '__subclasshook__', '_formatter_field_name_split', '_formatter_parser', 'expandtabs', 'find', 'format', 'index', 'isalnum', 'isalpha', 'isdigit', 'islower', 'isspace', 'istitle', 'isupper', 'join', 'rfind', 'rindex', 'splitlines']

>>> eval_wrapper("'hola'.index('a')")

Nuser dijo...

No entiendo tu ejemplo. ¿Podrías explicarme un poco más por qué no funciona lo que yo he puesto? Aparte de eso, digo que mi eval_wrapper funciona porque lo que quiero hacer es bastante limitado (como limitado debería ser el mismo uso de eval). Lo único que quería hacer notar es que no hay forma de saltarse nada en mi función para usar cosas por detrás.

alejolp dijo...

El siguiente programa es perfectamente valido en Python, devuelve el indice de la letra "a" en el string "hola":

>>> 'hola'.index('a')
3

Usando eval() con este mismo programa devuelve el resultado deseado:

>>> eval("'hola'.index('a')")
3

Sin embargo con tu versión de eval_wrapper no funciona:

>>> eval_wrapper("'hola'.index('a')")
Traceback (most recent call last):
File "", line 1, in
File "", line 7, in eval_wrapper
__main__.PermissionError: You cannot execute the sentence: 'hola'.index('a')

La razón es muy simple: al filtrar por keywords inválidas de Python se está tomando la keyword "in" y buscándola como un substring en el codigo a hacer eval, que es un substring de "index".

Nuser dijo...

Ah ahora entiendo. Sí es cierto lo sé y soy consciente. La idea de dicho wrapper es tener muy claro qué vas a permitir y qué no. Desde luego en temas de seguridad no hay una receta mágica que funcione siempre. A mi me funciona porque lo único que necesito es ejecutar

obj_list = eval('model_class.objects.filter(' + field_id + '=' + value_field_id + ')')

Lo que quería decir es que mi eval_wrapper es tremendamente restrictivo. Pero lo que quería mostrar es que para cada aplicación construir un eval restrictivo e ir añadiendo excepciones a esas restricciones. Por ejemplo:

def eval_wrapper(expression):
PYTHON_RESERVED_WORDS = ('and', 'assert', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except',
'exec', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'not', 'or',
'pass', 'print', 'raise', 'return', 'try', 'while')

EXCEPTION_PYTHON_WORDS = ('index',)


if not type(expression) is str:
raise ValueError('The expression should be a str object')
for reserved_word in PYTHON_RESERVED_WORDS:
if reserved_word in expression:
for excep_word in EXCEPTION_PYTHON_WORDS:
if excep_word not in expression:
raise PermissionError('You cannot execute the sentence: {0}'.format(expression))

return eval(expression)


Eso sí, SIEMPRE SERÁ RESPONSABILIDAD DEL PROGRAMADOR.
En fin gracias por tus comentarios y por tu saber.

alejolp dijo...

Pero ahora con poner "index" en cualquier lugar del string podés conseguir evitar que se evite la validación.

>>> eval_wrapper("__import__('modulomaligno') # index")

Nuser dijo...

Cierto...

¿qué tal esta versión mejorada?, tira todas las expresiones que contengan palabras reservadas que no formen parte de las expresiones legales.

def eval_wrapper(expression):
PYTHON_RESERVED_WORDS = ('and', 'assert', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except',
'exec', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'not', 'or',
'pass', 'print', 'raise', 'return', 'try', 'while')

EXCEPTION_PYTHON_WORDS = ('index',)

if not type(expression) is str:
raise ValueError('The expression should be a str object')


forbidden_words = [reserved_word for reserved_word in PYTHON_RESERVED_WORDS if reserved_word in expression ]
for fw in forbidden_words:
for exw in EXCEPTION_PYTHON_WORDS:
if not fw in exw:
raise PermissionError('You cannot execute the sentence: {0}'.format(expression))

return eval(expression)