Right. First day. Someone gave you the laptop, someone showed you the coffee machine, and now they’ve handed you “the website” and walked away. Congratulations — you are now responsible for keeping a PHP application alive on the open internet. No pressure.
Here’s the secret nobody tells you on day one: your WAF is not enough. Your fancy NGINX rules are not enough. Keeping WordPress updated is not enough. Bots are scanning your IP right now, looking for the one outdated plugin you forgot. And when they find it, they upload a tiny PHP file to wp-content/uploads/ and your evening goes sideways.
This is the post I wish someone had handed me when I was twenty and starting out. We’re going to install a thing called PHP-Snuffleupagus — yes, named after Big Bird’s furry friend — and turn your PHP runtime into a place where attackers’ tools simply don’t work, even when they get in.
I’ll explain everything. No assumed knowledge. If you’ve never touched /etc/php/ before, you’re in the right place.
The story of every PHP compromise, ever
Picture this. It’s 2 a.m. Your phone buzzes. The client emails: “Google is showing a warning that my site contains malware.” You log in. The site is serving fake pharmaceuticals in five languages. You start digging.
What happened? Almost always the same thing:
- A plugin has a bug. The bug lets an attacker upload a file.
- The attacker uploads a tiny PHP file pretending to be an image. Maybe
shell.php.jpg. Maybe justimage.phpwith a misconfigured upload check. - The attacker visits that file in a browser. PHP runs it. The file calls
eval($_POST['x'])and now the attacker can run any PHP code, as your web server user, on your server. - They drop more files. They edit your WordPress database. They install backdoors in your themes. You spend the weekend nuking and restoring from backup.
Your WAF saw the upload and thought “that’s a JPEG, fine.” Your NGINX config doesn’t care about eval(). WordPress itself has no idea any of this happened.
Here’s the thing: nothing along the way actually checked what the PHP code was doing. The HTTP request was fine. The file extension was fine. PHP just… ran whatever was in the file. That’s the gap. That’s where Snuffleupagus lives.
So what is Snuffleupagus?
Snuffleupagus is a small piece of code that loads inside the PHP interpreter every time PHP starts. It’s not a separate server. It’s not a firewall. It’s a Zend extension — in plain English, a plugin for PHP itself.
Once it’s loaded, every function call your PHP code makes goes through Snuffleupagus first. Want to call eval()? Snuffleupagus checks its rulebook: “is eval allowed in this script?” If not, eval returns false and your PHP code can’t do the bad thing. The attacker’s exploit chain breaks at the interpreter level — before the dangerous function actually executes.
An analogy I always come back to:
- NGINX / your WAF — the bouncer at the front door of the restaurant. Checks IDs, kicks out the obviously drunk.
- Snuffleupagus — the bouncer who lives inside the kitchen. Even if someone sneaks in through a window, they still can’t touch the knives.
You want both. They watch different doors.
Real WordPress RCEs Snuffleupagus would have stopped dead
“Theoretical” rarely sells anything. Here are real, in-the-wild WordPress plugin vulnerabilities from the last few years, and exactly which Snuffleupagus rule kills the exploit chain. Every one of these was a “tens of thousands of sites compromised in a weekend” story.
File Manager 6.9 — CVE-2020-25213
An unauthenticated attacker could hit a vulnerable elFinderConnector.php endpoint and upload anything they wanted, including a one-line PHP webshell. The shell then called system($_GET['c']) to give the attacker arbitrary command execution as the web server user. Snuffleupagus’s wordpress-strict.rules blocks system() across every script in wp-content/ — the upload still happens, but the shell can’t run a single command. The attacker uploads a paperweight.
Backup Migration — CVE-2023-6553
A require call inside the plugin trusted a $_GET parameter without sanitisation, letting an unauthenticated attacker include any file path — including remote URLs when allow_url_include was on. Snuffleupagus’s INI lock (sp.ini.key("allow_url_include").set("0").ro();) keeps the dangerous PHP setting off no matter what plugin code tries to override it. Even if the attacker controls the parameter, the include can’t reach a remote payload.
The eternal “eval-of-base64” backdoor
Open any compromised WordPress install at 2 a.m. and you’ll find files like wp-content/uploads/2023/04/.htaccess.php containing <?php @eval(base64_decode($_POST['x']));. It’s the same backdoor people were dropping in 2009. Every Snuffleupagus rulebook in the myguard pack drops eval() outright in any file under wp-content/uploads/ — the file sits there, perfectly harmless, until you find it and delete it.
The pattern is always the same: a single dangerous function call is the difference between “the attacker poked at your site” and “the attacker owns your site.” Snuffleupagus removes that function call from the attacker’s toolbox at the interpreter level. No rule update, no signature, no vendor — just a flat “no” from PHP itself.
A picture of where Snuffleupagus sits
Here’s the path a single request takes through your server. Snuffleupagus is the last line of defence, the one closest to the actual damage:
Browser ─────► NGINX / Angie ─────► PHP-FPM pool ─────► PHP interpreter
│ │ │
│ │ └─► Snuffleupagus ──► your code
│ │ │
↓ ↓ ↓
checks URL, picks worker, every function
method, headers sets ini values call is filtered
(ModSecurity) (memory_limit etc.) against the rulebook
The interesting bit: by the time a request reaches the PHP interpreter, the WAF has already approved it and the FPM pool has already spawned a worker. If anything malicious got through, Snuffleupagus is your last chance to stop it before system("rm -rf /") actually runs.
Step one: install the thing (one minute, promise)
This is the easy part. The myguard APT repository ships php-snuffleupagus as a regular Debian/Ubuntu package, pre-compiled for PHP 7.0 through 8.5. No gcc, no phpize, no submodules. Just apt.
If you’ve never added the myguard repository before, do this once:
wget https://raw.githubusercontent.com/eilandert/deb.myguard.nl/main/myguard.deb
sudo dpkg -i myguard.deb
sudo apt update
Now install the extension for whichever PHP version you use. Don’t know which one? Run php -v and read the first line. Most modern servers are on 8.3 or 8.4:
sudo apt install php8.4-snuffleupagus
That’s it. The package drops the .so file in the right place, drops an extension=snuffleupagus line into mods-available, symlinks it into both fpm/conf.d/ and cli/conf.d/, and installs five ready-to-use rulebooks under /etc/php/8.4/php-snuffleupagus/.
Reload PHP-FPM so the extension actually loads:
sudo systemctl reload php8.4-fpm
Verify it’s there:
php -m | grep -i snuf
You should see snuffleupagus in the output. If you don’t, check journalctl -u php8.4-fpm for the reason. Usually it’s a typo in a rule file, and the error message tells you which line.
Step two: pick your rulebook
This is where most tutorials go off the rails. They hand you 400 lines of rules copied from upstream, half of which break WordPress and the other half of which lock down things you don’t actually have. Skip that.
The myguard package ships five small, focused rulebooks. Pick one. You can change later. Here’s the decision tree:
Is this server running WordPress?
│
├── No: is it Roundcube webmail?
│ │
│ ├── Yes ──────► roundcube.rules
│ │
│ └── No: is it your generic PHP app / Drupal / a Symfony thing?
│ │
│ └─────► php-relax.rules
│
└── Yes: does it have plugins that shell out?
(backup plugins, image converters, anything that
calls mysqldump or cwebp under the hood)
│
├── Yes ────► wordpress-lax.rules
│
└── No ─────► wordpress-strict.rules
Plus: if this server also runs wp-cli or an MCP agent on a
separate FPM pool, that pool needs mcp-agent.rules (no
function blocking, just RCE guard).
Each rulebook does one job:
- php-relax.rules — the bare minimum that catches the worst stuff (remote includes, eval-of-base64, mail header injection, environment hijacking) without blocking anything a normal app does. Safe for any PHP application.
- wordpress-strict.rules — everything in relax, plus an outright ban on
exec,system,shell_exec,proc_open,phpinfo, and friends. Use this if your WordPress doesn’t have plugins that need subprocesses. - wordpress-lax.rules — strict’s friendlier cousin. Lets subprocess calls through if they don’t contain shell metacharacters. Use this if you have UpdraftPlus, BackWPup, ShortPixel local mode, or other plugins that call out to
mysqldump/cwebp. - mcp-agent.rules — for a privileged internal pool. No call-blocking, no
ini_protection— just the RCE primitives (remote include, eval+decode chains, environment hijacking). Perfect for wp-cli or an automation agent. - roundcube.rules — Roundcube has its own session handler, never shells out, and lives behind authentication. This rulebook reflects that: strict on subprocess and recon, lenient where Roundcube needs flexibility.
Step three: wire the rulebook into your FPM pool
This is the part that trips everyone up the first time, so we’ll go slow.
PHP-FPM is the bit that actually runs your PHP. It’s organised into pools: each pool is a group of worker processes with the same configuration. Most servers have just one pool, called www, in /etc/php/8.4/fpm/pool.d/www.conf. Bigger servers split into multiple pools so different sites or different parts of the same site can have different settings.
Open your pool file:
sudo nano /etc/php/8.4/fpm/pool.d/www.conf
Scroll to the bottom and add this one line:
php_admin_value[sp.configuration_file] = /etc/php/8.4/php-snuffleupagus/wordpress-strict.rules
That’s it. That one line tells the FPM pool which rulebook to load. Save (Ctrl-O, Enter, Ctrl-X in nano) and reload:
sudo systemctl reload php8.4-fpm
If PHP-FPM refuses to start, run:
sudo journalctl -u php8.4-fpm -n 50
and read the last few lines. Snuffleupagus error messages are blunt but accurate — usually a missing file path or a typo in a rule.
Important thing nobody warns you about: do not put sp.configuration_file in /etc/php/8.4/mods-available/snuffleupagus84.ini. The package deliberately doesn’t set it there. If you set it globally, it merges with per-pool overrides in weird ways and you get errors that point at the wrong line in the wrong file. Always set the rulebook at the pool level.
Two pools, two rulebooks: a worked example
Here’s a real-world setup. You’re running a WordPress site, and you also run wp-cli commands via cron and a small management agent. The wp-cli stuff needs to call exec() and proc_open() all day long. The public site never should.
Make two pools. Call them www (public traffic) and agent (the trusted internal one). The public pool gets strict, the internal pool gets the relaxed agent ruleset:
# /etc/php/8.4/fpm/pool.d/www.conf
[www]
listen = /run/php/php8.4-fpm.sock
user = www-data
group = www-data
pm = dynamic
pm.max_children = 25
php_admin_value[memory_limit] = 512M
php_admin_value[sp.configuration_file] = /etc/php/8.4/php-snuffleupagus/wordpress-strict.rules
# Leave disable_functions EMPTY when using Snuffleupagus — the rulebook
# already does that job, and mixing both produces weird boot errors.
php_admin_value[disable_functions] =
# /etc/php/8.4/fpm/pool.d/agent.conf
[agent]
listen = /run/php/php8.4-agent.sock
user = agent
group = www-data
pm = dynamic
pm.max_children = 4
php_admin_value[memory_limit] = 1024M
php_admin_value[sp.configuration_file] = /etc/php/8.4/php-snuffleupagus/mcp-agent.rules
php_admin_value[disable_functions] =
In your NGINX or Angie config, route the public site at fpm.sock and your internal endpoint (say, /wp-json/agent/) at agent.sock. The same Snuffleupagus extension is loaded in both pools, but each enforces a completely different rulebook. The attacker hits the public pool and runs into a wall. Your cron hits the agent pool and gets the work done.
Things that will trip you up (so you can skip the pain)
I’ve made every one of these mistakes. Pay attention, save yourself an evening.
1. The misleading error trace
Snuffleupagus sometimes reports a violation with a stack trace that points at code that literally doesn’t contain the function it claims. You’ll see things like strcoll() expects 2 arguments, 1 given on a line that obviously calls strtolower(). Or password_hash() expects at least 2 arguments on a line that just calls define().
This is not a PHP bug. This is Snuffleupagus telling you a rule fired, but the location and function name in the trace are misleading. When you see a fatal that “can’t possibly be true” given the source, suspect Snuffleupagus first.
To confirm: temporarily comment out the sp.configuration_file line in your pool, reload FPM, and try again. If the fatal disappears, it’s a rule firing.
2. The memory_limit trap
You can write sp.ini.key("memory_limit").max("2G").rw(); in your rulebook. It will silently fail to parse the G suffix and the cap falls back to a tiny default. The whole site 500s. Use max("1024M") instead. Always megabytes.
3. Don’t try to chain rulebooks
Upstream’s strict.rules uses .include "other-file.rules" to pull in default.rules, suhosin.rules, and friends. On recent Snuffleupagus, this silently breaks: each included file re-sets the secret_key, calls sp.ini_protection.enable() a second time, and defines conflicting memory_limit ranges. Symptoms: random 500s with the body memory_limit.
The myguard rulebooks are each self-contained on purpose. Pick one. Don’t .include anything.
4. Old syntax for cookie encryption
If a tutorial tells you to write:
sp.cookie_encryption.name("PHPSESSID");
sp.cookie_encryption.encrypt();
that’s the old, two-statement form. It’s rejected at parse time now. The current syntax is one chained statement:
sp.cookie_encryption.name("PHPSESSID").encrypt();
The myguard rulebooks already use the new form.
5. The disable_functions double-up
Old habit: put a list of dangerous functions in php_admin_value[disable_functions] in your pool file. With Snuffleupagus, don’t. The pool-level disable_functions directive plus a Snuffleupagus rulebook that locks disable_functions read-only causes a fatal during PHP boot that looks like a totally unrelated arity error (see point 1). Leave the pool’s disable_functions empty and let the rulebook do the work.
When a rule fires: how to debug
Once Snuffleupagus is on, you’ll occasionally see a feature on your site stop working. A plugin breaks. A cron job fails. That’s expected: a real attacker breaks the same way. Your job is to tell legitimate breakage from attempted exploitation.
Snuffleupagus logs violations to PHP-FPM’s error log:
sudo tail -f /var/log/php8.4-fpm.log
Look for lines starting with [snuffleupagus]. They tell you the rule that fired, the script that triggered it, and the function call that was blocked.
A real example:
[snuffleupagus][/wp-content/plugins/some-plugin/loader.php][system][drop]
- Aborted execution on call of the function 'system' in
/var/www/html/wp-content/plugins/some-plugin/loader.php on line 47
Now you have a choice. Did your plugin legitimately need system()? Probably not, and it’s safer to remove that plugin. Does the plugin need it for a specific narrow case? Add a per-script allow rule above the global drop:
sp.disable_function.function("system")
.filename("/var/www/html/wp-content/plugins/some-plugin/loader.php")
.allow();
Always tighten, never loosen the global rules. Add carve-outs for the one script that needs an exception, keep the rest of your site locked down.
Performance: is this going to slow my site down?
Honestly? No. Snuffleupagus runs inside the PHP process, so there’s no extra HTTP hop. Every function call goes through a hash-map lookup against the loaded rules — we’re talking microseconds per request, well under a percent on any real workload.
The myguard build is compiled with -O3 -flto -fvisibility=hidden -fno-plt and the standard Debian hardening flags (RELRO, BIND_NOW, stack-protector, no-execstack). It’s smaller, faster, and harder to attack than the upstream-stock build. If you’re benchmarking against a no-Snuffleupagus baseline, you might see a 0.5–1% throughput dip on a busy site. You will not see it on a normal site.
For context: a single wp_query() with a couple of joins takes longer than Snuffleupagus’s per-request overhead for an entire WordPress page load. This is not where your performance budget goes.
Snuffleupagus vs ModSecurity vs Suhosin vs disable_functions
People often ask “isn’t this just ModSecurity?” or “didn’t disable_functions already handle this?” Short answer: no, they all watch different things. Here’s the honest breakdown.
| Layer | Where it runs | What it sees | What it can’t see |
|---|---|---|---|
| ModSecurity / WAF | NGINX / Apache request handler | HTTP request bytes, headers, body | What PHP does once the request is dispatched |
disable_functions |
PHP php.ini / pool config |
Function name (e.g. system) |
Caller, arguments, file path; can’t whitelist per-script |
| Suhosin | PHP extension (legacy) | Session/cookie/include hardening; PHP 7.x mostly | PHP 8.x abandoned upstream; very limited rule expressiveness |
| Snuffleupagus | PHP extension (Zend) | Function name and arguments and caller file/line; per-script and per-pool | HTTP-layer attacks that never reach PHP (use ModSec for those) |
Verdict: they pair, they don’t compete. ModSecurity stops drive-by junk before it costs you a PHP request. Snuffleupagus stops the exploit chain that successfully bypassed the WAF, the file extension check, and your plugin’s “sanitisation.” disable_functions is the blunt hammer that breaks legitimate code along with the attacks. Suhosin is a museum piece on modern PHP.
For the matching WAF-layer guide on this stack, read How to install ModSecurity and OWASP CRS on NGINX next. And no, before you ask: ModSecurity v3 / libmodsecurity3 is not end-of-life. Trustwave ended commercial support in July 2024 and handed the project to OWASP in January 2024. It’s actively maintained at owasp-modsecurity/ModSecurity on GitHub today.
Snuffleupagus inside a Docker container
Containerising PHP-FPM doesn’t change anything fundamental — Snuffleupagus still loads as a Zend extension, still reads a rulebook file, still enforces it on every function call. You just need to make sure the rulebook is actually inside the container at runtime.
Two ways to do it. The easy way: bake the rulebook into your image at build time.
# Dockerfile
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates wget && \
wget -qO /tmp/myguard.deb \
https://raw.githubusercontent.com/eilandert/deb.myguard.nl/main/myguard.deb && \
dpkg -i /tmp/myguard.deb && \
apt-get update && \
apt-get install -y --no-install-recommends \
php8.4-fpm php8.4-snuffleupagus && \
rm -rf /var/lib/apt/lists/* /tmp/myguard.deb
COPY ./conf/wordpress-strict.rules /etc/php/8.4/php-snuffleupagus/active.rules
COPY ./conf/www.conf /etc/php/8.4/fpm/pool.d/www.conf
EXPOSE 9000
CMD ["php-fpm8.4", "--nodaemonize"]
The flexible way: mount the rulebook at runtime, so you can hot-swap rule packs without rebuilding:
# docker-compose.yml
services:
php:
image: deb.myguard.nl/php-fpm:8.4-snuf
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
user: "33:33"
tmpfs:
- /tmp
- /var/run
volumes:
- ./conf/wordpress-strict.rules:/etc/php/8.4/php-snuffleupagus/active.rules:ro
- ./conf/www.conf:/etc/php/8.4/fpm/pool.d/www.conf:ro
- wp-content:/var/www/html/wp-content
The read_only: true + cap_drop: ALL + no-new-privileges combo is the Docker hardening pattern we cover end-to-end in Docker Hardening for Self-Hosters. Pair it with Snuffleupagus and you’ve got two completely independent layers of “no” between the attacker and your filesystem.
Quick reference: the sp.* directives you’ll actually use
The rulebook syntax looks alien at first. It’s not — it’s just chained method calls. Here are the seven directives that cover ~95% of real-world configs.
| Directive | What it does | Example |
|---|---|---|
sp.disable_function | Block / allow / log a function call, optionally filtered by file, args, hash | .function("system").drop(); |
sp.eval | Globally drop eval() (eval is always dangerous; no filename needed) | sp.eval.drop(); |
sp.cookie_encryption | Encrypt a named cookie at runtime (PHPSESSID, custom session names) | .name("PHPSESSID").encrypt(); |
sp.ini.key | Constrain an INI value to a range and lock it read-only | .key("memory_limit").max("1024M").rw(); |
sp.readonly_exec | Refuse to execute any PHP file that is also writable by the running user — kills “upload-then-execute” attacks | sp.readonly_exec.enable(); |
sp.global_strict | Force strict comparisons in PHP — closes a whole class of == bypass tricks | sp.global_strict.enable(); |
sp.upload_validation | Run an external script against every uploaded file before the move is allowed | .script("/usr/local/bin/clamscan").enable(); |
All seven are demonstrated, in real use, in the rulebooks the myguard package installs. cat /etc/php/8.4/php-snuffleupagus/wordpress-strict.rules is your best friend the first month.
PHP version support: 7.0 through 8.5
The myguard repository ships php-snuffleupagus as a per-PHP-version package. You install the one that matches your interpreter:
sudo apt install php7.0-snuffleupagus # legacy long-tail apps
sudo apt install php7.4-snuffleupagus # WordPress on Debian 11
sudo apt install php8.0-snuffleupagus
sudo apt install php8.1-snuffleupagus # Debian 12 default
sudo apt install php8.2-snuffleupagus
sudo apt install php8.3-snuffleupagus
sudo apt install php8.4-snuffleupagus # Debian 13 Trixie default
sudo apt install php8.5-snuffleupagus # latest
Why per-version? Snuffleupagus is a Zend extension — it has to be compiled against the exact PHP API version it runs in. Upstream provides source; the myguard packages compile against every officially supported PHP build, so you don’t need a compiler or a CI pipeline just to deploy this WAF-inside-your-interpreter.
Each version is built with identical flags (-O3 -flto -fvisibility=hidden -fno-plt plus Debian hardening: RELRO, BIND_NOW, stack-protector, no-execstack), so the security guarantees are the same regardless of which PHP you’re on. The only practical difference between PHP 7.x and 8.x for Snuffleupagus is that 8.x exposes a few extra hookable internal functions — the rule syntax is unchanged.
One quirk to know: PHP 8.5 with tracing JIT enabled mis-runs WordPress under Snuffleupagus on some workloads. If you see weird strtolower/strcoll arity errors, set opcache.jit=off in that pool. We hit this on our own MCP agent pool and the fix is straightforward; it’s an interaction between PHP 8.5’s tracing JIT and WordPress’s internal code paths, not a Snuffleupagus bug.
Frequently asked questions
owasp-modsecurity/ModSecurity, and our repo ships current builds. “ModSec is dead” headlines refer to the vendor exit, not the code.phpX.Y-snuffleupagus Debian/Ubuntu package. Install the one that matches the PHP your site actually runs (php -v tells you).sudo journalctl -u php8.4-fpm -n 100 and read the last messages. Snuffleupagus reports the file path and line number of the broken rule. The most common cause is a typo in sp.configuration_file — double-check the path exists with ls -la. If the error trace looks impossible (claims a function call that isn’t in the source), see “things that will trip you up” above — it’s almost always a Snuffleupagus rule, not a PHP bug.wordpress-strict.rules if they shell out to external binaries. Switch that pool to wordpress-lax.rules and most things start working. If a specific plugin still complains, check the FPM log for the rule that fired and either tighten the plugin’s behaviour or add a narrow allow-rule for that one script..drop() and .kill() in a rule?.drop() makes the blocked function return false — the PHP script continues, just without the dangerous call succeeding. .kill() terminates the PHP process immediately. Default to .drop(); use .kill() only when you’re certain an active exploitation is in progress and you’d rather drop the request than let the script proceed.sp.cookie_encryption on a cookie name, existing unencrypted cookies of that name become unreadable and users get logged out. Plan for that — deploy at low-traffic time, or whitelist the existing session cookie name temporarily during the transition.sp.configuration_file line in your pool’s .conf and reload FPM. Each pool can use a different rulebook, and you can switch between strict/lax mid-life without reinstalling anything.What to do tomorrow
You did it. You’ve installed a real, production-grade hardening layer on your first day. The plant is watered. The website is harder to break. Tomorrow, do this:
- Visit your site as a normal user. Click around. Log in, log out. Check that
tail -f /var/log/php8.4-fpm.logshows nothing unusual. - If you run cron jobs, run them manually once and watch the log.
- If you administer plugins, install one and uninstall it — that’s where weird shell-outs happen.
- Pick a small page you don’t mind breaking and try a few WordPress admin actions that touch files (theme editor, plugin install). If a Snuffleupagus rule fires on a legitimate action, you’ll see it in the log and you can decide if that plugin should be allowed the exception.
Welcome to the job. You’re going to be alright.
Related posts
- How to install ModSecurity and OWASP CRS on NGINX — the HTTP-layer bouncer to pair with Snuffleupagus.
- Docker Hardening for Self-Hosters: Rootless, Read-Only, Distroless — the container-layer hardening that pairs with this PHP-layer hardening.
- What is the BREACH attack? How it works and how to stop it — the compression side-channel you also want to defend against.
- NGINX modules overview — the other 50+ security and performance modules in this repo.
- How to add the myguard APT repository — if you skipped step one.