This morning started with a batch of security alerts in my inbox. Nothing unusual. CaptainCore captures every site’s homepage daily and flags new external scripts. Most of the time, it’s a false positive. A plugin update added a new CDN, or a sister site is sharing assets. I review them, confirm they’re safe, and close them out.

But one alert led somewhere I didn’t expect.

Most injection alerts are false positives.

Seven “Security Alert: Injection detected” emails landed between 5am and 9am, each flagging a different site for loading unknown external scripts. I pulled them all up in Claude Code and started working through them.

Claude Code Session
Austin
Pull up all of the Security Alert: Injection detected from this morning
Claude
Here are the 7 injection alerts from this morning (March 25), all open

Six of the seven were straightforward. Sister sites sharing plugin assets across domains. Known CDNs from website builders. A staging site loading its production theme CSS. All safe, all explainable.

Then there was one left.

A 142KB football widget had no business being there.

The alert flagged an external script loading from widgetlogic.org:

https://widgetlogic.org/v2/js/data.js?t=1774396800&ver=6.0.0

I curled it. The response was a minified Vue.js application for displaying live football scores. On a client’s business website.

The source was a plugin called Widget Logic. A name most WordPress developers would recognize. It’s been around since 2008 and has over 3 million downloads. The original plugin, by Alan Trewartha, did one simple thing: let you control which widgets appear on which pages using conditional tags.

But the version installed on this site wasn’t doing that at all.

Someone bought a trusted name and gutted it.

I had Claude Code download multiple versions of Widget Logic from wordpress.org to trace when the injection started.

Claude Code Session
Austin
Can you audit the widget-logic plugin. Download multiple versions to figure when this JS injection happened.
Claude
Interesting version history. Jumps from 0.57 straight to 5.7.0. Let me download key versions to trace the takeover.

Here’s the ownership chain we pieced together:

VersionDateAuthorExternal JS
0.2 – 0.572008 – 2015Alan Trewartha (original)None
5.7.0 – 5.10.42017 – Jul 2019WPChef / wpchefgadgetNone
5-year gap. No updates.
6.0.0Aug 6, 2024widgetlogic.orgEvery page
6.02Aug 28, 2024widgetlogic.orgEvery page
6.0.9Jan 15, 2026widgetlogic.orgOnly when block used
6.07Jan 7, 2026widgetlogic.orgEvery page
6.08Jan 12, 2026widgetlogic.orgEvery page

The domain widgetlogic.org was registered on June 6, 2024. Exactly two months before version 6.0.0 appeared. The new owner replaced the original single-file plugin with a multi-file structure that includes a “Live Match Widget” and a config file pointing to their domain.

Here’s the injection point in widget.php from version 6.02:

add_action('wp_enqueue_scripts', function() {
    $cfg = require('widget_cfg.php');
    $url = $cfg['base'];  // https://widgetlogic.org/
    $ver = $cfg['ver'];   // v2
    $t = time();
    $t = $t - $t%(12*60*60);

    wp_enqueue_script(
        'widget-logic_live_match_widget',
        "{$url}{$ver}/js/data.js?t={$t}",
        array(), '6.0.0', true
    );
});

No conditional check. No widget detection. Just wp_enqueue_script on every single frontend page load.

Version numbers lie.

At first I thought this might be an honest mistake. A new developer who didn’t realize they were loading scripts unconditionally. There was even a support thread on wordpress.org about it, and version 6.0.9 appeared to fix the issue by switching to wp_register_script, which only loads the JS when the Live Match block is actually placed on a page.

But then I noticed the version numbers.

WordPress uses PHP’s version_compare() to determine whether a plugin update is available. The “fixed” version is 6.0.9. The injecting versions are 6.02, 6.07, and 6.08. Here’s how PHP interprets those:

version_compare('6.02', '6.0.9');  // 6.02 -> parsed as 6.2 -> HIGHER than 6.0.9
version_compare('6.07', '6.0.9');  // 6.07 -> parsed as 6.7 -> HIGHER than 6.0.9
version_compare('6.08', '6.0.9');  // 6.08 -> parsed as 6.8 -> HIGHER than 6.0.9

WordPress auto-updates will never move a site from 6.02, 6.07, or 6.08 to 6.0.9. It thinks they already have a newer version.

And the timeline makes the intent clear. Versions 6.07 and 6.08 were released just days before the 6.0.9 “fix”:

Jan 7, 2026
v6.07 released. Uses is_admin() ? wp_register_script : wp_enqueue_script. Still force-enqueues on the frontend.
Jan 12, 2026
v6.08 released. Same pattern, same injection. Version number still higher than the fix.
Jan 15, 2026
v6.0.9 released. The “fix” using wp_register_script. No affected site will ever auto-receive it.

The code in 6.07 and 6.08 is worth examining. It uses a ternary that looks like it conditionally registers:

call_user_func(
    is_admin() ? 'wp_register_script' : 'wp_enqueue_script',
    'widget-logic_live_match_widget',
    "{$url}{$ver}/js/data.js?t={$t}",
    array(), '6.0.0', true
);

On the admin side, it registers. On the frontend, where your visitors are, it enqueues on every page —just enough plausible deniability to look like an oversight.

WordPress.org closed it in 20 minutes.

I drafted a detailed report and sent it to the WordPress.org Plugin Review Team. The version timeline, the version_compare manipulation, the scope: 13 sites across our fleet at Anchor Hosting stuck on versions that would never auto-update to the fix.

Their response:

WordPress Plugins Team
Thank you for reporting a guideline violation in this plugin and for the analysis of the situation, you did a great summary of the current situation and we will check this within the team. As for now we closed the plugin and asked the author to fix the version issue.

Plugin closed. But that raised a new concern.

A closed plugin is still a loaded gun.

The plugin was closed on wordpress.org, but the data.js file is still served from their domain. They can change it at any time, for any site still loading it.

So I set up a git-based monitor. A shell script runs hourly, fetches the JS payload, and commits to a local git repository if anything changes. If a change is detected, it scans for suspicious patterns and sends an alert email.

#!/bin/bash
# Monitor widgetlogic.org JS payload for changes
MONITOR_DIR="/Users/austin/Cove/Scripts/widgetlogic-monitor"
JS_URL="https://widgetlogic.org/v2/js/data.js"

cd "$MONITOR_DIR" || exit 1

# Fetch current payload
TIMESTAMP=$(date +%s)
curl -s -o data.js.new "${JS_URL}?t=${TIMESTAMP}"

# Compare with previous version
if diff -q data.js data.js.new > /dev/null 2>&1; then
    rm -f data.js.new
    exit 0  # No changes
fi

# Changes detected -- check for suspicious patterns
for pattern in "eval(" "document.cookie" "localStorage" \
               "sendBeacon" "new Image" "createElement.*script"; do
    NEW_COUNT=$(grep -c "$pattern" data.js.new)
    OLD_COUNT=$(grep -c "$pattern" data.js)
    if [ "$NEW_COUNT" -gt "$OLD_COUNT" ]; then
        SUSPICIOUS="true"
    fi
done

# Commit the change
mv data.js.new data.js
git add data.js
git commit -m "Payload changed - $(date +%Y-%m-%d %H:%M)"

# Send alert via Missive
wp missive draft \
    --to="support@anchor.host" \
    --from="Austin Ginder " \
    --subject="[ALERT] widgetlogic.org JS payload changed" \
    --send \
    --body="Payload changed. Review the git diff."

The full script also checks for endpoint downtime (403, 404) since taking the URL offline temporarily could be a precursor to swapping in a new payload. Every change is preserved in git history, so we have a forensic trail if anything happens.

A sports widget today, a credit card skimmer tomorrow.

This wasn’t sophisticated malware. No backdoor, no data exfiltration. Just a sports widget loading on small business websites. But the mechanism is exactly how supply chain attacks work. Acquire a trusted plugin with 3 million downloads, inject external JavaScript, and use version number manipulation to prevent the fix from reaching affected sites.

The difference between a sports widget and a credit card skimmer is one line of code on their server.

What caught it was a simple daily check: capture the homepage, diff it against yesterday’s, flag anything new. No AI model, no machine learning. Just a diff. The investigation that followed used Claude Code to pull up emails, SSH into sites, download and compare plugin versions, draft the report to WordPress.org, and set up the monitor. What would have been a full morning of manual work was compressed into a single conversation.

If you’re running Widget Logic and you’re on version 6.02, 6.07, or 6.08, your site is loading external JavaScript from widgetlogic.org on every page. WordPress auto-updates won’t fix it. You’ll need to manually reinstall or remove the plugin.