On the night of 13 April 2026, between 22:21:20 and 22:21:27 UTC, someone — or more accurately, some script — extracted the admin API key from my self-hosted Ghost blog. The entire exfiltration took seven seconds. I was asleep. By the time I looked at the logs the next morning, the stolen key had already been used against my admin API.

This is the post-mortem. It's not a dramatic story — there was no clever evasion, no targeted attack, no nation-state. It was an automated scanner firing a public proof-of-concept at every Ghost instance on the internet, and mine happened to be in the vulnerable version range. That's the whole plot.

But the log forensics are worth walking through, because blind SQL injection looks surprisingly recognisable once you know what you're looking at.

The vulnerability in one paragraph

CVE-2026-26980 is an unauthenticated blind SQL injection in Ghost's Content API, affecting versions 3.24.0 through 6.19.0 and fixed in 6.19.1. The vulnerable code path is the slugFilterOrder utility, which builds an ORDER BY CASE statement from user-supplied slug values without parameterisation. Ghost's NQL query parser validates most dangerous characters but lets single quotes pass through inside array notation — so a filter like slug:['||CASE WHEN 1=1 THEN 0 ELSE EXP(710) END||',news] breaks out of the string literal and injects arbitrary SQL.

The Content API key is embedded in every Ghost site's HTML by design, so this is fully unauthenticated. Any Ghost blog on the public internet is reachable by any scanner.

What the attack looks like in a log file

Here's a representative snippet from my nginx access log, with keys redacted:

22:21:20 GET /ghost/api/content/tags/?filter=slug:[' OR (ASCII(SUBSTR((SELECT secret FROM api_keys WHERE type=0x61646d696e LIMIT 1) FROM 1 FOR 1))>=80) THEN (SELECT exp(710)) WHEN slug=',...] 500
22:21:20 GET /ghost/api/content/tags/?filter=slug:[' OR (ASCII(SUBSTR((SELECT secret FROM api_keys WHERE type=0x61646d696e LIMIT 1) FROM 1 FOR 1))>=56) THEN (SELECT exp(710)) WHEN slug=',...] 500
22:21:20 GET /ghost/api/content/tags/?filter=slug:[' OR (ASCII(SUBSTR((SELECT secret FROM api_keys WHERE type=0x61646d696e LIMIT 1) FROM 1 FOR 1))>=46) THEN (SELECT exp(710)) WHEN slug=',...] 500
22:21:20 GET /ghost/api/content/tags/?filter=slug:[' OR (ASCII(SUBSTR((SELECT secret FROM api_keys WHERE type=0x61646d696e LIMIT 1) FROM 1 FOR 1))>=51) THEN (SELECT exp(710)) WHEN slug=',...] 200

Every element here tells part of the story:

  • type=0x61646d696e is "admin" in hex — the attacker is asking the database for the admin API key row, not the content key they already have.
  • SELECT exp(710) triggers a MySQL DOUBLE overflow error. When the surrounding OR condition evaluates true, the error fires and nginx returns 500. When it's false, exp(710) is never evaluated and the request returns normally with 200. That's the oracle: 500 means yes, 200 means no.
  • ASCII(SUBSTR(... FROM 1 FOR 1)) extracts the first character of the admin key as an integer. The comparison >=80, >=56, >=46, >=51 is a binary search over the printable ASCII range.

In that first second the scanner determined character 1 of the admin key with about seven queries. Then it moved to character 2, then 3, in parallel across 15 threads. Ghost admin keys are 64 hex characters. Seven seconds, ~600 requests, and the scanner had the whole thing.

The boolean-blind pattern is old enough to have its own Wikipedia article. What's new is that the public PoC on GitHub automates this so cleanly that running it against a freshly-discovered vulnerable host takes less than a minute including setup.

The timeline

I pieced this together from timestamps the next morning:

21:33 — Generic "spray and pray" scanner hits me looking for leaked .env files, phpinfo.php, WordPress paths, _profiler/phpinfo. 130 probes in two seconds, all 404s. This is constant background noise on any public-facing site; it wasn't the actual attack.

22:21:19 — A different scanner issues its first request. Payload is the slug-filter injection.

22:21:27 — Same scanner issues its last request. Admin API key fully extracted.

Later that night — A burst of authenticated requests hits the admin API. The affected posts were mostly drafts I'd been meaning to clean up, so it's ambiguous whether this was me doing housekeeping or the attacker poking around with the stolen key. Either way, the conclusion is the same: the admin key is burned and has to be rotated.

The active attack window — from first probe to successful key extraction — was seven seconds. The scanner's total time-on-target, including the earlier reconnaissance sweep: nine seconds. I was asleep in Montevideo the entire time.

Why this was inevitable

The mean time between a CVE being publicly disclosed and automated exploitation scanners hitting every public instance on the internet is measured in days. Sometimes hours. For CVE-2026-26980 specifically, a working PoC was on GitHub within days of disclosure, and by the time I was hit, the scan traffic was already background radiation.

There's no defensive posture that makes this not happen. What you can do is compress the window between "CVE disclosed" and "I'm patched" to be shorter than "CVE disclosed" and "scanners find me." For a hobbyist-managed Ghost instance, that window is, realistically, as long as it takes for me to notice the advisory. Which is too long.

What I did about it

I won't walk through the specifics of my hardening — publishing a detailed map of any particular site's defences is a gift to the next person scanning it. But the high-level shape of the response is unremarkable and worth sharing:

Upgrade first, and immediately. Ghost 6.19.1 parameterises the vulnerable query. Everything else is defence-in-depth around a patched application; if you skip the upgrade, the rest is theatre.

Rotate every credential the attacker could have touched. Not just the one you can prove was stolen. You can't prove anything about what they didn't do during the window they had access, so assume nothing is safe. API keys, admin passwords, session tokens, SMTP credentials, database passwords if they shared any infrastructure with Ghost.

Apply reasonable perimeter hygiene. Rate limits on the exposed API endpoints. Automated blocking of obvious scanner patterns at the reverse proxy. A log-based ban system so the fourth bad request from a given IP is the last one it gets through. None of this would have prevented a seven-second blind SQLi against an unpatched application, but it narrows the window for future unknown vulnerabilities.

Off-site backups with retention. Had the late-night admin API activity turned out to be the attacker rather than me, I would have needed a clean snapshot from before 22:21 to restore. The cost is cents per month; the alternative is catastrophe.

Subscribe to upstream security advisories. This is the one change that would have let me skip the whole incident. Ghost publishes advisories on GitHub with an RSS feed. Subscribe, route it somewhere you actually check, and treat "there's a CVE in something I run" as a drop-everything signal.

Lessons, briefly

The attack was uninteresting. Boolean-blind SQL injection with a timing oracle is textbook. The attacker was an automated scanner running someone else's PoC. I am not a valuable target; I'm just a reachable one.

The defensive posture that matters isn't "prevent every attack" — it's "be already patched when the scanners arrive." That's a calendar-management problem, not a security problem. Everything else just buys you time on the days you fail at the calendar-management part.

Logs are only useful if you read them. My nginx logs captured the entire attack in real-time, clearly, with every payload visible. Nobody was watching.

Seven seconds. Internalise that number. That's how long a modern automated exploit takes against an unpatched application with a public PoC. You don't respond to that in real time. You respond to it by not being the unpatched application when the scanner arrives.

I was lucky that CVE-2026-26980 is "read arbitrary data" rather than "execute arbitrary code," and that the attacker was a dumb scanner rather than someone who wanted to use the admin key to do real damage. Next time I might not be lucky. Patch promptly, rotate credentials when in doubt, and never assume the scanners aren't already here.