很多开发者第一次接触 eval 命令时,都会觉得它不过是一个“把字符串变成命令”的小工具。我在刚开始写 shell 脚本时也这么想,直到有一次线上脚本因为一行 eval,把原本正常的变量展开搞得面目全非,我才意识到:这个看似简单的关键词背后,藏着完整的命令处理流程。后来我又在 Python、JavaScript、PHP 里碰到同名的 eval 家族,才彻底明白动态执行是一把极其危险的双刃剑。这篇我就完整聊聊 eval 命令在不同环境下的行为原理、常见使用场景、隐蔽的坑,以及为什么在今天的代码审计和安全竞赛(比如 CTFHub 上的 eval 执行类题目)里,eval 几乎总是重点排查对象。
如果你是刚学 shell 的运维、写过几年业务代码的后端同学,或者正在刷 CTF 入门题,这篇文章应该能帮你把这个知识点彻底打通。
1. eval 命令的身份:它不是一个命令,而是一套执行机制
1.1 系统里的 eval:shell 内建命令
先明确一个容易混淆的概念:在 Linux 和 Unix 环境中,eval并不是一个位于/usr/bin之类的独立二进制程序。它是 Bash、Zsh 等 shell 的内建命令。也就是说,它的作用不是“启动某个外部程序”,而是让当前 shell 自己去做一次额外的解析。
你可以直接在终端里试试:
type -a eval输出会告诉你它是一个 shell builtin。这个细节很重要,因为它意味着 eval 的执行环境就是当前 shell 环境——它能访问当前 shell 的所有变量、函数、别名和已导出的环境变量。
eval 的基本行为是:把传给它的所有参数先拼接成一个字符串,然后把这个字符串当作新的命令行,重新交给 shell 去执行一遍。关键就在“重新”两个字。普通命令行在输入后,shell 会依次做分词、展开、重定向、执行。而 eval 相当于在第一次执行结果的基础上,又开了一个新的解析循环。
举一个非常安全的例子,你能直观看到二次解析的作用:
str='$HOME' echo "$str"这里输出的是字面上的$HOME,因为双引号内的$str展开后已经是字符串$HOME,shell 不会再去展开字符串内部的那个$。但如果写成:
eval echo "$str"eval 会把echo $HOME重新交给 shell,这时$HOME被展开成真实路径,输出结果就是你的家目录。所以 eval 的核心能力,是让“已经呈现为字符串的 shell 语法”重新获得语法意义。
这个特点也让 eval 在脚本里常被用来处理“变量中的变量”、动态拼接命令、读取特殊格式的配置文件等场景。但它的代价也很明显:如果你拼接的内容里有不该被展开的部分,或者有来自用户输入的内容,行为会变得非常难以预测。
1.2 各编程语言里的“eval 家族”:不同名字,同一个灵魂
离开 shell,我们会发现几乎所有动态语言里都有 eval 的影子。它们字面意思一样,都是 evaluate 的缩写,核心逻辑也很一致:接收一段代码字符串,在当前运行时环境里把它解析并执行。
| 语言 | 形式 | 执行内容 | 返回值行为 |
|---|---|---|---|
| Shell | eval "cmd" | 任意命令 | 返回最后一条命令的退出状态 |
| Python | eval(expression) | 仅限表达式 | 返回表达式的值 |
| Python | exec(code) | 语句块 | 不返回值,仅执行 |
| JavaScript | eval(code) | 代码字符串 | 返回最后一个表达式的值 |
| PHP | eval('code') | PHP 语句 | 返回 null 或捕获的值;注意它不是函数 |
| Lua | load(code)/loadstring(code) | 代码块/表达式 | 返回函数,调用后执行 |
| Ruby | eval(code) | 任意表达式 | 返回表达式结果 |
这里特别要注意 Python 的eval和exec区别非常大。eval只能执行一个表达式,不能执行语句,比如你不能在eval里写x = 1,但可以写x + 1。而exec可以执行一段完整的代码块。很多初学 Python 的人会把两者混用,但它们在语法层面边界分明。JavaScript 的eval则狂野得多——它本身是一个函数,但内部执行的代码可以访问到调用位置的局部作用域,这也导致它很难被静态分析。PHP 的eval是一个语言构造而不是函数,所以调用时不要想着把它当回调函数传。
有意思的是,一些新语言从设计上就拒绝 eval。比如 Go 语言没有内置 eval,想动态执行代码必须引入 Lua 这类嵌入式解释器;Rust 也没有标准库层面的 eval。这种“刻意缺失”本身就是一种安全态度:动态执行能力越强,安全边界就越难守住。
2. 为什么 shell 的 eval 需要“两次扫描”才生效
2.1 命令行处理顺序与二次解析
要真正理解 eval,不能只记住“它会执行字符串”,还要知道它到底改变了哪一步。Bash 处理一条命令行的顺序大致是:从输入文本中识别命令名、参数、重定向符号,然后做各种展开——大括号展开、波浪号展开、参数展开、命令替换、算术展开、分词,最后执行。
这里容易被忽略的是分词时机。变量展开的结果并不会无条件重新分词,除非你用了$@、$*或者不加引号的$var。因为你加不加引号,直接影响 shell 怎么切分参数。
eval 等于把这个流程整体又跑了一遍。第一次解析后的结果,会作为第二次解析的输入。这意味着,原来已经变成普通字符串的$HOME、$(...)、通配符、管道符,都可能再一次获得语法能力。
看一下经典的位置参数例子:
n=2 set -- a b c eval echo \$$n一步步拆解:先看命令行eval echo \$$n。第一个反斜杠把第一个$转义成字面量;$n展开为数字2。所以传给 eval 的参数字符串是echo $2。第二次扫描时,$2被展开为b,最终输出b。如果不是用 eval,直接写echo \$$n,shell 只会把第一个$转义,第二个$n展开成 2,最终输出的是$2这个字面文本,完全不是同一个结果。
这也是为什么 eval 被称为“可以实现间接引用”的工具。你可以在不知道变量名的情况下,通过另一个变量保存变量名,然后用 eval 取到目标变量的值。现代 Bash 里推荐用${!var}或declare -n,但在老脚本里,eval 几乎是唯一选择。
2.2 拼接带引号参数的经典陷阱
eval 的另一个典型用途是构造带引号的命令行。假设你想执行的命令是:
printf '%s\n' "hello world"如果你先把这个命令放进一个变量,再直接$cmd执行,问题就来了。因为$cmd展开后,引号只是普通字符,shell 不会再重新解析它们。结果就是printf收到了多个参数,世界完全不一样。而 eval 可以解决这个问题:
cmd="printf '%s\\n' \"hello world\"" eval "$cmd"eval 让引号在第二次扫描时重新获得边界意义,命令就正常了。也正因为如此,eval 经常出现在一些需要动态构造复杂命令的脚本里。
但我必须说,这个用法非常不建议在新代码里出现。因为只要你开始用 eval 拼命令,就在跟引号转义做斗争。今天你费劲把命令拼对了,明天用户输入里多一个空格、一个分号或一个反引号,整个脚本的行为就不可控了。拼命令这件事,Bash 有更好的工具:数组。
2.3 现代 Bash 里替代 eval 的干净写法
与其用字符串拼命令,再用 eval 去“救”,不如从一开始就用数组保存参数:
args=(printf '%s\n' "hello world") "${args[@]}"数组能把每个参数都保留原样,不需要引号二次解析,也不会有分词问题。如果目的是根据条件选择执行不同命令,可以用函数加case:
case "$action" in start) do_start ;; stop) do_stop ;; *) echo "unknown action" >&2 ;; esac这样既清晰又安全。如果是想读配置文件里的键值对并动态生成变量,Bash 4 以上可以用关联数组或declare的引用功能,也完全不需要 eval。道理其实很简单:eval 的本质是“让字符串重新变成代码”,但字符串一旦夹杂不可信文本,这就等于把你 shell 的控制权交了出去。能用数据结构解决的问题,尽量不要用字符串去描述问题。
3. 语言层面的 eval:从计算器到模板引擎
3.1 常见的合法使用场景
eval 不是十恶不赦的,它确实服务过不少合法需求。最典型的是实现一个交互式计算器。比如 Python 交互式解释器本身就是个“读入-求值-输出”循环,你输入1 + 2,它内部就在执行类似 eval 的流程。很多早期的 Web 项目也会用 eval 处理用户提交的数学公式,比如eval("price * quantity"),这样运营人员就不用改代码,直接配公式就行。模板引擎的表达式语法,比如让用户写{{ user.name }},如果内部没有专门的解析器,也会选择在沙箱里 eval。
在 JavaScript 的发展史上,eval 还曾被广泛用来解析 JSON 数据。在JSON.parse还没被普遍支持的时候,标准做法是:
var obj = eval("(" + jsonString + ")");包一层括号是为了让 JSON 中的对象字面量被正确解析为表达式。这个用法曾经到处都是,也留下了非常多安全教训,后面我会专门讲。
算法竞赛、动态规划之类的场景里,eval 也偶尔作为快速实现出现。比如你需要动态执行一段用户输入的代码,跑一个在线判题系统。不过正规的在线评测系统几乎不会用 eval 去执行提交代码,而是会起一个隔离进程,配合资源限制来做这件事。
3.2 作用域、性能和代码可读性问题
就算不考虑安全,滥用 eval 也会带来一堆麻烦。首先是作用域问题。JavaScript 的eval在直接调用时,可以读写包含它的作用域里的变量。一方面这看起来很灵活,但另一方面会污染作用域,让程序状态变得难以追踪。你在一个函数里调用eval("var x = 1"),这个x就成了函数局部变量,可能覆盖掉之前的东西。
Python 的eval允许你传入自定义的 globals 和 locals 字典。这听起来不错,但如果没传,它就会使用当前作用域。一个不谨慎的变量名冲突,可能让整个线上的配置静默改变。
其次是性能。eval 无法被现代编译器提前优化,因为 compiler 在编译期根本不可能知道字符串里是什么代码。同样的操作,写成普通函数可能比 eval 快数倍。某些极端情况下,频繁调用 eval 还会导致 JIT 优化失效,进而拉低整个应用性能。在追求吞吐的后端服务里,这是很致命的。
第三是可读性。eval 出来的代码是字符串,IDE 无法补全、无法重构、无法静态检查。一旦逻辑复杂起来,调试体验会非常差。我记得以前接手过一个项目,所有计算逻辑都放在数据库里,再用 eval 拼出来执行。每次排查线上问题都得把那段字符串在本地跑一遍,然后逐层打印中间变量,效率极低。后来全部改成函数注册表,反而干净多了。
3.3 eval 与 JSON 的历史纠葛
前面提到的 JS 用 eval 解析 JSON,是学习 eval 风险最好的反面教材。JSON 本身只是一种数据格式,它只包含键、值、数组、数字、字符串等字面量。但eval("(" + jsonString + ")")会把整段 JSON 当成 JavaScript 代码来执行。如果这个 JSON 字符串不是纯数据,而是被人恶意写入了一段可执行代码,那么eval就会把这段代码真的当作 JavaScript 执行。本质上,你是在用“执行代码”的方式来“解析数据”,把数据通道和代码通道完全混在一起。
正确的做法是永远让数据保持数据的身份:
const obj = JSON.parse(jsonString);Python 里则对应json.loads,PHP 里是json_decode。这些专用解析器只会按语法构造内存对象,不会去执行任何计算或调用。现在还有多少人会去 eval 一段 JSON?答案应该是零。
4. 从 CTFHub 的 eval 执行题目看 eval 的安全问题
4.1 这类题目的共同模型
在 CTFHub 等靶场平台上,eval 执行是 Web 方向非常经典的一类题目。这类题目的源码通常很短,可能只有几行,但核心模型都一样:后端代码把某个变量直接或间接地传给 eval,而这个变量又来自用户提交的请求参数。曾经我见过一个简化版示例结构是这样(这里不展示完整漏洞代码,只说明危险模式):
// 危险:从请求参数获取表达式并执行 eval($_GET['cmd']);这不是让你背利用代码,而是要理解为什么会有这么大的风险。eval 是一个“代码执行入口”,而$_GET['cmd']是用户可控内容。两者一结合,就相当于你把系统的解释器权限开放给了来访者。CTF 题目考察的不是你会不会背几个特殊字符,而是你有没有真正理解动态执行的边界:什么样的数据可以安全地交给 eval,什么样的数据绝对不能。
如果只从防御和安全审计角度看,见到 eval 且参数来自外部输入,第一反应就应该是高风险。哪怕外面套了各种过滤,也不值得冒险。因为语言语法的变体太多了,任何基于字符串黑名单的防护都难以治本。
4.2 我在代码审计里是如何快速筛查 eval 隐患的
这几年我参与过不少代码审计,总结出一套比较高效的排查思路,只从防御视角看:
第一,全局搜索 eval 相关调用。在 Python 项目里搜eval(、exec(,在 JavaScript 里搜eval(、Function(,在 PHP 里搜eval(、assert(、preg_replace的/e修正符等,在 shell 脚本里搜单独的eval。把这些调用点全部列出来,不管有没有问题,先建一个清单。
第二,追踪数据流。对每个 eval 调用点,向上追踪它传入的变量来自哪里。我会问自己几个问题:这个变量是否来自 HTTP 请求参数?是否来自外部配置文件?是否被其他用户写入过?是否经过了任何白名单验证?只要有一环是不可信的,风险等级就拉高。
第三,检查过滤方式。有的项目会给 eval 包一层正则校验,比如只允许数字和加减乘除。这比什么都不做强,但仍要小心例外情况。尤其是正则写得宽泛时,比如“只要不含分号就行”,那基本等于没过滤。因为不加分号也可以执行完整逻辑,语言语法本身充满等价变换。最终建议是:即使加过滤,也只能防御已知的简单攻击,无法防御未知变体。
第四,看上下文环境。同样的 eval,如果运行在独立容器、只监听内网、没有私密数据,风险会小很多;如果运行在共享环境、拥有数据库权限、暴露在公网,那风险等级就完全不同。环境隔离可以降低危害,但不能消除问题。
4.3 为什么黑名单过滤救不了 eval
很多人试图通过对输入做黑名单来保护 eval,例如把import、system、exec、open等关键字替换成空字符串。这种做法在原理上就存在巨大漏洞。首先是过滤器只能匹配固定的字面量,而语言允许通过字符串拼接、编码转换、属性访问等方式达到同样的语义。其次,不同语言里的关键字和内置函数数量庞大,维护一份“完整”黑名单几乎不可能。即使这次把能想到的都堵了,语言标准库更新后又会出现新的可利用风格。
从安全设计角度,更好的思路是“白名单校验 + 不执行”。例如想实现一个计算器,应该规定用户只能输入数字和哪些运算符,其他字符一律拒绝。更进一步,用抽象语法树对输入进行结构校验,只允许出现符合预期类型的节点,然后自己实现求值逻辑。这样无论输入怎么变,你只对“已知安全的结构”做出响应,从根本上封住动态执行的熵。
其实 CTF 题目里最值得学习的不是“怎么打”,而是“为什么这里能被打”。当你看到 eval 拼接外部输入时,你应该立刻警惕这行代码把所有防护都绕过了。实战中的修复往往也很简单:不要 eval 用户数据,改用结构化的参数、函数映射或安全的表达式解析器。
5. 如果非要动态执行:安全的替代方案
5.1 用 AST 代替 eval 实现安全表达式求值
正则白名单只适合极简单的表达式,而 AST(抽象语法树)方案更适合生产级实现。以 Python 为例,我们可以写一个只允许四则运算的安全计算器,核心思路是先解析表达式,再检查 AST 节点类型,最后自己遍历节点求值。
import ast import operator ALLOWED_OPERATORS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Mod: operator.mod, } def safe_expr_eval(expr: str): tree = ast.parse(expr, mode="eval") def eval_node(node): if isinstance(node, ast.Expression): return eval_node(node.body) if isinstance(node, ast.Constant): if isinstance(node.value, (int, float)) and not isinstance(node.value, bool): return node.value raise ValueError("only numbers are allowed") if isinstance(node, ast.BinOp): if type(node.op) not in ALLOWED_OPERATORS: raise ValueError("operator not allowed") left = eval_node(node.left) right = eval_node(node.right) return ALLOWED_OPERATORS[type(node.op)](left, right) if isinstance(node, ast.UnaryOp): if isinstance(node.op, (ast.UAdd, ast.USub)): operand = eval_node(node.operand) return operand if isinstance(node.op, ast.UAdd) else -operand raise ValueError("unsupported unary operator") raise ValueError("unsupported expression node") return eval_node(tree)这段代码的特点是只接受数字常量、加减乘除取模、正负号,所有函数调用、变量名、属性访问都会被拒绝,因此即使输入里夹带__import__之类的符号,也到不了执行阶段。在实际业务里,你还可以加上对运算结果范围的限制,防止超大数幂运算造成 CPU 消耗。
AST 方案比 eval 安全得多,但也要注意:不同语言的 AST 库使用复杂度不同,如果项目里没有现成轮子,可以用成熟的开源表达式解析库,而不是自己手写一套。安全的目标不是“完全无风险”,而是“把风险降到可控范围”。
5.2 让数据做数据:序列化与配置解析
很多 eval 调用其实是用来“解析数据”的,比如读取一个配置文件、解析一段 JSON、把一个 Python 字面量字符串变成对象。这些场景完全不需要 eval,有专门的解析器:
| 数据格式 | 错误用法 | 正确用法 |
|---|---|---|
| JSON | eval("(" + json + ")") | JSON.parse/json.loads/json_decode |
| Python 字面量 | eval(data) | ast.literal_eval(data) |
| YAML | yaml.load(data) | yaml.safe_load(data) |
| Shell 配置 | 用 eval 读取配置 | 用source更安全?其实也需谨慎,建议用其他格式 |
这里特别想提醒的是ast.literal_eval这个函数。它看起来很像 eval,但它的求值范围被严格限制在 Python 基础字面量(字符串、数字、元组、列表、字典、布尔值、None)以及若干常量表达式。它不会执行任意函数调用,不会读文件,也不会导入模块。因此当你在 Python 项目里只是想把一段数据字符串转换成对象,可以直接用ast.literal_eval替代 eval,安全可靠。
5.3 最后的兜底:即使必须执行,也要控制输入空间
某些场景下你可能确实需要一个动态执行能力,比如在线考试系统需要运行用户提交的 Python 代码片段;或者你自己的脚本需要根据外部参数选择不同的分析器。这种情况我建议至少做到以下几点:
- 最小权限运行。让执行代码的进程使用单独的、只读的系统账号,文件系统上只开放必要的目录,网络权限也尽量关闭。容器环境里最好把 capabilities 缩小,别以 root 跑 eval。
- 设置资源上限。用操作系统层面的超时和内存限制,防止恶意代码无限循环或耗尽内存。Python 里可以用
resource.setrlimit,容器里可以设置 CPU 和内存配额。 - 与核心数据隔离。确保动态执行环境无法直接访问数据库凭据、内部 API 密钥等敏感信息。把这些信息通过接口注入,而不是放进环境变量。
- 全程日志记录。记录执行来源、输入摘要、执行时长和结果,方便事后追溯。但注意日志里不要记录完整的用户原始输入,避免把敏感数据写入日志。
即便如此,我仍然建议在架构层面尽量避免动态执行。如果产品经理要求“用户填写公式”,你就提供一组固定的函数和字段让用户配置,后端用 AST 求值;如果要求“用户提交自定义脚本”,你就应该把它当成一个真正的代码审查对象,而不是在底层 eval 一把梭。很多安全事故都始于一个“我只是临时用一下 eval”的念头。
这几年做开发和审计,我的体会是:eval 本身并不邪恶,它只是一个把数据变成代码的工具。但任何工具一旦把“用户数据”和“代码执行”之间的那条线打通,它就从普通的函数变成了攻击面。理解 eval 的原理,不是为了炫技,而是为了在写代码时清楚地知道:自己正在给系统打开一扇什么样的门。如果你非要用,请一定确保这扇门只对你完全信任的输入敞开。