How a CVE-2026-26980 (Content API SQL injection) breach brought down our GhostAPI-powered blogs, and how we recovered over a weekend!
It started with a site that looked wrong
There was no dramatic alert. No pager, no SIEM klaxon, no ransom note. What I had was two of my self-hosted Ghost sites — aetheriumarcana.org and bordercybergroup.com — suddenly rendering like it was 1996. The post lists were there. The titles were there. But the CSS was gone: no theme, no layout, just a naked stack of links to my last ten posts, and clicking one gave me the word "Undefined."
Every other site on the box was fine. That detail mattered, and it's worth dwelling on, because it's the single most misleading clue in the whole story. When two independent applications break the same way at the same moment while their neighbors are untouched, instinct says: look for what they share. And what these two shared, obviously, was that they were both Ghost. So my first, entirely reasonable theory was that a ghost update had gone sideways and left both instances on a theme the new version couldn't render. Two Ghost versions were sitting in each install directory. The story wrote itself: botched upgrade, frontend breakage, restore and move on.
That theory was wrong, and being disciplined about why it was wrong is what eventually surfaced the truth. A broken stylesheet is a symptom with many possible causes, and "the update broke the theme" is the comfortable one. The uncomfortable one — the one I'd been quietly not-considering — was that something had changed my active theme out from under me.
Because that's exactly what had happened. The reason the sites rendered as unstyled post lists is that the active theme in the database was no longer my theme. It had been swapped for a stranger.
CVE-2026-26980, in one paragraph
The vulnerability that let this happen is CVE-2026-26980: an unauthenticated SQL-injection flaw in the Ghost Content API, the read-only public API that themes and front-ends use to fetch posts. It was being exploited at scale as part of the broader ClickFix campaign — the family of attacks that injects a JavaScript "loader" into legitimate sites to redirect or socially-engineer visitors. The important properties for a defender: it requires no credentials to begin (the Content API is public by design), and a successful injection gives the attacker a foothold to write to the database and, from there, to reach functionality that should have required authentication. There is no backport fix for the Ghost 5.x line. The only remediation is moving to Ghost 6.x. Hold that fact; it shapes the entire back half of this story.
I am deliberately not publishing the injection mechanics, the payload, or the deobfuscated loader. This is a defender's retrospective, not a recipe. What I will share generously are the indicators of compromise and the recovery, because those are what actually help the next person.
The forensic dig: a directory and a spray
Once I stopped assuming "bad update" and started treating it as "possible intrusion," the picture resolved fast, and it had two distinct parts.
Part one: a directory that shouldn't exist. In each site's content/themes/ sat a theme called edition. I had never installed it. It was a minimal, mostly-inert package — and on bordercybergroup.com, the database's active_theme setting had been changed to point at it. That was the cause of the broken formatting: the attacker had uploaded a theme (an action that requires Admin API access) and, on at least one site, made it the live theme. My real theme, wave, was still on disk, untouched — it just wasn't the one being served. The "missing CSS" wasn't missing at all. The site was faithfully rendering a different, nearly-empty theme.
The theme upload is the louder half of the intrusion but the less dangerous one. The quieter, nastier half was in the data.
Part two: a snippet spray through the database. The attacker had injected a JavaScript loader into the stored content itself — into post bodies and code-injection fields, the places Ghost renders verbatim into every page. This is the ClickFix payoff: every visitor to an affected post would have been served the loader. Finding it meant grepping the database for fingerprints rather than eyeballing 150 posts, and three indicators made that tractable:
eralfduolc— the loader's fingerprint string. Read it backwards. It'scloudflarereversed, a cute little tell the campaign uses to dodge naive string filters.CVE-2026-26980-PoC— a proof-of-concept marker the campaign stamps into what it touches.staticcloudflare.pro— the loader's callout domain. Note how close it sits to the realcdnjs.cloudflare.comthat legitimately appears in my code injection for syntax highlighting. That near-collision is intentional; it's designed to survive a tired human skimming the page source. (It would later cause exactly one moment of false-alarm during recovery, which I'll get to.)
Grepping for those across the databases gave me hard numbers, and the numbers told a genuinely interesting story about how unevenly the same vulnerability hit two sites on the same box.
Same CVE, two very different blast radii
This is the part I find most worth sharing, because it cuts against the intuition that "compromised" is a binary.
bordercybergroup.com — my security-themed site, which is its own small irony — took the brunt. 134 of its 150 posts carried the loader. Its active theme had been switched to the attacker's edition. This was a thorough, deliberate-looking spray: the attacker walked the post table and injected the great majority of it.
aetheriumarcana.org — by contrast, carried a single CVE-2026-26980-PoC marker. One. No mass injection. Its active theme had not been flipped (it was still on wave).
Same vulnerability, same server, same window — and one site got a full payload while the other got what looks like a proof-of-vulnerability tag and little else. I won't over-theorize the why, because honest forensics means admitting the limits of what the artifacts can tell you. The plausible readings: bordercybergroup may simply have been the juicier or earlier target, or the automated campaign got further into it before moving on; aetheriumarcana may have been tagged as vulnerable and queued for a weaponization pass that never came, or was hit later/more shallowly. What the asymmetry does teach is concrete and actionable: do not assume uniform impact. I verified each site independently, against the same fingerprints, and let the counts — 134 versus 1 — drive how much I trusted each one. Had I treated "we're compromised" as a single global fact and remediated both identically without measuring, I'd have either over-reacted on one or under-investigated the other.
The earliest injected content dated to around May 21, 2026. I detected the breakage on May 30. So the loader had, realistically, been live to visitors for somewhere in the neighborhood of nine days. That window is its own line item — more on it under "what this costs you" later.
What the attacker got — and, more importantly, what they did not
This is the section I'd most want to read if I were the next victim, because the instinct after finding a compromise is to assume total compromise, and that instinct will send you rebuilding an entire server from bare metal when you may not need to. Precision here saves days. So I scoped it carefully and refused to round up.
What the attacker demonstrably obtained:
- Write access to the Ghost databases, via the Content API SQLi. This is the root capability everything else descended from.
- Admin-API-level reach sufficient to upload a theme and change the active theme. Theme upload is an authenticated admin action, so the SQLi foothold was leveraged into admin-equivalent capability against the application.
- The Ghost Admin and Content API keys — these are stored in the database and so must be treated as exposed.
- The API key for my self-hosted analytics integration ("Analytics Collector"), which was likewise reachable.
- The database user passwords, which I treated as burned on principle.
What the attacker did not get — and this is the load-bearing finding:
- No OS-level or shell access. There was no evidence of command execution on the host, no foothold outside the Ghost application layer. The compromise lived entirely inside the app and its database.
- No root. Nothing touched root-owned configuration — not nginx, not systemd units, not the firewall, nothing that would require privileges the Ghost process doesn't have.
- No persistence outside the app. A sweep for the usual re-entry mechanisms — rogue OS users, unexpected cron jobs, stray listeners, added SSH keys, injected systemd services — came back clean. The only "persistence" was the application-level stuff we could see and remove: the
editiontheme and the injected content. - No lateral movement. The five other sites on the same box — none of them Ghost — showed zero signs of involvement. The blast was contained to the two Ghost applications.
- Payments stayed out of reach in practice, and I severed them anyway. I proactively cut the Stripe connection during containment rather than trust that it hadn't been probed.
Drawing that boundary precisely — "app and database: yes; operating system and everything else: no" — is what made a targeted recovery possible instead of a from-scratch server rebuild. It's the difference between two days and two weeks. The discipline that earned it was refusing to either downplay (it was a real, deep application compromise) or catastrophize (the box itself was not owned). You verify each claim, you state what you saw versus what you inferred, and you let the boundary fall where the evidence puts it.
Containment: dark, but not deleted
With scope understood, containment came first, and it came with one principle I'd underline for anyone in the same seat: a stopped process is not a contained site. A stopped Ghost process restarts — on boot, on a watchdog, on a stray systemctl. The only state I trusted as "dark" was the nginx vhost being removed from sites-enabled entirely, with a static maintenance page (HTTP 503, valid cert) served in its place. That, plus firewalling the Ghost ports and blocking the attacker's source IP (111.90.139.202) in ufw, is what actually took the sites off the public internet. Process-down was necessary but never sufficient.
Then, before anything destructive, the backups — and a hard rule that paid for itself repeatedly: back up before every destructive step, and verify the backup before trusting it. Clean SQL dumps, content tarballs, an off-box copy to a second machine, and a fresh provider snapshot. Crucially, I kept the pre-cleanup dumps too — the ones that still contained the payload — clearly labeled as evidence-only and quarantined so they could never be imported by accident. (That label mattered. During recovery I twice nearly grabbed a tainted dump because its filename was ambiguous. The fix wasn't more care; it was moving every contaminated file into a directory literally named EVIDENCE_DO_NOT_IMPORT so a fat-finger was physically impossible.)
Decontamination was surgical, not nuclear. Rather than roll back to a pre-injection state and lose nine days of legitimate posts, I stripped the loader from the affected content in place and then verified to zero against all three fingerprints — eralfduolc, CVE-2026-26980-PoC, staticcloudflare. Not "I removed it." Zero hits, confirmed by query. The distinction between "I did the cleanup" and "I measured the result of the cleanup" is the whole game in incident response. I rotated the database passwords, severed Stripe, and confirmed the persistence sweep was clean before moving on.
At that point the emergency was genuinely over. The sites were dark, the databases were clean and verified, the scope was known, and everything was backed up on and off the box. What remained wasn't firefighting. It was construction — and construction keeps. So I stopped, slept, and came back to the rebuild fresh. That decision is underrated. The riskiest surgery in this whole affair happened on day two with a clear head, not at 2 a.m. on day one running on adrenaline.
The rebuild: why a security fix turned into a full-stack migration
Here's where the "no 5.x backport" fact comes due. The only fix for CVE-2026-26980 is Ghost 6. But Ghost 6 doesn't just want a ghost update — it requires a different database engine and a different language runtime:
- MySQL 8 (MariaDB, what I'd been running, is no longer supported).
- Node.js 22 (Ghost 6 won't run on Node 18; Ghost 5 won't run on Node 22 — they don't overlap, which constrains the order of operations hard).
So patching one vulnerability meant migrating MariaDB 10.11 → MySQL 8, Node 18 → 22, and Ghost 5.x → 6.43.1, in that dependency order, on a live production box, twice (once per site). What follows are the war stories — the seven places this bit, every one of which is a transferable lesson for anyone else making the same jump.
1. The cross-engine collation crash-loop
MariaDB dumps don't import cleanly into MySQL 8 — the engines diverge on character-set collations. The obvious fix is to normalize the dump's collations during import. The non-obvious, expensive trap is which collation you normalize to. I first normalized to utf8mb4_general_ci, imported cleanly, and pointed Ghost 6 at it. The v6 migration then hit a wall and the service entered a boot crash-loop — start, fail, systemd restarts it, fail again, forever.
The cause, once I read the actual error instead of guessing: a Ghost 6 migration adds a gifts table with a foreign key into members, and a foreign key in MySQL 8 requires identical collations on both columns. My existing tables were now general_ci; the migration created its new column at MySQL 8's native default, utf8mb4_0900_ai_ci; the two didn't match; MySQL rejected the constraint (ER_FK_INCOMPATIBLE_COLUMNS); the migration died mid-transaction; systemd restarted it; repeat. The fix: normalize the dump to utf8mb4_0900_ai_ci — Ghost 6's own native collation — not general_ci. Then every table, existing and migration-created, agrees, and no foreign key can mismatch. Re-importing with the right collation target made the entire crash-loop evaporate. (One smaller cousin of this: MariaDB dumps carry a NO_AUTO_CREATE_USER SQL mode that MySQL 8 removed entirely, so the import bombs on the first SET sql_mode line until you strip that token out of the dump.)
2. The Node upgrade that silently doesn't take
Ghost's systemd unit launches node from a fixed path. If you upgrade Node with nvm, you change the node in your shell — not the node the service uses. I'd done exactly that previously and watched an upgrade attempt quietly roll back to 5.x because the service kept launching the old runtime. The lesson: upgrade the system node in place, via NodeSource, so /usr/bin/node itself becomes v22 and the unit's ExecStart path stays valid without edits. And verify it by full path (/usr/bin/node -v), not by what your shell reports — because your shell may be lying to you through an nvm shim. (Bonus gotcha: any compiled native module — my analytics collector used better-sqlite3 — must be recompiled against the new Node ABI, or it throws NODE_MODULE_VERSION errors on load. A quick npm rebuild fixes it.)
3. MySQL 8's default auth rejects the migration
MySQL 8 creates users with the caching_sha2_password plugin by default. Ghost's database driver authenticated fine for read checks but the v6 migration path got Access denied. The fix is to create (or ALTER) the Ghost DB user with mysql_native_password and make the config match. Worth knowing before you stare at an "access denied" that contradicts a passing health check.
4. ghost-cli looks for the unit in a different directory
One site's systemd unit lived in /etc/systemd/system/; the other's in /usr/lib/systemd/system/. The newer ghost-cli (1.29) only looks in /usr/lib/ for its doctor and node-version checks, so it reported the unit "unable to parse" on the site whose unit was in /etc/. Both locations are valid to systemd; the CLI just has an opinion. Moving the unit to /usr/lib/systemd/system/ and reloading the daemon resolved it — and incidentally explained why the two sites behaved differently at the same step.
5. You can't jump straight to v6
ghost update --v6 refuses to run unless you're already on the latest 5.x. One site was on 5.130.6 (the tip) and jumped fine; the other was two patches behind at 5.130.3 and had to be brought to the 5.x tip first, then across the major boundary. A one-line ghost update v5 first, then the v6 update.
6. Deleting the attacker theme broke the migration — because it was still "active"
This one was poetic. I deleted the malicious edition theme from disk early, as cleanup. But on bordercybergroup.com, edition was still set as the active theme in the database (that was the original cause of the broken formatting, remember). So when the Ghost 6 migration ran its theme validator (GScan) over the active theme, it tried to read a directory I'd just deleted and failed with ENOENT. The fix wasn't to restore the malware — it was to set active_theme back to my real theme, wave, in the database before migrating. Lesson: removing a theme that's still registered as active orphans it; reset the active theme first.
7. Email 2FA on a mail server that doesn't send is a lockout trap
After going live, I hit Ghost 6's "reset all authentication" — a single button that rotates every API key, signs out every session, and forces a password reset, explicitly built for "use after a suspected credential compromise." Perfect for the situation. It also, naturally, logged me out and emailed a reset link. Which never arrived — because the mail transport on that site was a jury-rigged dev configuration pointing at a local mail-catcher that delivers nothing. Cue a detour into resetting the password directly in the database (generating a proper bcrypt hash with Ghost's own bundled library — and learning the hard way that a bcrypt hash full of $ characters gets mangled if you pass it on a shell command line instead of via a quoted heredoc). The transferable rule: verify your mail actually delivers before you enable any email-based 2FA, or you build yourself a locked door with the key on the inside. (And: the Gmail account that receives those codes for both sites is now a single point of failure for both admins — it's getting its own 2FA immediately.)
That false-alarm I promised earlier? It was cdnjs.cloudflare.com — the real Cloudflare CDN, legitimately in my code injection for syntax highlighting — showing up in a grep next to my hunt for staticcloudflare. Different domain, benign, but exactly the kind of near-miss the attacker's lookalike naming is designed to exploit against a tired eye. I name it here because at go-live you'll grep your own live pages for the loader, and a real CDN reference will be sitting right there to make your heart skip.
Back online — and the whole thing fit in two days
By the end, both sites were running Ghost 6.43.1 on MySQL 8.0 and Node 22, on Ubuntu 24.04 — patched against CVE-2026-26980, serving their fully decontaminated content over HTTPS with valid certificates. The attacker's edition theme is gone from both. Every Admin and Content API key has been regenerated (twice over, via the reset-all-auth button), staff sessions cleared, the password reset, email-2FA enabled, and the self-hosted analytics integration reconnected with fresh keys. The malicious loader is verified absent — in the database, on disk, and in what the live sites actually serve to the public.
From the moment the formatting broke on May 30 to both sites back online and clean: about two days. That timeline was only possible because of the boundary I drew during scoping — application and database compromised; operating system not — which meant a targeted decontamination-and-migration rather than a bare-metal rebuild. Catastrophizing would have cost a week I didn't need to spend; downplaying would have left the loader live. The win was in measuring precisely and acting on the measurement.
What I'd tell the next person
A few things crystallized that I'd hand to anyone running self-hosted Ghost, or really any self-hosted app:
- A weird symptom deserves a real diagnosis. "The update broke it" is the comfortable explanation; "something changed my active theme" was the true one. The gap between those two is where breaches hide.
- Scope before you scorch. The most valuable hour I spent was the one proving what the attacker didn't touch. Precision turns a two-week rebuild into a two-day recovery.
- Containment means the vhost is gone, not the process stopped.
- Verify to zero, don't assume to zero. Cleanup you haven't measured isn't cleanup.
- The security fix may drag a full-stack migration behind it. Budget for the dependency chain (here: engine + runtime + app), not just the patch.
- Mind the unglamorous footguns — collation defaults, runtime paths, auth plugins, mail delivery. Every one of the seven things that bit me was mundane. None of them were the CVE. The vulnerability got me in two days; the migration is what took the skill.
There's still a short after-list: harden the mail transport for real (so newsletters and resets actually deliver), reconnect Stripe deliberately, and weigh a breach notice to members for that ~nine-day window the loader was live, depending on jurisdiction. But the fire is out, the doors are re-keyed, and both sites are running on supported, patched software for the first time in a while.
Two days. I'll take it.
Indicators of compromise referenced in this post, shared for other defenders: loader fingerprint eralfduolc; PoC marker CVE-2026-26980-PoC; loader callout domain staticcloudflare[.]pro; source IP 111.90.139.202; attacker-uploaded theme named edition. If you run Ghost 5.x, the only fix for CVE-2026-26980 is to move to 6.x — and now you know what the move involves.
Jonathan Brown is a cybersecurity researcher and investigative journalist at bordercybergroup.com.
Member discussion: