Working out what you have
Rewrite rules injected into .htaccess
Apache reads .htaccess on every request, before PHP starts, so a rule written there beats every plugin you have. You do not have one of these files. You have several, most of them put there by software you installed, and telling those apart from the injected one is most of the work.
What an injected rewrite rule looks like
The complaints arrive before the evidence does. A reader clicks your page in Google results and lands on a pharmacy. A customer opens the site on a phone and gets a prize draw. Your own visit works, every time, which is why you spend the first day doubting the person who reported it.
That pattern is the point of the attack. An injected rewrite rule almost
never fires for everyone. It reads something about the request first,
normally the Referer header or the User-Agent
header, and it acts only when the request came from a search engine or
from a phone. You arrive by typing the address, from a bookmark, or from
the admin bar, so the condition never matches and you never see it.
What makes this layer different from injected PHP is where it sits.
Apache reads .htaccess before any of your code runs. Its own
documentation puts it plainly: the file "is loaded every time a document
is requested", and Apache assembles the rules by looking in every
directory from the document root down to the one holding the file. So a
security plugin cannot block the redirect, a maintenance mode cannot stop
it, and disabling every plugin changes nothing. PHP never starts. It also
means a stylesheet or an image is caught by the same rule, which is the
property you use to confirm it.
If you have not yet narrowed the redirect to the server configuration, start at your site redirects visitors to somewhere else. It separates five layers in about ten minutes, and four of them are not this page.
Prove the file is doing it
Two commands settle it. Both change one thing about who is asking and read the status code that comes back. Run them from your own machine against the live site.
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" \
-e "https://www.google.com/" https://example.com/
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" \
-A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
https://example.com/
A healthy site answers 200 with nothing after it. Both of
these returned exactly that on the clean install this page was checked
against. A 301 or 302 with a host you do not
own in the second column is your redirect, reproduced on demand, and you
can now use the same command to tell whether a fix worked.
Then ask for something Apache serves without touching PHP. A rewrite rule catches a static file; injected PHP cannot, because nothing executes.
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" \
-e "https://www.google.com/" \
https://example.com/wp-includes/js/wp-embed.min.js
A redirect on that URL puts you in the server configuration layer, which
is this page. A clean 200 on it while the home page
redirects puts you in PHP, which is not.
Check that Apache is reading the file at all
Before you spend an afternoon reading rewrite rules, find out whether
your server honours them. Apache ignores .htaccess entirely
unless AllowOverride permits it, and the documented default
is None. nginx has no per-directory configuration file and
never reads one. LiteSpeed and OpenLiteSpeed do read it.
The test takes a minute. Make a directory, put a plain text file in it, request the file, then add a deny rule and request it again.
mkdir -p wp-content/uploads/httest
echo hello > wp-content/uploads/httest/probe.txt
printf 'ErrorDocument 404 "httest"\nRedirect 302 /wp-content/uploads/httest/probe.txt /?httest\n' \
> wp-content/uploads/httest/.htaccess
curl -s -o /dev/null -w "%{http_code}\n" \
https://example.com/wp-content/uploads/httest/probe.txt 302 means Apache read your file and obeyed it, so an
injected file anywhere on this site is live. 200 means the
server walked past it and your redirect is somewhere else. Delete the
test directory afterwards.
Probe with a redirect rather than with Require all denied.
Overrides are granted per class, and Require belongs to
AuthConfig while every directive on this page belongs to
FileInfo. A host set to
AllowOverride FileInfo Options answers a
Require probe with 500 and honours injected
rewrite rules perfectly well, which is the wrong answer to the question
you asked. Measured on Apache 2.4.67 with
AllowOverride All: 200 with no file,
403 with Require all denied, and
500 with a deliberately broken one.
Remember that 500. Apache refuses a .htaccess
it cannot parse and returns a server error for every request in that
directory, so a site that broke the moment you edited the file has a typo
rather than a second infection.
Reading the fatal
error covers the equivalent on the PHP side.
Find every .htaccess on the site
Every guide that opens with "edit your .htaccess file" has already lost the thread. Apache checks each directory along the path of the request, so any directory can carry rules, and the injected one is often not the one in the web root. Ask the filesystem how many you have.
find . -name ".htaccess" Run from the WordPress root on an install carrying a handful of plugins, that returned eight files, and none of them was the root file.
| Path, relative to the WordPress root | What put it there |
|---|---|
wp-content/plugins/akismet/.htaccess |
Akismet, shipped inside the plugin. Denies everything, then names
its own CSS, JavaScript and images in two
<FilesMatch> blocks.
|
wp-content/plugins/wpterm/languages/.htaccess | WPTerm, blocking direct requests for its translation files. |
wp-content/uploads/rosenheinrich-multisite-migrate/.htaccess | A migration plugin, protecting the archives it writes. |
wp-content/uploads/wp-file-manager-pro/fm_backup/.htaccess |
A file manager, blocking .zip and .gz
downloads. It opens <FilesMatch> and closes
</Files>, which is a syntax error the plugin
shipped.
|
| Four more, in one security plugin's data directory and its three subdirectories | Written at activation. All four are byte for byte identical and share one SHA-256. |
Two lessons sit in that table. Most .htaccess files on a
WordPress site are legitimate and are there to deny access rather than
grant it. And a legitimate plugin shipped a broken block, so an odd
looking file is not proof of anything on its own. If a scanner flags one
of these, work out
whether the detection is right before you delete it.
The files find will not show you
find from the WordPress root misses two places, and both
matter.
- Above the web root. If WordPress lives in a subdirectory, or if
your document root is
public_htmland WordPress is under it, Apache still reads a.htaccessin every parent directory down from the document root. With shell access, search from your home directory instead of from the WordPress root. - Directories WordPress knows nothing about. An old staging copy,
a
backup/folder, a subdomain served from the same account. Rules in those affect requests to those paths, and anErrorDocumentorauto_prepend_fileline in a parent directory reaches everything below it.
A filename that starts with a dot is hidden by default in almost every tool a non-technical owner uses. cPanel's File Manager has a Show Hidden Files option in its Settings dialogue and it is off until you turn it on. FileZilla has Force showing hidden files under its Server menu. Turn both on before you conclude a directory is clean, because the file you are looking for is invisible in the default view.
Look for .user.ini in the same pass
php_value lines in .htaccess only work when PHP
runs as an Apache module. On the FastCGI and PHP-FPM setups most hosts
run today, the equivalent is .user.ini, which PHP reads from
the directory of the requested file upwards to the document root. It
accepts the same per-directory settings, including
auto_prepend_file. PHP caches it for
user_ini.cache_ttl seconds, 300 by default, so a change
there can take five minutes to appear and five minutes to disappear.
find . -name ".htaccess" -o -name ".user.ini" -o -name "php.ini" Take a fingerprint of everything you found. You will want to know later which of these files changed while you were working.
find . -name ".htaccess" -exec sha256sum {} +
find . -name ".htaccess" -printf "%T+ %p\n" | sort Then read all of them at once, filtering for the directives that can redirect, execute or hide.
grep -rn --include=".htaccess" -E \
"RewriteCond|RewriteRule|auto_prepend_file|auto_append_file|AddType|AddHandler|ErrorDocument|SetHandler|ForceType" .
Expect hits, and expect the stock ones first. The measured install had no
root .htaccess at all, so the grep matched nothing across
its eight files and exited 1. Most Apache sites with pretty permalinks do
have one, and there the same command prints the two
RewriteCond lines and two RewriteRule lines
that WordPress writes itself. Learn that block in the next section, then
every remaining line needs a reason to be there.
Tell the WordPress block from the injected one
You cannot recognise a stranger in the file until you know exactly what belongs there. WordPress writes one block, it is short, and it is the same on every site running pretty permalinks.
What WordPress writes, line for line
This is the literal output of the function that generates it, on WordPress 7.0.4, including the three comment lines the writer inserts above the rules.
# BEGIN WordPress
# The directives (lines) between "BEGIN WordPress" and "END WordPress" are
# dynamically generated, and should only be modified via WordPress filters.
# Any changes to the directives between these markers will be overwritten.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress
Read it once and you will never mistake it for anything else.
RewriteEngine On turns the module on. The
HTTP_AUTHORIZATION line copies the Authorization header into
an environment variable so application passwords survive hosts that strip
it; WordPress 5.6 added it and older sites that never re-saved their
permalinks do not have it, which is normal rather than suspicious.
RewriteBase and the index.php rule set the
starting point. The last three lines are the whole of pretty permalinks:
if the request is not a real file and not a real directory, hand it to
index.php.
Nothing in that block names another host. Every
destination is a path on your own site. WordPress has never written a
RewriteCond on %{HTTP_REFERER} or on
%{HTTP_USER_AGENT}, and it has never written an
ErrorDocument. That is your single strongest test.
A site in a subdirectory has a different RewriteBase and a
different target path, and multisite writes a longer block. Both are
documented on WordPress.org. Neither adds a condition on a header.
The markers are a boundary, and attackers know it
WordPress rewrites only the lines between # BEGIN WordPress
and # END WordPress. Everything above and everything below
is copied through untouched. That was measured: a scratch file with an
ErrorDocument line above the block and an
AddType line below it kept both after WordPress regenerated
the rules.
So an injection placed outside the markers survives every permalink save
you will ever do, while one placed inside them is wiped the next time
anything regenerates the block. Attackers put it outside. Some also
forge a second marker pair with a plausible name so the block reads like
a plugin's. Plugins do write their own marked blocks through the same
core function, insert_with_markers(), so
# BEGIN and # END around a caching plugin's
name is ordinary. Check the name against your installed plugin list
rather than against your instinct.
Redirect on the referrer
The classic shape, still the most common. Sucuri's 2024 write-up of
.htaccess malware prints this one:
RewriteEngine On
RewriteCond %{HTTP_REFERER} .*google.* [OR]
RewriteRule ^(.*)$ hxxp://example.invalid/in.cgi?3 [R=301,L]
A RewriteCond applies only to the RewriteRule
that immediately follows it, so read the pair together.
[OR] chains a condition to the next one instead of the
default AND, [NC] makes the match case-insensitive,
[R=301] forces an external redirect with that status, and
[L] stops the rewriting there. Real samples list five or six
search engines and often a long tail of social networks. The destination
above is defanged, as it is in the source.
Redirect on the user agent
Same mechanism, different header. Jetpack's write-up shows both variants in the wild:
RewriteCond %{HTTP_USER_AGENT} (bing|google|yahoo|msn|aol) [NC,OR]
RewriteCond %{HTTP_USER_AGENT} "iPhone|android" [NC]
RewriteRule ^(.*)$ hxxp://example.invalid/go [R=302,L] The crawler list and the mobile list serve different goals. Matching crawlers feeds spam into the index under your domain, which is the mechanism behind pharmacy spam in your pages and search results, where the payload is served rather than redirected. Matching phone browsers monetises visitors while keeping the desktop experience clean for the owner. A site can carry both blocks at once.
ErrorDocument pointing at a remote URL
A quieter one, and easy to skim past because ErrorDocument
is a normal directive. Sucuri's 2024 sample sets it on both 400 and 404,
pointing at a remote script.
ErrorDocument 400 hxxp://example.invalid/inject/index.php
ErrorDocument 404 hxxp://example.invalid/inject/index.php Apache's own documentation explains why this works so well for an attacker. When the target is a remote URL, Apache "will send a redirect to the client to tell it where to find the document, even if the document ends up being on the same server", and the client never receives the original status code. Every mistyped URL and every dead link on your site then becomes a redirect to somebody else's page, and it never fires for you because you do not browse your own 404s.
A stock WordPress install has no ErrorDocument line at all.
A local one your host added, such as
ErrorDocument 404 /404.html, is fine. A remote one is not.
php_value auto_prepend_file
This one is not a redirect. It is a backdoor that needs no backdoor file in your code. Sucuri documented a 2013 server-level compromise built on it, and the shape has not changed since:
<files ~ ".js$">
AddHandler php5-script .js
php_value auto_prepend_file /path/to/attacker/file
php_flag display_errors Off
</files> auto_prepend_file makes PHP include a file before the
requested script runs. Point it at attacker code and every PHP request in
that directory and below executes it first, without a single one of your
files being edited. Pair it with AddHandler on
.js, as above, and your JavaScript files start running PHP
too. display_errors Off is there so mistakes stay invisible.
The directive by itself is not evidence. Wordfence's Extended Protection
sets auto_prepend_file deliberately, pointing at
wordfence-waf.php in your site root, so its firewall loads
before WordPress. Judge the path it names, not the directive. A file
belonging to a plugin you installed is expected; a path outside your site
or a name you do not recognise is not.
AddType and AddHandler turning one extension into another
Both directives map a filename extension onto something else, and both
are allowed in .htaccess. Attackers use them in two
directions.
AddType application/x-httpd-php .png
AddHandler php5-script .jpg
AddType text/plain .php
ForceType text/plain
The first pair makes the server execute an image as PHP, which is how a
file that passed an upload filter as a picture becomes a shell. Apache's
documentation is explicit that AddType sets the handler as a
side effect: "If no handler is explicitly set for a request, the
specified content type will also be used as the handler name". That is
why Apache tells administrators who restrict AddHandler to
restrict AddType as well. Extensions are matched
case-insensitively and a filename may carry several, so
shell.php.png is caught by a rule about
.png.
The second pair runs the other way and makes the server hand PHP source back as plain text. Attackers apply it to a directory holding their own tooling so a curious administrator sees nothing execute, and SiteLock documents the same trick used to neutralise a directory wholesale. Either direction, applied to a directory of yours, is a finding.
Files and FilesMatch whitelisting one dropped file
The most self-incriminating shape of all, because it protects exactly one file. SiteLock's write-up prints the pattern:
<FilesMatch "\.(php|php5|suspected|py|phtml)$">
Order allow,deny
Deny from all
</FilesMatch>
<FilesMatch "^(index\.php|system_log\.php)$">
Order allow,deny
Allow from all
</FilesMatch> Read the second block first. Everything in the directory is denied, then two files are allowed back, and one of them is the attacker's. The purpose is to keep other intruders and your own scanner away from a working shell while leaving it reachable.
Akismet's shipped file has the same structure: deny everything, then allow a named list. The difference is what the list contains. A block naming CSS, JavaScript and image files belonging to the plugin around it is housekeeping. A block naming one PHP file in a directory that should hold no PHP at all is not.
Take the injection out
Work through this once, in order, on every file your inventory turned up. A text editor is enough.
- Copy every file you found to somewhere outside the web root. Keep the paths in the copy. Some of what you are about to delete will turn out to be a redirect somebody set up years ago, and nothing on the server can reconstruct it for you.
- Start with the root file. Rules there apply to the whole site,
so it is where a redirect that affects every page has to live. Open it
and read every line, top to bottom, including the lines above the
# BEGIN WordPressmarker, which is where most people stop looking. - Delete anything that names a host you do not own. A
RewriteRulewhose target starts withhttp, anErrorDocumentpointing off-site, anauto_prepend_filenaming a path you cannot account for. These do not need interpreting. Cut the whole block, conditions included, since aRewriteCondleft behind attaches itself to whatever rule follows it. - Delete every rule that tests a request header.
%{HTTP_REFERER},%{HTTP_USER_AGENT},%{HTTP_COOKIE}. If you did not write it, and no plugin you recognise documents it, it goes. Legitimate uses exist, hotlink protection being the common one, and they point at your own domain. - Replace the WordPress block with the exact text above. Keep both
marker lines. If the block is missing entirely, paste it in, adjusting
RewriteBaseand theindex.phppath if WordPress lives in a subdirectory. - Or let WordPress write it. Open Settings, then Permalinks, and press Save Changes without changing anything. WordPress regenerates the marked block and leaves the rest of the file alone. It only works when the file is writable, so undo any read-only mode first.
- Now the files under wp-content. Most of them should stay. A deny-everything block protecting a plugin's own backups, cache or language files is doing a job, and deleting it exposes what it was covering. Remove a file only when its contents are an injection, and remember that a plugin will normally rewrite its own file when you deactivate and reactivate it.
- Re-run the curl probes. Referrer, then user agent, then the
static JavaScript URL. All three should answer
200with an empty redirect field. Anything else means you have another file, or another layer. - Come back in an hour and compare fingerprints. Run the
sha256sumcommand again against what you recorded. A hash that changed on a file nobody touched is the next section.
WP-CLI has wp rewrite flush --hard, and most guides
recommend it without the caveat. On a normal install it prints "Warning:
Regenerating a .htaccess file requires special configuration. See usage
docs." and writes nothing at all. It needs
apache_modules: [mod_rewrite] in your
wp-cli.yml first. The Permalinks screen has no such
condition.
Find what keeps rewriting the file
This is the part people skip, and it is the reason the rules are back
before lunch. Nobody edited your .htaccess over FTP. PHP
running on your own site wrote it, and that PHP is still installed. The
file is the output. Cleaning output on a schedule is not a fix.
Four places carry almost all of it. Check them in this order.
- Scheduled WordPress events. A hook you do not recognise, often
due in a few minutes and named to look like core.
wp cron event list --fields=hook,next_run_relativeprints the lot. Core hooks start withwp_; plugin hooks are prefixed with the plugin's slug. Something matching neither is the first thing to read. - Must-use plugins and drop-ins. Anything in
wp-content/mu-plugins/loads on every request and cannot be deactivated from the admin.ls -lathat directory rather than trusting the plugin screen, and list drop-ins withwp plugin list --status=dropin --fields=name,title, which prints only a header row on a clean install. - System cron.
crontab -lover SSH. An entry running a PHP file directly, especially one underwp-content/uploadsor in a temp directory, restores the infection independently of anything WordPress does. - Recently written PHP. Sort the whole tree by modification time and read what changed around the moment the redirect started.
find . -name "*.php" -newermt "-7 days" -printf "%T+ %p\n" | sort
find wp-content/uploads -name "*.php"
Then look for code that writes the file by name. This produces false
hits: a security plugin, a mail plugin, a migration plugin and a file
manager all touch .htaccess for honest reasons. On the
checked install that grep returned nineteen files across four plugins,
every one of them entitled to be there. Read the matches inside plugins
you did not install, and inside directories that hold no plugin at all.
grep -rl --include="*.php" "htaccess" wp-content/ | sort
grep -rlE --include="*.php" "eval\(|gzinflate|str_rot13|assert\(" wp-content/ Do not run anything you find to see what it does. Reading it is enough, and the second command usually points straight at an obfuscated block you can decode without executing.
A timestamp is forgeable and attackers who bother do forge it, so a normal-looking date is weak evidence in favour. If the writer does not turn up in any of the four places, the entry point is wider than this page: the malware came back after the cleanup works through stolen credentials, a vulnerable plugin still installed, and the accounts that keep letting it back in.
Why chmod 444 is a speed bump
The standard advice is to make .htaccess read-only. It is
worth understanding what that buys before you rely on it.
PHP normally runs as the user that owns your files. An owner can change
the mode of their own file whether or not the write bit is set. Measured
on a live install: with the file at 0444, PHP reported it
not writable and a direct write failed, then the same script called
chmod to 0644, wrote its content, and set
0444 back. Afterwards ls -l showed
-r--r--r--, exactly as before, with different contents
inside. Malware that already writes your files handles this in three
extra lines.
What read-only reliably stops is your own software. WordPress checks
writability before it touches the file, and with the file at
0444 its writer returned false and changed nothing. So your
Permalinks screen stops saving, your caching plugin stops updating its
block, and neither tells you loudly. If your host gives you root, the
immutable attribute (chattr +i) is a harder stop, because
even the owner has to clear it first. Most shared hosting does not.
Set the file read-only after you have removed the writer, as a tripwire that tells you when something tried. Setting it before that just hides the evidence under unchanged permissions.
Questions
- Can I just delete .htaccess?
- The root one, yes, on a WordPress site with no other server configuration in it. WordPress regenerates its own block when you open Settings then Permalinks and press Save. What you lose with it is every non-WordPress rule the file carried: a caching plugin's block, a redirect you set up years ago, an HTTPS rule your host added. Read the file and take a copy before you delete it, because nothing on the server will reconstruct those. The .htaccess files inside wp-content are a different matter and most of them should stay.
- I cleaned the file and the rules were back within the hour. What is doing that?
- PHP that runs on your site and writes the file. It is usually reached three ways: a scheduled WordPress event, a must-use plugin or drop-in that loads on every request, or a system cron entry your host runs. The file is the output, not the infection, so cleaning it on a loop achieves nothing. Find the writer first and clean the file last.
- Does chmod 444 stop the file being rewritten?
- Only against code that does not think to ask. Measured on a live install: a read-only file refused a direct write from PHP, then the same script called chmod to 0644, wrote, and set 0444 back. The permissions afterwards looked untouched and the contents were not. What 444 does reliably is stop WordPress and your plugins writing the file, so your permalinks screen quietly stops working. Use it after you have found the writer, not instead of finding it.
- My host runs nginx. Can this still be my problem?
- Not directly. nginx has no per-directory configuration file and ignores .htaccess completely, so an injected file sits there doing nothing. LiteSpeed and OpenLiteSpeed do read .htaccess, and they are common on shared hosting, so check what you are actually running before you rule it out. On nginx, a redirect that survives a static file request lives in the server configuration and only your host can change it.
- There are .htaccess files inside wp-content. Are those the hack?
- Almost certainly not. Plugins drop a two-line deny-everything file into their own upload and cache directories on purpose, and Akismet ships one inside its plugin folder. On the install this page was checked against, eight .htaccess files existed and every one of them belonged to a plugin. Judge each by what is in it: a deny block is protecting something, a rewrite to another domain is not.
- Is auto_prepend_file always malware?
- No, and this one catches people. Wordfence's Extended Protection sets auto_prepend_file so its firewall loads before WordPress does, pointing at wordfence-waf.php in your site root. The directive itself is neutral. What matters is the file it names: a path inside your own site that belongs to a plugin you installed is one thing, a path in a temp directory or a filename you have never seen is another.