V8 Snapshot / Nw.js Source Protection 研究笔记

zsx in 代码分析 / 4 / 14035

刚刚在深圳的0CTF/TCTF全球总决赛结束,然后本蒟蒻果断地打出了GG,大佬们太强了。成功达成国际赛不当倒一的目标,也算不虚此行。

我有相当一部分时间花在了nwjs这一题,虽然最后没解出来。更令人难过的是,这一题我本来想在RCTF 2018里出的,因为发现难度太高,因此放弃出题,没曾想竟然还能被考到了。这里写一篇博客大概记录一点阶段性成果。另外顺便膜一下刘大佬的暴搜思路。

以下代码,我使用的Nwjs版本为v0.30.5,v8版本为 6.6.346.32。

首先是,什么是Nw.js的Source Protection。
在Nwjs的官方文档中,有这么一个 Protect JavaScript Source Code。经过它,也就是 nwjc 的编译之后,会变成在一般意义上人类不可读的二进制文件。

而我们可以阅读一下nwjc的代码,能轻易发现,实际上这是V8 Snapshot文件。

v8 Snapshot内部,保存着被编译成V8字节码的JavaScript代码。因此,我们的任务很明确,就是从v8 Snapshot文件还原出原本的逻辑,进而逆向出JavaScript。然而,关于V8 Snapshot,因为其是内部数据结构,因此它几乎没有任何文档。当然,更悲惨的是,V8 Bytecode也几乎没有文档。天知道这玩意怎么弄下来的。两份悲惨合在一起,当然……是变得更悲惨了啊。(对了,v8升级个版本就改API,让人愉悦)

那我们首先先看看字节码存在V8 ByteCode的哪个角落吧。通过nwjc --print-bytecode,或者node --print-bytecode来执行 JavaScript ,可以轻易看到相关字节码。

由此我们可以看出一部分的十六进制与字节码之间的对应关系,当然,也可以通过多函数对比来大致看到每个函数在Snapshot中的存放方式。这里很容易看出,以00 40 02为起始点,以 return 的字节码 a2 配合 00 作为终止点。这是每一个函数字节码存放的位置。

知道字节码大致的存放位置之后,我们就要想办法自己写脚本,让它变得可读。v8 的每一个版本的字节码定义都不一样,因此我们需要针对特定的 v8 版本来进行处理。经过在这里的调试,我确认字节码引自bytecode.h.

接着,我们就可以写这么一段代码了。以下代码是在比赛期间写的,无任何可读性,仅供临时使用。另外它不支持长立即数(这里的指令较为特殊)。
https://gist.github.com/zsxsoft/95647a309642074df0194d39f0dbce94

执行结果如图。另外,我们还可以从字节码的定义和--print-bytecode的执行结果来猜出:

  1. v8 内有 1 个 ACC,当字节码定义的第一个参数为AccumulatorUse::kWrite或是AccumulatorUse::kReadWrite时,其的返回值将会被放置到 ACC 内。
  2. v8 字节码的定义中,参数类型为OperandType::kImm,代表此处参数为立即数。
  3. 参数类型为OperandType::kReg,代表此处参数为寄存器编号。
  4. 参数类型为OperandType::kRegList,代表此处参数为某个起始寄存器的编号,下一个参数即为OperandType::kRegCount,即使用的寄存器的个数。

再回来,我们还可以继续猜测部分指令的行为。如:

  1. Ldar -> Load Data to ACC,把某个值从寄存器读到ACC里。
  2. Star -> Save data from ACC,把某个值从ACC保存到寄存器中。
  3. LdaSmi -> Load Immediate to ACC,把某个立即数保存到ACC里。

由此,字节码部分的逆向基本就结束了。剩下的没别的,就是人肉活动了。

然而,并没有这么简单。正常的代码中,总是不可避免地有着各种数据,正如ELF或者PE都有一个数据区一样,v8 Snapshot也有这么个东西。在 v8 的编译参数中打开v8_enable_object_print= true,即可在--print-bytecode中看到Constant pool。网上的v8字节码教程均无一例外开启了它,然而文章里提都没提一嘴=_,=

LdaGlobalLdaNameProperty等会调用OperandType::kIdx的字节码,会从这个Constant pool里查找相应的常量。包括但不限于变量名等信息,都会存储在这个常量池内部。

我们再看看hexdump。

红色为“看起来像”的数据区,绿色为代码区。根据内存地址和文件内容,我们可以很悲惨地发现,对于这些数据v8并没有开辟一个公共的区域,基本就在每个函数的前面部分。并且,很容易能发现,字符串以01 10 91 c2开头,插四位没用的,padding长度以后再padding明文字符串。然而,这只是当前函数涉及的字符串类型的常量而已。我们仍然不知道, 包括Int8Array、Int等类型的存储方式。更关键的是,这里每个字符串只出现一次,不会再写入常量池。同时,最令人讨厌的是,我们很容易发现除了 01 以外,剩下的东西我们都不知道是什么东西。我魔改v8后确认,此处二进制并不与操作对应,如图所示。

因此,我们很难分析出相关格式。怎么办呢。那就魔改一个读取,看看v8到底是如何读取 Snapshot 的。在我上文的Gist地址内有nwjc-load.cpp,是我参照evalNWBin写的,修改BUILD.gn拿去丢给ninja编译就行。

接着就打点吧。很容易找到打点区域:

把需要的 bin 丢回 nwjc-load,输出结果:

注意此处,09 代表反向引用,那就是它了。由以上方式,即可推断出每个函数的所有常量、类型以及对应的读取方式,也能知道函数之间到底如何互相调用的。当然,我们也可以逆向出变量名等重要信息(本题变量名还mangle了,丧心病狂)。而我卡在这里,一直到写博文时才想起来到底应该怎么解读这玩意儿。

解读出来以后,剩下的就是单纯的暴力工作了。据说本题最后是个 xtea 算法魔改,在常量池里能找到该找的所有东西。另外这题竟然还有坑点,真是绝了。这种差一点就做出来的感觉,果然我还是太菜了。

如果本文对你有帮助,你可以用支付宝支持一下:

Alipay QrCode
李云滨 at 2018/7/11[回复]
ADB把sw.js拦截了,文章列表出不来。
zsx at 2018/7/15[回复]
sw.js是ServiceWorker,被拦截也不影响文章啊……而且为啥ADB会拦截这个
云滨 at 2018/7/16[回复]
刚看了一下,又看不到了,屏蔽脚本变成了social-share.min.js,分享按钮,可能和我的ADB屏蔽项开的太多有关吧。文章列表还是显示不出来。
大佬真是什么都会,cpp逆向安全phpjs无所不能