PHP Snuffleupagus Tutorial: Harden PHP-FPM Against Injection, XSS and Dangerous Functions

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.

LayerModSecurity (NGINX WAF)PHP-Snuffleupagus
Where it runsNGINX process, HTTP layerInside PHP-FPM, interpreter layer
What it seesRaw HTTP headers, URL, bodyPHP function calls, arguments, return values
StopsSQLi in URLs, XSS in forms, path traversal in requestseval(), shell_exec(), type juggling, cookie theft, bad file uploads
Bypass riskEncoding tricks can fool pattern matchingVery 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

Will Snuffleupagus break my WordPress site?
Not if you start in simulation mode and review the logs first. WordPress core is generally well-written and doesn’t use dangerous functions like eval() or system(). However, third-party plugins are a different story — some use exec() for image processing or PDF generation. Simulation mode will show you exactly what would be blocked before you commit to blocking it.
Does Snuffleupagus replace PHP’s disable_functions in php.ini?
They’re complementary but Snuffleupagus is more powerful. PHP’s disable_functions is a global on/off switch — you can’t conditionally allow exec() only for ImageMagick calls. Snuffleupagus lets you block by function, by parameter value, by calling file path, or by request context. You can run both simultaneously — use disable_functions for the absolute hard blocks and Snuffleupagus for the conditional rules.
Is Snuffleupagus compatible with PHP 8.4?
Yes. The myguard php-snuffleupagus package supports PHP 7.4 through 8.4. Check the changelog for the exact version mapping. The extension is actively maintained and tracks PHP releases.
Can I use Snuffleupagus with a PHP-FPM pool running as a different user?
Yes. The extension loads per PHP process, and PHP-FPM pools run as whatever user you configure. You can have a strict pool for your WordPress site and a lenient pool for a legacy app on the same server, with completely different rule sets per pool using php_admin_value[sp.configuration_file].
What happens when Snuffleupagus blocks a function call?
With .drop(), the function returns false (as if it failed). With .kill(), the PHP process terminates immediately. Most rules should use .drop() — .kill() is extreme and should only be used for truly unrecoverable situations like a detected deserialization exploit in progress. Always log blocked calls so you can investigate them later.
Does cookie encryption break existing sessions?
Yes — when you enable cookie encryption, existing unencrypted cookies become invalid and users get logged out. Deploy this during a maintenance window or at a low-traffic time. If you can’t afford any disruption, whitelist the existing session cookie name from encryption and rotate cookie names after the transition.
Where does the name Snuffleupagus come from?
Mr. Snuffleupagus (or “Snuffy”) is Big Bird’s best friend on Sesame Street — a large, friendly creature that nobody else could see for many years. The PHP security extension is invisible to attackers in the same way: it silently guards the PHP interpreter without being visible at the HTTP layer. The developers have a good sense of humour.

Related posts