VPS Hosting

How We Updated 24 WordPress Installations Through QEMU Guest Agent and Deliberately Skipped One

10 min read

The trigger was a publicly disclosed range of WordPress versions associated with the wp2shell vulnerability. We had several QEMU virtual machines, different Linux distributions, multiple hosting control panels, and a fairly long list of domains. Logging into every server by hand, finding each installation, and running updates one at a time was not a serious option.

Manual work is not beneath us. It was simply too easy to miss something here. One server runs Plesk, another cPanel, and a third myVesta. WordPress may live in public_html, a Plesk subscription directory, a staging directory, or even below cgi-bin. A DNS domain list is not enough either. A domain may still exist while no longer being hosted. It may be active in the web server but absent from the control panel. A WordPress installation can also exist on disk without a direct public domain.

So we started at the hypervisor and worked through QEMU Guest Agent.

First, we had to establish what actually existed

The inventory process visited every running libvirt guest with a responsive QEMU Guest Agent. Inside each VM, it queried the sources that already knew something about hosted domains:

  • cPanel/WHM API
  • Plesk CLI
  • Vesta and myVesta CLI
  • active nginx configuration
  • Apache virtual host configuration

There was no network scan, no attempt to log into websites, and no guessing domains from certificates. We read the state that already existed on the servers.

That immediately showed how messy a domain inventory can become. Alongside real websites, we found autodiscoverwebmailcpanel, temporary cPanel hostnames, local Plesk names, localhost.localdomainexample.com, and similar technical values.

The first filter also contained a small but useful mistake. Its hostname regex accepted IPv4 addresses as domains because numeric labels passed neatly through [a-z0-9]. That was not a security flaw, but it was a good way to produce a report nobody would fully trust. We added a separate check that rejects the IPv4 form before hostname validation.

Another mistake was more mundane. A regex had accidentally been split across two lines, and the Bash script could not even start. bash -n caught it immediately. Security design does not help much when the script has not passed a syntax check.

The final inventory was saved in two forms: a detailed TSV containing the VM, source, and domain, plus a cleaned list of public domains.

We searched for WordPress in the filesystem, not on homepages

The next step was not an HTTP request to every domain. That would have introduced redirects, CDNs, protection services, staging sites without DNS records, disabled virtual hosts, and installations that existed on disk but were not publicly reachable.

Instead, we inspected the usual web trees through Guest Agent:

/var/www
/home
/srv/www
/usr/local/www

The scanner looked for wp-includes/version.php, stayed on the same filesystem, and skipped known trouble spots such as cPanel virtfs trees and users’ .trash directories.

At this stage, the version was read directly from the file. No PHP code was executed. That reduced the chance that a compromised wp-config.php, plugin, or theme could influence the audit itself.

The discovered installations were then mapped to domains. For cPanel, we used document-root data from the WHM API. With Plesk and myVesta, standard directory layouts and the known domain inventory helped. Mapping by the nearest path component mattered. One Plesk subscription may contain several additional domains, so the subscription name is not necessarily the domain of a particular WordPress installation.

The audit found 25 WordPress installations whose versions matched the affected range we were checking.

Several cases were already interesting:

  • a main site and a separate WordPress installation in a subdirectory
  • staging installations
  • addon domains inside a Plesk subscription
  • two installations associated with the same public domain
  • WordPress files whose database did not match the configured table prefix

The last case turned out to be the most important one.

A dry run is more useful when it knows how to say no

The update was designed around WP-CLI, with a requirement to remain on the current release branch. WordPress provides an official command for that:

wp core update --minor

Before changing anything, the script checks:

  • whether QEMU Guest Agent responds
  • whether a supported PHP CLI with the mysqli extension is available
  • whether WP-CLI is usable
  • the Unix owner of the installation
  • that the owner is not root
  • the owner’s home directory
  • whether that owner can write to the WordPress root, wp-admin, and wp-includes
  • the current WordPress version
  • the result of wp core is-installed
  • whether WP-CLI sees an available minor update

Every WordPress command runs as the site’s owner through runuser. We did not fix failed checks with chmod -Rchown -R, or a similar hammer. If an installation is not writable by its real owner, it is skipped.

The dry run exposed several problems that a simple loop around wp core update would not have handled well.

WP-CLI was missing on some VMs. On one Plesk server, WP-CLI existed, but its shebang used /usr/bin/env php while no generic php command was installed. Plesk’s PHP CLI was available at a path such as:

/opt/plesk/php/8.4/bin/php

Creating a new global symlink was unnecessary. The script finds a suitable PHP CLI and invokes WP-CLI explicitly:

/opt/plesk/php/8.4/bin/php /usr/local/bin/wp

This avoids changing the system PATH or guessing which PHP version the administrator wants as the default.

Where WP-CLI is genuinely absent, a separate prepare mode handles installation. It downloads the official PHAR, signature, and public key, verifies the published fingerprint and GPG signature, tests the PHAR in a temporary directory, and installs it as:

root:root 0755 /usr/local/bin/wp

An unknown or invalid signature stops the installation. There is no quiet fallback.

One database was healthy, but WordPress still was not installed

The most interesting result came from a staging installation.

wp db check successfully checked a large number of tables. At first glance, the database looked perfectly usable. However:

wp core is-installed

returned an error.

WP-CLI reported that wp-config.php expected the prefix wpstg0_, while the database contained WordPress tables under a different prefix.

The first version of our logic accepted a successful wp db check as a fallback confirmation and allowed the preflight to continue. That was too optimistic. A MariaDB table integrity check does not prove that the current WordPress root and its wp-config.php describe an installed application using those tables.

We tightened the fail-closed behaviour. A failed wp core is-installed now always skips the installation. wp db check still runs as a diagnostic, but it no longer grants permission to update.

That decision left one installation unpatched. It was the correct result. An exit code of 1 and a clear SKIPPED_NOT_INSTALLED are better than a successful-looking report after changing the wrong files or database.

What happened during the actual run

For every installation that passed preflight, the script created an individual SQL backup in the owner’s home directory:

~/.wp-core-update-backups/TIMESTAMP/

The core update started only after a successful export. Afterwards, the script checked:

  • the new version
  • that it remained on the same major.minor branch
  • that the version was no longer in the affected range
  • the WordPress database version
  • core checksums
  • ownership and permission values on important directories

The result:

  • 24 WordPress installations updated successfully
  • one installation safely skipped
  • no jump to a new major or minor branch
  • no failed database backup among the updated installations
  • no observed ownership or basic permission changes

Versions moved from 6.9.4 to 6.9.5, and from 7.0 or 7.0.1 to 7.0.2.

During some core updates, WordPress also updated translation packages automatically. That was not a separate command from our script; it was behaviour inside the WordPress updater. The complete command output was retained, so this detail is visible in the log.

A checksum warning is not a verdict

On several installations, wp core verify-checksums reported additional files:

wp-admin/error_log
wp-includes/.htaccess
wp-cli.phar
wp-gv.zip
wp-cloudflare-single-purge.tar

Most error_log files probably have an ordinary explanation. An .htaccess file inside wp-includes may be a local protection measure. Archives and a locally stored wp-cli.phar deserve closer attention.

The message File should not exist does not prove compromise. It only says that the file is not part of the official WordPress package for that version and locale.

The script therefore does not delete those files. Automatically removing everything the checksum does not recognise would be dangerous, especially on older or customised installations. The findings go into the log for separate review.

On one staging installation, checksum verification failed before the update because a standard file was missing and several extra files were present. After a full localised core update, the official core passed verification again, while the local extra files remained listed as warnings. That distinction is useful: the integrity of the official core was confirmed, but local content was not automatically declared safe.

An updated version is not the same as a clean server

Moving away from a vulnerable version fixes the immediate version problem. It does not tell us whether the vulnerability had already been exploited.

If an installation was publicly reachable while running an affected version, it still makes sense to inspect:

  • additional PHP files in core directories
  • unexpected administrator accounts
  • changes to wp-config.php
  • active plugins and must-use plugins
  • cron events
  • recent file changes
  • web and PHP logs
  • processes and outbound connections
  • known web-shell patterns

A checksum is a useful signal, but it is not a malware scanner. A version audit is not incident response either.

From three scripts to one tool

After the remediation run, we combined inventory, audit, and update logic into one tool: QGA WordPress Manager.

Running it without arguments opens an interactive menu:

chmod 0700 qga-wp-manager.sh
./qga-wp-manager.sh

It includes two profiles:

  • wp2shell, the default profile for a targeted check of the known affected range
  • minor, which checks all discovered installations and updates within the current release branch

The menu guides the operator through the host check, inventory, audit, dry run, WP-CLI preparation, and update application. PREPARE and APPLY require the operator to type an exact confirmation. Pressing Enter on an empty prompt cancels the action.

The advanced command-line mode remains available for automation and cron jobs, but it is no longer the primary interface.

The code and documentation are public:

github.com/lukapaunovic/qga-wp-manager

This tool is not a magic button. Guest Agent can execute powerful commands inside a VM, often with elevated privileges. The safety comes from a narrow set of operations, input validation, timeouts, running WordPress commands as the site owner, mandatory backups, and a willingness to leave a questionable installation alone.

The most valuable result was not the number 24. It was the one installation the script refused to touch.