很多开发者第一次接触 eval 命令时都会觉得它不过是一个“把字符串变成命令”的小工具。我在刚开始写 shell 脚本时也这么想直到有一次线上脚本因为一行 eval把原本正常的变量展开搞得面目全非我才意识到这个看似简单的关键词背后藏着完整的命令处理流程。后来我又在 Python、JavaScript、PHP 里碰到同名的 eval 家族才彻底明白动态执行是一把极其危险的双刃剑。这篇我就完整聊聊 eval 命令在不同环境下的行为原理、常见使用场景、隐蔽的坑以及为什么在今天的代码审计和安全竞赛比如 CTFHub 上的 eval 执行类题目里eval 几乎总是重点排查对象。如果你是刚学 shell 的运维、写过几年业务代码的后端同学或者正在刷 CTF 入门题这篇文章应该能帮你把这个知识点彻底打通。1. eval 命令的身份它不是一个命令而是一套执行机制1.1 系统里的 evalshell 内建命令先明确一个容易混淆的概念在 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展开后已经是字符串$HOMEshell 不会再去展开字符串内部的那个$。但如果写成eval echo $streval 会把echo $HOME重新交给 shell这时$HOME被展开成真实路径输出结果就是你的家目录。所以 eval 的核心能力是让“已经呈现为字符串的 shell 语法”重新获得语法意义。这个特点也让 eval 在脚本里常被用来处理“变量中的变量”、动态拼接命令、读取特殊格式的配置文件等场景。但它的代价也很明显如果你拼接的内容里有不该被展开的部分或者有来自用户输入的内容行为会变得非常难以预测。1.2 各编程语言里的“eval 家族”不同名字同一个灵魂离开 shell我们会发现几乎所有动态语言里都有 eval 的影子。它们字面意思一样都是 evaluate 的缩写核心逻辑也很一致接收一段代码字符串在当前运行时环境里把它解析并执行。语言形式执行内容返回值行为Shelleval cmd任意命令返回最后一条命令的退出状态Pythoneval(expression)仅限表达式返回表达式的值Pythonexec(code)语句块不返回值仅执行JavaScripteval(code)代码字符串返回最后一个表达式的值PHPeval(code)PHP 语句返回 null 或捕获的值注意它不是函数Luaload(code)/loadstring(code)代码块/表达式返回函数调用后执行Rubyeval(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、$(...)、通配符、管道符都可能再一次获得语法能力。看一下经典的位置参数例子n2 set -- a b c eval echo \$$n一步步拆解先看命令行eval echo \$$n。第一个反斜杠把第一个$转义成字面量$n展开为数字2。所以传给 eval 的参数字符串是echo $2。第二次扫描时$2被展开为b最终输出b。如果不是用 eval直接写echo \$$nshell 只会把第一个$转义第二个$n展开成 2最终输出的是$2这个字面文本完全不是同一个结果。这也是为什么 eval 被称为“可以实现间接引用”的工具。你可以在不知道变量名的情况下通过另一个变量保存变量名然后用 eval 取到目标变量的值。现代 Bash 里推荐用${!var}或declare -n但在老脚本里eval 几乎是唯一选择。2.2 拼接带引号参数的经典陷阱eval 的另一个典型用途是构造带引号的命令行。假设你想执行的命令是printf %s\n hello world如果你先把这个命令放进一个变量再直接$cmd执行问题就来了。因为$cmd展开后引号只是普通字符shell 不会再重新解析它们。结果就是printf收到了多个参数世界完全不一样。而 eval 可以解决这个问题cmdprintf %s\\n \hello world\ eval $cmdeval 让引号在第二次扫描时重新获得边界意义命令就正常了。也正因为如此eval 经常出现在一些需要动态构造复杂命令的脚本里。但我必须说这个用法非常不建议在新代码里出现。因为只要你开始用 eval 拼命令就在跟引号转义做斗争。今天你费劲把命令拼对了明天用户输入里多一个空格、一个分号或一个反引号整个脚本的行为就不可控了。拼命令这件事Bash 有更好的工具数组。2.3 现代 Bash 里替代 eval 的干净写法与其用字符串拼命令再用 eval 去“救”不如从一开始就用数组保存参数args(printf %s\n hello world) ${args[]}数组能把每个参数都保留原样不需要引号二次解析也不会有分词问题。如果目的是根据条件选择执行不同命令可以用函数加casecase $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.loadsPHP 里是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, modeeval) 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有专门的解析器数据格式错误用法正确用法JSONeval(( json ))JSON.parse/json.loads/json_decodePython 字面量eval(data)ast.literal_eval(data)YAMLyaml.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 的原理不是为了炫技而是为了在写代码时清楚地知道自己正在给系统打开一扇什么样的门。如果你非要用请一定确保这扇门只对你完全信任的输入敞开。