Python 的 exec 与 eval :动态代码执行的能力、风险与工程实践

0 阅读6分钟

Python 是一门极其灵活的语言,而 execeval 则是这种灵活性最极端的体现——它们能让你在运行时把一段字符串当作代码来执行。这种能力听起来很酷,用起来却像在玩火。本文从原理出发,深入剖析这两个函数的本质差异、潜藏的安全地雷,以及在真实工程中如何做出理智的取舍。


一、它们到底是什么?

evalexec 都是 Python 的内置函数,但定位截然不同。

eval 专门用于求值表达式,它接收一个字符串,把它当作一个 Python 表达式来计算,并返回结果:

result = eval("1 + 2 * 3")
print(result)  # 输出 7

exec 则更"野",它可以执行任意的 Python 代码块,包括函数定义、循环、赋值等,但不返回值(返回 None):

code = """
def greet(name):
    return f"Hello, {name}!"
"""
exec(code)
print(greet("World"))  # 输出 Hello, World!

两者的核心区别可以这样理解:eval 是一个计算器,只能算出一个值;exec 是一个解释器,能跑完整的程序。


二、底层原理:Python 其实在偷偷编译

很多人以为 exec / eval 是"直接解释字符串",其实不然。CPython 在执行这两个函数时,会先把字符串编译成字节码(bytecode) ,再交给虚拟机执行——这和正常 import 一个模块的流程本质上是一样的。

export_wug3c.png

这意味着两件事:

  • 性能损耗是真实存在的:每次调用都要经历编译,比直接调用函数慢得多。如果在循环里反复 eval,性能会很难看。
  • compile() 可以预编译:如果你确实需要反复执行同一段动态代码,可以先用 compile() 生成 code object,再传给 exec,避免重复编译的开销。

三、安全噩梦:为什么说它们是"装了子弹的枪"

这是最关键的部分。evalexec 的危险程度,远超大多数开发者的想象。

3.1 任意代码执行(RCE)

假设你写了一个"在线计算器",用 eval 来处理用户输入:

# 危险!永远不要这样做
user_input = input("请输入表达式:")
result = eval(user_input)

用户只需要输入:

__import__('os').system('rm -rf /')

你的服务器就可能被清空。这就是远程代码执行(RCE) 漏洞——攻击者可以通过精心构造的字符串,在你的服务器上执行任意系统命令。

3.2 "沙箱"是假的

很多人会想到"限制命名空间"来防御:

# 看起来很安全?其实不是
eval(user_input, {"__builtins__": {}})

但这种防御形同虚设。Python 对象系统极其复杂,攻击者可以通过对象的 __class____subclasses____globals__ 等魔术属性,绕过任何手工构造的沙箱,最终找到通往系统调用的路径。

一个经典的绕过姿势:

# 即使 builtins 被清空,也能找到 os 模块
eval("().__class__.__bases__[0].__subclasses__()")

这会列出所有子类,其中往往藏着可以访问文件系统的类。

3.3 风险全景图

export_fv0h8.png


四、更安全的替代方案

在大多数场景下,你根本不需要 evalexec,换个思路就能解决问题。

4.1 ast.literal_eval:只解析数据,不执行代码

如果你只是想把一个字符串形式的 Python 字面量(数字、列表、字典等)转换成对象,用 ast.literal_eval 就够了,它只允许解析安全的字面量,拒绝任何可执行代码:

import ast

# 安全:只解析字面量
data = ast.literal_eval("{'name': 'Alice', 'age': 30}")
print(data)  # {'name': 'Alice', 'age': 30}

# 会抛出异常,而不是执行
ast.literal_eval("__import__('os').system('ls')")  # ValueError!

4.2 用字典替代动态变量名

Reddit 上那个游戏开发的例子很典型——用 eval(weaponName + ".get_stat()") 来动态访问对象。正确的做法是用字典:

# 不好的写法
thing = eval(nameOfWeapon + ".get_itemStat()")

# 好的写法:用字典存储对象
weapons = {
    "sword": Sword(),
    "bow": Bow(),
}
thing = weapons[nameOfWeapon].get_itemStat()

4.3 getattr:动态访问属性

需要动态访问对象属性时,getattr 是正确选择:

# 不好的写法
eval(f"obj.{method_name}()")

# 好的写法
getattr(obj, method_name)()

4.4 替代方案对比

场景危险做法安全替代
解析数据字符串eval("{'a': 1}")ast.literal_eval(...)
动态访问属性eval(f"obj.{attr}")getattr(obj, attr)
动态调用方法eval(f"obj.{method}()")getattr(obj, method)()
按名称获取对象eval(name)dict[name]getattr(module, name)
解析 JSONeval(json_str)json.loads(json_str)

CITE_2


五、工程实践:什么时候可以用,怎么用

execeval 并非一无是处,在某些特定场景下它们是合理的工具。关键在于:你是否完全控制了输入来源

5.1 合理的使用场景

  • REPL / 交互式解释器:Jupyter Notebook、IPython 本质上就是在用 exec 执行用户输入的代码,但用户就是自己,风险可控。
  • 代码生成工具:元编程、ORM 框架在内部动态生成类或方法时,输入完全由框架自身控制。
  • 配置文件执行:某些框架(如 web2py)用 exec 加载配置,但这些配置文件来自可信的本地文件系统。
  • 测试框架pytest 等工具在内部用 exec 动态执行测试代码。

5.2 工程中的决策树

export_m5ra5s.png

5.3 如果非用不可,这样做

import traceback

def safe_exec(code: str, allowed_globals: dict = None):
    """
    一个相对安全的 exec 封装(仅适用于可信输入)
    """
    # 1. 限制全局命名空间,只暴露必要的内容
    safe_globals = {
        "__builtins__": {
            "print": print,
            "range": range,
            "len": len,
            # 只暴露你真正需要的内置函数
        }
    }
    if allowed_globals:
        safe_globals.update(allowed_globals)
    
    # 2. 捕获所有异常,避免崩溃
    try:
        exec(compile(code, "<string>", "exec"), safe_globals)
    except Exception as e:
        print(f"执行失败:{traceback.format_exc()}")

即便如此,这种"安全封装"也只适用于可信输入,对于真正的外部用户输入,没有任何手工沙箱是可靠的。CITE_4


六、总结

evalexec 是 Python 动态性的极致体现,但它们更像是语言留给框架开发者和工具作者的"后门",而非普通业务代码的常规工具。

一句话概括工程原则:如果输入来自用户,永远不要碰 eval/exec;如果输入来自自己,先想想有没有更简单的替代方案。 大多数时候,ast.literal_evalgetattr、字典映射就已经够用了。真正需要动态执行代码的场景,往往意味着你在构建一个解释器或框架——那时候,你自然会知道自己在做什么。CITE_2CITE_4


参考来源