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.

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.

  1. 01 · Any visitor A page is requested WP-Cron has no clock of its own; page views are its heartbeat.
  2. 02 · WordPress Due events are found in the cron option get_option( 'cron' ) One serialised array: timestamps, hook names, arguments and schedules.
  3. 03 · WordPress wp-cron.php is called in the background wp-cron.php A loopback request, so the visitor’s page is not held up.
  4. 04 · WordPress Each due hook is fired do_action_ref_array( $hook, $args ) If no code has registered that hook, nothing happens.
  5. 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.

WP-Cron on a default install, where page views trigger it. Sites that set DISABLE_WP_CRON run the same steps from a server cron job instead.

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.

user@site:~/public_html
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' );
Both commands only read. The hook name, file name and timings are illustrative; the tells are a hook nothing installed explains, a schedule faster than core uses, and a callback in a place PHP should not be. Illustrative reconstruction.
  1. 01

    List the events and their schedules

    Hook, time to the next run, and how often it repeats:

    WP-CLI
    wp cron event list --fields=hook,next_run_relative,recurrence --skip-plugins --skip-themes

    Core 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.

  2. 02

    List the schedules that exist

    A one-minute recurrence needs a custom schedule, and custom schedules are registered in code:

    WP-CLI
    wp cron schedule list

    This 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.

  3. 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:

    shell
    mkdir -p ~/incident
    wp option get cron --format=json --skip-plugins --skip-themes > ~/incident/cron-option.json

    This is the evidence of what was scheduled, and when. Take it before deleting anything.

  4. 04

    Or read it straight from the database

    Without loading WordPress at all; substitute your table prefix:

    SQL · read-only
    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.

  1. 01

    Search for the hook name

    Everywhere code can live, not only plugins:

    shell
    grep -rn "HOOK_NAME" wp-content/ wp-config.php

    A 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.

  2. 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.

  3. 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:

    shell
    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.

  4. 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.

  1. 01

    Preserve the evidence

    The cron option you saved above, the callback file with its timestamps and hash, and a database export:

    shell
    wp db export ~/incident/before-cleanup.sql
    cp -p PATH/TO/CALLBACK.php ~/incident/

    Keep everything outside the web root.

  2. 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.

  3. 03

    Delete the event

    With the code gone, remove every scheduled instance of the hook:

    WP-CLI
    wp cron event delete HOOK_NAME

    Deleting by hook name removes all of its scheduled occurrences, whatever their arguments.

  4. 04

    Confirm it stays gone

    Load a few pages to let WP-Cron run, then list the events again:

    WP-CLI
    wp cron event list --fields=hook,next_run_relative,recurrence --skip-plugins --skip-themes

    If the hook is back, something else is still registering it: another file, a drop-in, or code above WordPress.

  5. 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.