Update — May 2026. Shortly after this was first written up, the same store was hit again. The reinfection did not come through a backdoor we missed — it came through the front door: an unpatched application-level flaw (a SQL injection in the Wishlist blockwishlist module chained to the Smarty MySQL-cache RCE, CVE-2022-36408) that our in-place cleanup contained but never closed. Removing the malware is not the same as closing the entry vector. The deeper, application-level remediation — virtual-patching the specific vulnerabilities and locking the origin down behind Cloudflare — is its own subject, covered in Advanced PrestaShop Hardening for Stores You Can't Upgrade Yet. The conclusion is unchanged: containment buys time; migration off 1.7.x is the cure.

This is a real incident from a PrestaShop 1.7.x store we recovered. Identifiers are anonymized, but the control flow, file locations, payload structure, and detection logic are preserved exactly. The point of writing it up is not to scare anyone — it is to give other shop owners and developers a concrete reference for what a modern Magecart-style attack on PrestaShop actually looks like from the inside, so that when you see the same shapes in your own files you recognize them in minutes instead of days.

The owner contacted us because the checkout page was showing a payment form they did not recognize. It looked like a Stripe Elements card form. The store does use Stripe, so the visual match was close enough that nobody had questioned it for almost two days. By the time we looked, the skimmer had been quietly copying card details from every customer who reached checkout. This article walks the investigation in the order it actually unfolded — first clue, server payload, exfiltration trick, the JavaScript, the root compromise, and finally the entry point — because the order is the lesson.

The first clue: a payment form that should not exist

Stripe Elements renders its card inputs inside cross-origin iframes. The parent page cannot read what the customer types into them — that isolation is the entire reason Elements exists, and it is what keeps a compromised storefront from skimming a properly integrated Stripe form. So the first question on any suspected skimmer is simple: are the card fields in an iframe, or are they raw inputs on the page?

The form we were looking at used raw, first-party HTML inputs:

Injected fieldWhat it tells you
<input id="cardNumber" onkeyup="smenu(this)">No iframe — the parent page can read every keystroke
<input id="cc_cid" onkeyup="smenu(this)">An onkeyup handler on every field, calling a custom smenu() function
<button onclick="processF()">PLACE ORDER</button>A custom submit handler that bypasses the real payment module

Three immediate tells: no iframes, a keystroke handler on every input, and a subtle language mismatch between the injected form and the rest of the store. The legitimate Stripe module was still installed and configured — the fake form had simply been painted on top of it. The benefit of knowing this one rule (real gateways use iframes; raw card inputs on a PrestaShop checkout are almost always malicious) is that any merchant can run the check themselves, today, with the browser inspector and no special tooling — see the indicators list at the end of this post.

Finding the server-side payload

A browser keylogger is only useful if it can send what it captures somewhere. We grepped the entire config/ directory for suspicious patterns and found the same block prepended to the top of every PHP file in it: a snippet that watched for a $_POST['order_llx'] field, and when it saw one, forwarded the contents via curl to two base64-encoded URLs with CURLOPT_SSL_VERIFYPEER disabled, then unset the field so nothing downstream noticed.

Thirteen files in config/ carried this block, and every one shared the same inode change timestamp — down to the same second. The attacker had scripted the deployment. The two base64 strings decoded to IP addresses of two servers on a large Asian cloud provider: the exfiltration endpoint. One file, settings.inc.php, had been replaced wholesale — only the malicious code remained — and the store kept working anyway, because on PrestaShop 1.7 the real database configuration lives in app/config/parameters.php, not in the deprecated settings.inc.php. The attacker either knew that or got lucky.

The two-stage exfiltration trick

The genuinely clever part of this attack is how the data leaves. Most Content Security Policies, ad blockers, and anti-skimmer extensions are built to catch the browser talking directly to an attacker's server. This skimmer never does that. The compromised store acts as a relay:

  • Step 1 (browser → store). The fake form's processF() handler POSTs the captured card data to / on the customer's own domain, in a field named order_llx. From the browser's perspective this is ordinary first-party traffic — same origin, no CORS, nothing for a third-party-only CSP to flag.
  • Step 2 (store → C2). The injected PHP in config/ catches that field server-side and forwards it via curl to the attacker's IPs. This is a server-to-server request the browser never sees: it does not appear in DevTools, in the network tab, or to a network admin watching the customer's connection.

That is the whole evasion. A network admin sees a connection to the legitimate store and stops investigating; a CSP that only blocks third-party scripts catches nothing. This same-origin relay is one of the harder Magecart shapes to spot from the client side. It is worth being honest that it is not the only shape — Sansec and other trackers continue to see browser-direct exfiltration, hidden server-side storage, and delayed-pull variants running in parallel, with no single dominant pattern — but the relay is the one that defeats most off-the-shelf client-side defenses.

The JavaScript skimmer, and the size delta that gave it away

The fake form itself lived in the theme. We grepped for the unique tokens we had already seen — cc_owner, processF, smenu — and found the injection appended to the theme's ps_shoppingcart.js. The file-size delta alone was the smoking gun: a clean ps_shoppingcart.js is around 2.7 KB, and the infected copy was roughly 340 KB. One 337 KB blob had been copy-pasted to the end of 74 different JavaScript files across the parent and child theme — every theme.js, every custom.js, every module front-end script in the theme's vendored module folders.

The payload was obfuscated but not cleverly: a hex-encoded string array (named _0x5aa5, 225 entries) referenced by index throughout, so that document[_0x5aa5[39]](_0x5aa5[158]) simply resolved to document.getElementById("cardNumber"). Decoding the array hands you the whole intent — entries for cardNumber, exp-date, cc_owner, cc_cid, the billing fields, the exfil field name order_llx, and the campaign tag Zhang. The skimmer assembles a pipe-delimited string of every captured field, hex-encodes it, base64-wraps it, and POSTs it as FormData to /. Several decoy fields (products_hash, amount_hash, billing_hash) ride along as random noise, so the POST reads like a routine analytics beacon in the logs.

The compiled-asset trap (a PrestaShop-specific one)

We replaced the infected ps_shoppingcart.js with a clean copy. The fake form still appeared. This is the trap that catches people cleaning a PrestaShop store specifically, so it is worth understanding precisely.

PrestaShop's CCC pipeline (Combine, Compress, Cache) bundles the front-office scripts registered for the current page — anything declared via registerJavascript without async/defer — into combined files like bottom-<hash>.js under themes/<theme>/assets/cache/. The infected scripts were registered exactly that way, so the malicious code had been baked into the compiled bundles while the source was still infected. Until we purged that cache directory, the browser kept receiving the skimmer from the compiled bundle no matter what we did to the source file. The fix was to replace the whole themes/ tree from a known-clean source and purge every asset cache — themes/<theme>/assets/cache/, var/cache/, and var/compile/ — plus any Redis or Memcached object cache. Anything less leaves the bundles regenerating from infected source.

The bigger problem: root compromise

While hunting for more skimmer payloads, we ran find / -perm -4000 -type f and got an unpleasant surprise: two ELF binaries sitting in the webroot, disguised with .js extensions. SUID bit set, owned by root, 1.7 MB each, identical SHA256 — and not JavaScript at all, but x86_64 ELF executables (file reported setuid ELF 64-bit LSB executable, statically linked, stripped). Four more SUID-root binaries were in /usr/bin with random names, plus one named pkexem — a one-letter typo on the legitimate pkexec, designed to slide past a casual ls.

This was the moment the incident changed category. Creating a SUID root binary requires root. PHP running as the site user cannot do it, no matter how empty disable_functions is or how loose open_basedir is. The mere existence of those files proved that root had been compromised, not just the web user — which means cleaning the webroot can never be the end of the job, because persistence can hide anywhere on the host. For a store owner the practical translation is blunt: at this point the only fully defensible end state is to reprovision from a clean OS image, migrate verified-clean code and data across, rotate every secret, and treat the original host as a temporary containment surface kept alive only long enough to keep serving customers.

The actual entry point: a web shell in index.php

We had cleaned the skimmer and pulled the backdoor binaries, but still did not know how the attacker first got in. The access logs showed one IP making POST / requests over a period of weeks, and the response sizes were the fingerprint:

Response sizeWhat the attacker was doing
~46 KBFull homepage — exploit probing with the wrong parameters
1–2 KBCommand output (ls, cat, whoami) — reconnaissance
~695 BFile-write confirmations — planting payload
~36 KBOne larger response, still far below a database dump

That distribution told us two things. First, POST / was itself a code-execution endpoint — ordinary homepage requests do not produce a steady stream of 695-byte file-write confirmations. Second, a full customer-table export would run to hundreds of kilobytes minimum, and nothing in the logs came close; the attacker was using the shell to write files, not to bulk-extract data. That mismatch is genuinely reassuring evidence to be able to show a worried owner, and it is the kind of finding the access logs give you for free if you know to read response size rather than just status code.

The endpoint led us to index.php. The live site's copy had been cleaned at some point (probably by an earlier recovery attempt), but a dev site in a sibling directory sharing the same docroot still held the original. It opened with an obfuscated loader: a single string literal acting as an alphabet, with the dangerous function names (create_function, assert, base64_decode) spelled out one character at a time by indexing into it — dodging any naive grep. The loader decoded a small base64 payload, built an executable PHP closure from it at runtime, and invoked it. The decoded payload was the web shell itself: any request whose product_id parameter matched a hardcoded MD5 hash got to eval() arbitrary PHP passed in the image_id parameter. The parameter names are deliberately chosen to look like ordinary e-commerce traffic in the logs.

One detail is worth its own sentence, because it is an accidental gift of upgrading: the loader depends on create_function and string assert() evaluation, both of which PHP removed in 8.0. On a modern PHP 8 host this backdoor would fatal-error on its own. Moving this codebase to PHP 8 would inadvertently break the shell — one more reason the real fix is forward, not sideways.

The timeline, and what it implies

We could not date the original web-shell plant — it predated the available log retention — but the rest reconstructs cleanly from file timestamps and access logs:

Day relative to discoveryEventEvidence
−60 or earlierWeb shell planted in index.phpPredates log retention
−25SUID-root binaries dropped in the webrootFile creation timestamps
−22SUID-root binaries dropped in /usr/binFile creation timestamps
−10 to −5Reconnaissance via the web shellPOST / responses, 695–1844 B
−2Skimmer JS deployed to 74 theme filesInode change timestamps
−2 (≈30 min later)Skimmer PHP deployed to 13 config filesInode change timestamps
0Owner reports the suspicious formVisual inspection

The 25-day gap between root compromise and skimmer deployment is the most telling line in that table. It is consistent with initial access and monetization being separate stages — access landed first, the card-skimming payload came weeks later. We cannot prove from this evidence alone whether the access was sold to a second group or simply queued by the same operator working through a list of targets, but either way the lesson for a defender is the same: the quiet weeks are not safety, they are a countdown. A store that catches the intrusion during those weeks loses nothing; a store that waits for the symptom loses every card processed in between.

Remediation, in the order that matters

Two constraints shaped this recovery, and they are the constraints most real stores face. The site was live and processing orders, so it could not go dark for hours. And a 1.7.x → 8.x/9.x upgrade is a multi-week project — module compatibility, theme refactoring, payment rewiring — not a one-day job. So the plan was containment and hardening now, with a clean rebuild staged in parallel. The full step-by-step runbook (evidence preservation, credential rotation, backdoor hunting, firewalling the C2 IPs, removing exposed admin tools, WAF tuning) is its own document — we keep it as the PrestaShop Security Hardening Checklist so it stays current. What is specific to this incident, and worth carrying away, is the order and a few PrestaShop-aware moves:

  • Preserve evidence before you touch anything. Snapshot the disk (or at minimum tar the webroot, /etc, and /var/log), archive the access and error logs, and sha256sum every suspicious file. You will want these for the post-mortem, for any payment-processor inquiry, and for the inevitable second pass when you find a backdoor you missed.
  • Stop the customer-facing bleeding first. Strip the PHP injection from the 13 config files, restore config/settings.inc.php from a clean copy of the exact same PrestaShop release (and keep DB/cookie rotation focused on app/config/parameters.php, where PS 1.7+ actually keeps that configuration), replace the entire themes/ tree from clean source, and purge every cache directory and OPcache — otherwise the compiled bundles keep serving the skimmer, as above.
  • Then hunt persistence across the whole host, not just the webroot. Because root was compromised, the dangerous artifacts can live in /etc/cron*, systemd timers and units, every user's ~/.ssh/authorized_keys, /etc/sudoers.d/, shell profiles, vhost files, and the MySQL mysql.user table plus any new events or triggers. Anything modified inside the compromise window is suspect.
  • Make the entry-point files immutable. chattr +i on index.php and all of config/*.php. A future web shell cannot rewrite an immutable file without first running chattr -i, which needs root. (Caveat: this only works on filesystems that honor extended attributes — ext4/xfs/btrfs are fine; some container overlay and ZFS setups are not.)

Once root has been touched, in-place cleaning is containment, not a cure — and if the reinfection vector is an unpatched application vulnerability rather than a missed file, the runbook above will not save you. That application-layer problem (virtual-patching specific CVEs, locking the origin behind Cloudflare, constraining the PHP runtime correctly) is exactly what Advanced PrestaShop Hardening for Stores You Can't Upgrade Yet exists to cover. And if card data did reach the C2 — assume it did until proven otherwise — your reporting obligations to the payment processor start immediately; the full sequence is in our Data Breach Response guide.

Indicators of compromise — what to grep for

If you run a PrestaShop store and want to check for this specific family, here is the high-signal set. Run large recursive greps against a snapshot or in read-only mode where you can — a heavy scan on a busy webroot can spike I/O and trip its own alerts.

Where to lookIndicator
PHP / JS sourcerg -n "order_llx|_0x5aa5|processF|smenu|__Pres_[il]dk" /var/www
Config-file injectionThe literal @deprecated 1.7$ar=[ prepended to files in config/
Obfuscated loaderseval(base64_decode(…)), assert(base64_decode(…)), or create_function(…base64…) chains
SUID backdoorsfind / -perm -4000 -type f 2>/dev/null (run as root)
ELF disguised as JSfind /var/www -name '*.js' -exec file {} + | grep ELF
Prepend persistenceauto_prepend_file / auto_append_file in .htaccess or .user.ini

And in the browser, on the rendered checkout: raw <input> fields with IDs cc_owner, cardNumber, cardExpiry or cc_cid (real Stripe/Adyen/Braintree forms use iframes — raw card inputs are the red flag), cookies named __Pres_idk or __Pres_ldk being set, and an index.php that opens with an obfuscated <?php $e4d6=-style single-letter-plus-hex variable immediately after the opening tag. The campaign tag Zhang (base64 Wmhhbmc=) shows up in the exfil payload, but it is too generic to trust alone — only weight it when it co-occurs with another indicator above. For deeper sweeps, point php-malware-finder, the public Sucuri/ESET YARA rules, or a file-integrity monitor (AIDE, Wazuh, OSSEC) at the webroot — but skip the Magento-specific scanners; their signatures will not match a PrestaShop-shaped attack.

What this incident is really about

The web shell was the wound. The skimmer was the visible symptom. Cleaning the skimmer without finding the shell would have left the door wide open to redeploy the same payload — or a smarter one — within days, which is precisely how the reinfection at the top of this post happened. The single most important habit this incident teaches is to keep pulling the thread: a fake card form leads to injected config files, which lead to a compiled-bundle trap, which leads to SUID-root binaries, which lead to a web shell in index.php. Stop at any layer above the last and you have cleaned a symptom, not closed an attack.

And if you are running PrestaShop 1.7.x today, the honest framing is this: you are a target, and the exploits used to plant the original shell are years old, well-known, and bundled into automated scanners. Migrating to a supported, fully patched PrestaShop branch — with all modules and themes updated and the host rebuilt and hardened — is the durable path. In the interim — realistically weeks or months for a non-trivial store — the hardening in the hardening checklist and the application-layer measures in advanced hardening for stores you can't upgrade yet do not make you invincible, but together they raise the cost of attack and — just as valuable — make an intrusion noisy enough that you have a real chance of catching it during the quiet weeks instead of after the cards are gone.

Official PrestaShop references

For official guidance, see PrestaShop's security alert on digital skimmers, the project post on a major PrestaShop security vulnerability, the 2025 note on SQL injection attacks and injected JavaScript, and PrestaShop's store security best practices. None of these report this specific incident, but they are useful official context for skimmer checks, credential rotation, and escalation obligations.

Share this post:
David Miller

David Miller

Over a decade of hands-on PrestaShop expertise. David builds high-performance e-commerce modules focused on SEO, checkout optimization, and store management. Passionate about clean code and measurable results.

Enjoyed this article?

Get our latest tips, guides and module updates delivered to your inbox.

Comments

No comments yet. Be the first!

Be the first to ask a question or share useful feedback.

Loading...
Back to top