Okay, here’s what helped me:
After manually reverting the obvious malicious activity (a script added to the footer of every post and page), I ran ghost backup.
I then sftp’ed the zip file, and the backups folder, and the next most recent backup zip file, to my local machine. Roughly something like (informative only, I actually used a gui):
scp my.server.com:/var/www/my-site/*.zip ~/ghost-backups/
scp -R my.server.com:/var/www/my-site/backups ~/ghost-backups/
I then unzipped the zip files and made the first key discovery: the zips contain a folder called data, which has the same content JSON file and members csv file as the backups folder. In addition, the zips contain files, images, media, settings and themes.
So step one in assurance was to a do a folder diff between the two unzipped backups. I used Beyond Compare. It was quickly obvious that the only differences were new files I’d attached to posts since the previous backup, plus a lot of changes to the content JSON.
So step two was a diff of the content JSON. To make a text diff work well I formatted the two json files first with something like:
jq 'walk(if type == "object" then del(.mobiledoc, .lexical) else . end)' content-from-v5.26.4-on-2025-07-02-11-13-44.json > content-from-v5.26.4-on-2025-07-02-11-13-44.pretty.json
I used the walk command to remove fields that were producing a lot of distracting differences. My case is severe, since my last backup was from Ghost v5! The mobiledoc content seems to have moved to the lexical field, so ignoring them both was an easy fix. Adjust to your tastes.
By diff’ing the result it was pretty easy to see whether I’d missed anything. Sure enough, all the old posts were identical between the two files, giving me confidence there weren’t edits I’d missed. But, a strange addition to the site-wide footer caught my attention. I’d ignored it in my manual reversion effort thinking it must have been a v6 addition, but seeing it in the diff made me reconsider.
The addition was:
<script src=\"https://cookie-consent-gdpr.pages.dev/consent.min.js\"></script>
Pretty sus looking, so I asked Claude to tell me what the script does and sure enough it’s an ad injection script masquerading as a GDPR cookie consent script.
Deleted that and now I’m pretty confident I’ve reverted all the malicious activity.
Now to figure out how to run more frequent backups… I’m starting here.