Anatomy of a compromise: a Magento skimmer hiding behind an image URL
Most card skimming on Magento in 2019 was JavaScript in the checkout page, the Magecart style. This one was a few lines of PHP on the server instead. That makes it invisible to anything that only looks at what the browser receives, and very easy to miss in a code review if you are not looking for it.
What it did
On every request, if there was any POST data at all, it:
- Flattened the whole
$_POSTarray into akey=value&key=valuestring, walking into nested arrays. Magento posts card fields aspayment[cc_number],payment[cc_cid]and so on, so nested arrays were the point. - Gave the visitor a random id in a cookie, if they did not have one already, valid for about a day. This let the operator stitch together the separate posts from one visitor: login, billing address, then payment.
- Base64-encoded the string and sent it, with the shop's host name and the visitor's id, as a POST to a remote URL ending in
.jpg.
The shape of it, defanged:
if (count($_POST) >= 1) {
$data = flatten_post('', $_POST, 0);
// set or reuse a visitor id cookie, then POST base64($data) to hxxps://<remote-host>/<logo>.jpg
}
Because it caught every form, it collected customer logins and addresses as well as card details on any page that took them.
Why the image name
The exfiltration went out with file_get_contents() and a stream_context_create() POST context, so there was no curl call to grep for. And if anyone did glance at outbound traffic or a proxy log, a request for something like a payment provider's logo .jpg looks harmless. The remote "image" was a script that took the POST body.
Sucuri wrote up a JavaScript variant of the same idea in 2017, also sending data to a URL dressed as a JPG.
Finding code like this
It is plain PHP with no obfuscation, which is exactly why signature scanners can miss it. Look for the combination of things it has to do.
Files that both read $_POST and build an outbound HTTP request:
cd /var/www/example.com
grep -rlE --include='*.php' --include='*.phtml' 'stream_context_create|http_build_query|curl_init' . \
| xargs grep -l '\$_POST'
On Magento that gives a manageable list; most of core and the payment modules use the request object rather than $_POST directly, so anything in a template, a theme or index.php that touches $_POST deserves a look.
Files that set cookies with names nothing in your code uses:
grep -rn --include='*.php' --include='*.phtml' 'setcookie(' . | grep -v '/lib/'
Files changed recently, bearing in mind that timestamps can be faked with touch:
find . -type f \( -name '*.php' -o -name '*.phtml' \) -mtime -30 -not -path './var/*'
And compare core against a clean copy of the same Magento release, as in the other parts of this series. Any core, library or index.php file that differs needs reading.
Clean-up
- Remove the code and replace any core file it lived in with a clean copy.
- Look for the way in. Code like this does not write itself; there will be a webshell, a stolen admin or FTP login, or an unpatched hole.
- Assume every customer who submitted a form while it was live is affected, and treat it as a card data breach: tell your acquirer or payment provider, and get advice on notification.
- Rotate admin, database, FTP and SSH credentials.
What would have prevented it
- Patching. Magento shops that were behind on security patches were being broken into in bulk.
- Card fields that never touch your server. A hosted payment page or the provider's hosted fields means there is no card number in
$_POSTto steal. - Restricting outbound connections from the web server. A shop needs to talk to its payment gateway, its mail relay and not much else. With a default-deny outbound rule, the POST to the remote host would have failed.
- If nothing in the shop fetches remote URLs with PHP's file functions, set
allow_url_fopen = Off. It would not stop a skimmer that usescurl, but it closes the route this one used. - File integrity monitoring on the code base, so a changed core or template file raises an alert the same day.