Blog

Blog

IT News, Tips and Tricks, and other useful info

IT Tips & Notes

The web shell that isn't a .php file: how .htaccess hides PHP malware behind fake extensions

The web shell that isn't a .php file: how .htaccess hides PHP malware behind fake extensions

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 .htaccess that makes Apache execute those files as PHP. Because most scans and manual reviews only look at .php files, 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 .php only (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 *.ico or *.jpg isn't in their sample set.
  • Humans skim for the obvious. When you're manually reviewing a hacked site, your eye goes to *.php in 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 .htaccess is 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 .htaccess files get planted.
  • AllowOverride None in the vhost; rewrite rules live in server config, not .htaccess.
  • security.limit_extensions = .php in every FPM pool.
  • PHP engine off in uploads/cache/temp directories, enforced with php_admin.
  • Pin auto_prepend_file and disable stray .user.ini files.
  • 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.


Commands above are standard Linux/Apache tooling and are safe to run read-only, but always understand what a command does before running it on a production server, and take a backup before changing Apache or PHP-FPM configuration.