My blog has not been updated for several months.
Indeed, I was peacefully enjoying an ice cream in front of The Simpsons when I received this email from WordPress (French notification text, shown as received):
|
Quelqu’un a demandé la réinitialisation du mot de passe pour le compte suivant : |
|
Titre du site : Stunivers |
|
Identifiant : [nom du compte admin ici] |
|
Si ceci est une erreur, ignorez cet e-mail et rien ne se passera. |
|
Pour renouveler votre mot de passe, cliquez sur le lien suivant : |
|
> lien de réinitialisation ici |
|
Cette demande de réinitialisation de mot de passe provient de l’adresse IP xxx.xxx.xxx.xxx . |
|
1. Initial Information
After some investigation, it appears that the hacker likely didn’t hide their tracks and was part of an automated web scan.
The IP address mentioned in the logs appeared multiple times before the hack, a total of 17 times.
The origin country is India, specifically Delhi.
Given the location, I doubt this is a false geolocation, though it won't be much help to me.
Worst of all, the User-Agent showed an outdated Google Chrome on Windows 10, while the attack script itself was running from a VPS.
I can’t express how frustrating that is.
2. The Damage
It was impossible for me to connect to my production environment with my own credentials. However, it seems no sensitive information leaked based on the logs and actions taken on the site.
3. What Was in Place for Security
My blog doesn’t attract much attention online; currently, only three visits per year!
But that doesn't mean automated hacking campaigns have no impact—this is likely what happened to me.
However, despite its minimalism, my hosting provider had quite a few protections in place, such as suspicious activity detection. This definitely kept many bots at bay (and there are plenty when you're on WordPress!).
On my end, I made sure to change the wp-admin login link to avoid a major attack vector.
I also manually updated my blog since I have an update pipeline in place!
But then I realized that this pipeline ... it worked for some versions but not others.
It didn't fail completely though, so I wasn't receiving any failure emails. As a result, I wasn't worried.
And that's entirely my fault—I can only blame myself.
4. What the Hacker Used
The hacker exploited this specific CVE:
CVE-2026-19632 — TranslatePress
This is the plugin I use to translate my site, and it had a vulnerability up until version 3.3.1 when it was patched.
This silly bug allowed an unauthenticated attacker to retrieve the password reset URL of an administrator, then use it to set a new password and take over the admin account.
The mechanism is quite ridiculous: The hacker triggers a password recovery procedure first, and TranslatePress can log the reset URL in its translation database.
Via a publicly accessible AJAX request, it can then retrieve this URL, including the password reset key.
The attacker can then change the admin's password—or, well, that's how you get thoroughly owned...
5. Complete Rewrite of Translations
Given how quickly things can go wrong on WordPress, I coded all my plugins.
All except TranslatePress, which is a lot of work...
The TranslatePress plugin offers some interesting features, and I must admit that seeing it, I was lazy.
So, I trusted the plugin to handle translations without having to recode them.
And in the end, I ended up coding my own translation plugin.
It took me a lot of time because the devil is in the details.
First, I wanted to read TranslatePress's code and then understand the parts that concerned and interested me.
My code isn't based on it; it’s more inspired by it.
All this to avoid having something public.
6. Rewrite of the Update Pipeline
The first time I set up my blog, I used my hosting provider's tools.
I only had email alerts at that point.
Then, I installed WordPress myself to overcome a silly limitation that shouldn't have blocked anyone but was blocking me on something I can’t even remember now.
Here, I decided not to chase after updates but to create my own update pipeline.
It's far from being a simple git pull on the project.
I used Cron, WP-CLI with plugin update --all, obviously core update, and no theme updates since I had coded mine very quickly.
We don't forget to verify checksums via wp core verify-checksums, and we're good.
Since I only have one external plugin, it seemed fine.
But my pipeline broke precisely because of a silent failure caused by TranslatePress not updating anymore.
And that was entirely my fault.
I had planned to capture the return code from WP-CLI, but my handling behind the scenes was completely flawed.
Basically, I handled one specific error correctly during testing. But if any other error occurred, I just logged it and continued.
So something like this:
|
output=$(wp plugin update --all 2>&1) |
exit_code=$? |
|
if [[ $exit_code -ne 0 ]]; then |
if [[ "$output" == *"Error: Download failedd."* ]]; then |
echo "An error occured" |
log-mail "$output" |
exit 1 |
else |
log "$output" |
exit 2 |
fi |
fi |
|
# The rest of the pipeline |
|
Alright.
First of all, the code is pretty messy.
But more importantly, the problem lies here: I handled a known error, not general errors.
My pipeline could therefore encounter an issue during plugin updates, log it, and then consider everything was fine enough to continue.
And since I didn't have any alerts in this specific case, I obviously received no emails.
I thought my updates were working.
They weren’t.
As a result, WordPress wasn't updating either, everything was stuck because of poor code management on my end.
The most frustrating part is that this error came directly from my tests.
I had written specific handling for the case I was interested in at the time, it worked, I was happy, and I deployed it to production without thoroughly testing other cases or zooming out to look at the script as a whole.
I was too excited about getting everything online.
And a few months later, I ended up with a vulnerable plugin that hadn't been updated, even though I had written an entire pipeline specifically to avoid such situations.
Great job.
6.1. What I Changed
Obviously, I fixed my mistake.
But I didn’t just add three lines to handle the TranslatePress case.
I reviewed how my pipeline handles errors and the order of operations it performs. Here's a simplified and completely modified version to show you how I thought about it:
#!/bin/bash |
|
set -o pipefail |
|
SITE_DIR="/home/USER/www" |
LOG_DIR="/home/USER/logs" |
LOG_FILE="$LOG_DIR/update-$(date '+%Y-%m-%d').log" |
|
MAIL_TO="admin@example.com" |
SITE_NAME="Mon WordPress" |
|
mkdir -p "$LOG_DIR" |
|
# ============================================================ |
# LOG + MAIL |
# ============================================================ |
|
log-mail() { |
local text="$1" |
local exit_code="$2" |
local date |
date="$(date '+%Y-%m-%d %H:%M:%S')" |
|
echo "[$date] $text" >> "$LOG_FILE" |
|
if [ "$exit_code" -eq 0 ]; then |
printf '[%s] SUCCESS : %s\n' "$date" "$text" |
printf '%s\n' "$text" | mail \ |
-s "[OK] $SITE_NAME - Pipeline" \ |
"$MAIL_TO" |
else |
printf '[%s] ERROR : %s\n' "$date" "$text" |
printf '%s\n' "$text" | mail \ |
-s "[ERREUR] $SITE_NAME - Pipeline" \ |
"$MAIL_TO" |
fi |
} |
|
|
# ============================================================ |
# EXECUTE A COMMAND |
# ============================================================ |
|
run_step() { |
local name="$1" |
shift |
|
echo "" |
echo "==========================================" |
echo "$name" |
echo "==========================================" |
|
local output |
output=$("$@" 2>&1) |
local exit_code=$? |
|
echo "$output" >> "$LOG_FILE" |
|
if [ "$exit_code" -ne 0 ]; then |
log-mail "$name as failled : |
|
$output" "$exit_code" |
|
exit "$exit_code" |
fi |
|
log-mail "$name completed successfully : |
|
$output" 0 |
} |
|
|
# ============================================================ |
# INITIALISATION |
# ============================================================ |
|
cd "$SITE_DIR" || { |
log-mail "Unable to accesss to $SITE_DIR" 1 |
exit 1 |
} |
|
log-mail "Start the updating pipeline" 0 |
|
|
# ============================================================ |
# WORDPRESS - CORE |
# ============================================================ |
|
run_step \ |
"Updating WordPress Core" \ |
wp core update |
|
|
# ============================================================ |
# WORDPRESS - PLUGINS |
# ============================================================ |
|
run_step \ |
"Updating WordPress Plugins" \ |
wp plugin update --all |
|
|
# ============================================================ |
# WORDPRESS - THEMES |
# ============================================================ |
|
run_step \ |
"Updating THEMES (i'm not using that but ..)" \ |
wp theme update --all |
|
|
# ============================================================ |
# PHP - COMPOSER |
# ============================================================ |
|
run_step \ |
"Updating Composer Dependencies" \ |
composer update --no-interaction --prefer-dist |
|
|
# ============================================================ |
# NODE / REACT |
# ============================================================ |
|
run_step \ |
"Updating Node Dependencies" \ |
npm ci |
|
|
# ============================================================ |
# REACT - BUILD |
# ============================================================ |
|
run_step \ |
"Build React" \ |
npm run build |
|
|
# ============================================================ |
# FIN |
# ============================================================ |
|
log-mail "Pipeline completed with success." 0 |
|
exit 0 |
The key takeaway is that an error can no longer be something I simply log and forget.
And most importantly, it must be testable and verifiable.
So I tested my code instead of just thinking:
« It works now, so it will always work. »
If a critical step fails, the pipeline should know about it, stop correctly, and alert me.
And most importantly, I no longer consider it sufficient to test only the errors I am aware of.
Because that's exactly how I ended up with a pipeline that worked perfectly... until it didn't anymore.
This time, I also reviewed my blog’s dependencies.
Now, I will have only WordPress code and my own code left.
As you can see, we always come back to writing tests, even for a pipeline script.
7. Going Live
I am writing this article from my development environment, which won't be the case when you read it.
You know that everything I do is open source.
The code of my new plugin is not yet released.
I want to take the time to detect any bugs I might have overlooked.
I will keep you updated once I'm ready to release it.
Comments
No approved comments yet.
Sign in with a commenter account to post a comment. Sign in.