【第45期】一行 eval 换来任意代码执行:用白名单解析与随机源注入重做 Python 小游戏
系列:《从小白到 AI 大模型开发工程师的进阶之路》 技术点:AI-0133 简单计算器与数字游戏 主人公:小蓝伞|环境:Windows 11、Python 3.12.14、仅标准库
小蓝伞用 eval(input()) 写出了一行计算器,1+2 确实返回 3;换成 __import__("os").name,同一个输入框却开始执行 Python 代码。另一边,猜数字游戏直接调用全局 random.randint(),每次测试的答案都不同,失败无法重现。本文用完整匹配的白名单语法替代 eval,用 Decimal 明确数值边界,并把随机源注入游戏对象。产出不仅能处理正常输入,还能稳定拒绝危险字符串、除零和越界猜测,交互层也不再吞掉真实代码错误,失败状态可以被自动验证和追踪。
一、问题现场与约束
计算器只支持两个十进制数和一个 + - * / 运算符,不执行函数、属性、变量和括号表达式。数字游戏的秘密数范围为 1~20,非法输入不消耗猜测次数,测试必须能固定秘密数。两部分都要把输入解析、业务计算和终端交互分开,避免异常被一个宽泛的 except 吞掉。
原稿的“遍历运算符后 split 一次”还有一个隐藏问题:-3--2 同时包含负号与减号,靠包含关系无法判断哪个是符号、哪个是运算符。最小语法虽简单,也应该完整匹配,而不是继续堆字符串特判。
二、先说工程结论
用户输入是数据,不是 Python 源码。 本文采用锚定到整行的正则提取两个数字与一个白名单运算符,再调用固定函数;猜数字游戏接收独立 Random 实例或测试桩,不读取全局随机状态。
| 方案 | 优点 | 风险或代价 | 适用条件 | 本文选择 |
|---|---|---|---|---|
eval | 代码最短、表达式能力完整 | 可执行任意 Python | 只处理程序内可信常量 | 禁止用户输入 |
ast.literal_eval | 只解析 Python 字面量 | 不计算 1+2 | 配置与字面量 | 不采用 |
| 正则 + 运算符白名单 | 边界清楚、易测 | 语法扩展需显式设计 | 两操作数计算器 | 采用 |
| 完整表达式解析器 | 支持优先级与括号 | 实现和安全审计成本高 | 真正公式产品 | 超出本期 |
不推荐通过删除 __builtins__ 把 eval 包装成“沙箱”。表达式执行器的攻击面远大于四则运算需求,最稳妥的权限是根本不提供执行能力。
三、最小可复现实现
保存为 safe_game.py:
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, DivisionByZero, InvalidOperation
from random import Random
from typing import Protocol
import operator
import re
NUMBER = r"[+-]?(?:\d+(?:.\d*)?|.\d+)"
EXPRESSION = re.compile(
rf"^\s*({NUMBER})\s*([+-*/])\s*({NUMBER})\s*$"
)
OPS = {
"+": operator.add,
"-": operator.sub,
"*": operator.mul,
"/": operator.truediv,
}
def calculate(text: str) -> Decimal:
"""计算一个白名单四则表达式,不执行 Python 代码。"""
matched = EXPRESSION.fullmatch(text)
if matched is None:
raise ValueError("格式应为:数字 运算符 数字")
left_text, symbol, right_text = matched.groups()
try:
left = Decimal(left_text)
right = Decimal(right_text)
return OPS[symbol](left, right)
except (InvalidOperation, DivisionByZero) as exc:
raise ValueError("数字无效或除数为 0") from exc
class RandomSource(Protocol):
"""游戏只依赖生成整数这一项能力。"""
def randint(self, start: int, end: int) -> int: ...
@dataclass
class GuessGame:
"""保存秘密数和有效猜测次数。"""
secret: int
upper: int = 20
tries: int = 0
@classmethod
def create(cls, rng: RandomSource, upper: int = 20) -> "GuessGame":
"""使用注入的随机源创建游戏。"""
if upper < 1:
raise ValueError("上限必须大于等于 1")
return cls(secret=rng.randint(1, upper), upper=upper)
def guess(self, value: int) -> str:
"""处理一次范围内猜测,并返回方向提示。"""
if not 1 <= value <= self.upper:
raise ValueError(f"猜测必须在 1 到 {self.upper} 之间")
self.tries += 1
if value == self.secret:
return "correct"
return "low" if value < self.secret else "high"
def demo() -> None:
"""运行可复制的确定性演示。"""
print(calculate("-3--2"))
game = GuessGame.create(Random(0))
print(game.secret, game.guess(10), game.tries)
if __name__ == "__main__":
demo()
执行命令:
python safe_game.py
Python 3.13 下 Random(0).randint(1, 20) 的预期秘密数为 13,因此输出应为:
-1
13 low 1
再验证三类失败输入:
bad_inputs = ["__import__('os').name", "1+2+3", "1/0"]
for value in bad_inputs:
try:
calculate(value)
except ValueError as exc:
print(type(exc).__name__, exc)
成功判据是三条输入均得到可读 ValueError,没有执行导入、没有忽略多余表达式,也没有把除零变成无穷值。
四、踩坑与误判纠正
第一个错误是把“能算表达式”误认为“适合接用户输入”。eval 的职责本来就是执行 Python 表达式,它返回系统名称不是漏洞复现失败,而是按设计工作。定位证据是危险字符串无需任何计算符号便能得到结果。修复不是补一条黑名单,因为可以调用的对象和组合方式无法穷举;修复是把可接受语言缩到业务真正需要的四则语法。
第二个错误是使用全局随机数,然后在测试失败时重新运行,下一次秘密数已经变化。小蓝伞最初把它归因于测试“偶发不稳定”,实际根因是业务逻辑偷偷拥有外部状态。把 Random(0) 作为依赖传入后,同一 Python 实现下秘密数固定,测试可断言高、低、正确与次数变化。
第三个错误是把所有异常都写成 except Exception: print("输入错误")。这样程序不会崩,但代码缺陷、终端中断和用户格式错误全部失去区别。本文只在边界转换预期异常;KeyboardInterrupt、编程错误和未知异常继续向上抛出。
五、功能实验与证据边界
| 输入或动作 | 预期结果 | 覆盖的风险 |
|---|---|---|
12 + 5 | Decimal('17') | 正常加法 |
-3--2 | Decimal('-1') | 负号与减号消歧 |
.5 * 2 | Decimal('1.0') | 小数格式边界 |
1+2+3 | ValueError | 拒绝超出语法的表达式 |
1/0 | ValueError | 除零可读失败 |
| 导入表达式 | ValueError | 不提供代码执行能力 |
| 越界猜测 21 | ValueError,次数不变 | 失败不修改状态 |
| 固定随机源 | 相同秘密数与提示 | 回归可复现 |
这组实验只覆盖两个十进制操作数。它不支持括号、指数、函数和运算优先级,也没有定义无限精度或舍入策略。Decimal 避免常见二进制浮点展示误差,但金融系统还必须明确币种、小数位、舍入模式和审计要求,不能把教学计算器直接用于账务。
从核心函数接到交互层
终端循环只负责读取文本、调用核心函数和展示结果,不应该在其中重新实现解析规则。推荐把每次输入当成一个独立事务:读取后先判断退出命令,再调用 calculate();只捕获 ValueError 输出用户可理解的提示,其他异常记录后交给上层处理。这样核心函数既能被 CLI 调用,也能被 Web 接口或测试直接复用。
猜数字同样分成三层:输入层把字符串转换为整数,游戏对象判断范围并修改次数,展示层把 low/high/correct 翻译为文案。若产品决定非法输入也消耗机会,只修改游戏契约和对应测试,不需要改终端解析。边界拆开后,规则变化不会散落在多个 input() 分支里。
资源限制也是输入契约的一部分。即使没有 eval,长度百万的数字仍可能消耗不必要的解析时间和内存。入口应先限制字符长度,再做正则与 Decimal 转换;复杂公式产品还需要限制表达式深度、节点数量和计算时间。白名单解决“允许什么”,资源上限解决“最多允许多少”。
失败样本如何进入回归集
每发现一条绕过输入,都保存最小字符串与期望异常类型:多运算符、只有符号、指数写法、Unicode 类似字符、超长小数和零除。不要只收集正常算式,否则白名单在后续扩展时会悄悄变宽。对随机游戏则保存动作序列与假随机源返回值,让失败与具体种子解耦。
安全回归还应验证副作用没有发生。危险字符串失败后,当前目录不应新增文件,游戏次数不应变化,日志中不应出现秘密数。单纯得到 ValueError 只能证明表面失败,不能证明失败前没有执行半段操作。
如果后续允许百分号、乘方或括号,应先写语法和优先级表,再选择解析器,并把旧的拒绝样本保留下来。功能增加不是简单扩充 OPS 字典;输入语言每扩大一步,歧义、资源消耗和安全审计范围都会同步扩大。只有产品确实需要,才值得承担这部分复杂度。
六、原理与可迁移判断
- 安全输入处理的核心不是“过滤坏字符”,而是定义并只接受一门足够小的语言。
- 解析与执行分开后,解析器可以拒绝未知结构,执行层只接收已经确认的操作符和数值。
- 随机数、当前时间和网络响应都属于外部依赖;注入它们,失败才能复现。
- 输入失败前不修改状态。猜测越界不增加次数,与第 43 期坏 JSON 不覆盖数据属于同一条纪律。
- 可重复伪随机适合测试和模拟,不适合密码、Token 或安全抽样;安全场景应使用
secrets。
三个工程追问
为什么不用一个复杂正则支持括号? 一旦出现嵌套、优先级和函数,正则会变得难审计,应使用词法与语法解析器,而不是继续加分支。
固定种子是否保证所有 Python 版本结果永远相同? 不应把跨语言、跨实现兼容建立在具体随机序列上。更严格的单元测试可注入返回固定值的假随机源,只在集成测试验证真实 Random。
错误输入要不要消耗游戏次数? 这是产品规则,不是技术真理。关键是先写进契约,再让状态变化与测试一致;本文选择不消耗。
七、验证清单与适用边界
- 合法整数、小数和负数运算结果正确。
- 危险字符串、多运算符、空输入和除零都明确失败。
- 失败输入没有执行函数、导入模块或修改游戏次数。
- 注入相同随机源后,秘密数和判断序列可重复。
- 终端入口只把预期输入错误转换为用户消息,没有宽泛吞异常。
本文是教学级四则运算器与猜数字核心,不是通用公式引擎、代码沙箱或金融计算系统。若需要复杂表达式,应选有明确语法、资源限制和安全审计的成熟方案。
总结与下一期
小蓝伞用更多几行代码换掉了两个不可控边界:eval 的任意执行能力和全局随机状态。真正可迁移的原则是:输入只获得业务需要的最小语法,变化的依赖必须能被替换,失败发生前不能修改状态。
下一期是 AI-0134 Python 项目规范。我们会把 41~45 期的分析器、待办、抓取器和小游戏收进可安装、可测试、可提交的 src 布局,并用一次“从空目录复现”检验 README 是否只是摆设。你见过最危险的输入捷径是 eval、动态 SQL,还是把字符串直接拼进系统命令?
官方资料
- Python
eval:docs.python.org/3/library/f… - Python
ast.literal_eval:docs.python.org/3/library/a… - Python
decimal:docs.python.org/3/library/d… - Python
random:docs.python.org/3/library/r… - Python
secrets:docs.python.org/3/library/s…