Catching Magecart and card skimmers on WooCommerce: file integrity scripts vs MalCare
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
A Magecart skimmer is JavaScript injected into your checkout that copies card details as customers type them and sends them to the attacker. On WooCommerce it gets in through a core or plugin file, a theme, a must-use plugin, or a database option, and it often hides from logged-in admins. Catch it in three layers: compare every file with WordPress.org's official checksums and a baseline of everything else, query the database for injected scripts, and send a Content-Security-Policy header so the browser refuses to send card data to hosts you didn't list. Our tested Python script found all 10 planted changes on a real WordPress 7.1.3 and WooCommerce 11.2.1 install with no false positives, and in headless Edge an enforced CSP blocked all 5 exfiltration channels we tried. MalCare does the scanning on its own servers, covers files and database, adds a firewall and cleans up on paid plans: the better choice if nobody will read a nightly report.
A card skimmer doesn't crash anything. The checkout works, orders come in, payments clear, and for weeks or months every card number typed into your checkout is also sent to someone else. You usually find out when a card network or your payment processor tells you that your store is the common point of purchase for a batch of fraud. This guide covers how skimmers get into a WooCommerce store, three checks that catch them, and when to pay MalCare to do the watching.
We tested the scanner on our machine against the real WordPress 7.1.3, WooCommerce 11.2.1 and MalCare 6.76 releases from WordPress.org, checked against the live WordPress.org checksum APIs, plus a made-up premium plugin. Then we planted 10 changes in the places skimmers and backdoors use. The planted files were harmless stand-ins: the scanner compares checksums, so payload content doesn't matter, and real web-shell code gets flagged by antivirus (Windows Defender quarantined our first test file). We tested the SQL query on MariaDB 11.4 against WordPress-shaped tables, and the CSP in headless Edge 155. MalCare's features and prices come from its pricing page and WordPress.org listing, checked in October 2026; we didn't test MalCare's detection.
1. The short answer
| Check | Catches | Misses |
|---|---|---|
| File integrity (our script, or WP-CLI for core only) | Any change to core, wordpress.org plugins, themes, premium plugins, mu-plugins, root files; PHP in uploads | Skimmers stored in the database; a backdoor already present when you make the baseline |
SQL query on wp_options and wp_posts | Scripts injected into widgets, theme settings, header/footer and code-snippet plugins | Anything in files; scripts split up to avoid simple patterns |
| Content-Security-Policy, enforced | Card data sent to hosts not on your list, whatever injected the skimmer | Exfiltration through hosts you allowed, or by sending the browser to another page |
| MalCare (paid plans) | Files and database, scanned on MalCare's servers, plus a firewall and one-click cleanup | You're trusting a vendor's signatures and schedule (every 24 hours on Protect, hourly on Fortify) |
2. How a skimmer gets into a WooCommerce checkout
The details change between campaigns, but the pattern is stable. Recent WooCommerce campaigns reported by security researchers hooked into WordPress's wp_enqueue_scripts so their script only loaded at checkout, checked for the admin bar and removed themselves when an administrator was logged in, and replaced Stripe's card fields with a fake form. That last part matters: a hosted payment field (Stripe's Payment Element, PayPal's buttons) keeps card numbers out of your page, but a skimmer can still draw its own fields on top. Hosted fields reduce your PCI scope; they don't make your checkout page safe by themselves.
Three practical consequences:
- Checking the checkout while logged in proves nothing. Use a private window, or the checks below.
- The injection can be anywhere code runs. Minified core JS is a favorite because nobody reads it; must-use plugins don't appear in the normal plugin list; database options survive reinstalling every plugin.
- Removing the skimmer isn't the end. Whatever let it in (the vulnerable plugin, the leaked password) is still there until you find it.
3. Layer 1: file integrity against official checksums
WordPress.org publishes a checksum for every file in every WordPress release (api.wordpress.org/core/checksums/1.0/) and every version of every plugin it hosts (downloads.wordpress.org/plugin-checksums/). Comparing your files against those lists finds a single changed byte in a 300 KB minified file, with no signatures and no guessing. For everything WordPress.org doesn't cover (themes, premium plugins, must-use plugins, wp-config.php, .htaccess, .user.ini), the script keeps its own baseline of SHA-256 hashes and reports what changed since.
"""File integrity check for a WordPress/WooCommerce install, aimed at checkout skimmers and backdoors.
- Core files and wordpress.org plugins are compared against the official checksums (api.wordpress.org).
- Everything else in wp-content (themes, premium plugins, mu-plugins, drop-ins) is compared against a local
baseline of SHA-256 hashes made on the first run. Keep the baseline off the web server or it can be rewritten.
- Any PHP file under wp-content/uploads is reported: WordPress never puts code there.
Python 3.9+, standard library only. Read-only: it never changes a file.
Usage: wp_integrity.py /var/www/html --baseline /root/wp-baseline.json [--accept]
Exit code 1 when something is reported. Run --accept after a reviewed update to rewrite the baseline."""
import argparse, hashlib, json, os, re, sys, urllib.error, urllib.request
CODE = ('.php', '.phtml', '.phar', '.php5', '.php7', '.js', '.mjs', '.html', '.htm', '.htaccess')
# Triage hints only: legitimate minified code matches some of these, a clean file can't explain a checksum mismatch
HINTS = {'eval': r'\beval\s*\(', 'base64': r'base64_decode|atob\s*\(', 'fromCharCode': r'fromCharCode',
'exfil': r'sendBeacon|new\s+WebSocket|new\s+Image\(\)\.src', 'card fields': r'cc[-_]?(number|num|exp|cvv)|cardnumber|cvc',
'remote include': r'(include|require)(_once)?\s*\(?\s*[\'"]https?://', 'hex escapes': r'(\\x[0-9a-f]{2}){20}'}
PHP_EXT = ('.php', '.phtml', '.phar', '.php5', '.php7', '.pht')
# Root files WordPress doesn't ship but attackers edit (auto_prepend_file, redirects, DB credentials): baseline them
ROOT_TRACKED = ('wp-config.php', '.htaccess', '.user.ini')
SKIP_DIRS = {'cache', 'upgrade', 'wflogs', 'node_modules', '.git'}
def fetch_json(url):
req = urllib.request.Request(url, headers={'User-Agent': 'wp-integrity/1.0'})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
def digest(path, algo):
h = hashlib.new(algo)
with open(path, 'rb') as f:
for block in iter(lambda: f.read(1 << 20), b''):
h.update(block)
return h.hexdigest()
def hints(path):
try:
text = open(path, 'rb').read(4 << 20).decode('latin-1')
except OSError:
return ''
found = [k for k, p in HINTS.items() if re.search(p, text, re.I)]
return ' [' + ', '.join(found) + ']' if found else ''
def walk(root, rel=''):
for dirpath, dirs, files in os.walk(os.path.join(root, rel)):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for name in files:
yield os.path.relpath(os.path.join(dirpath, name), root).replace(os.sep, '/')
def header(path, field):
m = re.search(r'^[ \t/*#@]*' + field + r':\s*(.+)$', open(path, encoding='utf-8', errors='replace').read(8192), re.M | re.I)
return m[1].strip() if m else None
def check_core(wp, report):
version = re.search(r"\$wp_version\s*=\s*'([^']+)'", open(os.path.join(wp, 'wp-includes/version.php')).read())[1]
data = fetch_json('https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=en_US' % version)
sums = (data or {}).get('checksums') or {}
if not sums:
report('ERROR', 'no official checksums for WordPress %s' % version)
return version
for rel, md5 in sums.items():
if rel.startswith('wp-content/'):
continue # default themes and plugins: the baseline covers wp-content
p = os.path.join(wp, rel)
if not os.path.exists(p):
report('MISSING', 'core ' + rel)
elif digest(p, 'md5') != md5:
report('MODIFIED', 'core ' + rel + hints(p))
for rel in walk(wp):
top = rel.split('/')[0]
if rel in sums or top == 'wp-content' or rel in ROOT_TRACKED:
continue
# Unknown files inside wp-admin and wp-includes are never legitimate; at the root only code is reported
if top in ('wp-admin', 'wp-includes') or (('/' not in rel) and rel.lower().endswith(PHP_EXT)):
report('UNKNOWN', 'core ' + rel + hints(os.path.join(wp, rel)))
return version
def check_plugins(wp, report):
"""wordpress.org plugins against their published checksums; returns the folders the checksums cover."""
covered = set()
base = os.path.join(wp, 'wp-content/plugins')
for slug in sorted(os.listdir(base)) if os.path.isdir(base) else []:
d = os.path.join(base, slug)
if not os.path.isdir(d):
continue
mains = [f for f in os.listdir(d) if f.endswith('.php') and header(os.path.join(d, f), 'Plugin Name')]
version = mains and header(os.path.join(d, mains[0]), 'Version')
data = version and fetch_json('https://downloads.wordpress.org/plugin-checksums/%s/%s.json' % (slug, version))
if not data:
continue # premium or custom plugin: the baseline covers it
covered.add('wp-content/plugins/' + slug + '/')
files = data['files']
for rel, sums in files.items():
p = os.path.join(d, rel)
want = sums['sha256'] if isinstance(sums['sha256'], list) else [sums['sha256']]
if not os.path.exists(p):
report('MISSING', 'plugin %s %s' % (slug, rel))
elif digest(p, 'sha256') not in want:
report('MODIFIED', 'plugin %s %s%s' % (slug, rel, hints(p)))
for rel in walk(d):
if rel not in files and rel.lower().endswith(CODE):
report('UNKNOWN', 'plugin %s %s%s' % (slug, rel, hints(os.path.join(d, rel))))
return covered
def main():
ap = argparse.ArgumentParser()
ap.add_argument('wp', help='WordPress root, the folder with wp-config.php')
ap.add_argument('--baseline', required=True, help='JSON file with hashes of everything not covered by wordpress.org')
ap.add_argument('--accept', action='store_true', help='rewrite the baseline from the current files')
a = ap.parse_args()
found = []
def report(kind, what):
found.append(kind)
print('%-9s %s' % (kind, what))
version = check_core(a.wp, report)
covered = check_plugins(a.wp, report)
current = {r: digest(os.path.join(a.wp, r), 'sha256') for r in ROOT_TRACKED if os.path.exists(os.path.join(a.wp, r))}
for rel in walk(a.wp, 'wp-content'):
p = os.path.join(a.wp, rel)
if rel.startswith('wp-content/uploads/'):
if rel.lower().endswith(PHP_EXT):
report('UPLOADS', rel + hints(p))
continue
if not any(rel.startswith(c) for c in covered):
current[rel] = digest(p, 'sha256')
if a.accept or not os.path.exists(a.baseline):
json.dump(current, open(a.baseline, 'w'), indent=0, sort_keys=True)
print('baseline written: %d files' % len(current))
else:
old = json.load(open(a.baseline))
for rel in sorted(set(old) | set(current)):
if rel not in current:
report('DELETED', rel)
elif rel not in old:
report('NEW', rel + hints(os.path.join(a.wp, rel)))
elif old[rel] != current[rel]:
report('CHANGED', rel + hints(os.path.join(a.wp, rel)))
print('WordPress %s, %d wordpress.org plugin(s) verified, %d file(s) in the baseline, %d finding(s)'
% (version, len(covered), len(current), len(found)))
return 1 if found else 0
if __name__ == '__main__':
sys.exit(main())
Run it nightly from cron as a user that can read the site but not write to it, and keep the baseline somewhere the web server user can't write. A baseline the attacker can rewrite proves nothing:
# /etc/cron.d/wp-integrity: report to the admin mailbox only when something changed
[email protected]
30 3 * * * integrity python3 /opt/wp-integrity/wp_integrity.py /var/www/html --baseline /opt/wp-integrity/baseline.json >/tmp/wpi.txt || cat /tmp/wpi.txt
After you update a premium plugin or theme, run it once by hand, read what changed, and then run it with --accept to save the new state as the baseline. Updates to core and wordpress.org plugins need nothing: the script fetches the checksums for whatever version is installed. Output from our test after planting 10 changes:
MODIFIED core wp-includes/js/jquery/jquery.min.js [base64, fromCharCode, exfil, card fields]
UNKNOWN core wp-l0gin.php
UNKNOWN core wp-includes/class-wp-cache-helper.php [base64]
MODIFIED plugin woocommerce templates/checkout/form-checkout.php
UNKNOWN plugin woocommerce assets/js/frontend/checkout-helper.js [base64, exfil, card fields]
UPLOADS wp-content/uploads/2026/10/thumb.php
NEW .user.ini
NEW wp-content/mu-plugins/wp-sys-cache.php
CHANGED wp-content/plugins/acme-gateway/assets/checkout.js [base64, exfil, card fields]
CHANGED wp-content/themes/twentytwentyfive/functions.php [fromCharCode]
WordPress 7.1.3, 3 wordpress.org plugin(s) verified, 403 file(s) in the baseline, 10 finding(s)
The words in brackets are hints for triage, not verdicts. Unmodified jQuery contains fromCharCode too. What matters is the first column: an official file that doesn't match its checksum has been changed, whatever it contains.
Test results
| Check | Result |
|---|---|
| Clean install (about 6,100 files: core, WooCommerce, MalCare, Akismet, default themes) | 0 findings: the real releases match WordPress.org's checksums exactly |
Skimmer appended to core jquery.min.js | MODIFIED, with hints |
Script tag added to WooCommerce's checkout/form-checkout.php template | MODIFIED |
New PHP file in wp-includes, new PHP file at the root | UNKNOWN |
| New JS file inside the WooCommerce plugin folder | UNKNOWN |
PHP file in uploads/2026/10/ | UPLOADS |
| Must-use plugin that loads a script on checkout for logged-out visitors | NEW |
.user.ini with auto_prepend_file | NEW |
Changed theme functions.php, changed premium plugin JS | CHANGED |
wp-config.php, .htaccess, robots.txt, images in uploads | Not reported (config files are in the baseline) |
After --accept | Baseline findings cleared; the 6 core, plugin and uploads findings remain until the files are restored |
| Run time | About 10 s for a normal run. The first run, on freshly extracted files, took under 5 minutes, most of it, we believe, antivirus scanning every new file |
WP-CLI has wp core verify-checksums and wp plugin verify-checksums, which cover core and wordpress.org plugins and are a good start if you already use WP-CLI. The script adds themes, premium plugins, mu-plugins, root config files and uploads, and it never executes PHP from the site it's checking.
4. Layer 2: scripts hiding in the database
Widgets, theme settings, header/footer plugins and code-snippet plugins all store HTML or JavaScript in the database, and a skimmer stored there survives reinstalling every file. This query lists every option and post that holds a script tag or common obfuscation:
-- Options and posts that hold a script tag or common obfuscation. Change wp_ if your table prefix differs.
-- Expect a few legitimate rows (analytics in a header/footer plugin): anything else is a lead.
SELECT 'option' AS kind, option_name AS name, LENGTH(option_value) AS bytes,
REGEXP_SUBSTR(option_value, 'https?://[a-z0-9.-]+') AS first_url
FROM wp_options
WHERE option_value REGEXP '<script|atob\\(|fromCharCode|eval\\(|createElement\\(.script|(\\\\x[0-9a-f]{2}){10}'
AND option_name NOT LIKE '\_transient\_%' AND option_name NOT LIKE '\_site\_transient\_%'
UNION ALL
SELECT CONCAT('post:', post_type), CONCAT(ID, ' ', post_title), LENGTH(post_content),
REGEXP_SUBSTR(post_content, 'https?://[a-z0-9.-]+')
FROM wp_posts
WHERE post_content REGEXP '<script|atob\\(|fromCharCode|eval\\(|createElement\\(.script'
AND post_status <> 'trash' AND post_type <> 'revision'
ORDER BY bytes DESC;
Run it with wp db query < skim_db.sql or the mysql client. On our test tables (50,000 filler options plus a handful of planted rows) it took 0.16 s on MariaDB 11.4 and returned:
option ihaf_insert_header 126 https://www.googletagmanager.com
option widget_custom_html 109 https://cdn-jquery-static.example
option theme_mods_storefront 103
post:wpcode 2 Header snippet 71 https://cdn-jquery-static.example
option wc_sys_cache 59
The first row is legitimate: Google Analytics added through a header plugin. The other four are the planted injections, including one with no script tag at all (only atob) and one hex-escaped string. Transients, trashed posts and a YouTube embed weren't listed. Save the output when the site is clean; afterwards, any new row is a lead. Also look at your payment gateway settings now and then: older WooCommerce attacks simply changed the PayPal email address.
5. Layer 3: CSP, so the browser won't send the card anywhere else
File and database checks find a skimmer after it's planted. A Content-Security-Policy header limits what it can do while it's there: the browser refuses to load scripts from, or send data to, any host not on your list. We built a fake checkout with an inline skimmer that tried five ways to send a card number to another origin, and loaded it in headless Edge 155 three times:
| Header | What reached the attacker | Violation reports |
|---|---|---|
| None | All 5: external script, fetch, sendBeacon, image URL, WebSocket | None |
Content-Security-Policy-Report-Only | All 5 | connect-src, img-src, script-src-elem |
Content-Security-Policy (enforced) | Nothing | connect-src, img-src, script-src-elem |
Two lessons. Report-Only only tells you; it blocks nothing, so it's the first step, not the end state. And the policy allowed 'unsafe-inline' scripts, as most WooCommerce checkouts must because WordPress prints inline scripts, yet the enforced policy still stopped all five channels, because connect-src and img-src control where data can go. This is the policy we tested, as an nginx header limited to the checkout:
# http {} block: the header is only sent for /checkout/ (an empty value means nginx doesn't send it)
map $request_uri $checkout_csp {
~^/checkout/ "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.stripe.com; connect-src 'self' https://api.stripe.com; img-src 'self' data:; frame-src https://js.stripe.com; form-action 'self'; report-uri /csp-report";
default "";
}
# server {} block. Start with -Report-Only, switch to Content-Security-Policy once the reports are clean.
# A location that has its own add_header lines doesn't inherit this one: repeat it there.
add_header Content-Security-Policy-Report-Only $checkout_csp always;
We didn't run this nginx config itself; the policy is the one the browser test enforced. Run it in Report-Only for a week or two: the reports list every host your checkout really uses (Stripe's 3D Secure frames, analytics, a reviews widget), and you add the ones you trust. Check your payment gateway's documentation for its full list; Stripe publishes one. The limits: a skimmer can still send data through a host you allowed, or send the whole browser to another page with the data in the URL. CSP makes the common skimmer fail; it doesn't make the page tamper-proof.
6. If you find a skimmer
- Stop the bleeding: put the checkout into maintenance mode or switch to a redirect-only payment method while you work.
- Keep evidence: copy the changed files, the query output and web server logs before you clean anything. Your payment processor may ask for them.
- Restore from known-good sources: reinstall core and wordpress.org plugins from WordPress.org, premium plugins from the vendor, and remove the injected database rows. Then run the scanner and the query again.
- Close the way in: update every plugin, delete unused ones, check for admin users you don't recognize, and change WordPress, hosting, SFTP and database passwords, the salts in
wp-config.php, and payment gateway API keys. - Tell the people who need to know: your payment processor, and depending on where you and your customers are, a data protection authority. A skimmer that copied card numbers is a personal data breach.
On PCI: since March 31, 2025, PCI DSS 4.0.1 requires script authorization and change detection on payment pages (requirements 6.4.3 and 11.6.1). The SAQ A revision of January 2025 removed those two line items for merchants whose payment form is fully hosted in an iframe, but it added an eligibility criterion: you confirm your site isn't susceptible to attacks from scripts that could affect your e-commerce system. The checks in this guide are a practical way to back that statement up. Ask your acquirer or QSA which SAQ applies to you.
7. MalCare: the managed route
MalCare installs as a plugin that syncs your site to MalCare's servers, where the scanning happens. According to MalCare, "the scan runs on our servers, so your site doesn't slow down", and it covers both files and the database. That suits small VPS and shared-hosting plans where a full scan inside PHP competes with real customers for CPU. Plans per site per year, from MalCare's pricing page in October 2026:
| Plan | Price, 1 site | Malware scan | Firewall | Cleanup |
|---|---|---|---|---|
| Free | $0 | Every 7 days | Basic | No |
| Protect | $99/year | Every 24 hours | Advanced, plus bot protection | No |
| Repair | $299/year | Every 12 hours | Real-time | Automatic |
| Fortify | $499/year | Every hour | Real-time | Automatic, plus unlimited manual fixes |
- What you get beyond our scripts: someone else maintains the signatures, the firewall blocks known attacks before they land, and on Repair and above a detected infection is cleaned without you reading diffs at midnight.
- What to know: MalCare's firewall is plugin-based, so it runs on your server, unlike Sucuri's, which filters traffic before it reaches you. MalCare's own plugin is a wordpress.org plugin, so our script verified its files against WordPress.org's checksums in the test, and you can run both.
- Scan frequency matters for skimmers: a skimmer that's live for a week takes far more cards than one that's live for an hour. For a store taking real volume, daily is the minimum, which is the Protect plan.
8. Our opinion: trade-offs
- You or a developer will read a nightly report: the script plus CSP. Checksums are a stronger signal than signatures for files WordPress.org knows about, and the enforced CSP protects customers even on the day you miss the report. It costs nothing but attention.
- Nobody will read a report: MalCare. A check nobody reads is worth nothing. Protect at $99 a year gets daily scanning and a firewall; Repair adds the cleanup most owners can't do themselves.
- Heavy attack traffic, or you want the filtering off your server: Sucuri. Its firewall is a proxy in front of the site; see our carding attacks guide.
- Whatever you pick, add the CSP. It's the only layer here that works in the customer's browser, at the moment the card is typed.
Related: verified backups you can restore after a cleanup, scoring fraudulent orders before they ship and monitoring SSL, checkout and API health.
9. Tools
Top picks
| Tool | Role | Why it fits |
|---|---|---|
| Cloud malware scanning, firewall and cleanup for WordPress | Scans files and database on its own servers, so the store's server isn't loaded. Free plan to try it; daily scans from $99 a year. | |
| Proxy firewall, monitoring and malware cleanup | Filters traffic before it reaches the server, and includes cleanup. Readers get 25% off the first year. |
Alternatives: free tools and references
We don't earn anything from these links.
- Wordfence: a free plugin with a firewall and a malware scanner that also compares core and wordpress.org plugin files with the originals. It scans inside your server, so large sites may notice the load.
- WP-CLI verify-checksums: core and plugin checksum checks from the command line.
- MDN: Content Security Policy: how each directive works.
- Stripe's CSP requirements: the hosts Stripe.js needs.
- PCI Security Standards Council: PCI DSS 4.0.1 and the SAQ documents.
10. Pitfalls
- Making the baseline on a site that's already infected. The baseline accepts what's there. Make it right after a clean install or a cleanup, and treat the first run's list of non-wordpress.org files as something to read.
- Keeping the baseline on the web server. An attacker with write access can update it. Keep it read-only for the web server user, or on another machine.
- Testing the checkout while logged in. Many skimmers hide from admins. Use a private window.
- Leaving CSP in Report-Only. It only reports. Switch to enforcing once the reports are clean.
'unsafe-inline'in script-src isn't a reason to skip CSP. Our test showsconnect-srcandimg-srcstill stop the data leaving.- Cleaning without closing the way in. The same vulnerable plugin or leaked password will be used again within days.
- Nulled premium plugins. A "free" copy of a paid plugin is a common way skimmers get installed by the owner. The checksum check can't help: there's no official copy to compare against.
Check files against WordPress.org's checksums and your own baseline every night, query the database for injected scripts, and enforce a CSP on the checkout so a skimmer that slips through can't send card data to its own host. If nobody will read the nightly report, MalCare does the scanning off your server and cleans up on its paid plans; Sucuri moves the firewall in front of the site. In both cases, keep the CSP.