The phone rings: what you know and what you do not
The client describes a symptom, rarely a cause: the site redirects visitors, sometimes on mobile only; the browser shows "Deceptive site ahead"; Google flags hacked content; the host has suspended the account. The official WordPress documentation points out that "hacked" is an ambiguous term: first record the indicators of compromise (site listed by a search engine, account disabled, users nobody created). Write down the time of the call, the time zone, the client's own words and the latest known changes: a new plugin, a changed theme, access given to a contractor. This is the first line of the incident log.
Six rules to set from the first call
- One incident lead — a single person decides, writes to the log and speaks to the client; everyone else executes.
- One voice towards the client — the client does not receive two versions of the facts.
- A dated log — every action is recorded with the UTC time, the author, the command run and its result.
- Nothing is deleted before it is copied — a deleted file no longer tells you how the attacker got in.
- No live fixes — isolate, analyse, then decide.
- No promise about the cause or the duration — "fixed" is only said after the reopening checks.
Isolate without destroying evidence
Open the incident log (5 min)
date -u +%Y-%m-%dT%H:%M:%SZ
Record this time, the reported symptom, the site name, the host and the people involved. Every following line gives the UTC time, the action and its result. The file will be handed to the client, so it stays factual.Cut public access (10 min)
Serve a maintenance page with HTTP status 503 from the web server or the CDN, or restrict access to the team's IP. Do not rename plugin or theme folders yet: that changes the modification dates you will want to read. If the site is serving malware, cutting access comes before copying.
Copy files and database (15 min)
mkdir -p ~/incidents/client-a/YYYY-MM-DDrsync -a /var/www/client-a/ ~/incidents/client-a/YYYY-MM-DD/files/wp db export ~/incidents/client-a/YYYY-MM-DD/base.sql --skip-plugins --skip-themesfind ~/incidents/client-a/YYYY-MM-DD -type f -exec sha256sum {} + > ~/incidents/client-a/hashes-YYYY-MM-DD.txtchmod -R a-w ~/incidents/client-a/YYYY-MM-DD
The options--skip-plugins --skip-themesavoid loading plugins and theme, but the site's code remains suspect. The hashes and the removal of write permissions let you prove later that the copy did not change.Copy the logs (5 min)
Collect the web server access and error logs and those of the hosting panel. Retention is short on many plans: this is the evidence that disappears fastest, and the one that dates the intrusion.
Close non-essential access (10 min)
Change the panel, SFTP and SSH passwords, remove contractors' access, then replace the salts in
wp-config.phpto invalidate sessions. This rotation is provisional: a backdoor can hand control back to the attacker, so it is repeated after cleaning.Notify the host (5 min)
On shared hosting, the infection can go beyond the site. Inform support, ask for the logs to be kept and, if the account is suspended, for read access rather than reactivation. Ask whether the server IP appears on a mail blocklist.
Check integrity, command by command
Core files (5 min)
wp core verify-checksums --include-root
Compares installed files with the WordPress.org checksums. A "File doesn't verify against checksum" message points to a modified file and "File should not exist" to an added one;--include-rootextends the check to the root directory.Plugins (5 min)
wp plugin verify-checksums --all
Same principle for plugins hosted on WordPress.org. A premium plugin or a custom theme has no published checksum: compare it with a clean copy from the vendor.PHP where it does not belong (10 min)
find wp-content/uploads -name '*.php'find . -type f -name '*.php' -mtime -30
The media folder holds no code: any PHP there is suspect. The second command lists PHP files modified in the last thirty days; replace 30 with the estimated age of the intrusion. A modification date can be forged: it guides, it does not prove.Suspicious code patterns (10 min)
grep -rEl 'eval\(|base64_decode\(|gzinflate\(' wp-contenteval,base64_decodeandgzinflateare used to hide code, but legitimate plugins use them too: read every result. A search with no result does not prove the absence of compromise.Accounts and settings (10 min)
wp user list --role=administrator --fields=ID,user_login,user_email,user_registeredwp option get siteurlwp option get homewp option get users_can_registerwp option get default_role
Look for an unknown administrator, a creation date close to the intrusion, a modifiedsiteurlorhome(a frequent cause of redirects), open registration with an overly high default role. This data lives in thewp_users,wp_usermetaandwp_optionstables; the prefix may differ.Scheduled tasks and boot files (10 min)
wp cron event listcrontab -lls -la wp-content/mu-pluginsfind . -name '.htaccess'
An unknown scheduled task can recreate the malicious file every night. Must-use plugins (mu-plugins) load without activation. The.htaccessfile is often modified: rules reserved for mobile visitors or visitors coming from a search engine explain why the client sees nothing from their desk. There may be others in subfolders.Database content (10 min)
wp db search '<script' --all-tables-with-prefixwp option get active_plugins --format=json
Look for script tags injected into posts, widgets and options, and reread the list of active plugins: an unknown entry stands out immediately.
An empty search proves nothing
Malicious code is often obfuscated, split up or loaded from the database. Checksums only cover core and plugins from the official repository: a custom theme or a file added to the content folder escapes them. Treat these results as clues for the decision that follows, never as a certificate of cleanliness.
Date the intrusion before choosing
The client asks "do we clean or restore?", and the answer depends on one date: that of the intrusion. Restoring removes the visible symptom, but a backup that already contains the backdoor replays the infection, and restoring without fixing the flaw lets the attacker return the same way. Cleaning by hand often leaves a forgotten file. To date the intrusion, cross at least two sources: the oldest suspicious file, the creation date of the unknown account (user_registered), the first abnormal request in the access logs, the installation date of a vulnerable plugin. Keep the oldest date you can prove, then subtract a margin. The date of the last healthy backup is then compared with it.
Clean, restore or rebuild: the decision table
Scroll the table
| Situation | Decision | Reason |
|---|---|---|
| Last healthy backup predates the intrusion, little content written since | Restore, then fix the flaw | The rollback is short and verifiable with the integrity checks |
| Old healthy backup, but orders or content written since (shop, forms) | Restore or rebuild, then bring back recent data from the preserved copy | You recover the useful tables, not the doubtful files; the sorting is validated with the client |
| Unknown intrusion date, or the backup itself is suspect | Rebuild: core and plugins reinstalled clean, content and media imported after sorting | No reliable starting point: only what can be verified is reused |
| A single modified file, known cause, clean copy of core available | Clean by replacing with official files | The scope is bounded and the checksums confirm it |
| Several modified files outside core, unknown accounts, unknown must-use plugin | Rebuild | The number of possible entry points exceeds what a manual review can guarantee |
| Vulnerable plugin still installed, no fix published | Remove or replace it, whichever route is chosen | Otherwise the attacker returns through the same door |
Reopen: update, then rotate secrets in order
Update core and plugins
wp core updatewp plugin update --all
Do this on a clean site, after restoring or cleaning. Also remove unused themes and plugins.Hosting panel, SFTP and SSH
New long, unique passwords kept in a shared vault. Remove unknown keys from
~/.ssh/authorized_keys. These come first: they allow files to be rewritten, so everything else is pointless while they are compromised.Database
New password for the database user, then update it in
wp-config.php. The official documentation suggests changing the user name as well.Salts in wp-config.php
Generate a new set at https://api.wordpress.org/secret-key/1.1/salt/ and replace the values: open sessions are logged out. Repeat it now: the initial rotation may have been bypassed.
WordPress accounts
Reset the passwords of all administrators and editors, delete unrecognised accounts, enable two-factor authentication and review roles with the command
wp user list --role=administrator.API keys and third-party services
Payment, SMTP, CDN, object storage, antispam: regenerate each key at the provider, not only in the site.
Remote management tokens
Multi-site management tools, online backup, monitoring: revoke the old tokens and reconnect the site with new ones.
Check before opening to the public
- Clean checksums —
wp core verify-checksumsandwp plugin verify-checksums --allno longer return warnings; plugins outside the repository are compared with a fresh vendor copy. - Accounts accounted for — the administrator list is reviewed with the client, line by line, and each account is tied to a person.
- No PHP in the media folder —
find wp-content/uploads -name '*.php'returns nothing. - Complementary scan — a plugin scanner and a remote scanner, because the official documentation notes that no single tool sees every case.
- Reinforced monitoring for a fixed period — for example fourteen days, with the integrity checks rerun daily and the login logs read; the end date is written in the incident log.
- A dated healthy backup — the copy taken right after reopening becomes the new reference point for the next decision.
The message to the client, in three steps and with one voice
The finding, within the hour
The agency writes, not the host or the technical team. Template: "Your site shows signs of compromise: [observed symptom]. We took it offline at [UTC time] to protect your visitors. The cause is not yet known." No assumed cause, no promised deadline.
The action, once isolation is done
Template: "A full copy has been preserved. We are checking the files, the accounts and the database. By [time], we will tell you whether we clean, restore or rebuild, and why." If recent orders are involved, say so: the client decides what they accept to lose.
The follow-up, at reopening
Template: "The site has been reopened since [date]. Access has been renewed and we are monitoring it for [duration]. Please change the password of your accounts and tell us about any unusual behaviour." Only write "fixed" for what the checks actually verified.
The incident log, handed to the client
Attach the timestamped log: finding, actions, decision and reason, reopening, monitoring period. It stays factual and assigns no blame.
Getting out of the Google warning after reopening
If the browser showed "Deceptive site ahead" or the results flagged hacked content, the way out goes through Search Console. The Security issues report lists the affected pages. Google's documentation asks you to request a review only once all issues are fixed on all pages: a premature request can lengthen the delay of the next one, or even get the site classed as a repeat offender. The request describes the problem, the steps taken and the outcome. The announced delay ranges from a few days to several weeks: give the client that range rather than a date. Also fix the flaw: otherwise the site will be infected again.
When the output does not say what it seems to say
- "File should not exist" at the root — the file is absent from the official list, so it was added. Not all are hostile (an ownership verification file, for example), but each one must be explained.
- "File doesn't verify against checksum" on
readme.htmlorwp-config-sample.php— often harmless if the language or version differs. Compare--localeand--versionwith what the Updates screen of the dashboard shows before concluding. - Checksum download fails — the server may have no outbound access. Do not add
--insecureon a suspect machine: the option disables certificate validation and exposes you to interception. - Plugin missing from the check — it is not in the official repository. Compare its files with a clean vendor copy rather than declaring it healthy.
- The site still redirects after cleaning — test like a visitor:
curl -sI -A 'Mozilla/5.0 (iPhone)' https://your-domain.com/thencurl -sI -e https://www.google.com/ https://your-domain.com/. A redirect seen only this way comes from a.htaccessrule, a server cache or the CDN: list every.htaccessand purge the caches.
Going further
- A dated backup-plugin incident — /blog/updraftcentral-cles-volees-backdoors-wordpress-sauvegarde describes a clean restoration in a specific case; this guide gives the general procedure.
- Prove the restoration before you need it — /blog/tester-restauration-sauvegarde-vps-client-agence-procedure explains how to test a backup outside any incident.
- Check the estate every month — /blog/checklist-audit-securite-mensuel-vps-clients-agence prevents some of the situations described here.
Official documentation
WordPress "My site was hacked" FAQ: https://wordpress.org/documentation/article/faq-my-site-was-hacked/
Hardening WordPress: https://wordpress.org/documentation/article/hardening-wordpress/
wp core verify-checksums command: https://developer.wordpress.org/cli/commands/core/verify-checksums/
wp plugin verify-checksums command: https://developer.wordpress.org/cli/commands/plugin/verify-checksums/
wp db search command: https://developer.wordpress.org/cli/commands/db/search/
Google Search Console Security issues report: https://support.google.com/webmasters/answer/9044101