Title: Snuffleupagus 0.14.0 - Blumenbach
Date: 2026-09-01 14:30

[![snuffleupagus logo]({static}/images/sp.png)](https://snuffleupagus.readthedocs.org)

I just published a new release of
[Snuffleupagus](https://github.com/jvoisin/snuffleupagus/releases/tag/v0.13.0),
the hardening module for php7+ and php8+, version `0.14.0`, codename
"Blumenbach", named after [Johann Friedrich
Blumenbach](https://en.wikipedia.org/wiki/Johann_Friedrich_Blumenbach), the
scientist who gave the woolly mammoth its first scientific name, Elephas
primigenius (first-born elephant), in 1799.

There aren't any new flashing features, only bug fixes and improvements, better
PHP85 support, a fresh hardening ruleset for SPIP because people keep burning
RCE for it, and also a bug fix that was so tedious that I'm going to ramble
about it here.

PHP 8.5 shipped with an innocuous-looking optimizer improvement: opcache
can now inline trivial user functions. For example, functions that are taking
no arguments and are returning a constant are completely removed with the
constant. No more `INIT_FCALL`, no `DO_UCALL`, no function call at all.
Performance-wise, this is pretty cool. Less so when our beloved
extension hooks `zend_execute_ex` to inspect return values.

Let's have an example. Imagine someone wrote a rule like
`sp.disable_function.function("get_role").ret("admin").drop();`, dropping the
execution if the `get_role` function returns `admin`. Under the hood,
Snuffleupagus replaces `zend_execute_ex` with its own wrapper. When a user
function returns, the wrapper checks the return value against the configured
rules. On PHP 8.5 with opcache enabled, a function like `function get_role() {
return "admin"; }` would compile to `INIT_FCALL "get_role"; V0 = DO_UCALL,
…`, but after opcache's optimizer runs, it simply becomes `ECHO
string("admin")`. The function call is gone. `zend_execute_ex` is never
invoked. The security rule never fires. The application runs as if the rule
didn't exist.

The obvious-ish fix would be to use PHP's observer API introduced in PHP:
[`zend_observer_fcall_register`](https://www.datadoghq.com/blog/engineering/php-8-observability-baked-right-in/).
It integrates with the VM and is supposed to work alongside opcache and JIT.
But in our case, they're useless: the observer init callback is invoked the
first time a function is *executed*. If the function is never executed, because
the optimizer replaced the call with a constant, the observer never sees it.

The inlining decision happens in opcache's
[`zend_try_inline_call()`](https://github.com/php/php-src/blob/fe52e5b64b945be211e2593af6592fdb32b5666b/Zend/Optimizer/optimize_func_calls.c#L77),
containing [several gating conditions](https://github.com/php/php-src/blob/fe52e5b64b945be211e2593af6592fdb32b5666b/Zend/Optimizer/optimize_func_calls.c#L81-L86):

```C
	if (func->type == ZEND_USER_FUNCTION
	 && !(func->op_array.fn_flags & (ZEND_ACC_ABSTRACT|ZEND_ACC_HAS_TYPE_HINTS|ZEND_ACC_DEPRECATED|no_discard))
		/* TODO: function copied from trait may be inconsistent ??? */
	 && !(func->op_array.fn_flags & (ZEND_ACC_TRAIT_CLONE))
	 && fcall->extended_value >= func->op_array.required_num_args
	 && func->op_array.opcodes[func->op_array.num_args].opcode == ZEND_RETURN)
        // inline the call
```

Most of those conditions can't really be influenced without breaking
everything. The one we can twiddle with is the
`(ZEND_ACC_ABSTRACT|ZEND_ACC_HAS_TYPE_HINTS|ZEND_ACC_DEPRECATED|no_discard)`
one. So the whole problem collapses into a smaller one: which one of those
flags can we set on the functions we care about?

PHP's Zend extension API provides an `op_array_handler` callback that fires
during
[`pass_two()`](https://github.com/php/php-src/blob/fe52e5b64b945be211e2593af6592fdb32b5666b/Zend/zend_opcode.c#L1091),
right after initial compilation but *before* opcache optimization. This is the
perfect hook point: we can see the function, check if it has return-value
rules, and set a flag that prevents inlining.

I picked `ZEND_ACC_HAS_TYPE_HINTS`: the runtime impact is negligible as it only
causes the VM to execute `ZEND_RECV` opcodes instead of skipping them. But
because functions eligible for inlining are trivial constant-return functions
(at least for now), they typically have zero arguments, meaning zero `RECV`
opcodes to execute. The implementation is straightforward. In
`sp_op_array_handler`, for each compiled user function, check if its name
matches a return-value rule, and if so, flip the flag: `if (has_ret_rule) {
op->fn_flags |= ZEND_ACC_HAS_TYPE_HINTS; }`.

Of course, the day that PHP decides that `ZEND_ACC_HAS_TYPE_HINTS` isn't a
blocker for inlining functions, I'll have to find something else.

While we're on the topic of tedious things, [an issue was
opened](https://github.com/jvoisin/snuffleupagus/issues/555) about a 2x
performance impact when using Snuffleupagus on a WordPress, ouch. The culprit
turned out to be "chained" rules, the ones written as
`sp.disable_function.function("system>base64_decode")` to only forbid a function
when it's called from another one.

The catch is that having even a single one of them flips on the global
execution hook, meaning every single function call in the whole
application, userland and internal, gets funnelled through it. For each of
those calls Snuffleupagus then walked the entire list of chained rules, and for
every rule the whole callstack was reconstructed. On a request that does
hundreds of thousands of function calls, that adds up fast.

The fix is boring in the best way: chained rules now live in the same
per-function hashtable as regular ones, keyed by the innermost function of the
chain: a call to `base64_decode` only ever looks at the rules that actually
mention `base64_decode`, and everything else pays nothing. As a bonus, the hot
path no longer rebuilds the current filename on every call when nothing can
possibly match. WordPress is fast again. Well, as fast as WordPress can go
anyway.

### Changelog

* Improved compatibility with PHP 8.5, including a fix for opcache inlining bypassing return-value rules
* Fix a ~2x slowdown caused by chained `disable_function` rules
* New hardening ruleset for SPIP
* Add coverage for yet another disable_functions bypass
* Fix a type confusion and a null-pointer dereference in the cookie-encryption code
* Reduce the lifetime of cryptographic material in memory
* Harden configuration parsing: reject non-ASCII characters in rules, show the
  config filename in errors, and stop parsing the config twice under
  Apache/php-fpm reloads.
* Fix `phpinfo()` reporting "enabled" when no configuration is loaded
* Improve `upload_validation` robustness (fork-failure handling, correct pid waiting, strtok_r misuse)
* Fix a truncation issue in the unserialization handling code
* Fix half a dozen minor memory leaks
* A bunch of code correctness / simplification / hardening changes, and a
  handful of new tests

Oh and also, somebody told me about a by-design virtual-patching bypass,
provided an attacker already has arbitrary PHP code execution, so I [made it
public](https://github.com/jvoisin/snuffleupagus/commit/bcec0cafc9edbf1a563f184debf01169aed64c85).

As usual, if you want to help, we have some
[low hanging fruits]( https://github.com/jvoisin/snuffleupagus/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) ♥

See you in your PHP stack!
