翻译自 Perfection Kills: State of function decompilation in Javascript by kangax

Javascript中的函数反编译的历史,现状和未来

  1. 理论

  2. 实践

  3. 现在的情况

    • 反编译的目的
    • 用户定义的函数
    • 函数的构造器
    • 绑定函数
    • 非标准的情况
    • ES6所增加的
    • Minifiers及预处理器
  4. 长话短说

图1

去发现那些在Javascript世界中被称之为"magic"的东西,总是一件有趣的事情。

我最近遇到的一个这样的例子是AngularJS的 依赖注入机制。我从来没有去熟悉过这个概念,但我觉得它在实践中看起来聪明又方便,虽然并不是特别的神奇。

它是干什么的?简而言之:通过函数的参数来定义所需的“模块”。像这样:

angular.module('App', [ ])
  .controller('Ctrl', function($scope, $timeout, $http) {
    ...
  });

注意$scope$timeout$http这些标识符。

啊哈,所以,它并非将它们作为字符串或变量或什么其它的东西来传递,而是将他们定义为代码的一部分。当然,为了“读懂”这代码,有件事不得不提。

函数反编译

我们在prototype.js中使用的那种实现$super的方法还是早在2007年?是的,就是它。后来实现它的方式来自于Resig的simple inheritance(用一个安全的方式)或其它地方。

看到像Angular这样一个现代化的框架使用函数反编译让我很惊讶。即使它不是Angular的专有依赖,但这个黑魔法已经让人觉得不习惯了很多年。我在2009年写了一些有关这个问题的东西

像这样本质上来说不标准并且在不同实现中也不一样的东西只能通过用户代理嗅探来进行比较。

但它是真的是这样吗?或者说,近日里事情并没有那么糟糕 ?我4年前研究过这一点 - 用了一大片的时间。当涉及到函数字符串表示形式时,将来会不会取得某种统一的实现?我是不是已经完全过时了?

出于好奇,我决定去看看目前的状况。函数反编译现在可以依赖么?我们究竟能够依赖什么?

首先。。。

理论

简单地说,函数反编译是将函数代码作为一个字符串来访问(然后解析它的内容或提取参数或其他)的过程。

在Javascript中,这是通过函数对象的toString()方法来实现的,所以fn.toString()String(fn)fn + ''或其他任何委托到Function.prototype.toString的用法都可以实现相同功能 。

这在Javascript中被认为是不可靠的原因是由于其不规范的性质 。ES5规范的一段著名引用这么说:

15.3.4.2 Function.prototype.toString ( )

返回一个依赖于实现的函数表达形式。这个表达符合一个FunctionDeclaration的语法。请特别注意在表达字符串中空格符,行终止,分​​号的使用是实现相关的。

当然,当某些东西是与实现相关的的时候 ,它必然会以各种可以想象的方式偏离轨道。

实践

..事实就是这样。比如你会认为这样的一个函数:

function foo(x, y) {
  return x + y;
}

..会被序列化成这样的字符串:

"function foo(x, y) {\n  return x + y;\n }"

它几乎确实这么做了,除了某些JS引擎可能会忽略掉换行。另一些引擎可能忽略掉注释。另一些会忽略掉那些“dead code”。另外一些则会包括进注释和(!)函数。而其它的则可能完全隐藏掉代码。

在以前,事情是非常糟糕的。例如在版本号<=2.X的Safari浏览器中,甚至不会检查函数声明的语法是否合法。如果使用像"(Inner Function)""[function]"或从NFE中舍弃那些标示符 将会是件疯狂的行为,这只是因为—

在以前,一些移动浏览器(黑莓,Opera Turbo)将完全隐藏代码(而代之以礼貌的"/ *源代码不可用* /"类似的注释 ),也许这是为了“节省”内存。真是一个公平的优化。

现在的情况

但现在又是什么情况呢?当然,事情肯定会变得更好。现在我们有着引擎的趋同,相对合理的WebKit的占有率,大量的标准化,和引擎性能的巨大增长。

事实上,事情看起来还不错。但是并不是所有的事情都很好,有些更加“好玩”的东西出现在我们的视野里。

我做了一个简单的测试页面 ,检查了函数和它们的字符串表示形式的不同情况。然后测试了它在桌面浏览器,包括那些非常“古老”的那些(IE6 +,FF3 +,Safari4 +,Opera9.6 +,Chrome浏览器),以及手机上 的表现,并观察了一些通用模式。

反编译的目的

了解Javascript函数反编译的不同目的,是非常重要的。

原生序列,内置函数和用户定义的函数在序列化上是不同的。例如,从Angular的角度来看,我们正在谈论的是用户定义的函数 ,所以我们不必关注本地函数是如何被序列化的。此外,如果我们谈论的只是获取参数 ,那需要处理的问题相比“解析”源代码就少得多了。

有些东西是更可靠的。 但是别人的东西,就少一些。

用户定义的函数

当涉及到用户定义的函数时,所发生的事情是相当一致的。

除了那些古怪的和垂死挣扎的环境,例如IE<9 — 这些浏览器有时会在字符串结果表示中包含那些函数周围的注释(甚至括号)——或Konqueror,它会在省略从new Function中产生的(即生成的函数)函数体的括号。

多数的差别都在空格 (和换行符)。有些浏览器(如Firefox<17)会从源代码中去除所有的注释,并删除“side”,无法访问的代码。

但是,不要高兴得太早,因为我们还没谈论未来会是什么样的呢...

函数的构造器

对于生成的函数(new Function(...))来说,事情会有些忙乱,但不会很麻烦。虽然大多数的引擎会给这些函数创建一个“anonymous”的标示符,但是对于空格和换行的处理是不一致的。Chrome将会在参数列表后插入额外的注释(额外的注释并不会影响什么,对么?)

new Function('x, y', 'return x + y')

事情会变成这样:

"function anonymous(x, y
/**/) {
return x + y
}"

绑定函数

在我所测试的每个引擎中,绑定(通过Function.prototype.bind)函数的表现与本地函数是一样的。是的,这意味着绑定函数在字符串表示中“丢失”了它们的源代码

"function () { [native code] }"

可以说,这是一个合理的做法,虽然有点“奇葩?”当你第一次看到它的时候,你可能会问:“为什么不直接使用“[绑定代码]”这样来表示呢?”

奇怪的是,有些引擎(例如最新的WebKit) 会保留函数的原始标识符,而另一些则不会。

非标准的情况

非标准的扩展是怎样的呢? 比如Mozilla的表达式闭包

var expressionClosure = function(x, y) x + y

是的,那些(非标准的函数)将像它们源代码一样被表示出来,没有函数体的括号(从技术上讲,这是不符合函数声明的语法的,但是Function.prototype.toString的MDN页面甚至都没有提及。 有些东西必须要进行修改了!)。

ES6所增加的

在我几乎已经完成了编写测试用例的时候,突然一个想法出现在我心里。等等,这些问题在EcmaScript 6里会是什么样的 ?

所有这些在这门语言中新加入的东西;让函数看起来不一样的新语法——类,生成器,不定参数,默认参数,箭头函数等功能。这些会不会影响到函数的序列化表示呢?

一些快速测试给出了答案 - 是的,它们会有影响。显而易见的是,Firefox24 +,ES6大队的引领者,向我们展示了这些新构造形式的字符串表示:

// Arrow functions
var fn = () => 5; // "() => 5"

// Rest params
function fn(...args) { } // "function fn(...args) { }"

// Default params
function fn(foo=1) { } // "function fn(foo=1) { }"

// Generators
(function *(){ yield 1 }); // "function *() { yield 1 }"

通过检查ES6规范我们进一步证实了这一点 :

将返回一个实现相关的此对象源代码的字符串表示。这个表示需要有FunctionDeclaration,FunctionExpression,GeneratorDeclaration,GeneratorExpession,ClassDeclaration,ClassExpression,ArrowFunction,MethodDefinition,或GeneratorMethod中的一个语法,这具体取决于对象的实际特性。需要特别注意的是对于空格、行终止符和分号在字符串表示中是如何被替换的,因为这也与实现相关。

如果对象是使用ECMAScript代码所定义的,并且返回的字符串表示形式是FunctionDeclaration,FunctionExpression,GeneratorDeclaration,GeneratorExpession,ClassDeclaration,ClassExpression,或ArrowFunction中的一种,则字符串表示必须是这样:如果使用eval来执行了字符串,并且是在一个等同于用来创建原始对象的词法上下文的上下文中使用eval,则将会产生一个新的但功能等效的对象。返回的源代码表示不能使用任何没有在原始函数的源代码中使用到的变量,即使这些“额外”的变量名确实原本就是在作用域中的。如果源代码字符串表示不符合这些规定,那么它就是一个在eval时将会抛出一个SyntaxError异常的字符串。

请注意ES6仍然将函数的表示留给实现自己决定 ,尽管它说明了不再仅检查是否符合FunctionDeclaration语法。我还发现了一个有趣的附加要求 —— “返回的源代码(表示)不能自由地使用任何没有在原始函数源代码中使用过的变量”(如果你在不到7次尝试中就理解了这一点,你可以得到加分!)。

我不太清楚这将如何影响未来的引擎和他们的表现,但有一点是可以肯定的,随着ES6的崛起,函数表示将不再只是一个后面跟着参数和函数体的可选的标识符。正在有一大堆新的东西将袭来。

那些已有的正则表达式将会再次被强制要求更新来应对这些变化。(话说我有说过这类似于UA嗅探吗? 嘘。)

Minifiers及预处理器

我还要提到几个陈词滥调,这些玩意从来没有和函数反编译好好相处过—— minifiers和预处理器

像UglifyJS这样的minifiers,和像Caja这样的预处理器/编译器倾向于自己调整这些地狱般的源代码,并重新命名参数。这就是为什么Angular的依赖注入与minifiers不能正常一起工作 ,除非你使用替代方法

也许这些并不是什么大不了的事,但它仍然是一个需要记住相关问题,不是吗?

长话短说

总结一下这些东西:看来函数反编译变得更安全了,但是——这取决于你的分析需求——它可能仍然还没有智能到你能够完全依赖它 。

你想把它用在你的应用程序/库中?

切记:

  • 它仍然还不是标准
  • 用户定义的函数一般看上去结果都很正常
  • 有些引擎很古怪 (特别是当它涉及到源代码的布局,空格,注释,死代码等
  • 要注意有可能存在的未来的奇怪引擎 (特别是那些需要保守处理内存/电量消耗的移动设备或其它不寻常的设备)
  • 绑定函数的字符串表示不会显示其源代码(但 有时会保留标识符 )
  • 你可能会遇到非标准的扩展 (如Mozilla的表达式闭包)
  • (冬天) ES6来了 ,函数现在可能会看起来与他们过去的样子非常不同
  • Minifiers /预处理器不是你的好伙伴

P.S. 那些被重写了toString方法和/或Proxy.createFunction的函数是一种特别的情况,在那些特殊的情况下,我们需要特殊的考虑一下。

特别感谢Andrea Giammarchi提供的一些移动端的测试(在BrowserStack上所没有的)。

如果你喜欢这个,欢迎捐款给我 :) Gittip / Flattr