A compromised OpenCart store rarely announces itself. There is no defacement and no ransom note — only a few details that look slightly wrong. An admin account nobody remembers creating. A PHP file in a directory that should hold nothing but JPEGs. A burst of small orders that never reach a paid status. Attackers who get into e-commerce want to stay, because a quiet foothold pays better than a loud one.
Below are the five signals we see most often when an owner asks us to look, each with the command or query that turns a suspicion into an answer. Every check is read-only. Run all five before you change anything: the first cleanup usually destroys the timestamps that tell you how far back the problem goes.
1. An admin account nobody created
The oc_user table is the shortest list in your database and the fastest thing to audit. Read every row, not only the recent ones: renaming a dormant account keeps the user count unchanged and attracts less attention than adding one.
SELECT user_id, username, user_group_id, status, date_added
FROM oc_user
ORDER BY date_added DESC;
SELECT user_group_id, name, LENGTH(permission) AS perm_size
FROM oc_user_group;
SELECT api_id, username, status, date_added, date_modified FROM oc_api;
SELECT * FROM oc_api_ip;Three things matter here. A user_group_id of 1 is a full administrator, whatever the username suggests. A permission column much larger than the others usually means somebody widened a low-privilege group instead of creating a visible admin. And oc_api is the account most owners forget: an API user with status 1 and no row in oc_api_ip is a login that never appears on the user list.
2. PHP files where PHP has no business
OpenCart writes uploads to image/catalog and system/storage/upload. Neither should ever contain executable code, and neither should system/storage/logs or cache. One small PHP file in any of them is the classic persistent backdoor: it survives password changes, theme reinstalls and most of what people mean by cleaning a site.
cd /var/www/store
find image system/storage -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.phar' \) -printf '%TY-%Tm-%Td %p\n'
find . -type f -name '*.php' -newermt '2026-07-01' | head -50
grep -rlE 'eval\(|base64_decode\(|gzinflate\(|assert\(' --include='*.php' catalog admin system imageTwo caveats from real cleanups. Pattern matching finds lazy backdoors, not careful ones: a file that writes a request parameter to disk contains no suspicious keyword. And timestamps lie — touch is one command. The comparison that holds up is against a clean copy of your exact release.
cd /tmp
curl -LO https://github.com/opencart/opencart/archive/refs/tags/4.0.2.3.zip
unzip -q 4.0.2.3.zip
diff -rq opencart-4.0.2.3/upload /var/www/store | grep -v 'system/storage'Everything diff reports under catalog, admin and system is either your own customization or somebody else's. On a store with no core edits that list should be almost empty, and every line on it should have an owner you can name.
3. Orders that arrive but never get paid
OpenCart writes the order row before the gateway answers, with order_status_id = 0 — the missing orders in the admin filter. A few a day is ordinary abandonment. Hundreds an hour from a narrow set of addresses is card testing: someone is using your checkout to find which stolen cards still work, and you pay for it in gateway fees and in a fraud ratio that can cost you the merchant account.
SELECT DATE(date_added) AS d, COUNT(*) AS n
FROM oc_order
WHERE order_status_id = 0 AND date_added > NOW() - INTERVAL 14 DAY
GROUP BY d ORDER BY d;
SELECT ip, COUNT(*) AS n, MIN(date_added) AS first_seen, MAX(date_added) AS last_seen
FROM oc_order
WHERE date_added > NOW() - INTERVAL 7 DAY
GROUP BY ip HAVING n > 20 ORDER BY n DESC LIMIT 20;oc_order also stores forwarded_ip, user_agent and accept_language. A block of orders sharing one user agent and one language string across dozens of addresses is automation, not customers. This is not always a compromise — often the checkout is simply being used as a free testing tool — but it belongs on the list, because the response is a security decision either way: rate limiting in front of the payment route and a challenge before payment.
4. Your storefront serves someone else's JavaScript
A card skimmer does not need a backdoor file. It needs one place where a fragment of HTML is stored and printed on every page, and OpenCart offers several: theme templates, layout modules, and the settings table itself. Owners inspect the file system and forget that a value in oc_setting is rendered just as faithfully.
SELECT store_id, `code`, `key`, LEFT(`value`, 120) AS preview
FROM oc_setting
WHERE `value` LIKE '%<script%' OR `value` LIKE '%.js%';Then read the templates. In 3.0.x the storefront theme lives under catalog/view/theme/<theme>/template; OpenCart 4 moved the default templates to catalog/view/template and third-party themes to extension/<name>/catalog/view/template. In both, the payload usually lands in the footer or the checkout template — printed everywhere, reviewed rarely.
grep -rn '<script' /var/www/store/catalog/view/template /var/www/store/extension \
| grep -viE 'catalog/view/javascript|jquery|bootstrap'One more hiding place exists only in 3.0.x: the oc_modification table. OCMOD changes are XML patches applied to a generated copy of the core under system/storage/modification, so a malicious modification can rewrite a controller at runtime without touching the file you would inspect. Check the table, then check what it wrote to disk. OpenCart 4 removed that mechanism — extensions there ship real files under extension/ — one of the few places where 4.x is genuinely harder to hide in.
5. Mail, load and reputation you did not ask for
The fifth signal usually arrives from outside: order mail lands in spam, the hosting provider sends a notice, or a payment provider asks about chargebacks. By then the compromise is normally weeks old. These are the checks that catch it earlier.
- A mail queue full of messages to addresses that are not your customers — read it with mailq or exim -bp on the server, not from the OpenCart admin.
- PHP-FPM CPU that stays high at four in the morning while the store is idle.
- Outbound connections from the web user to hosts you do not recognise; ss -tnp during a busy minute shows them.
- Cron entries under the web user that nobody added: crontab -l -u www-data.
- Search Console reporting indexed pages you never created — doorway spam, often served only to crawlers and invisible in your own browser.
Any single item here has an innocent explanation. Two together, or one plus anything from the first four sections, is a compromise until you have proved otherwise.
What to do in the first hour
- 1Take a snapshot: a full file archive plus mysqldump, stored off the server. This is evidence and a rollback point, not a backup you will restore blindly.
- 2If card data may be exposed, switch on maintenance mode in Settings, Server tab, and contact your payment provider before they contact you.
- 3Disable — never delete — suspicious admin users and API keys, and note every user_id with its date_added.
- 4Invalidate sessions: clear system/storage/session, or truncate oc_session if you run the database session engine.
- 5Rotate credentials in this order: hosting and SSH, the database user in config.php and admin/config.php, admin passwords through the interface, API keys, then extension settings.
- 6Only then touch files, and clean by replacing the core with a known-good release rather than by deleting what you happened to find.
Where the way in usually turns out to be
In the stores we have cleaned, the entry point is almost never a novel exploit. It is an outdated extension with an upload handler that does not check what it stores; a theme bought once and never updated; an admin password reused from a service that leaked; an old OpenCart on a PHP version that stopped receiving fixes; or a config.php left world-readable after a migration.
That list is also the remediation plan. Restoring files without closing the way in buys you a clean store and the same compromise a week later, so an incident should end with an inventory: every extension, its version, its last update, and a decision about the ones nobody maintains.
When to write to us
If two or more signals match your store, send us what you have: the suspicious rows, the file list, the log lines. During the launch campaign all work is billed hourly at $10 per hour plus VAT, incident response included — you get an estimate of the hours before anything starts and you approve it. First response is within two hours, Monday to Saturday, 09:00–22:00 (GMT+3). If you are mid-incident, say so in the subject line and do not clean anything before we have talked.
OAuth, JWT and OWASP-driven security architecture; threat modelling.