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.

Checked against a live WordPress install on .

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.

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

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

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

bash
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_html and WordPress is under it, Apache still reads a .htaccess in 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 an ErrorDocument or auto_prepend_file line 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.

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

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

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

.htaccess apache
# 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:

apache
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:

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

apache
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:

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

apache
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:

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

  1. 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.
  2. 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 WordPress marker, which is where most people stop looking.
  3. Delete anything that names a host you do not own. A RewriteRule whose target starts with http, an ErrorDocument pointing off-site, an auto_prepend_file naming a path you cannot account for. These do not need interpreting. Cut the whole block, conditions included, since a RewriteCond left behind attaches itself to whatever rule follows it.
  4. 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.
  5. Replace the WordPress block with the exact text above. Keep both marker lines. If the block is missing entirely, paste it in, adjusting RewriteBase and the index.php path if WordPress lives in a subdirectory.
  6. 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.
  7. 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.
  8. Re-run the curl probes. Referrer, then user agent, then the static JavaScript URL. All three should answer 200 with an empty redirect field. Anything else means you have another file, or another layer.
  9. Come back in an hour and compare fingerprints. Run the sha256sum command 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_relative prints the lot. Core hooks start with wp_; 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 -la that directory rather than trusting the plugin screen, and list drop-ins with wp plugin list --status=dropin --fields=name,title, which prints only a header row on a clean install.
  • System cron. crontab -l over SSH. An entry running a PHP file directly, especially one under wp-content/uploads or 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.
bash
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.

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

Next

Doing the inventory without the find command

The slow half of this is the inventory. Eight files on a small install, most of them hidden from the file manager, and each one has to be read before you can say the site is clean. Segurium walks the filesystem and hashes every file it finds, dot files included, so the list you built with find is a scan result instead of an afternoon. Where it recognises injected content in a file, it writes the cured bytes back and the file keeps working.

One thing is worth stating on this page in particular. The integrity check deliberately skips .htaccess, because your file is yours and there is no published copy to compare it against. Judgement there comes from what is in the file rather than from a checksum, so read the file after any cleanup and confirm the rule block is the one you wrote.