Clay Tech

"clay-works make things real"

translated from clazytech.com

I recovered a WordPress site left untouched for 13 years

Previously, I wrote about what came out of a WordPress installation left untouched for over 10 years.

This time is a record of the recovery work itself. It’s less a set of procedures than a record of where assumptions and reality diverged.

Before starting the work, I wrote a handover document for myself: symptoms, cause, environmental constraints, procedure. Three premises written there turned out to differ from the facts.

I should note I come from an electrical circuit design background. Running a web server is not my main job.


Premise 1: The symptom description was out of date

The document said this.

Your PHP installation appears to be missing the MySQL extension

The actual response was this.

HTTP/1.1 500 Internal Server Error
(Response body: 0 bytes)

The error message had stopped appearing.

The “current symptoms” in a handover document are a snapshot from the time it was written. When you resume work, you need to capture them again yourself.


Premise 2: The file migration hadn’t finished

What the document said.

Move the entire WordPress core from the root to the public folder

In reality it was still in the root.

~/web/
├── wp-admin/         still there
├── wp-includes/      still there
├── wp-content/ 319MB
└── mixture/          public folder
    ├── wp-config.php ○
    ├── .htaccess ○
    ├── wp-*.php ○
    ├── wp-admin/     missing
    ├── wp-includes/  missing
    └── wp-content/   missing

The directories had been copied one at a time through the browser’s FTP screen, and the work had stopped just before the three largest ones.

wp-settings.php was present but wp-includes/load.php was not. require failed, producing a fatal error, which became the 500 with zero bytes. This was the cause of the symptom change in Premise 1.

Because the migration hadn’t completed, the wp-content 319MB on the root side had been left as-is. Had the move been done as a single operation, it might not have been left behind on failure.

Transferring several thousand files isn’t realistic through a browser FTP screen. At this point I switched to the CLI.


Premise 3: The PHP version floor was set per domain

What the document said.

PHP version 7.4 (CGI). 7.4 is the floor; it cannot be lowered further.

This was written from looking at the dropdown in the admin panel. In fact, no options below 7.4 are displayed there.

Results from measuring other domains on the same account.

DomainPHPStatus
Recovery target7.4.33Down
Other site A5.4.45Running normally
Other site B5.3.29Running normally
Other site C8.3.33Running normally

The PHP version was set per domain, and the same account had 5.3 and 8.3 coexisting. “7.4 is the floor” described the options available for the target domain, not a constraint on the whole account. The rule against lowering the version once raised applies only to that one domain.

Shared hosting constraints differ depending on whether they’re scoped at the account level, the domain level, or the directory level. You need to check which scope you’re actually looking at.


Why only one side couldn’t connect to the same DB

The recovery target and other site A use the same database. The credentials are the same too. Only one side can’t connect.

The cause was the PHP version. PHP 5.4’s ext/mysql supports the old authentication protocol, but PHP 7’s mysqlnd does not.

This is what’s underneath it.

mysqldump: Got error: 2049: Connection using old (pre-4.1.1)
authentication protocol refused

The DB user’s password was stored in old_password format.

The mysql command could connect with the same credentials. This comes down to the mysql client having secure_auth OFF while mysqldump has it ON. The error message points at the client’s option, but the cause is the hash format stored server-side.


Resetting the password doesn’t fix it

The fix I’d written in the handover document.

PHP 7.1 and later can’t use the old_password format. Fix: reset the password in the admin panel to update it to native_password format

That doesn’t work on this server. Confirming it takes one line.

SELECT LENGTH(PASSWORD('probe')), LENGTH(OLD_PASSWORD('probe'));
-- 16, 16

A new-format hash is 41 bytes; the old format is 16 bytes. Both came out 16.

SHOW VARIABLES LIKE 'old_passwords';
-- old_passwords = ON

This MySQL 5.1 stores even a newly set password in the old format. Resetting it in the admin panel still won’t let PHP 7.4 connect.

At this point the option of “just fix the password” disappeared, and migrating the DB server became necessary. That was a step not in the original plan.

Password-reset-style fixes come up empty unless you check which format the server actually stores. You can tell with SELECT LENGTH(PASSWORD('any string')).


Checking the scope of impact

Before changing the DB, I checked the impact on other sites.

All 7 sites had the same string as the DB username in wp-config.php. It looked like they were sharing the same user.

To compare without leaving plaintext around, I matched on the first characters of the md5 hash.

DBPassword (md5 prefix)DB hostSites using it
A7fe38c58…mysql5175 sites
Beec6d786…mysql0252 sites
C21b4ea10…mysql1321 site

Each DB was different. The username strings just happened to match; since the DB hosts differ too, they’re actually separate users.

The scope of impact shrank from 7 sites to 5 sites.

If the only goal is comparison, there’s no need to expose the plaintext. Plenty of situations only require knowing whether two things match, not what the value actually is.


Commands available in the server’s shell

After switching to the CLI, the first thing I did was check the environment.

CommandAvailable
tar gzip unzip rsync findYes
mysql mysqldumpYes (5.6.51)
php (CLI)No
mktempNo
/dev/stderrNot usable

Without php CLI, WP-CLI can’t be used. This is worth confirming before you settle on an approach.

Without mktemp, temporary files need to be created at a fixed path. 2>>/dev/stderr fails and takes down the whole pipe with it, so output goes to a file instead.

A shared hosting shell isn’t a standard Linux environment. Take stock of it with command -v before starting work.


The migration destination was MySQL 8.4, the client was 5.6

Creating a new DB to resolve old_passwords resulted in MySQL 8.4 being assigned. PHP 7.4’s mysqlnd supports it.

Meanwhile, the mysql client on the server is 5.6.51. There was a chance it wouldn’t support MySQL 8.4’s default authentication method.

As a countermeasure, I installed a newer client on my own Mac and set up a route to import over SSH port forwarding. In the end I didn’t use it, since the 5.6 client turned out to connect fine.

When migrating, check the client-side version as well as the server side.


The wp-content/db.php drop-in

Checking wp-content/ before swapping the core, I found db.php present.

It was a WordPress drop-in: a caching plugin from 2011 that had replaced the DB connection layer with its own class.

$GLOBALS['wpdb'] = new dbrc_wpdb( DB_USER, DB_PASSWORD, DB_NAME, DB_HOST );

Even after updating the core, leaving this in place would break things on its own. It also used a style of constant definition that was removed in PHP 8.

db.php object-cache.php advanced-cache.php directly under wp-content/ don’t show up in the plugin list, but they load with priority. If symptoms persist even with all plugins deactivated, check these.

I renamed it to disable it. It’s a query-cache-only feature, so this is reversible.


Translation files in the Japanese-language package

What the handover document said.

rm -rf wordpress/wp-content   # to protect existing themes and plugins

Overwriting with the new core’s wp-content would wipe out themes, plugins, and uploaded images, so this decision itself was correct.

However, in the Japanese-language package, the translation files are included in wp-content/languages/.

wordpress/wp-content/languages/ja.mo         413KB
wordpress/wp-content/languages/admin-ja.mo
wordpress/wp-content/languages/ja-*.json

Deleting it left the old .mo from the 4.9 era in place, and part of the admin panel displayed in English.

I split out only languages/ separately and merged it into the existing directory.

wp-content = user data” is a premise of the English-language package. Check the contents with unzip -l before starting work.


WordPress’s auto-update had already run

After replacing the core with 5.9, I’d planned to migrate the DB schema with upgrade.php.

Checking db_version before running it, I found it was already at the value 5.9 requires.

Checking the generator tag, what had been deployed was 5.9, but what was running was 5.9.16. The file’s modification time was 5 minutes after deployment.

WordPress’s background auto-update had run via WP-Cron on the first access, and had carried out the core self-update, the schema migration via upgrade.php?step=upgrade_db, and even sent the completion notification email.

Since WordPress 3.7, automatic updates for minor releases are enabled by default. Even if you deploy an older version, a patch gets applied on first access.

Since 5.9 has a known vulnerability, ending up at 5.9.16 is desirable in itself. But during recovery work, my own operations and the results of automatic processes get mixed together. The only thing that let me tell them apart was the file timestamp.


The actual work procedure

The plan I wrote in the handover document.

1. Back up
2. Swap the core
3. Migrate schema with upgrade.php
4. Update to the latest version

The actual procedure.

1. Back up (files + DB)
2. Create a new DB (MySQL 8.4) and migrate 29 tables      ← added
3. Point wp-config.php at the new DB                       ← added
4. Copy wp-content from the root to the public folder       ← added
5. Disable the db.php drop-in                                ← added
6. Deploy the core + merge translations
7. Run upgrade.php with all plugins deactivated             ← changed
8. Verify display → restore plugins one at a time
9. Update to the latest version

Five steps were added. Three of them trace back to premises in the handover document that didn’t match the facts.

All three were the kind of error that writing down how to verify a conclusion, not just the conclusion itself, would have prevented. If it had read “the admin panel dropdown doesn’t show anything below 7.4” instead of “7.4 is the floor,” I would have caught the scope mix-up. A handover document should include the grounds for a conclusion, not just the conclusion itself.

The next post will cover the part of this work that took the most time: a case where a judgment was wrong four times over.


References


This piece was written with original concept and direction by Yuichiro Kuzuryu, text by AI.


Originally published in Japanese at https://clazytech.com/2026/08/1768/. Translated with LLM assistance and reviewed before publication.