The UpdraftCentral breach worked because thousands of WordPress sites held a key that let someone else control them, and many owners didn’t know it was there. The key was created by a pre-ticked checkbox, it never expired, and removing the site from the dashboard didn’t remove it.
UpdraftCentral isn’t special. Every WordPress site collects ways for outside parties to get in: management dashboards, backup services, application passwords, old admin accounts, logins that never ended. This post is a read-only audit you can run with WP-CLI to list all of them, plus the places WordPress can’t see.
What counts as remote access
Anything that lets someone change your site without sitting at your login screen:
- Connection keys for management and backup services, such as UpdraftCentral, ManageWP, MainWP, InfiniteWP, Jetpack and BlogVault. These usually live in
wp_options. - Application passwords, WordPress’s built-in tokens for the REST API, stored per user. Each one can do anything its user can.
- Accounts that can install code. Administrators, but also any role a plugin or backdoor has quietly given
install_pluginsormanage_options. The UpdraftCentral backdoor created an “Editor” with exactly those. - Active login sessions. A session cookie works without a password or two-factor check, and that backdoor logged in by setting one directly.
- Code that runs without being listed: must-use plugins and drop-ins, plus scheduled tasks that can call home.
The audit, step by step
Every command below only reads. Run them from the WordPress root on the server, or with --path or an SSH alias from your own machine. The application password commands need WP-CLI 2.5 or later.
1. Management and backup plugins
wp plugin list --fields=name,status,version
Look for anything that offers remote control. Common slugs include updraftplus (UpdraftCentral), worker (ManageWP), mainwp-child, iwp-client (InfiniteWP), jetpack, ithemes-sync (Solid Central), blogvault-real-time-backup and wp-health (WP Umbrella). That list isn’t complete. The question for every plugin is the same: can a service outside this server tell it what to do?
2. Keys and tokens in the options table
wp option list --search="*key*" --fields=option_name,size_bytes
wp option list --search="*token*" --fields=option_name,size_bytes
wp option list --search="*secret*" --fields=option_name,size_bytes
This lists names and sizes only, never values, so it’s safe to run in front of a screen-share. Expect noise: license keys, API keys for maps and email. You’re looking for anything you can’t explain, and for options belonging to plugins you no longer use. Uninstalling a plugin doesn’t always clean up after it.
For UpdraftCentral specifically, count the keys without printing them:
wp eval 'echo count( (array) get_option( "updraft_central_localkeys", array() ) ), PHP_EOL;'
Anything above zero means the site accepts UpdraftCentral commands.
3. Who can install code
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
Then check by capability, not just by role, because roles can be edited and individual users can be given extra capabilities:
wp eval 'foreach ( get_users() as $u ) {
if ( user_can( $u, "install_plugins" ) || user_can( $u, "manage_options" ) ) {
echo $u->ID, " ", $u->user_login, " ", implode( ",", $u->roles ), PHP_EOL;
}
}'
Anyone on this list who isn’t an administrator deserves an explanation.
4. Application passwords
for id in $( wp user list --field=ID ); do
echo "== user $id"
wp user application-password list "$id" --fields=name,created,last_used,last_ip
done
Each application password has a name, a creation date, and when and from where it was last used. Delete any you can’t name and any that haven’t been used in months. They don’t expire on their own.
5. Active sessions
for id in $( wp user list --role=administrator --field=ID ); do
echo "== user $id"
wp user session list "$id" --fields=login_time,expiration_time,ip,ua
done
Look for sessions from IP addresses or browsers you don’t recognize, or ones lasting far longer than normal. wp user session destroy <user> --all logs a user out everywhere, which is the right move after any cleanup.
6. Code that doesn’t show on the Plugins screen
wp plugin list --status=must-use --fields=name,version
wp plugin list --status=dropin --fields=name,version
wp cron event list --fields=hook,next_run_relative,recurrence
Must-use plugins run on every request and can’t be deactivated from the dashboard. Drop-ins such as db.php and advanced-cache.php replace parts of WordPress itself, and both are favorite hiding places for persistent malware. In the cron list, look for hooks you can’t match to a plugin you use.
The whole thing as one script
The same checks, read-only, in one file:
#!/usr/bin/env bash
# remote-access-audit.sh: read-only audit of who and what can control
# this WordPress site. Prints names, never secret values.
# Usage: ./remote-access-audit.sh [--path=/var/www/html]
set -u
WP="wp $*"
section() { printf '\n== %s\n' "$1"; }
section "Plugins (look for remote management and backup services)"
$WP plugin list --fields=name,status,version
section "Options that look like keys, tokens or secrets (names only)"
for term in key token secret; do
$WP option list --search="*${term}*" --fields=option_name,size_bytes --format=csv | tail -n +2
done | sort -u
section "UpdraftCentral keys (count)"
$WP eval 'echo count( (array) get_option( "updraft_central_localkeys", array() ) ), PHP_EOL;'
section "Users who can install plugins or change settings"
$WP eval 'foreach ( get_users() as $u ) {
if ( user_can( $u, "install_plugins" ) || user_can( $u, "manage_options" ) ) {
echo $u->ID, " ", $u->user_login, " ", implode( ",", $u->roles ), PHP_EOL;
}
}'
section "Application passwords"
for id in $( $WP user list --field=ID ); do
rows=$( $WP user application-password list "$id" --fields=name,created,last_used,last_ip --format=csv | tail -n +2 )
[ -n "$rows" ] && printf 'user %s:\n%s\n' "$id" "$rows"
done
section "Administrator sessions"
for id in $( $WP user list --role=administrator --field=ID ); do
rows=$( $WP user session list "$id" --fields=login_time,ip,ua --format=csv | tail -n +2 )
[ -n "$rows" ] && printf 'user %s:\n%s\n' "$id" "$rows"
done
section "Must-use plugins and drop-ins"
$WP plugin list --status=must-use --fields=name,version
$WP plugin list --status=dropin --fields=name,version
section "Scheduled events"
$WP cron event list --fields=hook,next_run_relative,recurrence
Save the output with a date in the filename. Next month, run it again and compare the two files. A new line in the diff is where to start asking questions.
What WordPress can’t see
A WP-CLI audit only covers what lives inside WordPress. Finish the list by hand:
- SSH keys in
~/.ssh/authorized_keysfor every account on the server - SFTP and FTP accounts in your hosting panel, including ones made for a contractor years ago
- Hosting panel users and API tokens, which can usually do anything on the account
- Deploy keys for Git and CI pipelines that push code to the server
- System cron jobs, with
crontab -lfor each user - Database users other than the one in
wp-config.php
Keeping the list short
For each item the audit turns up, ask four questions: who holds the other end, what can it do, when does it expire, and would you notice if someone else were using it? Remove anything that fails, and then:
- Give tools their own accounts. Each service or script gets its own user and its own application password, so you can revoke one without breaking the rest, and the logs show who did what.
- Give them the least they need. A tool that only publishes posts doesn’t need to install plugins. The same goes for remote management: if it doesn’t need
install_plugins, connect it through an account that doesn’t have it. - Disconnect what you don’t use. A dashboard you tried once and forgot about is still a key, and someone else may be keeping it.
The goal isn’t zero remote access. Most sites need some. The goal is a list where every line has a name, an owner and a reason, and nothing on it surprises you.