AST 安全求值

AST 安全求值 指的是:把表达式/代码先解析成抽象语法树(AST),然后不直接 eval / compile 执行,而是自己遍历 AST,只允许白名单内的节点,并按预定语义解释执行。核心目标是避免任意代码执行、沙箱逃逸和拒绝服务。

一句话:解析成 AST + 白名单解释器 + 资源限制 + 默认拒绝。


1. 为什么不直接用 eval?

Python 的 eval、JS 的 eval / new Function 都极其危险:

python

复制代码
eval("__import__('os').system('rm -rf /')")

即使限制 __builtins__,也可能通过属性链、类继承、生成器等方式逃逸。

所以安全求值不能依赖"过滤字符串",而应基于 AST 白名单。


2. 安全求值的基本原则

  1. 默认拒绝:没显式允许的 AST 节点一律报错。

  2. 不调用、不导入、不访问属性 :禁止 Call、Import、Attribute、Subscript 等。

  3. 变量来自受控环境 :只允许简单类型,如 int/float/str/bool/None。

  4. 运算符白名单 :只允许 + - * / // % **、比较、布尔等,并限制 **。

  5. 资源限制:输入长度、AST 节点数、递归深度、数值大小、字符串长度、执行时间。

  6. 纯函数、无副作用:求值不能修改外部状态。


3. Python 示例:安全表达式求值

只允许变量、常量、四则运算、比较、布尔、三元表达式。

python

复制代码
import ast
import operator as op

_BIN = {
    ast.Add: op.add,
    ast.Sub: op.sub,
    ast.Mult: op.mul,
    ast.Div: op.truediv,
    ast.FloorDiv: op.floordiv,
    ast.Mod: op.mod,
}

_UNARY = {
    ast.UAdd: op.pos,
    ast.USub: op.neg,
    ast.Not: op.not_,
}

_CMP = {
    ast.Eq: op.eq, ast.NotEq: op.ne,
    ast.Lt: op.lt, ast.LtE: op.le,
    ast.Gt: op.gt, ast.GtE: op.ge,
    ast.In: lambda a, b: a in b,
    ast.NotIn: lambda a, b: a not in b,
}

_ALLOWED_TYPES = (int, float, str, bool, type(None))

def safe_eval(expr: str, variables=None, max_nodes=1000, max_pow=100):
    variables = variables or {}
    tree = ast.parse(expr, mode="eval")

    if sum(1 for _ in ast.walk(tree)) > max_nodes:
        raise ValueError("表达式过大")

    return _eval(tree, variables, max_pow)

def _eval(node, env, max_pow):
    if isinstance(node, ast.Expression):
        return _eval(node.body, env, max_pow)

    if isinstance(node, ast.Constant):
        if isinstance(node.value, _ALLOWED_TYPES):
            return node.value
        raise ValueError("不允许的常量")

    if isinstance(node, ast.Name):
        if node.id in env and isinstance(env[node.id], _ALLOWED_TYPES):
            return env[node.id]
        raise ValueError(f"未知或不允许的变量: {node.id}")

    if isinstance(node, ast.BinOp):
        left = _eval(node.left, env, max_pow)
        right = _eval(node.right, env, max_pow)

        if isinstance(node.op, ast.Pow):
            if not isinstance(right, (int, float)) or abs(right) > max_pow:
                raise ValueError("指数过大")
            if isinstance(left, (int, float)) and abs(left) > 1e6:
                raise ValueError("底数过大")
            return left ** right

        fn = _BIN.get(type(node.op))
        if fn is None:
            raise ValueError("不允许的二元运算")

        # 防止字符串乘法耗尽内存
        if isinstance(node.op, ast.Mult):
            if isinstance(left, str) and isinstance(right, int) and len(left) * right > 10000:
                raise ValueError("字符串过长")
            if isinstance(right, str) and isinstance(left, int) and len(right) * left > 10000:
                raise ValueError("字符串过长")

        return fn(left, right)

    if isinstance(node, ast.UnaryOp):
        fn = _UNARY.get(type(node.op))
        if fn is None:
            raise ValueError("不允许的一元运算")
        return fn(_eval(node.operand, env, max_pow))

    if isinstance(node, ast.BoolOp):
        if isinstance(node.op, ast.And):
            result = True
            for v in node.values:
                result = _eval(v, env, max_pow)
                if not result:
                    return result
            return result
        if isinstance(node.op, ast.Or):
            result = False
            for v in node.values:
                result = _eval(v, env, max_pow)
                if result:
                    return result
            return result
        raise ValueError("不允许的布尔运算")

    if isinstance(node, ast.Compare):
        left = _eval(node.left, env, max_pow)
        for op_node, comparator in zip(node.ops, node.comparators):
            right = _eval(comparator, env, max_pow)
            fn = _CMP.get(type(op_node))
            if fn is None or not fn(left, right):
                return False
            left = right
        return True

    if isinstance(node, ast.IfExp):
        cond = _eval(node.test, env, max_pow)
        return _eval(node.body if cond else node.orelse, env, max_pow)

    raise ValueError(f"不允许的语法: {type(node).__name__}")

使用:

python

复制代码
safe_eval("a + 2 * (b - 1)", {"a": 3, "b": 5})
# 11

被禁止的语法会直接报错:

python

复制代码
safe_eval("__import__('os').system('ls')")
# ValueError: 不允许的语法: Call

4. 如果只处理字面量

Python 自带 ast.literal_eval:

python

复制代码
import ast

ast.literal_eval("{'a': [1, 2, 3]}")

它只允许字面量,安全边界较窄,但不能计算变量、表达式。


5. JS 中的思路

不要用:

js

复制代码
eval(expr)
new Function(expr)

应使用 acorn、espree 等解析成 ESTree,然后白名单解释:

  • 允许:Literal、Identifier、BinaryExpression、LogicalExpression、UnaryExpression、ConditionalExpression

  • 禁止:CallExpression、MemberExpression、AssignmentExpression、NewExpression、FunctionExpression、ArrowFunctionExpression、TemplateLiteral、ImportExpression、ThisExpression


6. 关键限制与结论

AST 安全求值不是"绝对安全",只是大幅缩小攻击面。生产环境还应:

  • 限制表达式长度、AST 节点数、执行时间、内存;

  • 禁止 Call、Attribute、Subscript、Import、Lambda、推导式、f-string;

  • 不把模块、函数、对象、类实例传入变量环境;

  • 必要时放到独立进程、容器、WASM 或 seccomp 沙箱中执行。

结论:

AST 安全求值 = 解析 + 白名单遍历 + 纯解释器 + 资源限制 + 默认拒绝。

复杂场景建议使用专用表达式引擎,如 CEL、JsonLogic、simpleeval、asteval 等,而不是自己从零实现。

相关推荐
这个昵称不容易7 分钟前
旅游管理系统的设计与实现(SpringBoot + MySQL + Vue 前后端分离)
java·springboot·毕设
Rain的Java大神之路8 分钟前
LeetCode 37 解数独面试攻略:回溯 + 位掩码 + MRV,从暴力到毫秒级求解
java·后端·leetcode
你看我名字8 分钟前
产品参数昨天还对,今天已过期:数眼智能与大模型应用如何做资料版本校验
开发语言·架构
老秦包你会1 小时前
计算机网络第二课 ----Socket(套接字)编程预备
开发语言·计算机网络·php
旖旎夜光1 小时前
【C++ 基础入门】C++多态(下):单/多继承虚表、多态补充与 21 道高频题
开发语言·c++·笔记·学习
天天被压力1 小时前
【A股分钟级高频数据完全手册 #11】券商实时行情数据接口:实时交易(券商源)接入与字段
java·人工智能·python
天天被压力1 小时前
【A股分钟级高频数据完全手册 #12】股票五档盘口数据接口:买卖五档实时盘口解析
java·人工智能·python
Wang's Blog10 小时前
Java 项目实战: 交友项目 - FastDFS:入门案例
java·开发语言·交友
apcipot_rain11 小时前
C++转Java日记(03)
java·开发语言·c++
皮皮林55111 小时前
Java实现微信扫码登录
java