Python计算器代码中eval函数的安全隐患及替代写法

Felix
Felix
发布时间:2026-11-15| 7人浏览过
原创

深入分析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 命令,导致系统被控、敏感数据泄露或磁盘文件被清空。

Python计算器代码中eval函数的安全隐患及替代写法
图 1:eval() 函数处理未经校验输入时的任意代码执行风险链路

二、为什么“黑名单过滤”是不可靠的防守?

意识到 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),立即拦截并报错。

Python计算器代码中eval函数的安全隐患及替代写法
图 2:AST 白名单验证与非法代码节点拦截机制

完整代码实现:基于 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 * +)。由于算法全程只处理操作数与运算符两个栈,天然不存在任何代码执行环境。

Python计算器代码中eval函数的安全隐患及替代写法
图 3:调度场算法解析算术表达式的双栈流转步骤

代码实现:简明调度场算术计算器

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函数的安全隐患及替代写法
图 4:安全计算器方案选型决策与系统工程防御规范

七、总结与避坑建议

在 Python 开发中,eval() 与 exec() 往往是高危漏洞的高发区。解决简易计算器的安全隐患,核心在于将“算术求值”与“代码执行”两件事彻底解耦:

  1. 弃用 eval():在任何接收用户输入并需要计算结果的场景下,坚决不再使用 eval()。
  2. 首选 AST 白名单解析:利用 Python 标准库的 ast.parse,通过显式声明允许的节点类型(如 ast.Add、ast.Mult、ast.Constant),拒绝一切 ast.Call 与未授权节点,实现既安全又灵活的算术解析。
  3. 防范计算 DoS 攻击:除了防范 RCE,还要警惕大数幂运算导致的 CPU 占满,建议限制表达式字符长度或在求值器中对指数上限做硬性检查。

通过采用受控的 AST 语法树或调度场算法,开发者不仅能够消除致命的命令注入隐患,还能建立起更清晰健壮的词法与语法处理结构。

相关专题:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。