Security, Tutorials, WordPress

Block PHP execution in wp-content/uploads on OpenLiteSpeed: the right .htaccess snippet

Computer monitor displaying terminal output: system metrics, file listings, and kernel error messages — typical sysadmin view (photo: Tima Miroshnichenko)

wp-content/uploads/ is the most predictable target on a WordPress install. It’s writable by the web server (so any compromise that gets a file uploaded lands here), it’s almost never inspected by malware scanners with the same vigilance as wp-includes/, and PHP execution there means an attacker only needs to drop one file to have a foothold. Closing that door is two minutes of work and stops a whole class of post-compromise persistence.

The “right” snippet is not the one you’ll find in most copy-paste tutorials. Here’s what works on OpenLiteSpeed, why the obvious approach quietly fails, and how to verify the block is actually live.

Why the obvious snippet doesn’t always work

Search “block PHP execution in WordPress uploads” and you’ll get this:

<FilesMatch "\.(php|phtml|phar)$">
  Require all denied
</FilesMatch>

That’s correct Apache 2.4 syntax. On Apache it works. On OpenLiteSpeed, in my experience, it doesn’t always take effect — drop a test PHP file in uploads/, curl it, and you’ll see HTTP 200 with the PHP output. LSWS reads the .htaccess with autoLoadHtaccess 1, but it doesn’t honor every Apache authz directive the same way Apache does, and the <FilesMatch>+Require combination is one of the rough spots — depending on the LSWS version and the order in which other handlers register, the directive can get bypassed entirely.

So you need a snippet that works whether the server is interpreting it as LSWS, Apache, or both — and the rewrite engine is the universal answer.

The snippet that works

Drop this into wp-content/uploads/.htaccess on every site:

# Block PHP / script execution in uploads (anti-malware persistence)
<IfModule LiteSpeed>
RewriteEngine On
RewriteRule \.(php|hph|phtml|phar|pht|php3|php4|php5|php7|php8|phps|inc|cgi)$ - [F,L,NC]
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule \.(php|hph|phtml|phar|pht|php3|php4|php5|php7|php8|phps|inc|cgi)$ - [F,L,NC]
</IfModule>
<FilesMatch "\.(php|hph|phtml|phar|pht|php3|php4|php5|php7|php8|phps|inc|cgi)$">
  Require all denied
</FilesMatch>

Three things going on:

  • The RewriteRule with the [F] flag returns 403 for any matching extension. This is the universal-PHP-server way to deny access by URL pattern, and it works on LSWS, Apache, and even Lighttpd.
  • Both <IfModule LiteSpeed> and <IfModule mod_rewrite.c> guards are present so the snippet is harmless if you ever migrate to plain Apache or to a server where neither module name matches — at worst no rule fires.
  • The <FilesMatch> block at the bottom is belt-and-braces for setups that do honor it. Won’t hurt where it doesn’t.

Notice the extension list. .php is the obvious one, but real-world malware loves the alternatives. .hph in particular is a trick I’ve seen used as a “backup copy” of an infected file — the attacker keeps a clean-looking .hph next to each .php shell so that if you clean the .php they can rename the .hph back. .phtml, .phar, and the version-suffixed variants are common too. Block them all.

Roll out across every site

If you have one WordPress site, a copy-paste is fine. If you have eight on the same LSWS box, here’s the loop:

HTACCESS_BLOCK='# Block PHP / script execution in uploads (anti-malware persistence)
<IfModule LiteSpeed>
RewriteEngine On
RewriteRule \.(php|hph|phtml|phar|pht|php3|php4|php5|php7|php8|phps|inc|cgi)$ - [F,L,NC]
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule \.(php|hph|phtml|phar|pht|php3|php4|php5|php7|php8|phps|inc|cgi)$ - [F,L,NC]
</IfModule>
<FilesMatch "\.(php|hph|phtml|phar|pht|php3|php4|php5|php7|php8|phps|inc|cgi)$">
  Require all denied
</FilesMatch>
'

for sp in /usr/local/lsws/{site1,site2,site3}/html; do
  uploads_dir="$sp/wp-content/uploads"
  [ -d "$uploads_dir" ] || mkdir -p "$uploads_dir"
  htaccess="$uploads_dir/.htaccess"
  if grep -q "Block PHP / script execution" "$htaccess" 2>/dev/null; then
    echo "  - $sp: already protected"
    continue
  fi
  printf "%s" "$HTACCESS_BLOCK" >> "$htaccess"
  chown nobody:nogroup "$htaccess"
  echo "  ✓ $sp updated"
done

sudo /usr/local/lsws/bin/lswsctrl restart

Idempotent — the grep guard keeps re-runs from appending duplicates. It also preserves anything already in the file (caching plugins like LSCWP write their own .htaccess inside uploads/ sometimes — appending instead of overwriting keeps both happy).

Verify it actually blocks

Don’t trust the rule, test it:

# Drop a test file
TEST=/usr/local/lsws/yoursite/html/wp-content/uploads/__verify_block_$(date +%s).php
echo '<?php echo "EXECUTED"; ?>' > "$TEST"
chown nobody:nogroup "$TEST"

# Curl it from the public side
relpath=$(echo "$TEST" | sed 's|^.*/html||')
curl -s -o /dev/null -w "%{http_code}\n" -k \
  --resolve "yoursite.com:443:127.0.0.1" \
  "https://yoursite.com${relpath}"
# expected: 403

# Cleanup
rm -f "$TEST"

If you get 403 with a small “Forbidden” page, you’re done. If you get 200 with EXECUTED in the body, the rule isn’t taking effect — common causes: the file is in a subdirectory that has its own .htaccess overriding (LSCache writes one in some setups), or you forgot to restart LSWS after dropping the rule, or autoLoadHtaccess is set to 0 on that vhost.

What this does and doesn’t protect against

This rule blocks direct execution of PHP files placed under uploads/. It does not stop:

  • An attacker who already has WordPress admin access — they can drop PHP through the theme/plugin editor (which is why DISALLOW_FILE_EDIT in wp-config.php is the natural pair to this).
  • A vulnerable plugin that takes a user-supplied filename and include()s it — that’s a code-path, not a URL — and would execute the file regardless of the request URL.
  • SEO-spam files written into wp-content/litespeed/, theme directories, or wp-includes/ by other malware. Those areas need their own audit.

What it does stop is the lazy, common case: a webshell uploaded as a fake image, a malware dropper saved as uploads/2024/03/admin.php, or a P.A.S. shell stashed in a forgotten uploads/ subfolder. That single rule has caught more attempted exploits on my boxes than any plugin.

Two minutes of .htaccess, applied across every site, no plugins, no recurring overhead. Hard to argue against.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.