WooCommerce + Dokan · 2026

Hacked marketplace, rebuilt

Cleaning out a hack, then rebuilding the parts of the marketplace that were never finished

Role Engineer — security remediation, custom build & redesign Year 2026 Visit live site
Hacked marketplace, rebuilt

Description

AladdinStores is a Gulf multi-vendor marketplace on WooCommerce and Dokan, running the Martfury theme. It had been hacked. By the owner’s account, thousands of casino spam posts and rogue accounts were pushed into it, and he deleted what he could see before a mass theme and plugin update landed on top. He wanted someone to prove the site was actually clean, repair what the hack and the update had broken between them, and finish a marketplace build that had been left half done.

Everything ran on live production. There was no usable staging copy, so safety came from backups and a written revert for every change.

Proving it was clean, then hardening it

I ran the audit read-only, in seven parallel tracks, then went back and attacked each finding to see whether it held up.

The files were fine. WordPress core matched its official checksums, 49 plugins verified against the public repository with 11 premium ones reviewed by hand, and 37,000 upload files narrowed to exactly ten PHP files, all accounted for.

The database was not fine. Six options set to load on every page request were malware: a payload that strips the real page out of the browser and writes a remote gambling script in its place, sitting inside a cluster of control flags belonging to a file-manager backdoor. It was dormant, and I proved that rather than assuming it, because the code that used to execute it had already been removed. My report opened by saying the site was not yet provably clean.

My first spam count was low. Keyword matching found 88 comments. Checking outbound links instead turned up another 214 that never used a gambling word at all, so the real figure was 302. All of it went, the four genuine comments stayed, and I closed the moderation settings that were auto-approving fresh spam while I worked.

Then the hardening. A clean firewall install after purging every trace of the previous one, rotated security salts to invalidate any stolen session cookie, XML-RPC and author enumeration blocked at the server, the admin file editor disabled, SSL forced on admin, and PHP execution stopped inside the uploads folder. A three-pass sweep of the entire database afterwards came back clean.

One finding was mine. A week in, I noticed the working backup folder I had created on the server was reachable over the public web. I reported it the same day, locked it, confirmed every probed file returned 403, and moved backups off the web-facing root entirely.

A sign-in page built from scratch

The theme hard-codes its account page layout, so there was no setting to change. I overrode the template in a child theme and rebuilt the page.

It took five rounds with the client. We started with two columns, sign in beside sign up, and he was right to push back: people do one or the other, not both. What shipped is a single card with a segmented Sign In and Sign Up toggle, a sliding indicator, and one form visible at a time. Arrow keys move between tabs, the panels are proper ARIA tabpanels, and the active tab is decided server-side so a failed signup reopens on the form the person was actually filling in. That last part sounds minor until you watch someone lose a completed form because the page reset to the login tab.

The details underneath took as long as the layout did. Browser-default radios and checkboxes were rendering as unstyled squares, so those are custom now, with the dot and tick positioned absolutely rather than with flexbox, which is unreliable across engines at that size. Fields got mail and lock icons and a password eye toggle. Every WooCommerce and Dokan hook survived the rewrite, so the seller-only fields still appear when someone picks the seller role. I also found a terms checkbox rendering in a disabled state, which would have blocked signups outright if it had ever been enforced server-side, and replaced it with plain consent text.

Verification that actually verifies

The site’s built-in email verification had never worked. It creates the account first and leaves a pending flag on it forever, and nothing was enforcing the flag, so the site had reached nine pending accounts and zero verified ones.

I replaced it with a six-digit code checked before any user row exists. Codes are stored only as a keyed hash and compared in constant time. On success the visitor gets a token held against their email address, so it cannot be replayed against a different one. The gate sits on the registration hook at an early priority and separately on checkout validation, because checkout creates customers through a different function that never fires the registration hook. Pulling the field out in devtools gets you a validation error, not an account.

Around it: a ten minute code lifetime, a sixty second resend cooldown, five wrong guesses before the code burns, four sends per address per hour and twelve per IP. All rolling, so there is nothing to unlock by hand.

I built it into the child theme rather than the site’s snippet plugin, deliberately. That plugin reads its whole snippet list from one cached option with no database fallback, so clearing the cache stops every snippet running. A security gate living there fails open. In the theme it fails closed.

I also broke buyer registration with it and nearly shipped that. Unlocking the form after a correct code also enabled nine hidden seller-only fields that are all required, so Chrome refused to submit and could not focus a hidden invalid control. No error, no validation bubble, no submit event. The button just sat there. Seller signups worked perfectly, which is exactly how something like that survives a test pass.

The rest of the custom build

  • A referral code field on seller registration, normalised on save, with a sortable admin column so the client can run his partner scheme by hand
  • Three transactional emails the platform does not ship: plan activated, payment confirmed on a bank transfer, and a “do not ship yet” notice replacing the misleading new-order email sellers were getting on unpaid orders
  • A branded email system, with the logo in the header, a dark footer carrying working social links, and PDF invoices attached to buyer emails, which had been enabled but attached to nothing
  • A guard stopping sellers marking their own unpaid orders as paid, closed across every route that reaches the setting and validated server-side rather than by hiding menu items
  • A fix for the client’s number one complaint, correct passwords being rejected, which turned out to be two plugins disagreeing about how quoted characters are stored
  • A Request Return and Refund flow that no longer sends buyers to a page with nothing to submit on it
  • A 48-assertion pre-flight check to run before and after any plugin update, because almost none of this would throw an error if an update broke it. It would just stop working quietly.

Design

The brief was consistent corners and something that reads current rather than dated. Everything picked up a shared radius token across the front end, the seller dashboard and the setup wizard, with the shop product grid deliberately left alone because he liked it as it was.

The buyer account dashboard was the worst of it. WooCommerce ships those two columns as floats at 30% and 68%, which on a wide screen gives you a 440px sidebar next to a cramped content panel. It is a grid now, a fixed 284px nav column and the content taking whatever is left. The width was also capped twice, once by the theme’s fixed container and once by an Elementor boxed setting, so both had to go before anything moved.

Buttons across the seller flows shipped at 3px radius with no consistent tap target. They match the rest of the site now, and destructive actions no longer render in the same solid brand orange as the action sitting next to them, which is how the plugin ships Cancel beside Submit and Skip beside Let’s Go.

The seller dashboard had a subtler problem. The theme sets the root font size to 10px, an old trick for easier rem maths, and the newer dashboard is built in Tailwind, which sizes everything in rem. Every dimension in it was rendering at 0.625 of intended. Body text at 8.75px. A toggle switch at 12.5 by 25 pixels. One line pinning the root back to 16px inside that dashboard fixed all of it, and I measured the theme’s own header, nav and footer before and after to confirm they were byte-identical, since they use absolute pixels and were never affected.

I also rebuilt the Help Center out of native Elementor widgets instead of a block of raw HTML, so the client can edit his own cards without calling me, and checked all sixteen links resolve.

Phones

124 of the 146 published pages, cart and checkout among them, rendered with no header and no footer. A single Elementor setting caused all of it.

Nobody could add anything to a cart on a phone. A quote button was painting directly on top of the add to cart bar, same rectangle, same stacking level, winning because it came later in the markup. Every property of the real button measured healthy: visible, opaque, right colour, right size. A hit test at the centre of its own box is what found it. None of this reproduces by resizing a desktop browser, because the theme sends phones a different render rather than a narrower one.

The account area was not responsive at all, and fixing the layout exposed something worse. The theme’s answer to a table that would not fit was to hide the Date and Total columns, so a buyer on a phone could not see when they ordered or what they had paid. Those stack properly now.

The site was also serving two separate homepages, one for mobile and one for desktop. That is two things to maintain and two places to forget. It is one responsive page now.

Stats

6 → 0 Malware options loading on every page request
302 removed Spam comments cleared, all 4 genuine ones kept
124 of 146 Published pages restored to a header and footer
9 pending, 0 verified State of email verification before I replaced it with a real gate
48 assertions Pre-flight check guarding the custom work against plugin updates
2 → 1 Homepages to maintain, after consolidating mobile and desktop

Tech stack

Dokan Elementor JavaScript LiteSpeed Cache Memcached MySQL PHP Rank Math SSH WooCommerce WordPress WP-CLI