Think your PHP site was hacked? First steps on your own server

Spam pages, strange redirects or an admin you don't know? Don't start deleting files. Keep a copy, close the site, find what changed, then clean up and change every password. For WordPress and Laravel on Ubuntu or Debian.

Don't panic-delete. If you delete the bad files right away, you also delete the clues that show how the attacker got in. Then they come back through the same hole. Take a copy first (step 2). It only takes a few minutes.

Signs that something is wrong

  • Pages you didn't write, often spam for pills, casinos or fake shops. Search Google for site:example.com to see them.
  • Visitors are sent to other sites, sometimes only on phones or only when they come from Google, so you may not see it yourself.
  • An admin user you don't know in WordPress, or a new user on the server.
  • The server is slow and the CPU is busy all the time. top shows a process you don't know, often running as www-data or the site's user.
  • New cron jobs that you didn't add.
  • Warnings from outside: Google or your browser marks the site as dangerous, your host writes to you, or your emails start landing in spam.

One sign alone doesn't prove a break-in. A plugin update can slow a site down, and a new admin may be a colleague. Check before you act, and write down what you see and when.

Before you start

  • Log in over SSH with a user that can use sudo. If you think your own computer may be infected, use a different one: stolen passwords often come from there.
  • The examples use a WordPress site in /var/www/shop, run by the user shop, and a Laravel app in /var/www/app, run by the user app. Change the names to yours.
  • If you'd rather have help, a professional can do this for you. The copy from step 2 is exactly what they'll ask for.

1. Write down what you see

Note the date, the time and what made you suspicious: a screenshot of the spam page, the address of the redirect, the name of the unknown user. Also note what changed lately: a new plugin, an update, a new team member. This takes five minutes and helps you, your host or a professional later.

2. Take a copy first

Copy the files, the database and the logs before you change anything. This copy is evidence, not a backup to restore: it contains the attacker's files too.

D=/root/incident-2026-11-12
sudo mkdir -m 700 $D
sudo tar -czf $D/site-files.tar.gz -C /var/www shop
sudo sh -c "mysqldump --single-transaction shop > $D/shop.sql"
sudo tar -czf $D/logs.tar.gz /var/log/nginx /var/log/apache2 /var/log/php* /var/log/auth.log*
sudo cp /etc/passwd $D/passwd.txt
sudo sh -c 'for u in $(cut -d: -f1 /etc/passwd); do crontab -l -u $u 2>/dev/null | sed "s/^/$u: /"; done' | sudo tee $D/crontabs.txt

Change shop to your database name. For a Laravel app, use -C /var/www app and the database name from its .env file. tar warns about logs that don't exist on your server, for example apache2 on an Nginx server or auth.log on Debian 13. That's fine.

Then copy it to your own computer. An attacker with full control of the server could delete it. Pack it into one file you can download:

sudo tar -czf ~/incident.tar.gz -C /root incident-2026-11-12
sudo chown $USER ~/incident.tar.gz

On your computer, run scp you@server:incident.tar.gz ., then delete ~/incident.tar.gz on the server.

3. Close the site for everyone but you

While you look around, stop visitors from seeing spam and stop the attacker from using what they left behind. Block the site in the web server, not in the app. In our test, WordPress's maintenance mode (wp maintenance-mode activate) and Laravel's php artisan down both showed a "503" page, but a planted PHP file still ran. The web server blocks every file.

Find your own IP address (search "what is my IP" in your browser). Nginx: add two lines to the site's server { } block, right under root, with your address:

allow 203.0.113.10;
deny all;
sudo nginx -t && sudo systemctl reload nginx

Apache: in the site's file in /etc/apache2/sites-available/, add this inside <VirtualHost> (if the file already has a <Directory> block for the site, put the Require line there instead of its Require all granted):

<Directory /var/www/shop>
    Require ip 203.0.113.10
</Directory>
sudo apache2ctl configtest && sudo systemctl reload apache2

Everyone else now gets "403 Forbidden", and you can still use the site. Remove the lines when you're done. If the attacker can still change files, a .htaccess file could undo the block, so put the Apache rule in the site's settings file, not in .htaccess.

4. Find files that changed

Files changed in the last 7 days. Make the number bigger if you think it started earlier:

sudo find /var/www/shop -type f -mtime -7 -not -path '*/cache/*'
sudo find /var/www/app -type f -mtime -7 -not -path '*/storage/*'

In our test, these found every file we planted. But the date on a file can be faked. -ctime -7 uses a date that is much harder to fake, so run the same command with -ctime too. It also lists files that were only moved or had their owner changed.

WordPress: compare with the official files.

cd /var/www/shop
sudo -u shop -- wp core verify-checksums
sudo -u shop -- wp plugin verify-checksums --all

File doesn't verify against checksum means a WordPress file was changed. File should not exist means someone added a file to the WordPress folders. In our test, both showed up for the files we planted. This only covers WordPress itself and plugins from WordPress.org, not your theme, paid plugins or uploads. Our WordPress hardening guide explains the output in more detail.

Laravel deployed with Git: Git shows every file that differs from your last commit:

cd /var/www/app
sudo -u app -H -- git status --short

M means changed, ?? means a new file. The vendor folder is usually not in Git, so check it with Composer:

sudo -u app -H -- composer status -v

It lists changed files inside your packages. In our test, it found a line we added to a Laravel file in vendor. You'll reinstall vendor anyway in step 10.

5. Look for PHP files where they don't belong

The WordPress uploads folder should only hold images and documents. A PHP file there is a strong warning sign:

sudo find /var/www/shop/wp-content/uploads -type f \( -iname '*.php*' -o -iname '*.phtml' -o -iname '*.phar' \)

In a Laravel app, the public folder normally has only one PHP file, index.php:

sudo find /var/www/app/public -type f -name '*.php'

Don't open a suspicious file in the browser, as that runs it. Read it on the server with less.

6. Search for code that hides what it does

Attackers often hide their code by packing it up. Words such as eval, base64_decode, gzinflate and str_rot13 unpack and run it. Search the folders where your own code and uploads live:

sudo grep -rlE 'eval\(|base64_decode\(|gzinflate\(|str_rot13\(' --include='*.php' /var/www/shop/wp-content

These are hints, not proof. Normal code uses these functions too: in our test, a clean WordPress had 20 matching files in its own folders, and a fresh Laravel vendor folder had 64. So a match in a well-known plugin is usually fine. Look for the odd ones out: a file in uploads, a very long line of random letters, a file name that almost matches a real one, or a plugin you never installed.

A virus scanner can help here too: ClamAV finds known web shells. It misses code written just for your site, so a clean scan doesn't mean a clean site.

7. Check users and SSH keys

WordPress admins:

sudo -u shop -- wp user list --role=administrator --fields=ID,user_login,user_email,user_registered

The user_registered date helps: an admin created last Tuesday at 3 a.m. stands out.

Server users. Only root should have the number 0. A second line here means a second root:

awk -F: '$3 == 0 {print $1}' /etc/passwd

Users that can log in (most system users end in nologin or false):

grep -vE '(nologin|false|sync)$' /etc/passwd

SSH keys. Every key in these files can log in without a password. Make sure you know each one:

sudo find /root /home -name authorized_keys -exec ls -l {} \; -exec cat {} \;

Recent SSH logins: sudo grep Accepted /var/log/auth.log, or sudo journalctl -u ssh | grep Accepted if there is no auth.log (Debian 13 has none by default). Every line shows the user, the address and the time. To match each login to a key, see who logged in and what they ran with sudo.

8. Check cron jobs and timers

Attackers like cron jobs: if you delete their file, the job downloads it again. Look at the jobs of each user (you saved them in step 2), the system cron folders and systemd timers:

sudo cat /root/incident-2026-11-12/crontabs.txt
sudo ls -lt /etc/cron.d /etc/cron.hourly /etc/cron.daily
sudo cat /etc/crontab
systemctl list-timers --all
sudo find /etc/systemd/system /etc/cron* -type f -mtime -7

The last command lists cron and timer files changed in the last 7 days. Watch for jobs that run curl or wget, or start files in /tmp, /dev/shm or a website folder.

9. Read the access log

Many backdoors are used with POST requests. This counts POSTs per address:

sudo zcat -f /var/log/nginx/access.log* | awk '$6 == "\"POST" {print $7}' | sort | uniq -c | sort -rn | head -30

On Apache, use /var/log/apache2/access.log*. Normal WordPress lines are wp-login.php, wp-cron.php, admin-ajax.php and xmlrpc.php (lots of POSTs there are usually password guessing). A POST to a file in uploads, or to a PHP file you don't recognise, is a strong sign. Then see who used it and when:

sudo zgrep -h 'uploads/2026/11/cache.php' /var/log/nginx/access.log*

The first time that file was used is a good guess for when the break-in started. Look at what that address did just before: often a request to an old plugin, which tells you the hole.

10. Clean up

Best: restore a backup from before the break-in. Use the date you found in steps 4 and 9, and pick a backup that is older. With our restic backup setup, restic snapshots lists them. Restore into a spare folder, check it with steps 4 to 6, then swap it in. Then update everything (below), or the same hole is still open.

No clean backup? Replace what you can with fresh copies:

  • WordPress itself: download the same version again over the old files. --skip-content leaves wp-content alone:
    sudo -u shop -- wp core download --force --skip-content --version=$(sudo -u shop -- wp core version)
    This doesn't remove added files. Run wp core verify-checksums again and delete every file it says should not exist.
  • Plugins and themes from WordPress.org: reinstall each one with sudo -u shop -- wp plugin install plugin-name --force. Delete the ones you don't use. Get paid plugins fresh from the seller.
  • Laravel's vendor folder: delete it and install it again from composer.lock:
    cd /var/www/app
    sudo -u app -H -- rm -rf vendor
    sudo -u app -H -- composer install --no-dev
  • Your own code in Git: git checkout -- file puts back a changed file. git clean -n lists new files. Look at them, then delete them.
  • Uploads: delete PHP files there, and look through the newest uploads by hand.
  • Remove what the attacker added: the WordPress user (wp user delete name --reassign=1 gives their posts to user 1), cron jobs (sudo crontab -r -u shop removes all of that user's jobs, so save the good ones first), timer files, SSH keys and server users. A second root user can't be removed with plain userdel; in our test, sudo userdel -f name worked.

11. Change every password and key

Assume the attacker read every file the site could read. Change all of it, and change your own passwords again once the site is clean.

  • Database password. Make a new one and set it in MySQL or MariaDB and in the site at once:
    NEW=$(openssl rand -hex 24)
    sudo mysql -e "ALTER USER 'shop'@'localhost' IDENTIFIED BY '$NEW';"
    sudo -u shop -- wp config set DB_PASSWORD "$NEW" --quiet
    sudo systemctl restart php8.3-fpm
    For Laravel, put it in DB_PASSWORD in .env instead. In our test, the site showed an error for a few seconds until PHP-FPM was restarted.
  • WordPress keys and salts. New ones log everyone out, the attacker too:
    sudo -u shop -- wp config shuffle-salts
  • WordPress user passwords: reset them all. --skip-email doesn't send emails; leave it out to let each user set a new one by email:
    sudo -u shop -- wp user reset-password $(sudo -u shop -- wp user list --field=user_login) --skip-email
  • Laravel's APP_KEY: php artisan key:generate --force makes a new one. Everyone is logged out, and data encrypted with the old key can't be read. Copy the old key into APP_PREVIOUS_KEYS in .env so Laravel can still read old data, as the Laravel docs explain, and plan to move that data over. Then run php artisan config:clear (or config:cache if you use it).
  • Every other secret in .env or wp-config.php: email, payment, storage and other API keys. Make new ones in each provider's dashboard and turn off the old ones.
  • SSH: remove keys you don't know, make a new key for yourself, and change passwords of users that can log in. Our SSH keys guide shows how, and Fail2ban slows down password guessing.
  • Outside the server: your hosting account, domain registrar, email and any panel or FTP account.

12. Update everything and close the hole

sudo apt update && sudo apt upgrade
sudo -u shop -- wp core update --minor
sudo -u shop -- wp plugin update --all
sudo -u shop -- wp theme update --all

For Laravel, update with composer update on your computer or a test copy, test it, and deploy the new composer.lock. Also run composer audit to list packages with known holes.

Then fix what let them in, if you found it: an old plugin, a weak password, an .env file anyone could download (how to block it), or PHP running in uploads (step 3 of our WordPress hardening guide). When you're done, remove the block from step 3 and watch the logs for a few days.

When to reinstall the whole server

Cleaning the site is enough when the attacker only had the site's user. Set up a new server and move only clean data to it when:

  • You found a second root user, new SSH keys for root, or changed files in /etc, /usr/bin or /usr/local/bin.
  • Cron jobs or timers run as root that you didn't add.
  • Programs you don't know keep coming back after you stop them.
  • You can't tell how far the attacker got.

Someone with root can hide almost anything, including from the commands in this guide. On the new server, install everything fresh, restore a clean backup or your code from Git, and change every password again. Don't copy over the old /etc or home folders.

If something goes wrong

  • You locked yourself out with step 3: your IP address changed. Fix it over SSH and reload the web server.
  • The site shows "Error establishing a database connection" after step 11: the password in MySQL and in wp-config.php or .env don't match. Run the commands again together, then restart PHP-FPM.
  • Laravel shows "The MAC is invalid": data was encrypted with the old APP_KEY. Add the old key to APP_PREVIOUS_KEYS and clear the config cache.
  • The bad files come back after you delete them: something is still running. Check cron jobs and timers again (step 8), and look for odd processes with ps aux. If you can't find it, reinstall the server.

Official sources

Next steps: set up backups you've tested, so next time you can simply restore, and work through the security checklist for a new PHP server.

ServerNest is being built to help with this: daily ClamAV scans of your app folders, a warning when files change, and putting one app's files or database back from a backup. Join early access to try it first.

← Back to the security blog