Here's a scenario we've run into more than once lately while cleaning up compromised Linux web servers: the site is clearly hacked — spam pages, redirects, mail being sent — but every malware scan of the .php files comes back clean. The admin greps the whole site for PHP shells, finds nothing, and re-uploads a “clean” copy. Two days later it's back.
The shell was there the whole time. It just wasn't named .php. It was sitting in an uploads folder as favicon.ico, or a .jpg that no image viewer would ever open, and a single line in a .htaccess file was quietly telling Apache to run it as PHP anyway.
TL;DR: Attackers hide web shells behind innocent-looking extensions (.ico,.jpg,.gif,.txt,.css) and drop a tiny.htaccessthat makes Apache execute those files as PHP. Because most scans and manual reviews only look at.phpfiles, the shell survives cleanups. The durable fix isn't a better scanner — it's taking the ability to define PHP handlers away from.htaccess(AllowOverride), pinning PHP-FPM to.phponly (security.limit_extensions), and disabling PHP execution in writable directories.
What the trick actually looks like
Apache decides how to handle a file based on its type and handler mappings. Normally only .php is mapped to the PHP engine. But those mappings can be added per-directory in a .htaccess file — and if the server lets .htaccess define handlers, an attacker who can write one file can redefine the rules for a whole folder.
The malicious .htaccess is usually just a line or two. Any of these will do it:
# Classic mod_php: run .ico files through the PHP interpreter
AddHandler application/x-httpd-php .ico
# Same idea, different directive
AddType application/x-httpd-php .jpg
# Scoped to one specific file so it's even quieter
<FilesMatch "^favicon\.ico$">
SetHandler application/x-httpd-php
</FilesMatch>
Now https://thesite.com/images/favicon.ico isn't an icon — it's a fully functional PHP web shell. The attacker POSTs commands to it, uploads more payloads, sends spam, or pivots deeper. To anyone browsing the file list, it's just a favicon.
There's a sneakier PHP-FPM variant that doesn't touch .htaccess handlers at all. PHP reads per-directory .user.ini files by default, and they can set auto_prepend_file — a script that runs before every PHP request in that tree:
# .user.ini dropped in the docroot
auto_prepend_file = /var/www/httpdocs/images/logo.gif
That logo.gif is the shell, and now it executes on every legitimate PHP page load. No obvious malicious .php file, no handler line to grep for in .htaccess.
Why it slips past cleanups
- Scanners are extension-biased. Most malware scanners and file-integrity tools focus on
*.php. A shell in*.icoor*.jpgisn't in their sample set. - Humans skim for the obvious. When you're manually reviewing a hacked site, your eye goes to
*.phpin weird places. A favicon and a couple of images don't raise an eyebrow. - The payload can look like binary. A shell can be prepended to real image bytes so the file still starts like a valid JPEG, defeating “does this look like an image?” checks.
- The
.htaccessis easy to miss. It's a dotfile, it's often one line buried in a deep folder, and re-uploading the CMS doesn't overwrite files in an uploads directory the CMS doesn't manage.
How to find it on a server you manage
You don't need a commercial scanner — you need to look in the right places. SSH in and run these against your web root (adjust the path).
1. Flag every .htaccess that defines a PHP handler. This is the fastest tell:
find /var/www -name .htaccess -print0 \
| xargs -0 grep -EnH 'AddHandler|AddType|SetHandler|php_value|php_flag|auto_prepend|x-httpd'
Legitimate sites almost never need AddHandler/SetHandler for non-.php extensions. Anything mapping images, text, or icons to PHP is a red flag.
2. Find non-PHP files that contain PHP code. A .jpg should never contain <?php:
find /var/www -type f ! -name '*.php' -exec grep -lI '<?php\|<?=' {} +
Then narrow to the usual shell fingerprints:
grep -rEln 'eval\(|base64_decode\(|gzinflate\(|str_rot13\(|assert\(|system\(|shell_exec\(|passthru\(' \
/var/www --include='*.ico' --include='*.jpg' --include='*.png' \
--include='*.gif' --include='*.txt' --include='*.css'
3. Hunt for .user.ini droppers. These are almost always malicious on a normal site:
find /var/www -name '.user.ini' -exec grep -lH 'auto_prepend\|auto_append' {} +
4. Sort by what changed recently. Compromises leave fresh timestamps:
find /var/www -type f -mtime -14 -printf '%TY-%Tm-%Td %p\n' | sort
5. Ask the access log who's been talking to your “images.” A POST to a .ico or .jpg that returns 200 is not normal traffic:
grep -E 'POST .*\.(ico|jpg|jpeg|gif|png|txt|css) ' /var/log/apache2/access.log \
| awk '$9 == 200'
How to shut it down for good
Finding one shell is cleanup. The goal is to make the whole technique impossible on your server, so the next foothold can't turn into code execution. These are the controls we put in place on the sites we host.
1. Take handler control away from .htaccess
The root cause is that .htaccess is allowed to define handlers at all. In your Apache virtual host, set AllowOverride None and move any required rewrite rules (Joomla, WordPress, etc.) up into the vhost. If .htaccess can't add a handler, none of the tricks above work:
<Directory /var/www/vhosts/example.com/httpdocs>
AllowOverride None
Require all granted
# paste the CMS's rewrite rules here instead of in .htaccess
</Directory>
If you genuinely can't drop .htaccess (shared hosting, a CMS that insists on it), at least do not grant the FileInfo override class loosely — that's the class that enables AddHandler, AddType, and SetHandler.
2. Pin PHP-FPM to .php only
PHP-FPM has a built-in guard that refuses to execute anything but the extensions you list. Make sure it's set — and set tightly — in the pool config:
; /etc/php/8.x/fpm/pool.d/example.conf
security.limit_extensions = .php
With this in place, even if Apache is tricked into proxying favicon.ico to PHP-FPM, FPM answers with “Access to the script has been denied” and nothing runs. This one line neutralizes the extension trick at the interpreter. (Beware configs that blank it out to allow .phtml or similar — that reopens the door.)
3. Kill PHP execution in writable directories
Uploads, cache, and temp folders should never run code. Use php_admin directives (mod_php) or a deny rule — php_admin_* settings cannot be overridden by a .htaccess or ini_set():
<Directory /var/www/vhosts/example.com/httpdocs/images>
php_admin_flag engine off
<FilesMatch "\.(php|phar|phtml|pht|php[0-9]?)$">
Require all denied
</FilesMatch>
</Directory>
4. Lock the dangerous knobs in the FPM pool
Set these as php_admin_value/php_admin_flag so nothing per-directory can loosen them — and specifically pin auto_prepend_file empty so a dropped .user.ini can't inject a prepend shell:
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,show_source
php_admin_value[open_basedir] = /var/www/vhosts/example.com/httpdocs:/tmp
php_admin_value[auto_prepend_file] =
php_admin_flag[allow_url_fopen] = off
If you don't use per-directory PHP settings at all, you can disable .user.ini processing outright in php.ini with user_ini.filename = (left blank).
5. Separate the code from the runtime user
If the user PHP runs as doesn't own the site's files, a shell can't rewrite your code or drop new payloads into the application tree. Deploy code read-only, keep writable paths to a short, known list, and never let the web user own its own source.
6. Watch the right files
Point file-integrity monitoring at everything, not just *.php — and alert specifically on new or changed .htaccess and .user.ini files. Those two dotfiles appearing where they didn't exist before is one of the highest-signal indicators of this attack.
The hardening checklist
- Patch the front door. This attack is stage two — the foothold is almost always an out-of-date CMS, plugin, or theme, or stolen FTP/SFTP credentials. Keep everything updated and require MFA on hosting and file-transfer access.
- Kill plaintext FTP. SFTP/FTPS only. Harvested FTP passwords are a leading way these
.htaccessfiles get planted. AllowOverride Nonein the vhost; rewrite rules live in server config, not.htaccess.security.limit_extensions = .phpin every FPM pool.- PHP engine off in uploads/cache/temp directories, enforced with
php_admin. - Pin
auto_prepend_fileand disable stray.user.inifiles. - Least-privilege ownership so a shell can't rewrite the app.
- Monitor all files + dotfiles, and log-alert on POSTs to static extensions.
The takeaway
A web shell doesn't have to be a .php file, and that's exactly why these compromises keep coming back after a “clean.” The extension is theater; the real switch is a one-line handler mapping that most people never think to audit. Fix it at the layer that decides what runs — the server config and the FPM pool — and the trick stops working no matter what an attacker manages to upload.
If you'd rather not audit this yourself, it's exactly the kind of thing we check and lock down on the sites we host and manage. If your site has been acting compromised and the scans keep coming back clean, that's a strong hint you're looking in the wrong place — and we're happy to take a look.
