Evaluar ecuaciones matemáticas de entrada de usuario insegura en Python

Tengo un sitio web donde el usuario ingresa ecuaciones matemáticas (expresiones) y luego esas ecuaciones se evalúan contra los datos (constantes) proporcionados por el sitio web. Las operaciones matemáticas necesarias incluyen símbolos, operaciones aritméticas, min() , max() y algunas otras funciones básicas. Una ecuación de ejemplo podría ser:

 max(a * b + 100, a / b - 200) 

Uno podría simplemente eval() esto usando Python, pero como todos sabemos, esto lleva a comprometer el sitio. ¿Cuál sería el enfoque seguro de hacer la evaluación de la ecuación matemática?

  • ¿Qué motores de análisis y evaluación de ecuaciones matemáticas hay para Python?

  • Si uno elige usar Python para evaluar la expresión, ¿existe algún sandbox de Python que limite a Python, de modo que solo estén disponibles los operadores y las funciones del proveedor usuario? Python de pleno derecho, al igual que la definición de funciones, debe estar totalmente deshabilitado. Los subprocesos están bien (ver Caja de arena de PyPy ). Especialmente, los bucles y otros orificios para explotar la memoria y el uso de la CPU deben cerrarse.

  • ¿Algún otro enfoque, por ejemplo, mediante el uso de un binario de línea de comandos (bc)?

Descargo de responsabilidad: soy el Alexer mencionado en el código en la otra respuesta. Para ser honesto, sugerí que el método de análisis de códigos de bytes solo era medio en broma, ya que tenía el 99% del código para un proyecto no relacionado y, por lo tanto, podía unir un POC en unos minutos. Dicho esto, no debería haber nada malo en ello, per se; es solo que es una maquinaria más compleja que se necesita para esta tarea. De hecho, debería poder desmontar simplemente el código [verificando los códigos de operación contra una lista blanca], verificando que las constantes y los nombres son válidos, y ejecutarlo con una evaluación simple y malvada después de eso. Solo debe perder la capacidad de insertar controles extra paranoicos en toda la ejecución. (Otro descargo de responsabilidad: todavía no me sentiría lo suficientemente cómodo para hacerlo con eval)

De todos modos, tuve un momento aburrido, así que escribí un código para hacer esto de manera inteligente; usando el AST en lugar del bytecode. Es solo una bandera extra para compile() . (O simplemente ast.parse() , ya que de todos modos querrá los tipos del módulo)

 import ast import operator _operations = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.div, ast.Pow: operator.pow, } def _safe_eval(node, variables, functions): if isinstance(node, ast.Num): return node.n elif isinstance(node, ast.Name): return variables[node.id] # KeyError -> Unsafe variable elif isinstance(node, ast.BinOp): op = _operations[node.op.__class__] # KeyError -> Unsafe operation left = _safe_eval(node.left, variables, functions) right = _safe_eval(node.right, variables, functions) if isinstance(node.op, ast.Pow): assert right < 100 return op(left, right) elif isinstance(node, ast.Call): assert not node.keywords and not node.starargs and not node.kwargs assert isinstance(node.func, ast.Name), 'Unsafe function derivation' func = functions[node.func.id] # KeyError -> Unsafe function args = [_safe_eval(arg, variables, functions) for arg in node.args] return func(*args) assert False, 'Unsafe operation' def safe_eval(expr, variables={}, functions={}): node = ast.parse(expr, '', 'eval').body return _safe_eval(node, variables, functions) if __name__ == '__main__': import math print safe_eval('sin(a*pi/b)', dict(a=1, b=2, pi=math.pi), dict(sin=math.sin)) 

Lo mismo se aplica a esto en cuanto a la versión de bytecode; Si compara las operaciones con una lista blanca y verifica que los nombres y valores son válidos, debería poder salirse con la llamada a eval en el AST. (Pero una vez más, todavía no lo haría. Porque paranoico. Y la paranoia es buena cuando se trata de evaluar)

Hay una forma relativamente fácil de hacer esto en Python sin paquetes de terceros.

  • Usando compile() para preparar una expresión de Python de una sola línea para que sea bytecode para eval()

  • No ejecuta el bytecode a través de eval() , sino que lo ejecuta en su bucle de código de operación personalizado y solo implemente los códigos de operación que realmente necesita. Por ejemplo, no hay elementos incorporados, no hay acceso a atributos, por lo que la caja de arena no puede escapar

Sin embargo, hay algunos errores, como la preparación para el agotamiento de la CPU y el agotamiento de la memoria, que no son específicos de este método y también son un problema en otros enfoques.

Aquí hay una entrada de blog completa sobre el tema . Aquí está una esencia relacionada . A continuación se muestra un código de ejemplo abreviado.

 """" The orignal author: Alexer / #python.fi """ import opcode import dis import sys import multiprocessing import time # Python 3 required assert sys.version_info[0] == 3, "No country for old snakes" class UnknownSymbol(Exception): """ There was a function or constant in the expression we don't support. """ class BadValue(Exception): """ The user tried to input dangerously big value. """ MAX_ALLOWED_VALUE = 2**63 class BadCompilingInput(Exception): """ The user tried to input something which might cause compiler to slow down. """ def disassemble(co): """ Loop through Python bytecode and match instructions with our internal opcodes. :param co: Python code object """ code = co.co_code n = len(code) i = 0 extended_arg = 0 result = [] while i < n: op = code[i] curi = i i = i+1 if op >= dis.HAVE_ARGUMENT: # Python 2 # oparg = ord(code[i]) + ord(code[i+1])*256 + extended_arg oparg = code[i] + code[i+1] * 256 + extended_arg extended_arg = 0 i = i+2 if op == dis.EXTENDED_ARG: # Python 2 #extended_arg = oparg*65536L extended_arg = oparg*65536 else: oparg = None # print(opcode.opname[op]) opv = globals()[opcode.opname[op].replace('+', '_')](co, curi, i, op, oparg) result.append(opv) return result # For the opcodes see dis.py # (Copy-paste) # https://docs.python.org/2/library/dis.html class Opcode: """ Base class for out internal opcodes. """ args = 0 pops = 0 pushes = 0 def __init__(self, co, i, nexti, op, oparg): self.co = co self.i = i self.nexti = nexti self.op = op self.oparg = oparg def get_pops(self): return self.pops def get_pushes(self): return self.pushes def touch_value(self, stack, frame): assert self.pushes == 0 for i in range(self.pops): stack.pop() class OpcodeArg(Opcode): args = 1 class OpcodeConst(OpcodeArg): def get_arg(self): return self.co.co_consts[self.oparg] class OpcodeName(OpcodeArg): def get_arg(self): return self.co.co_names[self.oparg] class POP_TOP(Opcode): """Removes the top-of-stack (TOS) item.""" pops = 1 def touch_value(self, stack, frame): stack.pop() class DUP_TOP(Opcode): """Duplicates the reference on top of the stack.""" # XXX: +-1 pops = 1 pushes = 2 def touch_value(self, stack, frame): stack[-1:] = 2 * stack[-1:] class ROT_TWO(Opcode): """Swaps the two top-most stack items.""" pops = 2 pushes = 2 def touch_value(self, stack, frame): stack[-2:] = stack[-2:][::-1] class ROT_THREE(Opcode): """Lifts second and third stack item one position up, moves top down to position three.""" pops = 3 pushes = 3 direct = True def touch_value(self, stack, frame): v3, v2, v1 = stack[-3:] stack[-3:] = [v1, v3, v2] class ROT_FOUR(Opcode): """Lifts second, third and forth stack item one position up, moves top down to position four.""" pops = 4 pushes = 4 direct = True def touch_value(self, stack, frame): v4, v3, v2, v1 = stack[-3:] stack[-3:] = [v1, v4, v3, v2] class UNARY(Opcode): """Unary Operations take the top of the stack, apply the operation, and push the result back on the stack.""" pops = 1 pushes = 1 class UNARY_POSITIVE(UNARY): """Implements TOS = +TOS.""" def touch_value(self, stack, frame): stack[-1] = +stack[-1] class UNARY_NEGATIVE(UNARY): """Implements TOS = -TOS.""" def touch_value(self, stack, frame): stack[-1] = -stack[-1] class BINARY(Opcode): """Binary operations remove the top of the stack (TOS) and the second top-most stack item (TOS1) from the stack. They perform the operation, and put the result back on the stack.""" pops = 2 pushes = 1 class BINARY_POWER(BINARY): """Implements TOS = TOS1 ** TOS.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] print(TOS1, TOS) if abs(TOS1) > BadValue.MAX_ALLOWED_VALUE or abs(TOS) > BadValue.MAX_ALLOWED_VALUE: raise BadValue("The value for exponent was too big") stack[-2:] = [TOS1 ** TOS] class BINARY_MULTIPLY(BINARY): """Implements TOS = TOS1 * TOS.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] stack[-2:] = [TOS1 * TOS] class BINARY_DIVIDE(BINARY): """Implements TOS = TOS1 / TOS when from __future__ import division is not in effect.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] stack[-2:] = [TOS1 / TOS] class BINARY_MODULO(BINARY): """Implements TOS = TOS1 % TOS.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] stack[-2:] = [TOS1 % TOS] class BINARY_ADD(BINARY): """Implements TOS = TOS1 + TOS.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] stack[-2:] = [TOS1 + TOS] class BINARY_SUBTRACT(BINARY): """Implements TOS = TOS1 - TOS.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] stack[-2:] = [TOS1 - TOS] class BINARY_FLOOR_DIVIDE(BINARY): """Implements TOS = TOS1 // TOS.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] stack[-2:] = [TOS1 // TOS] class BINARY_TRUE_DIVIDE(BINARY): """Implements TOS = TOS1 / TOS when from __future__ import division is in effect.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] stack[-2:] = [TOS1 / TOS] class BINARY_LSHIFT(BINARY): """Implements TOS = TOS1 << TOS.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] stack[-2:] = [TOS1 << TOS] class BINARY_RSHIFT(BINARY): """Implements TOS = TOS1 >> TOS.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] stack[-2:] = [TOS1 >> TOS] class BINARY_AND(BINARY): """Implements TOS = TOS1 & TOS.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] stack[-2:] = [TOS1 & TOS] class BINARY_XOR(BINARY): """Implements TOS = TOS1 ^ TOS.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] stack[-2:] = [TOS1 ^ TOS] class BINARY_OR(BINARY): """Implements TOS = TOS1 | TOS.""" def touch_value(self, stack, frame): TOS1, TOS = stack[-2:] stack[-2:] = [TOS1 | TOS] class RETURN_VALUE(Opcode): """Returns with TOS to the caller of the function.""" pops = 1 final = True def touch_value(self, stack, frame): value = stack.pop() return value class LOAD_CONST(OpcodeConst): """Pushes co_consts[consti] onto the stack.""" # consti pushes = 1 def touch_value(self, stack, frame): # XXX moo: Validate type value = self.get_arg() assert isinstance(value, (int, float)) stack.append(value) class LOAD_NAME(OpcodeName): """Pushes the value associated with co_names[namei] onto the stack.""" # namei pushes = 1 def touch_value(self, stack, frame): # XXX moo: Get name from dict of valid variables/functions name = self.get_arg() if name not in frame: raise UnknownSymbol("Does not know symbol {}".format(name)) stack.append(frame[name]) class CALL_FUNCTION(OpcodeArg): """Calls a function. The low byte of argc indicates the number of positional parameters, the high byte the number of keyword parameters. On the stack, the opcode finds the keyword parameters first. For each keyword argument, the value is on top of the key. Below the keyword parameters, the positional parameters are on the stack, with the right-most parameter on top. Below the parameters, the function object to call is on the stack. Pops all function arguments, and the function itself off the stack, and pushes the return value.""" # argc pops = None pushes = 1 def get_pops(self): args = self.oparg & 0xff kwargs = (self.oparg >> 8) & 0xff return 1 + args + 2 * kwargs def touch_value(self, stack, frame): argc = self.oparg & 0xff kwargc = (self.oparg >> 8) & 0xff assert kwargc == 0 if argc > 0: args = stack[-argc:] stack[:] = stack[:-argc] else: args = [] func = stack.pop() assert func in frame.values(), "Uh-oh somebody injected bad function. This does not happen." result = func(*args) stack.append(result) def check_for_pow(expr): """ Python evaluates power operator during the compile time if its on constants. You can do CPU / memory burning attack with ``2**999999999999999999999**9999999999999``. We mainly care about memory now, as we catch timeoutting in any case. We just disable pow and do not care about it. """ if "**" in expr: raise BadCompilingInput("Power operation is not allowed") def _safe_eval(expr, functions_and_constants={}, check_compiling_input=True): """ Evaluate a Pythonic math expression and return the output as a string. The expr is limited to 1024 characters / 1024 operations to prevent CPU burning or memory stealing. :param functions_and_constants: Supplied "built-in" data for evaluation """ # Some safety checks assert len(expr) < 1024 # Check for potential bad compiler input if check_compiling_input: check_for_pow(expr) # Compile Python source code to Python code for eval() code = compile(expr, '', 'eval') # Dissect bytecode back to Python opcodes ops = disassemble(code) assert len(ops) < 1024 stack = [] for op in ops: value = op.touch_value(stack, functions_and_constants) return value