Cryptominers on Your Server: 100% CPU, Disguised Processes and Mining Pools

A fake kworker at 400% CPU: unmask a cryptominer with /proc and ss, find its persistence, stop it in the right order and work out how it got in.

A CPU usage graph flat at 100 percent even at night, next to a terminal window in which a process called kworker is highlighted in red, with its executable deleted in /dev/shm and a connection to a mining pool on port 3333

The site responds slowly, the hosting panel reports that you have exceeded your resources, and the fan of the dedicated server in the machine room runs at full speed at night, when nobody is using it. Opening top shows a process with a harmless name, maybe [kworker/0:2] or php-fpm, using every available core. It is a cryptominer: a program that uses the server’s CPU to compute cryptocurrency hashes, almost always Monero, on behalf of someone else.

A miner does not steal data: it steals resources. But the fact that it is there means someone can run programs on the server, and that is the real problem.

In this article we look at how to recognize the process even when it is disguised, what you can learn from /proc, how to spot the connections to the mining pool, where the persistence hides, in what order to stop everything so the miner does not come back, and how to tell whether it got in through the website or through SSH.

The symptoms: 100% CPU and a slowing server

The main symptom is a constant, high CPU load that does not follow the traffic pattern: a site that peaks during the day and goes quiet at night shows, with an active miner, a flat line at maximum even at four in the morning. On VPS and cloud servers, the threshold alert or the bill often arrives first. On shared hosting the provider throttles or suspends the account, and the site owner learns of the problem from the support email.

The cleverer miners do not use the whole CPU: they limit themselves to some of the cores, and suspend themselves when someone is connected over SSH or when top starts, to stay under the threshold that triggers alarms. Even moderate but constant, unexplained consumption deserves a check, especially if it appears together with other signs: unknown files in /tmp, new lines in the crontab, a WordPress site with a vulnerable plugin that has not been updated in months.

Finding the process: top, ps and pidstat

The first step is to work out which process is consuming, under which user and for how long. top sorted by CPU and showing the full command line is the starting point; ps lets you add the parent process and the time elapsed since start; pidstat, from the sysstat package, measures consumption over an interval and keeps you from being fooled by a miner that slows down while you watch.

# The most CPU-hungry processes, with user, parent and elapsed time
ps -eo pid,ppid,user,etime,%cpu,%mem,cmd --sort=-%cpu | head -15

# Average usage per process over three 5-second intervals
pidstat -u 5 3

# Process tree: who launched whom
ps -ef --forest | less

The process’s user already tells you a lot. A miner running as www-data, apache, nginx or as the site’s user was almost certainly started through PHP, that is, by the web application. A miner running as root or as a system user with SSH access points to compromised credentials or a vulnerability in a service. The parent process completes the picture: a miner that is a child of php-fpm or of an sh launched by PHP tells a different story from one that is a child of cron or systemd.

Fake names and deleted binaries: what /proc says

The name shown in ps is chosen by whoever launched the process and proves nothing. Miners call themselves kernel threads ([kworker/0:2], [kthreadd]), system services (systemd-journald, sshd) or the site’s PHP. The truth is in /proc/PID/, where the kernel exposes the process’s real executable file, command line, working directory and environment:

PID=23817
ls -l /proc/$PID/exe                       # real executable, even if deleted
tr '\0' ' ' < /proc/$PID/cmdline; echo      # arguments: pool, wallet, threads
ls -l /proc/$PID/cwd                       # working directory
grep -E '^(Name|PPid|Uid)' /proc/$PID/status

# All processes whose executable was deleted or lives in temporary folders
ls -l /proc/[0-9]*/exe 2>/dev/null | grep -E '\(deleted\)|/tmp/|/var/tmp/|/dev/shm/'

A real kernel thread has PID 2 (kthreadd) as its parent and has no executable: the exe link cannot be read. A [kworker] with parent 1, with an exe pointing to /dev/shm/.x/kworker and a user other than root, is an impostor. Very often the file shows as (deleted): the miner deleted itself from disk after starting, and keeps running from memory. It is the same technique we described for PHP processes that live only in memory, and the same advice applies: before killing the process, recover the binary from /proc, because afterward it will no longer be possible.

mkdir -p /root/incidente
cp /proc/$PID/exe /root/incidente/miner-$PID.bin
tr '\0' '\n' < /proc/$PID/environ > /root/incidente/miner-$PID.env
sha256sum /root/incidente/miner-$PID.bin

The command line, when it is not obfuscated, often contains everything: the pool address (-o), the recipient’s wallet (-u), the number of threads. Many miners are variants of XMRig, a legitimate open source program, recompiled or configured with a JSON file hidden in the same folder. One documented case is Kinsing: according to Sysdig’s analysis, the malware writes the kdevtmpfsi miner to /tmp, runs it, constantly checks that it is active and restarts it if it is terminated, relying on the crontab for persistence.

On the left, the output of ps sorted by CPU: at the top a [kworker/0:2] process owned by www-data at 391 percent CPU, highlighted in red, below a fake [kthreadd] of the same user acting as a watchdog, normal php-fpm, mysqld and nginx processes and, at the bottom, the real [kworker/0:2] with parent 2. On the right, the comparison between the fake kworker and a real kernel thread. Fake: parent PID 1, executable /dev/shm/.x/kworker marked deleted, command line with -o pointing to a pool on port 3333 and -u with a wallet address, user www-data. Real: parent PID 2 kthreadd, unreadable executable, no command line, user root, almost zero CPU.
A name can be faked, /proc cannot: parent, executable and command line unmask the fake kernel thread.

Connections to the mining pool

A miner has to communicate with a pool, the server that hands out work and collects results. The most widely used protocol is Stratum, a persistent TCP connection carrying JSON messages. ss shows established connections along with the process that owns them:

# Outbound TCP connections with the associated process
ss -tnp state established

# Only those of the suspicious process
ss -tnp state established | grep "pid=$PID,"

# The content, if not encrypted: look for "login", "job", "submit"
tcpdump -i any -A -c 20 "host 203.0.113.80 and port 3333"

The ports depend on the pool. Many pools publish dedicated ports with different difficulty levels: for example, SupportXMR lists 3333, 5555 and 7777, plus 9000 and 443 for encrypted connections. The very use of 443 with TLS, also provided for in the XMRig documentation, makes a check based on ports alone unreliable: an encrypted connection to 443 looks like any other web traffic. The most robust criterion is a different one: a process that should not have outbound connections, such as a kernel thread or a PHP pool worker, but instead keeps a long, stable connection to an unknown address. Some miners go through a proxy or use direct IP addresses so as to leave no trace in DNS logs.

Persistence: crontab, systemd, .bashrc and SSH keys

Killing the process without removing the persistence does little good: within minutes a cron job or a service downloads it again and relaunches it. The places to check are always the same, and all of them must be checked, because miners often use more than one at once:

# Crontabs of all users and of the system
for u in $(cut -d: -f1 /etc/passwd); do crontab -l -u "$u" 2>/dev/null | sed "s/^/$u: /"; done
ls -la /etc/cron.d /etc/cron.hourly /etc/cron.daily /var/spool/cron* 2>/dev/null

# Recently added systemd services and timers
systemctl list-timers --all
find /etc/systemd /usr/lib/systemd /home/*/.config/systemd -newer /etc/hostname -type f 2>/dev/null

# Login scripts, authorized keys, library preloading
grep -nE 'curl|wget|/tmp/|/dev/shm|base64' /root/.bashrc /root/.profile /home/*/.bashrc /home/*/.profile 2>/dev/null
cat /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys 2>/dev/null
cat /etc/ld.so.preload 2>/dev/null

# Files made immutable to prevent modification
lsattr -a /etc/cron.d /var/spool/cron/crontabs /root/.ssh 2>/dev/null | grep -- '-i-'

Three details deserve attention. The first is /etc/ld.so.preload: if it exists and loads an unknown library, that library can hide the miner from ps and top, and the CPU consumption then looks unexplained; in that case you need statically compiled tools or an analysis from a system booted from clean media. The second is the immutable attribute set with chattr +i, which prevents even root from modifying crontabs and keys until it is removed. The third is SSH keys added to authorized_keys, which guarantee future access even after every password has been changed. For cron jobs, the considerations in the article on cron job backdoors apply.

Map of a cryptominer’s persistence points around the central process in /dev/shm. Connected to the process: a crontab that runs curl against a remote script every five minutes; a systemd unit with Restart=always; lines in .bashrc and .profile that relaunch the miner at login; an SSH key added to authorized_keys; a watchdog process that restarts the miner if it is terminated; a library in /etc/ld.so.preload that hides it from ps and top; the immutable attribute chattr +i on the configuration files. On the right, the outbound connection to the pool on ports such as 3333 or 443.
The process is only the center: around it are the mechanisms that restart it, hide it and protect its files.

The right order to stop it and clean up

The order of operations decides whether the miner stays off. The sequence we follow is this:

  1. Document: output of ps and ss, copies of the executables from /proc, crontabs and systemd units, before touching anything.
  2. Block outbound traffic to the addresses of the pool and of the servers the miner re-downloads from, with a firewall rule: even if something restarts, it does no work and cannot update itself.
  3. Remove the persistence: cron lines, services and timers, login scripts, foreign SSH keys, after removing the immutable attribute with chattr -i where needed.
  4. Stop the processes together: first suspend them all with kill -STOP, watchdog included, then terminate them with kill -KILL. Killing the miner while the watchdog is active means watching it start again a second later.
  5. Delete the files in /tmp, /var/tmp, /dev/shm and in the hidden folders indicated by /proc/PID/cwd.
  6. Verify after a few dozen minutes, and again the next day: CPU, processes, connections, crontabs.
# Outbound block to the pool (nftables)
nft add table inet incidente
nft add chain inet incidente out '{ type filter hook output priority 0; }'
nft add rule inet incidente out ip daddr 203.0.113.80 counter drop

# Suspend, then terminate miner and watchdog together
kill -STOP 23817 23790
kill -KILL 23817 23790
Six-step cleanup sequence, with the main commands: document with ps, ss and cp from /proc/PID/exe; block outbound traffic to the pool and download servers with nftables; remove persistence from crontab, systemd, login scripts and authorized_keys after chattr -i; suspend with kill -STOP and then terminate miner and watchdog together with kill -KILL; delete the files in /tmp, /var/tmp and /dev/shm; verify after thirty minutes and after twenty-four hours. Below, in red, the typical mistake: killing only the miner, which the watchdog or cron relaunches within seconds or minutes.
First take away the miner’s ability to restart, then stop the process: the other way around, the kill lasts a few seconds.

How it got in: web or SSH vector

Removing the miner without closing the entry point means finding it again, often within the same week. There are two main routes, and the logs let you tell them apart. If the process ran as the web server user, the miner was downloaded and executed through PHP: a web shell, a vulnerable plugin that allows code execution, an exposed application. In the access logs you then look for POST requests to unusual files shortly before the process start time, which you can derive from etime or from the creation date of the files in /tmp; in the PHP error log, look for errors generated by calls to functions such as shell_exec or proc_open when they are disabled: these are the failed attempts that often precede the successful one.

If instead the miner ran as root or as a user with a shell, the first suspect is SSH with weak passwords. In /var/log/auth.log or /var/log/secure you look for mass failed attempts and, above all, an Accepted password from an unknown address; last -i and lastb summarize successful and failed logins. When log volumes are large, correlating logins, requests and process start times is a job in which artificial intelligence models run locally help us. A miner that got in as root calls for more caution: the system may have been modified deeply, and often the safest choice is reinstallation.

# Successful SSH logins with password, and their origin
grep "Accepted password" /var/log/auth.log /var/log/secure 2>/dev/null
last -i | head -20

# POST requests around the miner’s start time
grep "14/Sep/2026:02:[45]" /var/log/nginx/access.log | grep POST

Finally, there is a third route, less tied to WordPress but common on self-managed servers: services exposed to the internet without authentication, such as a Redis instance listening on all interfaces or the Docker API reachable from outside. Checking the listening ports with ss -tlnp and comparing them against what should be public clarifies in a few seconds whether the server offers entry points nobody remembered opening.

Prevention: noexec on temporary folders and outbound filters

Two measures greatly reduce the chances of a miner, even when the site has a vulnerability. The first is to mount the temporary folders with the noexec,nosuid,nodev options, so that a binary downloaded to /tmp or /dev/shm cannot be run directly. It is not an absolute barrier, because a file can still be interpreted with a shell or loaded in other ways, but it blocks most automated scripts, which just download and execute.

# /etc/fstab
tmpfs  /tmp      tmpfs  defaults,noexec,nosuid,nodev,size=1G  0 0
tmpfs  /dev/shm  tmpfs  defaults,noexec,nosuid,nodev          0 0
# /var/tmp: bind mount with the same options, or a dedicated partition

mount -o remount /tmp && mount -o remount /dev/shm
findmnt -no OPTIONS /tmp

The second is outbound filtering. A web server rarely needs to open connections to arbitrary internet ports: it needs DNS, updates, a few external APIs, mail. With nftables you can restrict the web server user’s outbound traffic to only the necessary destinations, using the user match (meta skuid) and logging what gets blocked: a connection attempt to port 3333 of an unknown address becomes an alarm, not CPU consumption discovered weeks late. The other measures are the usual ones: SSH with keys only and no direct root login, timely updates of plugins and themes, command execution functions disabled in PHP where they are not needed. You will find them collected in the post-cleanup hardening checklist.

A cryptominer is often the “loudest” infection and the easiest to notice, but the process eating the CPU is only the last link in a chain that begins with an entry point and continues with persistence, backdoors and sometimes other, less visible activity. In our engagements, analysis of processes, memory, connections and system persistence is an integral part of WordPress malware cleanup, together with the search for the vector. If your server shows an unexplained load, the cleanup guaranteed in 6, 12 or 24 hours for every WordPress installation also covers this level: you can contact us, preferably without rebooting the server, because a reboot wipes out exactly the traces in memory that the analysis needs.

THE NEXT STEP

Is your site
infected?

Every hour counts: the malware keeps hitting visitors and customers, and Google, your host and the blacklists won’t wait. Choose a plan, pay online and share your access: the cleanup starts right away, at a fixed price and with guaranteed times.

Request a cleanup

Takes you to the plans: cleanup within 6, 12 or 24 hours of receiving access.

MANAGED SERVER S.R.L.info@malwarecleanup.com +39 02 5656 9681

Monday – Friday · 9:30am – 7:30pm Italian time (CET)

Prefer an online form?
Contact us at managedserver.it ↗