Deployment guide

Hacked client WordPress site: an agency recovery procedure

Deploy on a VPS Cloud →

Tutorial

Hacked client WordPress site: an agency recovery procedure

Security & Monitoring12 min read24 steps

The client calls: their site redirects visitors, the browser shows a warning or the host has suspended the account. Without a written procedure, the agency improvises within the hour, and that is when evidence is lost, an already infected backup is restored, or the client is promised a fix that nothing guarantees. This guide walks through a generic procedure, independent of any specific vulnerability: isolate, check, decide on written criteria, reopen with renewed access, then inform the client with a dated message.

Contents· The phone rings: what you know and what you do not1/14
  1. 01The phone rings: what you know and what you do not
  2. 02Six rules to set from the first call
  3. 03Isolate without destroying evidence
  4. 04Check integrity, command by command
  5. 05An empty search proves nothing
  6. 06Date the intrusion before choosing
  7. 07Clean, restore or rebuild: the decision table
  8. 08Reopen: update, then rotate secrets in order
  9. 09Check before opening to the public
  10. 10The message to the client, in three steps and with one voice
  11. 11Getting out of the Google warning after reopening
  12. 12When the output does not say what it seems to say
  13. 13Going further
  14. 14Official documentation

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

  1. 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.

  2. 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.

  3. Copy files and database (15 min)

    mkdir -p ~/incidents/client-a/YYYY-MM-DD
    rsync -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-themes
    find ~/incidents/client-a/YYYY-MM-DD -type f -exec sha256sum {} + > ~/incidents/client-a/hashes-YYYY-MM-DD.txt
    chmod -R a-w ~/incidents/client-a/YYYY-MM-DD
    The options --skip-plugins --skip-themes avoid 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.

  4. 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.

  5. Close non-essential access (10 min)

    Change the panel, SFTP and SSH passwords, remove contractors' access, then replace the salts in wp-config.php to invalidate sessions. This rotation is provisional: a backdoor can hand control back to the attacker, so it is repeated after cleaning.

  6. 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

  1. 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-root extends the check to the root directory.

  2. 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.

  3. 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.

  4. Suspicious code patterns (10 min)

    grep -rEl 'eval\(|base64_decode\(|gzinflate\(' wp-content
    eval, base64_decode and gzinflate are 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.

  5. Accounts and settings (10 min)

    wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
    wp option get siteurl
    wp option get home
    wp option get users_can_register
    wp option get default_role
    Look for an unknown administrator, a creation date close to the intrusion, a modified siteurl or home (a frequent cause of redirects), open registration with an overly high default role. This data lives in the wp_users, wp_usermeta and wp_options tables; the prefix may differ.

  6. Scheduled tasks and boot files (10 min)

    wp cron event list
    crontab -l
    ls -la wp-content/mu-plugins
    find . -name '.htaccess'
    An unknown scheduled task can recreate the malicious file every night. Must-use plugins (mu-plugins) load without activation. The .htaccess file 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.

  7. Database content (10 min)

    wp db search '<script' --all-tables-with-prefix
    wp 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

SituationDecisionReason
Last healthy backup predates the intrusion, little content written sinceRestore, then fix the flawThe 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 copyYou recover the useful tables, not the doubtful files; the sorting is validated with the client
Unknown intrusion date, or the backup itself is suspectRebuild: core and plugins reinstalled clean, content and media imported after sortingNo reliable starting point: only what can be verified is reused
A single modified file, known cause, clean copy of core availableClean by replacing with official filesThe scope is bounded and the checksums confirm it
Several modified files outside core, unknown accounts, unknown must-use pluginRebuildThe number of possible entry points exceeds what a manual review can guarantee
Vulnerable plugin still installed, no fix publishedRemove or replace it, whichever route is chosenOtherwise the attacker returns through the same door

Reopen: update, then rotate secrets in order

  1. Update core and plugins

    wp core update
    wp plugin update --all
    Do this on a clean site, after restoring or cleaning. Also remove unused themes and plugins.

  2. 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.

  3. Database

    New password for the database user, then update it in wp-config.php. The official documentation suggests changing the user name as well.

  4. 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.

  5. 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.

  6. API keys and third-party services

    Payment, SMTP, CDN, object storage, antispam: regenerate each key at the provider, not only in the site.

  7. 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-checksums and wp plugin verify-checksums --all no 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.html or wp-config-sample.php — often harmless if the language or version differs. Compare --locale and --version with what the Updates screen of the dashboard shows before concluding.
  • Checksum download fails — the server may have no outbound access. Do not add --insecure on 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/ then curl -sI -e https://www.google.com/ https://your-domain.com/. A redirect seen only this way comes from a .htaccess rule, a server cache or the CDN: list every .htaccess and 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

Restore one client's site without touching the others

On an infrastructure where each account is isolated and backups are daily on all Agency plans, restoring one site does not touch the others. With ServOrbit.com, the client keeps calling you, and restoring one account happens on infrastructure that is already partitioned.

Need help?

Browse our help center and FAQ, or reach our team — callback, WhatsApp or email. Support in French, English and Arabic.

Message us on WhatsAppopens in a new tab