Most security advice for PHP applications focuses on the wrong layer. You harden your NGINX config, add a WAF, keep WordPress updated — and then a zero-day plugin vulnerability drops and an attacker uploads a PHP webshell to wp-content/uploads/. The WAF saw HTTP traffic, the WAF saw an image upload, the WAF said “fine.” Three hours later you’re explaining to a client why their website is serving malware.
PHP-Snuffleupagus is a PHP extension that adds a security layer inside the PHP interpreter itself. Not at the HTTP layer. Not at the network layer. Inside PHP, where function calls actually happen. An attacker can craft an HTTP request that bypasses your WAF. They cannot bypass the PHP interpreter — Snuffleupagus runs inside it.
Think of it like this: ModSecurity is a bouncer at the front door of your restaurant. Snuffleupagus is a bouncer who lives in the kitchen. Even if someone sneaks in through the window, they still can’t touch the knives.
The myguard APT repository ships php-snuffleupagus as a pre-built package for Debian and Ubuntu, covering PHP 7.4 through 8.4. No compilation required. This guide goes deeper than the official docs — which are sparse — and covers everything from basic installation to WordPress-specific hardening to production tuning.
What Snuffleupagus actually blocks
Unlike a WAF that pattern-matches HTTP strings, Snuffleupagus controls what PHP is allowed to do. Here’s what that means in practice:
Dangerous function blocking
PHP has a set of functions that legitimate applications almost never need but attackers always want: eval(), system(), exec(), passthru(), shell_exec(), popen(), proc_open(). If an attacker injects code and it reaches PHP, these are what they call to execute commands on your server. Snuffleupagus blocks them at the interpreter — before execution, not after.
The crucial difference from PHP’s built-in disable_functions: Snuffleupagus is conditional. Block exec() only in requests coming from the uploads directory. Block file_get_contents() only when called with a remote URL. Block system() everywhere except one specific legacy script that genuinely needs it. Surgical, not blunt.
Cookie encryption and hardening
Snuffleupagus can transparently encrypt all cookies your application sets, adding an HMAC to detect tampering. It also enforces HttpOnly, Secure, and SameSite flags on every cookie, even ones your application forgot to flag. Session hijacking via cookie theft becomes dramatically harder.
File upload protection
One of the most common attack patterns: upload a PHP file disguised as an image, then trigger its execution via a URL. Snuffleupagus can block PHP from including or executing files from your upload directory entirely. A PHP file in wp-content/uploads/ cannot execute, period — no matter how it got there.
Type juggling prevention
PHP’s loose comparison operator (==) has infamous edge cases: "0e12345" == "0" is true because both evaluate as floating-point zero in scientific notation. Attackers exploit this to bypass password hash comparisons (the “magic hash” vulnerability). Snuffleupagus enforces strict comparisons in sensitive contexts.
Remote file inclusion blocking
PHP can include files from remote URLs if allow_url_include is on (it shouldn’t be, but legacy apps). Snuffleupagus blocks file_get_contents(), include(), and require() from fetching remote resources, regardless of PHP’s config.
XSS output filtering
Snuffleupagus can inject htmlspecialchars() calls transparently before PHP outputs content to the browser. This is a last-resort safety net for applications you can’t modify — it won’t catch every XSS vector but it catches the most common ones.
Snuffleupagus vs ModSecurity: use both
People always ask whether Snuffleupagus replaces ModSecurity. It doesn’t. They live at completely different layers and catch completely different attacks.
| Layer | ModSecurity (NGINX WAF) | PHP-Snuffleupagus |
|---|---|---|
| Where it runs | NGINX process, HTTP layer | Inside PHP-FPM, interpreter layer |
| What it sees | Raw HTTP headers, URL, body | PHP function calls, arguments, return values |
| Stops | SQLi in URLs, XSS in forms, path traversal in requests | eval(), shell_exec(), type juggling, cookie theft, bad file uploads |
| Bypass risk | Encoding tricks can fool pattern matching | Very hard — runs in the interpreter itself |
The attack chain typically goes: HTTP request → WAF layer → PHP layer → application. ModSecurity blocks at the WAF layer. Snuffleupagus blocks at the PHP layer. Run both and an attacker has to bypass two independent security systems at different levels of the stack. See the ModSecurity setup guide for the WAF side of this.
Step 1 — Install php-snuffleupagus
wget https://deb.myguard.nl/pool/myguard.deb
dpkg -i myguard.deb
apt-get update
apt-get install php-snuffleupagus
The package installs the extension for all PHP versions it finds on your system. Check it’s available:
ls /usr/lib/php/*/snuffleupagus.so
# You should see one file per installed PHP version
Step 2 — Enable the extension
You need to both load the extension and point it at a rules file. Do this per PHP version. For PHP 8.3:
cat > /etc/php/8.3/fpm/conf.d/20-snuffleupagus.ini <<'EOINI'
extension=snuffleupagus.so
sp.configuration_file=/etc/php/8.3/snuffleupagus.rules
EOINI
If you’re running multiple PHP versions (common with multiple sites), repeat for each version. The rules file path can be the same or different per version — using version-specific paths gives you more flexibility.
Create the rules file (empty for now — the extension requires it to exist):
touch /etc/php/8.3/snuffleupagus.rules
Restart PHP-FPM and verify the extension loaded:
systemctl restart php8.3-fpm
php8.3 -m | grep snuffleupagus
# Should output: snuffleupagus
Step 3 — The golden rule: start in log mode
Before you write a single blocking rule, understand this: every blocking rule is a potential outage. WordPress and its plugins use a broad range of PHP features. A rule that blocks exec() globally will break any plugin that calls it for legitimate image processing or PDF generation.
The workflow is always: log → review → whitelist false positives → block. Never jump straight to blocking on a live site.
In Snuffleupagus rules, .simulation() logs a violation but doesn’t block. .log() also just logs. .drop() blocks the call and returns false. .kill() terminates the PHP process entirely.
Start your rules file with simulation on the scary stuff:
sp.enable();
# Dangerous execution functions — simulation first
sp.disable_function.function("system").simulation();
sp.disable_function.function("exec").simulation();
sp.disable_function.function("passthru").simulation();
sp.disable_function.function("shell_exec").simulation();
sp.disable_function.function("proc_open").simulation();
sp.disable_function.function("popen").simulation();
Restart PHP-FPM, then watch the log:
systemctl restart php8.3-fpm
tail -f /var/log/php8.3-fpm.log | grep -i snuffleupagus
Browse your site. Send some test requests. If you see violations in the log, read them carefully before deciding whether they’re legitimate. A WordPress plugin calling exec() to run ImageMagick is probably legitimate. Same call from a request touching wp-content/uploads/ is not.
Step 4 — Production baseline rules
Once you’ve done a day or two of simulation and confirmed your false positive picture, build your production rules file. This baseline is conservative — it blocks things nearly all PHP applications never legitimately need:
sp.enable();
# ---- Cookie hardening ----
# Encrypt all cookies and enforce security flags
# WARNING: This invalidates existing sessions. Deploy during maintenance window
# or exclude specific cookies with sp.cookie.name("PHPSESSID").attribute().drop();
sp.cookie.name("*").encrypt();
# ---- Kill eval() ----
# Almost no legitimate application needs eval(). Template engines and ORMs
# that used to need it have moved on. Legacy apps: test first.
sp.disable_function.function("eval").drop();
# ---- Block remote command execution ----
sp.disable_function.function("system").drop();
sp.disable_function.function("exec").drop();
sp.disable_function.function("passthru").drop();
sp.disable_function.function("shell_exec").drop();
sp.disable_function.function("popen").drop();
sp.disable_function.function("proc_open").drop();
# ---- Block remote file inclusion ----
# Blocks file_get_contents("http://...") and file_get_contents("https://...")
# Add whitelists for APIs your code legitimately calls:
# sp.disable_function.function("file_get_contents").param("filename").value_r("^https://api.trusted.com").allow();
sp.disable_function.function("file_get_contents").param("filename").value_r("^https?://").drop();
# ---- Block PHP execution from upload directories ----
# Webshells in uploads can't execute if PHP can't include files from there
sp.disable_function.function("include").filename_r("/uploads/").drop();
sp.disable_function.function("include_once").filename_r("/uploads/").drop();
sp.disable_function.function("require").filename_r("/uploads/").drop();
sp.disable_function.function("require_once").filename_r("/uploads/").drop();
# ---- Information disclosure ----
sp.disable_function.function("phpinfo").drop();
sp.disable_function.function("getenv").param("varname").value("AWS_SECRET_ACCESS_KEY").drop();
sp.disable_function.function("getenv").param("varname").value("DB_PASSWORD").drop();
Step 5 — WordPress-specific hardening
WordPress is the most common PHP application and the most commonly attacked. These rules are tuned specifically for WordPress’s function usage patterns.
# ---- WordPress-specific rules ----
# Block PHP execution in WordPress upload directory
sp.disable_function.function("include").filename_r("/wp-content/uploads/").drop();
sp.disable_function.function("include_once").filename_r("/wp-content/uploads/").drop();
sp.disable_function.function("require").filename_r("/wp-content/uploads/").drop();
sp.disable_function.function("require_once").filename_r("/wp-content/uploads/").drop();
# Suspicious base64 decode output — log only (WP legitimately uses base64)
# Long base64-decoded strings are often shellcode. Log first, block once confirmed.
sp.disable_function.function("base64_decode").ret_r(".{500,}").log();
# Block use of assert() as eval() substitute (old WordPress exploit technique)
sp.disable_function.function("assert").param("assertion").value_r("\$").drop();
# Prevent direct access to wp-config.php via LFI
sp.disable_function.function("file_get_contents").param("filename").value_r("wp-config.php").drop();
# Cookie hardening for WordPress sessions
# Use specific cookie name instead of wildcard to avoid breaking payment plugins
sp.cookie.name("wordpress_logged_in*").encrypt();
sp.cookie.name("PHPSESSID").encrypt();
Testing your WordPress rules
After applying, test these WordPress functions work correctly:
- Log in and out — cookies must work
- Upload an image — the upload must work but PHP execution in uploads must fail
- Edit a post — the Gutenberg editor uses REST API calls, verify these work
- Run any background WP-Cron jobs manually:
wp cron event run --due-now --allow-root
Step 6 — Per-site rules with PHP-FPM pools
If you’re running multiple sites on the same server, you can apply different rule sets per PHP-FPM pool. A legacy application that legitimately calls exec() gets lenient rules. A modern WordPress site gets strict rules. No cross-contamination.
In each pool config (/etc/php/8.3/fpm/pool.d/mysite.conf):
[mysite]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm-mysite.sock
; Override the snuffleupagus rules file for this pool
php_admin_value[extension] = snuffleupagus.so
php_admin_value[sp.configuration_file] = /etc/php/8.3/snuffleupagus-mysite.rules
[legacyapp]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm-legacy.sock
; Minimal rules for legacy app that needs exec()
php_admin_value[extension] = snuffleupagus.so
php_admin_value[sp.configuration_file] = /etc/php/8.3/snuffleupagus-legacy.rules
The legacy rules file can block everything except the specific functions the legacy app needs:
sp.enable();
# Legacy app needs exec() for PDF generation — allow it but log
sp.disable_function.function("system").drop();
sp.disable_function.function("passthru").drop();
sp.disable_function.function("shell_exec").drop();
# exec() allowed — only log unusual calls
sp.disable_function.function("exec").param("command").value_r("[;&|`]").log();
Step 7 — Advanced rules
Block specific parameter patterns
You can match on function arguments, not just function names:
# Block file_get_contents() when the path looks like a traversal attack
sp.disable_function.function("file_get_contents").param("filename").value_r("../").drop();
# Block unserialize() on user-controlled data (common deserialization attack vector)
# The variable name check is heuristic — adjust to your app
sp.disable_function.function("unserialize").param("data").value_r("[Oo]:d+:").drop();
# Block preg_replace() with /e modifier (executes replacement as PHP code)
# This was deprecated in PHP 5.5 and removed in 7.0 but some legacy code still has it
sp.disable_function.function("preg_replace").param("pattern").value_r("/e").drop();
Global request-level rules
# Enable XSS output filtering as a last-resort safety net
# This transparently runs htmlspecialchars() on outputs when enabled
# Only use on applications you can't modify — it has false positives
# sp.global_strict = true;
# Log all calls to dangerous functions regardless of whether they're blocked
# Useful for forensics and building a whitelist before blocking
sp.disable_function.function("eval").simulation();
sp.disable_function.function("exec").simulation();
Environment-specific tuning
# Harden unserialize() with an allowed classes list
# Prevents object injection attacks — only allow specific classes to be unserialized
sp.disable_function.function("unserialize").param("allowed_classes").value_r("false").drop();
# Block PHP reflection API (used in some deserialization exploit chains)
sp.disable_function.function("ReflectionObject").drop();
# Block symbolic link creation (used in some privilege escalation chains)
sp.disable_function.function("symlink").drop();
Reading the logs
Snuffleupagus logs to the PHP-FPM error log. Each violation entry contains the rule that triggered, the function called, the file and line number, and the actual arguments. Learn to read these — they tell you exactly what happened:
tail -f /var/log/php8.3-fpm.log | grep -i "snuffleupagus|sp_"
# A blocked call looks like:
# [snuffleupagus][eval][block] in /var/www/html/wp-content/plugins/bad-plugin/shell.php:1
# [snuffleupagus][file_get_contents][drop] in /var/www/html/wp-content/uploads/cmd.php:1
# A simulation (would-have-been-blocked) looks like:
# [snuffleupagus][exec][simulation] called in /path/to/file.php:42
The file path tells you which application or plugin triggered the rule. If it’s from uploads/, it’s almost certainly an attack. If it’s from a known plugin directory, investigate whether the plugin legitimately needs that function.
Building a whitelist from simulation logs
After running in simulation mode, look for patterns in the log:
grep "snuffleupagus" /var/log/php8.3-fpm.log | grep simulation | awk '{print $NF}' | sort | uniq -c | sort -rn
The most common violations are usually the ones you need to whitelist. Add specific exceptions for them before switching to .drop():
# Whitelist exec() only for the specific ImageMagick plugin path
sp.disable_function.function("exec").param("command").value_r("convert").allow();
sp.disable_function.function("exec").drop(); # block everything else
Performance impact
A common question: does Snuffleupagus slow PHP down? The honest answer: yes, slightly. How slightly depends on your rules.
- Cookie encryption: adds one AES operation per cookie per request. On modern CPUs this is microseconds.
- Function call checking: each disabled function check adds a small overhead at the opcode level. For rules that fire rarely (blocked functions), this is negligible.
- Global strict mode (XSS filter): this touches every output operation and has measurable overhead. Only enable it if you genuinely can’t modify the application.
For a typical WordPress site serving PHP pages, expect 1–3ms additional processing time per request. On a well-cached site where most requests are served from cache by NGINX before reaching PHP, the impact on user-facing performance is essentially zero.
Frequently asked questions
Related posts
- NGINX ModSecurity WAF Setup — the HTTP layer to pair with Snuffleupagus for full-stack defence
- TLS Configuration for NGINX and Angie — hardening the transport layer alongside your PHP security stack
- Postfix + Dovecot Mail Server Setup — complete mail server guide for the same Debian/Ubuntu stack
- NGINX modules overview — ModSecurity is one of 50+ modules available via APT
- Full package list — php-snuffleupagus, libmodsecurity3, and all security packages
- How to add the myguard APT repository