Python计算器代码中eval函数的安全隐患及替代写法
深入分析Python简易计算器中使用eval函数带来的任意代码执行安全隐患,并提供基于AST抽象语法树白名单校验与调度场算法的安全替代实现方案。
在编写Python命令行或桌面简易计算器时,许多初学者习惯直接使用内置的 eval() 函数解析用户输入的算术字符串,比如传入 "1 + 2 * 3" 就能立即算出结果。这种做法虽然省去了编写词法分析和语法树的步骤,但却向系统敞开了任意代码执行(Remote Code Execution, RCE)的后门。本文将直接拆解 eval() 在计算器场景中的核心安全隐患,说明为什么简单的黑名单过滤无法提供可靠防护,并给出基于标准库 ast 抽象语法树白名单校验以及经典调度场算法(Shunting-yard Algorithm)的两种完整安全替代方案。
一、为什么初学者爱用 eval 以及它的致命安全隐患
初学者编写计算器时,最直观的代码通常只有两三行:读取输入的字符串,调用 eval(),输出结果。例如:
# 存在高危漏洞的经典初学者计算器实现
user_expr = input("请输入算术表达式: ")
result = eval(user_expr)
print(f"计算结果: {result}")
eval() 的作用不是“算术表达式计算器”,而是“Python 动态表达式执行器”。它会将传入的字符串当作一段具有最高执行权限的 Python 表达式直接交给解释器运行。只要输入中包含系统调用代码,解释器就会照单全收。
安全风险示例:攻击者并不需要输入数字与加减乘除,只需在输入框中提交一行内置调用:
__import__('os').system('rm -rf /') 或 __import__('subprocess').getoutput('whoami')
该计算器就会直接以运行当前 Python 进程的操作系统权限执行该 Shell 命令,导致系统被控、敏感数据泄露或磁盘文件被清空。
二、为什么“黑名单过滤”是不可靠的防守?
意识到 eval() 的危险后,许多开发者第一反应是写一个敏感词黑名单:只要输入中包含 "import"、"os"、"sys"、"__" 或字母,就拒绝计算。然而在安全工程实践中,黑名单过滤几乎注定会被绕过:
- 编码与格式绕过:通过 Unicode 变形字符、换行转义、十六进制或八进制转义序列,可以拼凑出关键字。
- 属性反射链:Python 的元编程能力极强,即使清除了
__builtins__,攻击者也可以通过空元组()、字面量字符串""获取其基类object,进而调用__subclasses__()遍历运行期内存中加载的所有类,找到能够执行系统调用的类方法。 - 防御与业务冲突:如果黑名单过于严苛(例如禁止一切字母),计算器就无法支持数学常量(如
pi、e)以及常用函数(如sin、cos、sqrt)。
因此,安全业界的核心准则是:永远不要使用黑名单修补动态求值漏洞,必须使用严格的白名单机制,或者彻底脱离动态解释执行环境。
三、替代方案一:针对纯字面量的内置方案 ast.literal_eval
Python 标准库提供了一个名为 ast.literal_eval() 的安全函数。它只解析 Python 的基本字面量结构(字符串、数字、元组、列表、字典、布尔值及 None),绝不会执行任何函数调用或系统指令。
import ast
# 安全解析字面量
val = ast.literal_eval("123") # 输出: 123 (int)
lst = ast.literal_eval("[1, 2, 3]") # 输出: [1, 2, 3] (list)
# 遇到恶意调用直接抛出 ValueError 异常
try:
ast.literal_eval("__import__('os').system('ls')")
except ValueError as e:
print("恶意代码被安全阻断:", e)
注意局限性:ast.literal_eval("1 + 2") 会直接抛出 ValueError: malformed node or string。因为 + 是运算符而不是字面量,ast.literal_eval 并不支持算术计算。因此,它适用于安全解析配置文件或数据结构,但不能直接作为算术计算器的求值引擎。
四、替代方案二:基于标准库 AST 构建受控白名单算术解析器(推荐方案)
要构建既安全又支持复杂运算符、括号甚至数学函数的计算器,最佳方案是使用标准库的 ast 模块对输入表达式进行语法树解析,并采用严格的节点白名单递归遍历。
它的工作原理是:将表达式文本解析为 AST 树后,检查每一个语法节点。如果节点属于白名单中的算术运算(如加减乘除、数字常量),则递归计算其值;只要出现任何未授权节点(如函数调用 Call、属性访问 Attribute、导入 Import、变量访问 Name),立即拦截并报错。
完整代码实现:基于 AST 的安全算术求值器
以下是一个健壮、无外部依赖的安全算术计算器实现,支持常见的二元运算(加、减、乘、除、整除、取模、幂运算)和一元运算(正负号),严格拦截所有未授权调用:
import ast
import operator
class SafeCalculator:
# 允许的双目运算符白名单
BINARY_OPS = {
ast.Add: operator.add, # +
ast.Sub: operator.sub, # -
ast.Mult: operator.mul, # *
ast.Div: operator.truediv, # /
ast.FloorDiv: operator.floordiv, # //
ast.Mod: operator.mod, # %
ast.Pow: operator.pow, # **
}
# 允许的单目运算符白名单
UNARY_OPS = {
ast.UAdd: operator.pos, # +
ast.USub: operator.neg, # -
}
def evaluate(self, expression: str):
"""解析并计算算术表达式字符串"""
if not expression or not expression.strip():
raise ValueError("表达式不能为空")
try:
# 将表达式解析为 AST,模式设为 eval 表示单个表达式
node = ast.parse(expression.strip(), mode='eval')
except SyntaxError as e:
raise ValueError(f"语法错误: 无法解析表达式 ({e})")
return self._eval_node(node.body)
def _eval_node(self, node):
# 1. 数字字面量 (兼容 Python 3.8+ Constant 与旧版 Num)
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return node.value
raise ValueError(f"不支持的常量类型: {type(node.value).__name__}")
elif isinstance(node, ast.Num): # 兼容性分支
return node.n
# 2. 双目运算 (例如 a + b, x * y)
elif isinstance(node, ast.BinOp):
op_type = type(node.op)
if op_type not in self.BINARY_OPS:
raise ValueError(f"不支持的双目运算符: {op_type.__name__}")
left_val = self._eval_node(node.left)
right_val = self._eval_node(node.right)
# 除零保护
if op_type in (ast.Div, ast.FloorDiv, ast.Mod) and right_val == 0:
raise ZeroDivisionError("数学错误: 除数不能为0")
return self.BINARY_OPS[op_type](left_val, right_val)
# 3. 单目运算 (例如 -5, +3)
elif isinstance(node, ast.UnaryOp):
op_type = type(node.op)
if op_type not in self.UNARY_OPS:
raise ValueError(f"不支持的单目运算符: {op_type.__name__}")
operand_val = self._eval_node(node.operand)
return self.UNARY_OPS[op_type](operand_val)
# 4. 其它所有节点(如 ast.Call, ast.Attribute, ast.Name)均属于非授权节点
else:
raise ValueError(f"安全拒绝: 检测到未授权的操作节点 [{type(node).__name__}]")
# 测试示例
if __name__ == "__main__":
calc = SafeCalculator()
# 正常计算测试
print("3 + 5 * (2 - 8) =", calc.evaluate("3 + 5 * (2 - 8)")) # 输出: -27
print("2 ** 3 + 10 / 2 =", calc.evaluate("2 ** 3 + 10 / 2")) # 输出: 13.0
print("-4.5 * 2 =", calc.evaluate("-4.5 * 2")) # 输出: -9.0
# 恶意注入测试
try:
calc.evaluate("__import__('os').system('whoami')")
except ValueError as err:
print("防御成功:", err) # 输出: 安全拒绝: 检测到未授权的操作节点 [Call]
安全性分析:在此实现中,输入的字符串从不经过 Python 代码执行引擎。即使攻击者传入任何包含模块导入、类反射或 Shell 注入的字符串,AST 都会将其标记为 ast.Call、ast.Attribute 或 ast.Name,直接触发白名单拒绝,从语法层面扼杀了漏洞。
五、替代方案三:经典调度场算法(Shunting-yard Algorithm)
如果业务场景要求完全脱离 Python 标准库解析模块(例如需要将代码移植到微控制器 MicroPython 环境,或希望独立控制运算栈深度),可以采用经典的调度场算法(Shunting-yard Algorithm)配合逆波兰表达式(RPN)求值。
调度场算法由计算机科学家 Edsger Dijkstra 提出,专门用于将人类易读的中缀表达式(如 3 + 4 * 2)转换为计算机易于处理的后缀表达式(即 3 4 2 * +)。由于算法全程只处理操作数与运算符两个栈,天然不存在任何代码执行环境。
代码实现:简明调度场算术计算器
import re
def tokenize(expression: str):
"""词法提取:仅允许提取浮点数、整数、运算符与括号"""
token_spec = r'\d+(?:\.\d+)?|[+\-*/()]'
tokens = re.findall(token_spec, expression)
# 验证是否包含非法字符
filtered = re.sub(token_spec, '', expression).replace(' ', '')
if filtered:
raise ValueError(f"输入包含非法字符: '{filtered}'")
return tokens
def shunting_yard(tokens):
"""中缀表达式转后缀表达式 (RPN)"""
precedence = {'+': 1, '-': 1, '*': 2, '/': 2}
output_queue = []
operator_stack = []
for token in tokens:
if re.match(r'^\d+(?:\.\d+)?$', token):
output_queue.append(float(token) if '.' in token else int(token))
elif token in precedence:
while (operator_stack and operator_stack[-1] in precedence and
precedence[operator_stack[-1]] >= precedence[token]):
output_queue.append(operator_stack.pop())
operator_stack.append(token)
elif token == '(':
operator_stack.append(token)
elif token == ')':
while operator_stack and operator_stack[-1] != '(':
output_queue.append(operator_stack.pop())
if not operator_stack:
raise ValueError("括号不匹配: 缺少左括号")
operator_stack.pop() # 弹出 '('
while operator_stack:
op = operator_stack.pop()
if op in ('(', ')'):
raise ValueError("括号不匹配: 缺少右括号")
output_queue.append(op)
return output_queue
def eval_rpn(rpn_tokens):
"""计算逆波兰表达式结果"""
stack = []
for token in rpn_tokens:
if isinstance(token, (int, float)):
stack.append(token)
else:
if len(stack) < 2:
raise ValueError("表达式格式不正确")
b = stack.pop()
a = stack.pop()
if token == '+': stack.append(a + b)
elif token == '-': stack.append(a - b)
elif token == '*': stack.append(a * b)
elif token == '/':
if b == 0: raise ZeroDivisionError("除数不能为0")
stack.append(a / b)
if len(stack) != 1:
raise ValueError("表达式求值错误")
return stack[0]
# 使用示例
if __name__ == "__main__":
expr = "3 + 4 * (2 - 1)"
tokens = tokenize(expr)
rpn = shunting_yard(tokens)
print(f"'{expr}' 的计算结果:", eval_rpn(rpn)) # 输出: 7.0
六、方案横向对比与技术选型指南
为了在实际开发中做出最合适的选择,我们对常见的计算求值方案进行多维度横向比对:
| 方案名称 | 安全性 | 功能灵活性 | 实现复杂度 | 适用场景建议 |
|---|---|---|---|---|
| eval() | 高危 (极低) | 完整 Python 语法 | 1 行代码 | 严禁使用于任何接收外部输入的场景。 |
| ast.literal_eval | 安全 (极高) | 仅支持基础字面量 | 1 行代码 | 用于解析 JSON-like 字典、元组或配置字面量,不支持算术运算。 |
| AST 白名单求值器 | 安全 (极高) | 可按需扩充函数/常量 | 50-80 行代码 | 首选推荐。通用性强、易维护,直接复用 Python 原生语法树。 |
| 调度场算法 (RPN) | 安全 (极高) | 完全自定义词法与语法 | 60-100 行代码 | 脱离 Python 运行时依赖,适合嵌入式微控制器或教学演示算法。 |
七、总结与避坑建议
在 Python 开发中,eval() 与 exec() 往往是高危漏洞的高发区。解决简易计算器的安全隐患,核心在于将“算术求值”与“代码执行”两件事彻底解耦:
- 弃用 eval():在任何接收用户输入并需要计算结果的场景下,坚决不再使用
eval()。 - 首选 AST 白名单解析:利用 Python 标准库的
ast.parse,通过显式声明允许的节点类型(如ast.Add、ast.Mult、ast.Constant),拒绝一切ast.Call与未授权节点,实现既安全又灵活的算术解析。 - 防范计算 DoS 攻击:除了防范 RCE,还要警惕大数幂运算导致的 CPU 占满,建议限制表达式字符长度或在求值器中对指数上限做硬性检查。
通过采用受控的 AST 语法树或调度场算法,开发者不仅能够消除致命的命令注入隐患,还能建立起更清晰健壮的词法与语法处理结构。
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













