Guide · WP-Cron persistence
A WP-Cron event is only a name and a time. The danger is the code that answers to it.
Malware returns minutes after you remove it, on a site where you have no server crontab, or WP-CLI lists a scheduled hook nobody can explain. WP-Cron is WordPress’s own scheduler: a list of events stored in one row of the options table and run when ordinary page views trigger it. A malicious event survives every file reinstall because it is not a file — but an event with no code behind it does nothing at all. This guide goes deeper than the WP-Cron section of the server cron guide: how the schedule is stored, how to read it without executing anything, how to find the callback behind a hook, and why the code has to go before the event.
Published Last reviewed
- Symptom
- An unknown hook in the schedule, or malware that returns on a timer
- Where it lives
- The cron row of the options table, plus a callback in a file
- How it runs
- Ordinary page views trigger wp-cron.php; no server access needed
- Removal order
- Preserve, remove the callback code, then delete the event
How it works
What a WP-Cron event actually is
Everything WP-Cron knows lives in a single option called cron. It is a serialised array keyed by Unix timestamp; under each timestamp sit the hooks due then, their arguments, and the schedule they repeat on. WordPress core keeps its own housekeeping there — update checks, trash emptying, expired transients — and plugins add theirs.
An event does not contain code. When it falls due, WordPress fires the hook by name, and whatever callbacks are registered for that name run. That splits a malicious event into two parts: the schedule entry in the database, and the add_action call in a file that connects the hook to real code. Malware often does both in the same file: it registers the callback and, if the event is missing, schedules it again.
Because WP-Cron runs from page views, it gives an attacker a timer on hosting where they have no shell access and no server crontab — which is why “the malware comes back every few minutes” on shared hosting so often traces back here.
Diagram · How a page view runs a scheduled hook
The event only names a hook. Whatever code registered that hook is what runs.
- 01 · Any visitor A page is requested WP-Cron has no clock of its own; page views are its heartbeat.
-
02 · WordPress
Due events are found in the cron option
get_option( 'cron' )One serialised array: timestamps, hook names, arguments and schedules. -
03 · WordPress
wp-cron.php is called in the background
wp-cron.phpA loopback request, so the visitor’s page is not held up. -
04 · WordPress
Each due hook is fired
do_action_ref_array( $hook, $args )If no code has registered that hook, nothing happens. - 05 · A hidden file The registered callback runs Rewriting files, injecting content, fetching a payload — and rescheduling itself for next time.
Delete the event and the code can reschedule it on the next page view; delete the code and the event is an empty name.
Read the schedule
List every event without running any of them
Listing events reads the cron option; it does not fire anything. The --skip-plugins and --skip-themes flags keep plugin and theme code from loading while WP-CLI reads it, although must-use plugins still load.
Figure 1 · One hook too many
Core’s housekeeping events, and one hook every minute that only a file in uploads knows about.
wp cron event list --fields=hook,next_run_relative,recurrence --skip-plugins --skip-themes
+--------------------------------------+-------------------+------------+
| hook | next_run_relative | recurrence |
+--------------------------------------+-------------------+------------+
| wp_privacy_delete_old_export_files | 38 minutes | 1 hour |
| wp_version_check | 4 hours 2 minutes | 12 hours |
| wp_scheduled_delete | 9 hours | 1 day |
| wp_maintenance_sync_7c1e | now | 1 minute |
+--------------------------------------+-------------------+------------+
grep -rn "wp_maintenance_sync_7c1e" wp-content/
wp-content/uploads/2026/09/.thumbs.php:4:add_action( 'wp_maintenance_sync_7c1e', 'wpms_run' );
- 01
List the events and their schedules
Hook, time to the next run, and how often it repeats:
wp cron event list --fields=hook,next_run_relative,recurrence --skip-plugins --skip-themesCore hooks have names beginning wp_ or describing housekeeping, on schedules from hourly to weekly. Plugin hooks usually carry the plugin’s prefix. A hook that matches nothing installed, or one repeating every minute or so, is the lead.
- 02
List the schedules that exist
A one-minute recurrence needs a custom schedule, and custom schedules are registered in code:
wp cron schedule listThis one loads plugins on purpose, because custom schedules only exist while the code that registers them is loaded. An interval no installed plugin explains points at the file that defines it.
- 03
Keep the raw option, with arguments
The list hides each event’s arguments, which can carry URLs or encoded data. Save the whole option outside the web root:
mkdir -p ~/incident wp option get cron --format=json --skip-plugins --skip-themes > ~/incident/cron-option.jsonThis is the evidence of what was scheduled, and when. Take it before deleting anything.
- 04
Or read it straight from the database
Without loading WordPress at all; substitute your table prefix:
SELECT option_value FROM wp_options WHERE option_name = 'cron';The value is serialised; read it as text and do not edit it by hand.
Find the code
Trace a hook back to the file that answers it
The event tells you the name. The file that registered it tells you what it does — and where else to look.
- 01
Search for the hook name
Everywhere code can live, not only plugins:
grep -rn "HOOK_NAME" wp-content/ wp-config.phpA match inside a plugin you installed and can name usually explains the event. A match in mu-plugins, a drop-in, the uploads directory, or a theme file nobody edited is the callback.
- 02
Read the callback, do not run it
Open the file in a text editor and follow the function named in add_action. Record its path, size, timestamps and SHA-256. Do not trigger the event to see what happens.
- 03
If nothing matches, widen the search
Malware builds hook names from encoded pieces so they never appear as plain text. Search for scheduling calls instead, and read every match outside known plugins:
grep -rnE "wp_schedule_event|wp_schedule_single_event|cron_schedules" wp-content/Then check the layers above WordPress — wp-config.php, a .user.ini auto_prepend_file target, modified core files — where a callback can be registered before any plugin loads.
- 04
Check what the arguments carry
Arguments stored with the event are passed to the callback. A URL, a file path or a base64 string in them is part of the payload, not configuration.
Remove it
Code first, then the event
Deleting the event while the callback file is still loaded lets the code schedule it again on the next page view. Remove them in this order.
- 01
Preserve the evidence
The cron option you saved above, the callback file with its timestamps and hash, and a database export:
wp db export ~/incident/before-cleanup.sql cp -p PATH/TO/CALLBACK.php ~/incident/Keep everything outside the web root.
- 02
Remove the callback code
Delete the rogue file, or replace a modified plugin, theme or core file from its official source rather than editing out lines by hand.
- 03
Delete the event
With the code gone, remove every scheduled instance of the hook:
wp cron event delete HOOK_NAMEDeleting by hook name removes all of its scheduled occurrences, whatever their arguments.
- 04
Confirm it stays gone
Load a few pages to let WP-Cron run, then list the events again:
wp cron event list --fields=hook,next_run_relative,recurrence --skip-plugins --skip-themesIf the hook is back, something else is still registering it: another file, a drop-in, or code above WordPress.
- 05
Close the way in and rotate
Something wrote that callback file. Find the entry point — a vulnerable plugin, a stolen login, a neighbouring site — and rotate administrator, application, database, hosting and SFTP credentials, plus the WordPress salts.
Beyond WP-Cron
When the schedule is not in WordPress at all
If WP-Cron is clean but files still return on a timer, the schedule is on the server: a user crontab, a file in /etc/cron.d, a systemd timer, or an at job. Those need shell access to see, and the server cron guide covers them. And a WP-Cron callback rarely works alone — the same access that planted it usually left a payload in the options table or a loader in mu-plugins as well.
Common questions
Common questions about malicious WP-Cron events
Can a WP-Cron event run malware by itself?
No. The event stores only a hook name, a time, arguments and a schedule. It does damage only when code has registered a callback for that hook. That is why removing the code matters more than removing the event — and why the code goes first.
I deleted the event and it was back within minutes. Why?
The callback file is still loaded and reschedules the event when it finds it missing. Remove or replace the file, then delete the event again and confirm it stays gone after a few page views.
Does setting DISABLE_WP_CRON stop it?
It stops page views from triggering WP-Cron, but the events still run whenever wp-cron.php is called — by a server cron job you set up, or by anyone requesting the URL. It is not a fix; removing the callback is.
How do I know which hooks are WordPress core?
Core’s scheduled hooks are a short list of housekeeping tasks such as update checks, emptying the trash and deleting expired transients, on hourly to weekly schedules. Compare against a fresh install of the same WordPress version if you are unsure.
Will a security plugin show malicious WP-Cron events?
Some list scheduled events, and that is useful. Whether a hook is malicious depends on the code behind it, which a list cannot show. Searching for the hook and reading the callback is the check that decides it.