eval() 不是魔鬼,只是被误解了
在整个 javascript 的语言体系中,我不敢说还有什么能比对 eval() 的误解更深的了。这个简单的函数将一个字符串当作一段 javascript代码执行,这也一直成为越来越多的审查及误解的源泉,在我的职业生涯中仅见。
“eval() 是邪恶的”这个说法最初要归因于 Douglas Crockford。他是这么说的:
"eval 函数(及其亲属,Function, setTimeout 和 setInterval)提供了访问 javascript 编辑器的通道。有些时候这个是必要的,但在大部分情况下它更容易产生极其糟糕的编码。eval 函数是 javascript 中最容易被滥用的特性。"
因为 Douglas 没有在他大部分的作品中标明时间,目前尚不清楚是否是他真正的首创了这个词,因为在 2003年发表的一篇文章中也使用了这个词但却并没有提及他。不管怎样,现在人们在代码看到 eval 就像看到汇编时代的 go-to 语句一样,不知道他们是否真的理解了它的用法。
尽管流行的理论(同时也是 Crockfor 的坚持),仅仅存在 eval() 并不能说明问题。使用 eval()不会自动向你开放跨域脚本攻击(XSS),也不意味着一些你不了解的挥之不去的安全漏洞。像其他工具一样,你需要知道的是如何正确的使用它,但即使你使用的不正确,潜在的危险仍然相当低的,可以接受。
滥用
在 “eval 是魔鬼”创造出来的时候,这个短语就成为了人们滥用的源泉,这些人大部分是那些不了解 javascript 的语言特性。使人惊讶的是这些滥用不是来性能或是安全有关,而是在于不理解或是不会构造 javascript 中的引用。假设,你有几个表单输入,包含一个数字字段,如“option1” 和 “option2”,常见的用法是:
function isChecked(optionNumber) { return eval("forms[0].option" + optionNumber + ".checked"); } var result = isChecked(1);
在这种情况下,开发者试图用 forms[0].option1.checked, 但并不知道怎么样用 evla() 办到。你会看到那些有着10年或以上开发经历的程序员写的代码中有很多像上面这样的写法,这些时候他们往往还不理解怎么正确的使用这门语言。这里函数 eval 的用法是不正确的不是因为它不是必须的而是因为这种写法很不好。你可以很简单重写这个函数:
function isChecked(optionNumber) { return forms[0]["option" + optionNumber].checked; } var result = isChecked(1);
在大多数涉及这种性质的情况下,你可以调用 eval 来代替上面的写法,只需要通过括号来构造正确的属性名称(也就是说,毕竟这也是它存在的原因之一)。那些早期写博客谈论 eval 滥用的人,包括Crockford,讨论的大多就是这种模式。
可调试性
避免使用 eval 的一个好的理由是其缺乏调试功能。直到最近,如果代码中出现问题还是不可以进入 eval() 语句的单步调试状态。这就意味着你的代码进入到一个黑箱状态然后直接输出。 Chrome 的开发者工具现在已经可以调试 eval()过后的代码,但使用过程仍然是痛苦的。你必须等到执行过一次并且出现在源面板之前。避开 eval() 过后的代码能使调试更加容易,让你查看和跟踪代码时更加简单。但这不会使 eval() 变得邪恶,只是不可避免的一点是在正常的开发流程中会出现一点小问题。
性能
另一个对 eval 的打击来自其对性能的影响。在较老的浏览器中,你的代码会被两次编译,也就是说你的代码被编译过后,eval()语句里的代码还要被编译一次。在一些没有内置 javascritp 编译引擎的浏览器中,程序的性能会慢10倍(甚至更糟)。
虽然现在出现了新一代的 javascript 编译引擎, eval()还是存在一些问题。大多数引擎运行代码的方式有两种:快速路径或者慢速路径。快速路径的代码是稳定的并且是可预测的,因此被编译后执行速度更快。慢速路径代码不可预测的,因此很难进行编译并且在执行过程中还需要再编译[3]。如果你的代码中出现了 eval() 那就意味着代码是不可预测的,因此也需要在编译器中运行——运行起来就像是在“老浏览器”中的速度,而不是“新浏览器”的速度(再一次,一个10倍的差异)。
另外,eval()的存在使 YUI 压缩工具不能在调用 eval() 函数域的时候替换其内的变量名称。因为 eval() 可以直接访问任何一个变量。重命名它们将导致错误(其他工具像 Closer Compiler 或者 UglifyJS 可能仍然会替换这些变量名——最终也会导致错误)。
所以在使用 eval() 的时候,性能仍然是一个大问题。再一次,这也很难让它变得邪恶,只是一个需要谨记的警告而已。
安全性
一旦讨论 eval() 的时候,一个必打的王牌就是安全性。通常这些谈话会从 XSS 攻击开始,然后讨论 eval() 是怎样将你的代码开放给了它们。表面上看,这些困惑是值得理解的。因为通过 eval() 的定义,它可以在一个页面上下文中执行任意的代码。这时如果你将用户的输入直接拿来执行是很危险的。但是如果你的输入不是来自用户的,还有什么真正的危险吗?
我已经收到了不止一个人来投诉我写的 CSS 解释器的一段代码,这段代码中就是使用了 eval()[4]。这段有争议的代码使用 eval()将一个字符串标记从 CSS 变成 javascript 的字符串的值。为了简化我设计的字符串解析器,这是最简单的方法去得到真正的字符串值。到目前为止,还没有人能够或者愿意生成一个攻击场景来让这段代码陷入麻烦,这是因为:
1. 执行过 eval() 过后的返回值来自于这个命令的发起者;
2. 命令的发起人已经验证过并且证明这是一段有效的字符串;
3. 代码的执行基本上都在命令行;
4. 即使是在浏览器端执行,这段代码也是被封装在一个闭包内,不能被直接调用。
当然,这段代码有一个直接访问命令行地址的原语,这也让问题有点复杂。
设计在浏览器端执行的代码需要面临许多不同的问题,但是,eval() 的安全显然不在其中。再一次要说明的是,如果你正在使用用户的输入并且将其作为 eval() 的执行参数,那么你就是在自找麻烦。千万不要这么做。但是,如果你调用 eval() 时的输入是经过你自己控制的并且不让用户更改,那么这样就不会出现安全问题。
最近这些天最常见的被引用来攻击 eval() 的向量来自于从服务端返回的 eval()ing 中的代码。这个模式通过引入著名的 JSON 而开始流行起来,迅速流行起来的原因是它可以快速的通过调用 eval() 将其转成 javascript 的代码。事实上,Douglas Crockford 自己也在他的开始使用 JSON 的过程中运用可 ecal() 函数,这也要归功与 eval() 的转换速度。他也确实添加一些判断保证里面真正没有可执行的代码,但实际上这就是基本的 eval()。
目前,为了这个目的,大多数浏览器开始内置了 JSON 的解析功能,尽管还是有些人仍然采取调用 eval() 函数的办法来获取任意的 javascript 可执行代码,这也是一个延迟加载的策略。对于这一点,一些人认为这才是真正的安全漏洞。如果中间人攻击法再改进一点,那么你将会在页面上执行任意攻击者的代码。
中间人攻击法也常用来攻击 eval() 潜在的危险性,打开了安全的缺口。然而这点却不是我所关心的,因为如果你连服务区端返回的数据都不能相信,那么也意味着你不能信任任何东西了,任何东西都有可能是坏的。中间人攻击法有很多方法将任意的代码嵌入到你的页面中:
1. 通过加载 javascript 的<script src="">返回攻击者控制代码;
2. 通过 JSON-P 请求返回攻击者控制代码;
3. 通过一个 eval()ed 的 Ajax 请求返回一个攻击者控制代码。
此外,这种攻击可以轻松的窃取用户的 cookies 和用户数据,而不用去修改任何东西。更不用说一些通过钓鱼网站返回的攻击者可控制的 HTML 和 CSS 的可能性。
总之,eval() 并不会使你更容易被中间人攻击法攻击,加载外部的 javascript 标签都会有这个风险。如果你不能信任从服务器返回来的数据,那么不管从哪点上看你的代码有比 eval() 更大的问题存在。
总结
我不是在说你应该在代码中到处开始使用 eval()。事实上,很少有比较的好的用例适合用在 eval() 上,不可忽视的肯定会存在的一些问题是代码的清晰度,调试性及代码的性能。但是在一个能够用到 eval() 的场合还是不要去吝啬(大胆)的使用 eval()。尽量不要在一开始就使用,但在使用 eval() 的过程中也不要被任何人说你的代码更加脆弱或是更不安全就吓到。
参考:
- About JSLint by Douglas Crockford (JSLint)
- Eval is evil, Part One by Eric Lippert (Eric’s blog)
- Know Your Engines by David Mandelin (SlideShare)
- eval() usage in my CSS parser by me (GitHub)