01
Context
Unikem Pharmacy is a community pharmacy in Cape Town with a WooCommerce shop of close to 900 products and an in-store clinic booked through RecoMed. In August 2026 the site was compromised. A remote-controlled spam plugin published 682 gambling posts, all listed in the public sitemap; the homepage was injected with 64 nested copies of itself, the endless scroll the client had noticed; and a documented self-healing backdoor kit sat in the plugins folder. The catalogue was untouched, but the install could no longer be trusted.
The work ran in three tracks: contain the infection on the live install, rebuild the store locally on a new design with the full catalogue verified against the client's own product sheet, then replace the old install with a fresh one rather than clean it in place. The rebuild has been on the production domain since October, held behind a coming-soon page at the client's request while payments, store photography and final prices are completed.
02
The Challenge
- The backdoor kit is documented to heal itself and to store intercepted admin logins, and the attacker had write access to the file system, so no in-place cleanup could prove the site clean.
- The install had grown to 171.8 GB, almost all of it backup archives and leftovers of failed backups, and a full backup to remote storage failed on quota.
- The product sheet behind the old store was saved in a legacy Windows encoding: six product names arrived corrupted, and the old store also showed 10 wrong product photos, 4 missing photos and 17 placeholder prices.
- The clinic booking provider forbids framing its pages, so the booking page embedded on the old site could never display.
03
Scope and Approach
Read-only forensic audit
Scanner findings, REST and Store API reads, plugin profiling and the homepage revision history, all without a single write. The audit mapped the spam plugin, the backdoor files, a pirated premium plugin and the attacker accounts' orphaned posts, and found the last clean homepage revision, from November 2025, as a restore point. Products were confirmed untouched by the attacker.
Live containment
Each action was checked against the plugin's own code or the vendor documentation before it ran. The spam plugin was removed through its own uninstaller, which dropped its links table; the backdoor file and an attacker marker file were deleted; the homepage was restored to the clean revision. Existing backups were never touched. A fresh full scan closed the step.
Scripted Elementor build
A new design, delivered as static HTML from a written brief, was rebuilt in Elementor Pro on Hello Elementor by a Python pipeline: it emits a JSON bundle that a WP-CLI script applies, idempotent by build key, with all styling in one stylesheet in the site kit. The homepage was checked against the design at 1440, 768 and 375 pixels, within about 20 pixels.
Catalogue, content and local search
887 products imported from the client's sheet and audited against both the sheet and the old store. A description for every product, with its source and a confidence grade. Twelve content pages including legal pages, Rank Math local business schema, per-service clinic booking links, and own-rider delivery priced by distance band.
Fresh-install migration
A nine-step server script replaced the old install. It checks the package hashes, saves a copy of the old database, moves the old site out of the web root, installs a fresh WordPress with new salts, imports the rebuild and rewrites the site address in plain and JSON-escaped form. It stops at the first error and refuses to run twice.
04
Architecture flow
05
Technical Deep Dive
Why a fresh install, not a cleanup
The kit keeps its state in dozens of database options, including intercepted credentials and forged login cookies, and is documented to restore itself from a stored copy of its source. Containment on the live install stopped the visible damage and bought time. Trust came only from new core files, plugins from official sources and a database rebuilt from the clean local build. The old install was quarantined, not deleted.
Product photos verified by pixels
Each product's expected photo was compared with the old store's thumbnail and the new featured image using perceptual hashes and normalised cross-correlation, after matching WordPress's 6:5 hard crop. Thresholds were validated on genuine pairs against the closest look-alike products: the hash alone overlapped, the correlation did not. No product fell in the grey zone, and every mismatch was confirmed by eye.
Distance delivery without a plugin
Every free distance plugin reviewed either depended on a retired Google distance API, locked distance bands behind a paid tier, or added too much code to a recently compromised site. Road distances from the store to each suburb were computed once, offline, with OSRM, and postcodes grouped into four WooCommerce core shipping zones. Nothing is called at checkout. Collect in store stays available everywhere.
Structured data that stays accurate
Rank Math drops opening hours from the schema for the Pharmacy business type. A filter in the child theme adds them back as opening hours specifications and removes the default Article and Person nodes, which exposed the admin login and avatar on every page. Public holiday hours, still unconfirmed, are left out rather than guessed.
06
Engineering Decisions
Move, never delete
The old site, its database and its backup archives were moved or copied out of the public web root, never deleted. Every step of the migration could be reversed, and the infected files remain available as evidence until the client decides otherwise.
Placeholder prices stay unpublished
Products with a zero, placeholder or missing price were imported as drafts, 101 of the 887, so nothing can be sold at a wrong price. Sheet errors found along the way, from duplicate rows to pack sizes, went to the client as a list rather than being silently corrected.
Link to the booking provider, do not embed it
The provider blocks framing, so each service gets its own booking link that opens with that service preselected. The links use the full clinic URL, because the short one redirects and drops the service parameter. Both behaviours were tested in a headless browser before the links were built.
07
Results
- Live infection contained before the rebuild: spam plugin uninstalled with its table, backdoor files deleted, homepage restored from 12.9 MB to 90 KB, and a fresh scan of 28,311 files returned no malware findings.
- Rebuild deployed to the production domain on a fresh WordPress install, verified with 17 pages returning HTTP 200, no leftover local references, no spam posts and the spam plugin's endpoints gone.
- 887 of 887 products imported and matched to the client's sheet by serial number, with every name, featured image and listed price equal to the sheet.
- 887 product descriptions written and inserted, each with its source link and a confidence grade: 526 checked against the exact product, 217 against the same range, 144 generic.
- Twelve content pages built and checked at three widths with no horizontal overflow and no broken internal links.
08
Planned next
- Connect the card payment gateway, approved by Peach Payments and awaiting a joint setup session, then test in sandbox before live mode; further gateways once the client's merchant accounts exist.
- Replace the nine placeholder photo slots with store photography and apply the client's confirmed prices and product sheet corrections.
- Confirm delivery fees, radius and postcodes, run the final end-to-end test, lift the coming-soon page, and close out post-migration hardening on the host.