In short. A single scanner cannot tell you whether a website is infected: external services only see what the server returned over HTTP. You need four independent layers — reputation databases and page scanners from the outside, DevTools in the browser, the file system on the server, and the database. Infection is confirmed by recently modified files, obfuscated PHP, and a site that answers differently to different User-Agents.
How an infection looks to the site owner
Owners almost never see malicious code directly. They see side effects, and nearly every one of them reads as "something broke" rather than "we were hacked". That is exactly why weeks usually pass between compromise and discovery: an infected site keeps working, loading, and taking orders.
The typical set of symptoms that starts an investigation:
- Redirects you cannot reproduce. Visitors report being sent to an unrelated site while everything looks fine on your screen. This is almost always a conditional redirect: mobile only, search-referral only, or first visit only.
- A spike of 404s in server logs and webmaster panels. Hundreds or thousands of URLs you never created — the leftovers of doorway pages that were removed or renamed after search engines indexed them.
- Foreign pages in the index. A
site:query returns pages about pharmacy, gambling, replicas, or loans. Your domain has become someone else's SEO asset. - An email from your hosting provider. "Spam is being sent from your account", "a malicious file was detected", "your account has been suspended". Hosts scan customer files and often learn about the problem before the owner does.
- Ranking drops with no changes on your side. Traffic falls while nothing was deployed, and a security warning appears in Search Console or Bing Webmaster Tools.
- A browser warning. The red "Deceptive site ahead" or "The site ahead contains malware" interstitial. This is the public stage: the domain is in the Google Safe Browsing database.
- Load spikes. The host complains about CPU limits, outbound traffic grows, but visitor numbers are unchanged. Usually a miner, a spam mailer, or a proxy script.
- New administrator accounts in the CMS that you did not create, or a changed email address on an existing admin account.
Why desktop antivirus is useless here
A common mistake is to "scan the website for viruses" with the antivirus on your laptop. It does not work, for three reasons worth understanding so you do not waste time.
First, the malicious code lives on the server, not on your machine. Antivirus software scans your local disk; a PHP backdoor sitting in a hosting directory is simply outside its field of view. Second, server-side code executes before the browser receives anything: a PHP shell never arrives as a file — you only receive the result of its execution, which is HTML. Third, antivirus engines target executables for your operating system, not PHP, injected JavaScript, or SQL-level content injections inside a CMS.
The one thing a local antivirus genuinely catches is an attempt to download an infected file from an already compromised site. In other words, it protects you as a visitor, not your site as a resource.
Rule: if you own the site, scan the server the site runs on, not the device you view it from. Everything else is a check on secondary evidence.

The table below maps each symptom to the place you should look — a useful starting point when you already have a signal.
| Symptom | Where to look | With what | What it usually means |
|---|---|---|---|
| Redirect on mobile only | Server response with a mobile User-Agent | curl -A with a mobile UA, device mode in DevTools | A User-Agent condition in .htaccess, index.php, or injected JavaScript |
| Redirect only when arriving from search | Response when a Referer header is present | curl -e "https://www.google.com/" | Referer-based cloaking, usually in a theme header or in must-use plugins |
| 404 spike | Web server logs, webmaster reports | grep over the access log, page reports | Doorway pages already deleted or renamed but still in the search index |
| Foreign pages in the index | Search results for a site: query | Google, Bing | A doorway generator is running — look for it in files and in the database |
| Host complains about spam | Mail queue and MTA logs | mailq, MTA logs, panel statistics | A mailer script on the site; a backdoor is almost always next to it |
| Rankings drop with no deploys | Security sections in webmaster panels | Search Console, Bing Webmaster Tools | A penalty for malicious code, phishing, or SEO spam |
| Browser warning | Reputation databases | Malware check, Safe Browsing | The domain, or a resource it loads, is on a blocklist |
| CPU and outbound traffic growth | Hosting metrics and server processes | top, panel statistics, access log | A miner, a mailer, an open proxy, or participation in an attack |
| New CMS administrators | The CMS users table | Admin panel, direct database query | Confirmed compromise: changing a password is not enough |
| Host suspended the site | The hosting scanner report | Hosting control panel | Infection already confirmed by a third party |
Four independent layers of malware detection
No single layer is complete. An external scanner will honestly report "clean" while a web shell sits on the server, because the shell is not served over HTTP without a specific request. A file search will not find malware that lives inside a database field. A database review will not see the cron job that recreates deleted files every night.
So the check is built as four independent slices, plus logs as the source of the answer to "how did they get in".
| Layer | What it finds | What it misses |
|---|---|---|
| Outside: reputation databases, page scanners | Domain on blocklists, malicious JavaScript in the returned HTML, redirects, defacement, phishing pages | PHP backdoors, cloaking under a different User-Agent, database injections, cron jobs, infection of neighbouring sites on the account |
| Browser: DevTools Network and Console | Third-party domains in requests, hidden frames, runtime errors from injected code, second-stage loading | Anything that does not execute in your particular session: server-side backdoors, conditional responses, time-delayed scripts |
| Server: the file system | Web shells, backdoors, doorway generators, modified CMS core files, foreign cron entries, unknown SSH keys | Malware that lives entirely in the database; payloads fetched from a remote host at request time |
| Database: content and CMS options | Scripts and links inside post bodies, replaced site URLs, extra administrators, payloads in options and widgets | File-based backdoors, template edits, web server configuration changes |
| Web server and mail logs | How and when they got in, from which addresses, which files were requested, the fact and volume of spam sending | Anything older than log rotation; actions through FTP or the control panel if those are not logged |
Practical order: outside first (fast, non-destructive), then the browser, then the server, then the database. Logs are reviewed in parallel with the server, because they save hours — they immediately show the timestamp of the first suspicious request, which then becomes the anchor for an mtime search.
Layer 1. Outside: reputation databases and page scanners
The fastest approach is to check the URL through external services. They fetch the page, parse HTML and JavaScript, observe where requests go, and cross-reference the domain and loaded resources against malicious-address databases.
- Google Safe Browsing — the baseline answer to "will Chrome show a red interstitial". Checked through the Transparency Report.
- VirusTotal — URL and file scanning across dozens of antivirus engines at once. Useful because it shows individual vendor verdicts and the domain's submission history.
- Sucuri SiteCheck — detection of known injection patterns, spam content, and blocklist presence.
- URLScan.io — less a verdict than a recording of page behaviour: every request, every domain, every resource. Often this is where the single foreign script becomes visible.
On enterno.io this slice is covered by the website malware check, which inspects the returned HTML, external scripts, and domain reputation, while the website security check adds headers and common configuration mistakes.
What this layer fundamentally cannot see: anything the server did not return for an ordinary request. A web shell at a path like /wp-content/uploads/2024/06/wp-conf.php stays silent until it is called with the right parameter. Cloaking serves a clean page to any scanner that identifies itself as a normal browser. Cron jobs sending spam never touch HTTP at all.
A "clean" verdict from an external scanner does not mean the site is not infected. It means the site is not infected in an externally visible way, right now, for that particular request.
Layer 2. In the browser: DevTools Network and Console
The second layer exists to show the page as a visitor sees it — with every subresource, redirect, and executed script. Open the site in a private window with extensions disabled: extensions add their own requests and muddy the picture.
The procedure: open developer tools, switch to the Network tab, enable cache disabling, and reload. Then sort requests by domain and read the list top to bottom. Only foreign domains matter.
- Unknown domains in requests. Write down every domain except your own, your analytics, and CDNs you deliberately connected. Everything left over is a candidate.
- URL shorteners and short throwaway domains anywhere in the loading chain are almost always injected.
- Redirect chains. Look at 301/302 responses and the Location header. One extra hop to a foreign domain is already a finding.
- Console errors. Injected code is frequently written badly and throws. The error message conveniently contains the path of the file it came from.
- Hidden frames with zero dimensions or positioned off-screen. Visible in the Elements tab by their size and their foreign source URL.
A useful detail: infections often load the second stage after a delay or on an event rather than immediately. Keep the Network tab open for half a minute after load and repeat the check while scrolling.
A quick way to collect external sources without a browser is to pull them straight out of the HTML.
# Every external script and frame on the home page
curl -sL https://example.com/ \
| grep -oE '(src|href)="https?://[^"]+"' \
| grep -v 'example\.com' \
| sort -u
# The same list with a mobile User-Agent — compare with the previous one
curl -sL -A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" \
https://example.com/ \
| grep -oE '(src|href)="https?://[^"]+"' \
| grep -v 'example\.com' \
| sort -u
If the two lists differ, the site serves different code to different clients. That is close to proven cloaking, covered separately below. While you are here, check what stack actually runs on the site — technology detection sometimes reveals a library or tracker you never installed.
Layer 3. On the server: the file system
This is the main layer. Everything else is indirect evidence; the malware itself is here. Work over SSH — searching through an FTP client is pointless, because you can neither sort thousands of files by modification time nor search their contents.
Recently modified files
A compromise almost always leaves traces in mtime. If the approximate incident time is known from logs, search around it; if not, start with the last 24 hours and widen the window.
# Everything modified in the last 3 days (excluding cache and logs)
find /var/www/example.com -type f -mtime -3 \
-not -path "*/cache/*" -not -path "*/logs/*" \
-printf '%TY-%Tm-%Td %TH:%TM %p\n' | sort
# Files modified AFTER a specific date and time
find /var/www/example.com -type f -newermt "2026-08-01 03:00" \
-not -path "*/upload/*" -ls
# Files modified inside a window between two moments
find /var/www/example.com -type f \
-newermt "2026-08-01 02:00" ! -newermt "2026-08-01 06:00" -ls
An important caveat: mtime can be forged with touch, and competent backdoors do exactly that, matching the dates of neighbouring files. So also inspect ctime — the inode metadata change time, which PHP cannot rewrite.
# Files with a recent ctime — catches mtime forged via touch
find /var/www/example.com -type f -ctime -7 \
-not -path "*/cache/*" -ls | head -100
Files in the wrong place
The second most reliable signal is an executable file where none should exist. Upload directories hold images and documents, not PHP. Theme image directories even less so.
# PHP inside upload and image directories — almost always a backdoor
find /var/www/example.com/wp-content/uploads -type f \
\( -name "*.php" -o -name "*.phtml" -o -name "*.php[0-9]" -o -name "*.phar" \) -ls
find /var/www/example.com/upload -type f -name "*.ph*" -ls
# Double extensions: an image that is really a script
find /var/www/example.com -type f -regex '.*\.\(jpg\|png\|gif\|webp\)\.php$' -ls
# World-writable files and directories
find /var/www/example.com -type f -perm 0777 -ls
find /var/www/example.com -type d -perm 0777 -ls
# Names containing control characters or a trailing space
find /var/www/example.com -name '*[[:cntrl:]]*' -o -name '* '
Obfuscation signatures
Malicious PHP almost always hides its payload: a string is packed and unpacked at execution time. The packing itself gives the backdoor away — legitimate theme or module code is not written that way.
One critical point: basic grep does not treat the pipe character as alternation. A command like grep -rl "eval|system" searches for a literal string containing a pipe, finds nothing, and creates a false sense of cleanliness. You need the -E flag.
# Search for obfuscation signatures and web shells (-E is mandatory!)
grep -rlE --include="*.php" --include="*.phtml" --include="*.inc" \
'eval[[:space:]]*\(|base64_decode[[:space:]]*\(|gzinflate[[:space:]]*\(|gzuncompress[[:space:]]*\(|str_rot13[[:space:]]*\(|assert[[:space:]]*\(|create_function[[:space:]]*\(|preg_replace[[:space:]]*\(.*/e|passthru[[:space:]]*\(|shell_exec[[:space:]]*\(|popen[[:space:]]*\(|proc_open[[:space:]]*\(' \
/var/www/example.com
# Superglobals passed straight into execution — a classic shell
grep -rnE --include="*.php" \
'\$_(GET|POST|REQUEST|COOKIE)\[[^]]*\][[:space:]]*\)' /var/www/example.com | head -50
# Strings assembled character by character to defeat name-based search
grep -rlE --include="*.php" '(\\x[0-9a-fA-F]{2}){4,}|(chr\([0-9]+\)\.){3,}' /var/www/example.com
# Single lines longer than 800 characters — nearly always a packed payload
grep -rlE --include="*.php" '.{800,}' /var/www/example.com
Matches are then reviewed by hand. What each signature means:
| Signature | Why it is suspicious |
|---|---|
eval(base64_decode(...)) | The classic pair: an encoded string is decoded and immediately executed. It does not appear in legitimate CMS code |
gzinflate, gzuncompress, str_rot13 chained | Multi-layer packing — the payload is unwrapped in several passes so string searches fail |
assert($_REQUEST[...]) | Before PHP 8, assert executed a string as code. A one-line web shell |
preg_replace with the /e modifier | A legacy way to execute code from a replacement. A sign of an old backdoor or an old vulnerable engine |
create_function | Builds a function from a string. Removed in PHP 8 — it has no place in modern code |
Escapes such as \x65\x76 | A function name assembled character by character so a word search finds nothing |
Concatenation like chr(101).chr(118) | The same trick by another means |
| A single line several kilobytes long | A packed payload. No one formats real code that way |
file_get_contents or curl_exec to a remote host inside a template | A second-stage loader: the body of the malware is pulled in at runtime and never stored on disk |
Variables with unprintable names in $GLOBALS | A concealment trick: the variable name is unreadable and unsearchable |
Beware of false positives.
base64_decodelegitimately appears in mail, JWT, and image-handling libraries. A signature is a reason to open the file, not a verdict. The verdict comes from comparison against a known-good copy.
Comparing against a known-good copy: git, checksums, diff
The most reliable way to separate your file from someone else's is to compare it with a clean copy. No heuristics needed: either the file matches the original or it does not.
# If the code is under git, this is the fastest answer
cd /var/www/example.com && git status --porcelain
git diff --stat
git clean -nd # what appeared that should not be there, dry run
# WordPress: verify the core against official checksums
wp core verify-checksums --path=/var/www/example.com
wp plugin verify-checksums --all --path=/var/www/example.com
# Universal method: download a clean distribution of the same version and diff
cd /tmp && curl -sO https://wordpress.org/wordpress-6.8.2.zip
unzip -q wordpress-6.8.2.zip -d /tmp/clean
diff -rq /tmp/clean/wordpress/wp-includes /var/www/example.com/wp-includes
diff -rq /tmp/clean/wordpress/wp-admin /var/www/example.com/wp-admin
# A checksum snapshot taken "before", so the next comparison takes a minute
find /var/www/example.com -type f -name "*.php" -exec sha256sum {} \; \
| sort -k2 > /root/checksums-$(date +%F).txt
diff /root/checksums-2026-07-01.txt /root/checksums-2026-08-01.txt
Note the last block: a checksum snapshot taken while the site is healthy turns the next investigation from hours into minutes. It is the cheapest preventive measure that exists.
Cron jobs, SSH keys, and extra users
The nastiest part of an infection is often not the file but the mechanism that restores it. You delete the shell, and an hour later it is back, because a scheduler entry recreates it hourly.
# Crontabs of every user
for u in $(cut -f1 -d: /etc/passwd); do echo "== $u"; crontab -l -u "$u" 2>/dev/null; done
ls -la /etc/cron.d/ /etc/cron.daily/ /etc/cron.hourly/
grep -rn "curl\|wget\|php -r\|base64" /etc/cron.d/ /var/spool/cron/ 2>/dev/null
# systemd timers and units you did not add
systemctl list-timers --all
ls -la /etc/systemd/system/ | grep -v '^total'
# Foreign SSH keys
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
echo "== $f"; cat "$f" 2>/dev/null
done
# UID 0 accounts and recently created users with a real shell
awk -F: '$3 == 0 {print}' /etc/passwd
grep -E '/bin/(ba)?sh$' /etc/passwd
ls -la --time-style=long-iso /home
Where backdoors usually hide in WordPress and Bitrix
Attackers are predictable: they place backdoors where nobody looks and where execution is guaranteed.
WordPress. The wp-content/mu-plugins directory — must-use plugins load on every request and do not appear in the normal plugin list, so check it first. The functions.php of the active theme and, more importantly, of inactive themes. wp-config.php and any include placed before the constants. Root-level index.php, wp-load.php, and wp-settings.php, where a single include line is added. The dated subfolders of wp-content/uploads, where a PHP file looks like just another attachment. Plus the wp_options table, covered in the database section.
1C-Bitrix. The file bitrix/php_interface/init.php and its per-site twin — both execute on every hit. The whole bitrix/php_interface/ directory. Site templates under local/templates/ and bitrix/templates/, especially header.php and footer.php. The upload/ directory, where PHP execution must be disabled at the web server level. The bitrix/modules/ directory, where a "custom module" with an innocuous name is dropped in. And .htaccess files in the root and subdirectories: conditional redirect rules are hidden at the very bottom, after dozens of lines of standard configuration.
A separate check for both platforms is .htaccess and the nginx configuration itself: a rule that says "serve a different file when the request carries a specific Referer" is not malicious code, but it does exactly the same job.

Layer 4. In the database: content injections and CMS options
Some infections never touch a file. The malicious insert lives in a database field, and the CMS faithfully renders it, because as far as the CMS is concerned it is ordinary content.
Three groups of entities need checking: post bodies, service options, and users.
# WordPress: scripts and frames inside posts and pages
SELECT ID, post_title, post_status FROM wp_posts
WHERE post_content LIKE '%<scr%ipt%'
OR post_content LIKE '%<ifr%ame%'
OR post_content LIKE '%display:none%'
OR post_content LIKE '%base64_%';
# A replaced site address — a standard traffic-hijacking trick
SELECT option_name, option_value FROM wp_options
WHERE option_name IN ('siteurl','home','users_can_register','default_role');
# Autoloaded options of abnormal size — a common payload hiding spot
SELECT option_name, LENGTH(option_value) AS len FROM wp_options
WHERE autoload = 'yes' ORDER BY len DESC LIMIT 20;
# Administrators and recently created accounts
SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON m.user_id = u.ID
WHERE m.meta_key = 'wp_capabilities' AND m.meta_value LIKE '%administrator%';
For 1C-Bitrix the logic is identical, only the tables differ: content lives in b_iblock_element and element properties, service values in b_option, users and groups in b_user and b_user_group. Check the mail template table separately — spam campaigns are often run through the CMS's own mail subsystem with a replaced template.
# Bitrix: foreign code inside infoblock element descriptions
SELECT ID, IBLOCK_ID, NAME FROM b_iblock_element
WHERE DETAIL_TEXT LIKE '%<scr%ipt%' OR PREVIEW_TEXT LIKE '%<scr%ipt%';
# Users in the administrators group and their last login
SELECT u.ID, u.LOGIN, u.EMAIL, u.DATE_REGISTER, u.LAST_LOGIN
FROM b_user u JOIN b_user_group g ON g.USER_ID = u.ID
WHERE g.GROUP_ID = 1;
Take a database dump before any write query. A botched bulk replacement in
post_contentcannot be undone without one, and "let me clean this up while I am here" is the most common cause of content loss during remediation.
Cloaking: clean for you, infected for Google
The most common reason for "the scanner says clean but users complain" is cloaking. The backdoor checks who is asking and serves the payload only to a chosen audience. The attacker's logic is simple: the less often the owner sees the problem, the longer the infection survives.
Typical conditions used to select a victim:
- Search engine User-Agents. Doorway content is served to Googlebot and other crawlers so the pages get indexed, while regular visitors see a normal site.
- Mobile User-Agents. The redirect fires only on phones — the owner sits at a desktop and sees nothing.
- A search engine Referer. Arrived from search means redirect; opened from a bookmark or typed manually means everything is fine. Owners almost always arrive the second way.
- First visit only. After firing once, a cookie is set and the redirect does not repeat. So "it happened once and now it does not" is not a sign of recovery.
- IP and geography. The payload is withheld from hosting ranges, crawler ranges, or the owner's country.
You test this by overriding headers in curl. The -A flag sets the User-Agent, -e sets the Referer, -I requests headers only, and -L follows the whole redirect chain.
# 1. Ordinary desktop browser — the baseline
curl -sSIL -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36" \
https://example.com/ | grep -Ei 'HTTP/|^location:'
# 2. Googlebot
curl -sSIL -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
https://example.com/ | grep -Ei 'HTTP/|^location:'
# 3. Bingbot
curl -sSIL -A "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)" \
https://example.com/ | grep -Ei 'HTTP/|^location:'
# 4. Mobile browser
curl -sSIL -A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" \
https://example.com/ | grep -Ei 'HTTP/|^location:'
# 5. Arriving from search: override the Referer
curl -sSIL -e "https://www.google.com/search?q=example" \
https://example.com/ | grep -Ei 'HTTP/|^location:'
curl -sSIL -e "https://www.bing.com/search?q=example" \
https://example.com/ | grep -Ei 'HTTP/|^location:'
If any variant returns a different status or a different Location header, cloaking is confirmed. The next step is comparing response bodies, which reveals injections that do not redirect but quietly add links.
# Compare the response body served to a crawler and to a human
curl -sL -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
https://example.com/ > /tmp/bot.html
curl -sL -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/128.0 Safari/537.36" \
https://example.com/ > /tmp/human.html
diff <(sed 's/>/>\n/g' /tmp/bot.html) <(sed 's/>/>\n/g' /tmp/human.html) | head -60
# And: how many external links are served to the crawler
grep -oE 'href="https?://[^"]+"' /tmp/bot.html | grep -v example.com | sort | uniq -c | sort -rn | head
A related signal is a sudden growth in broken links: the doorway generator produced pages, they entered the index and the internal link graph, and then were deleted. A crawl with the broken link checker helps here — if it returns hundreds of URLs you never created, the question of infection is settled.

Adjacent checks: HTTP headers, SSL/TLS, and mixed content
These checks do not find malware, but they show how easily it can be planted and how visible it will be once it is.
Security headers. Missing headers are not an infection — they are an open door. The minimum set:
Content-Security-Policy— restricts script sources. A properly configured CSP prevents an injected third-party script from executing and records the attempt in a report. It is the only header that actively interferes with an infection doing its job; inspect it with the CSP analyzer.X-Frame-Options, or theframe-ancestorsdirective in CSP — clickjacking protection.X-Content-Type-Options: nosniff— disables content type guessing. Without it, an uploaded "image" with code inside may be interpreted as a script.Strict-Transport-Security— enforces HTTPS for all subsequent visits.
Inspect the actual response headers with the HTTP headers checker, and get a consolidated configuration verdict from the website security check.
SSL and mixed content. One outdated belief needs retiring here: a valid certificate has long stopped being a sign that a site is safe. Free certificates are issued automatically, and a phishing page almost always has correct HTTPS. The padlock says the connection is encrypted, not that the other end is trustworthy.
What is genuinely worth checking on your own site: expiry and auto-renewal, chain completeness, support for current protocol versions, and the absence of mixed content. The last one is directly on topic — HTTP resources appearing on an HTTPS page are a frequent side effect of injected code. Use the SSL certificate check, the SSL error reference, and the mixed content checker.
Blocklists and browser warnings: checking and getting removed
There are several lists and they are independent. Getting removed from one does not clear the flag in another.
- Google Safe Browsing. Drives the red interstitial in Chrome and Chromium-based browsers, plus the label in search results. Checked through the Transparency Report and the Security Issues section of Search Console.
- Bing and other engines. Their webmaster panels carry equivalent security notifications with the affected URLs.
- DNSBL and IP reputation lists. These primarily hit email — messages from your server stop being delivered. On shared hosting the listed party is often a neighbour on the same IP, not you.
- Antivirus vendor and browser filter lists. These react to the domains your pages load, not only to your own domain.
- Regional access restrictions. A separate story from security, but with the same practical effect — unavailability for part of your audience; check with the blocking checker.
The removal procedure is the same everywhere and consists of three steps that cannot be reordered. First, eliminate the cause completely — not "I deleted the file that was found", but the whole payload removed and the vulnerability closed. Then submit a review request: in Search Console it is a button in the security issues section, and other panels have an equivalent. Then wait: the timeline depends on the queue on the search engine's side and is usually measured in days rather than hours. Nobody guarantees an exact turnaround.
Submitting a review request while the cause is unresolved is worse than not submitting one: the site gets flagged again and domain trust degrades. Verify yourself with an external scanner before you file.
The removal process is covered in more detail in the guide on checking a site against blacklists, and the signals of fraudulent and phishing pages in the guide on checking a website for fraud.
A step-by-step cleanup plan
Order matters more than speed here. Nearly every failed cleanup is a reordered sequence: restore from backup first, change passwords later, never touch the vulnerability.
- Remove the risk to visitors. Put up a maintenance page or return 503 to everyone but your own address. While the site serves malware, the number of victims and the depth of penalties keep growing.
- Take a full copy of the CURRENT infected state. Files plus a database dump. This is not a restore point, it is evidence: it is how you will find the entry point. Without it, after cleaning you will never learn how you were breached.
- Collect the logs. Web server access and error logs, mail logs, panel, FTP, and SSH logs. Rotation will destroy the traces faster than you get to the analysis.
- Rotate every secret at once. SSH and FTP passwords, the database user password, every CMS administrator password, API keys, integration tokens, and the salts and keys in the CMS configuration. Remove foreign keys from
authorized_keys. The mailbox tied to the admin account counts too. - Find the entry point in the logs. Look for the first request to a file that should not exist, POST requests to unusual paths, calls to an upload handler, and request bursts from a single address. The timestamp of that first request defines the window for the mtime search.
- Restore from a known-clean backup. The operative word is "known". Yesterday's backup of an infected site contains the same backdoor. Use the date established in step 5 and take a copy from safely before it. If no such copy exists, clean manually instead.
- Reinstall the CMS core and all extensions on top. Download distributions of the same or a newer version from official sources and replace core directories wholesale. That is cheaper and more reliable than cleaning core files one by one. User content and uploads are left in place but checked separately.
- Clean the database. Based on layer 4 results: delete extra administrators, restore correct site URLs, strip injections from content, review autoloaded options and mail templates.
- Close the original vulnerability. Update the CMS, themes, and modules to current versions. Delete what you do not use: an inactive theme with a flaw is exploited exactly like an active one. Forbid PHP execution in upload directories. Restrict admin access by address or a second factor. Add brute-force blocking, for example with fail2ban. Server-level measures are collected in the guide on web server hardening.
- Verify, then reopen. Run all four layers again, retest cloaking by overriding the User-Agent, and confirm no foreign pages remain in the index. Only then remove the maintenance page and submit review requests in webmaster panels.
Step 2 is the one most often skipped — and then nobody can answer "how did this happen". A copy of the infected state takes minutes and a few gigabytes; its absence means the reinfection will be investigated blind.
Why "I removed the virus" without closing the hole means a repeat within a week
The reinfection mechanism is mundane. The vulnerability they came through is still there: the same outdated module, the same upload form without type validation, the same weak password. Attacker scanners run continuously and find the site again — usually faster than the first time, because the address is now in their database as a successful target.
There is a second reason. An infection almost never consists of a single file: next to it sit backup backdoors, a scheduler entry that restores them, a core file with one added include line, and a spare administrator account. Deleting "the" file removes the symptom, not the mechanism.
A third reason is specific to shared hosting: if several sites live on one account without isolation, the cleaned site is reinfected from a neighbour the same day. All sites on the account must be cleaned together.
A practical readiness test: you can name the specific vulnerability, the specific date, and the specific log entry used to get in. If you cannot, the hole is open, and a repeat is only a matter of time. What to do immediately after discovering a breach is covered in the guide on what to do when a website is hacked, and formalising the response is covered by an incident response plan.

Prevention: file integrity monitoring and one-time settings
An infection is discovered either within the first hour or a month later; there is rarely anything in between. The difference is entirely determined by whether change detection is in place.
- Scheduled checksum snapshots. A daily
sha256sumover the site directory, automatically compared with the previous snapshot, with an email on divergence. Fifteen lines of shell and the highest-value measure available without a budget. - Code under version control. If the site directory is a git working copy,
git statusanswers "what changed" in a second. Uploads and cache stay outside the repository. - PHP execution disabled in upload directories at the web server level. A single rule for
uploadsanduploadneutralises an entire class of file upload attacks. - Separated privileges. The web server must not be able to write to code files: it only needs write access to uploads, cache, and logs. Mode 777 is never required.
- Separate accounts per site. One compromised site must not be able to read a neighbour's files. That is a hosting configuration question, not a CMS one.
- Backups with history, stored off the server. A single overnight copy is useless: the infection is older than that. You need weeks of depth and storage separate from production — see the website backup guide.
- Uptime and content monitoring. Check not only the status code but the presence of a control string in the HTML, so a replaced home page is detected immediately.
- Regular external scans. Running the site through reputation databases weekly is cheaper than hearing about a flag from customers.
On application-level vulnerabilities: a large share of infections start not with a guessed password but with code injected through a form or a parameter. Output escaping hygiene is covered in the guide on preventing XSS attacks, and the overall list of controls in the website security checklist.
How to check on enterno.io
- Website malware check — the external slice: returned HTML, foreign scripts, and domain reputation.
- Website security check — headers, HTTPS configuration, and common mistakes in one report.
- Technology detection — which scripts and libraries actually load; an unexpected tracker or library stands out immediately.
- Website monitoring — uptime and content checks with alerts, so a defacement or redirect does not live for days.
- Broken link checker — a spike of non-existent URLs exposes doorway pages created and deleted by an attacker.
- Blocking checker — whether the domain ended up in access restriction registries after the incident.
Frequently asked questions
I wanted to check whether someone else's site is safe, not my own. What should I do?
Check the domain against reputation databases: Google Safe Browsing, VirusTotal, and the malware scanner answer in seconds and need no access to the site. Also look at how recently the domain was registered with whois: a fresh registration plus a payment page is a classic phishing combination. A valid HTTPS certificate guarantees nothing. A dedicated breakdown of the signals is in the guide on checking a website for fraud. And remember: a file downloaded from an unknown site is checked by your local antivirus, not by a website scanner.
Can I check a website for malware online, without server access?
Partly. Online scanners find malicious JavaScript in the returned HTML, redirects, defacement, and blocklist presence. They do not find PHP backdoors, database injections, or scheduler entries, because none of that appears in an HTTP response. If the scanner is silent but symptoms persist, only server access will settle it.
The external scanner says clean, but visitors get redirected elsewhere. How?
That is cloaking. The payload is served conditionally — to mobile devices, to search referrals, or only on the first visit — while a scanner arriving as a normal browser from a data centre gets a clean page. Test it by overriding User-Agent and Referer in curl as shown above.
My host emailed me about one malicious file. Is deleting it enough?
No. The host is reporting that one of its signatures matched one file, not that this is the only file. Backup backdoors, a cron entry that restores them, and a spare administrator account are almost always nearby. Deleting one file silences the scanner, not the infection.
Does changing the admin password help?
On its own, barely. If the attacker already has a file-based backdoor, they do not need a password — the code runs outside the authentication path. Rotating secrets is mandatory, but it only works together with cleaning the files and closing the vulnerability, and every secret must be rotated at once, including database credentials and API keys.
How do I determine when the site was actually breached?
From logs and file timestamps. In the access log, look for the first request to a previously non-existent file or an unusual POST; that timestamp defines the window for find -newermt. If mtime was forged with touch, rely on ctime, which PHP cannot change.
How long until the browser warning is removed after cleanup?
Once the cause is eliminated and a review request is submitted in the webmaster panel, the flag is typically lifted within days. Exact timing is not guaranteed and depends on the search engine's review queue. Filing before the actual cleanup is pointless and harmful: a repeat flag damages domain trust further.
Do I need a paid scanner if free tools exist?
Free tools are sufficient to detect an infection and clean it by hand: SSH access, find, grep -E, checksum comparison, and external reputation databases cover all four layers. Paid products save time on routine work and provide continuous change detection, but they do not find a fundamentally different class of threat.
Website malware check checklist
- External slice checked: reputation databases, page scanner, security sections in webmaster panels.
- DevTools opened: no foreign domains in Network, no errors from injected code in Console.
- Cloaking tested: responses for desktop, mobile, crawlers, and search referrals all match.
- Files modified during the incident window identified by both mtime and ctime.
- No executables, double extensions, or unprintable names inside upload directories.
- Obfuscation signature search run with
-E, not with plaingrep. - CMS core and extensions compared against a known-good copy: checksums,
git status, or adiffwith a clean distribution. - Scheduler, systemd timers,
authorized_keys, and system users reviewed. - Database checked: post content, site URLs, autoloaded options, administrators, mail templates.
- Web server and mail logs collected before rotation; entry point identified.
- All secrets rotated: SSH, FTP, database, CMS administrators, API keys, configuration salts.
- Vulnerability closed: updates applied, unused extensions removed, PHP execution disabled in uploads.
- Restore performed from a known-clean copy, not from yesterday's.
- File integrity monitoring and page content monitoring configured.
- Review requests submitted only after the cause was actually eliminated.