Configuration

Warning

If you configure Snuffleupagus incorrectly, your website might not work correctly until either you fix your configuration, or revert your changes altogether.

It’s up to you to understand the features, read the present documentation about how to configure them, evaluate your threat model and write your configuration file accordingly.

Since PHP ini-like configuration model isn’t flexible enough, Snuffleupagus is using its own format in the file specified by the directive sp.configuration_file in your php.ini file, like sp.configuration_file=/etc/php/conf.d/snuffleupagus.rules.

You can use the , separator to include multiple configuration files: sp.configuration_file=/etc/php/conf.d/snuffleupagus.rules,/etc/php/conf.d/sp_wordpress.rules.

We’re also also supporting glob, so you can write something like: sp.configuration_file=/etc/php/conf.d/*.rules,/etc/php/conf.d/extra/test.rules.

To sum up, you should put this in your php.ini:

module=snuffleupagus.so
sp.configuration_file=/path/to/your/snuffleupagus/rules/file.rules

And the snuffleupagus rules into the .rules files.

Since our configuration format is a bit more complex than php’s one, we have a sp.allow_broken_configuration parameter (false by default), that you can set to true if you want PHP to carry on if your Snuffleupagus’ configuration contains syntax errors. You’ll still get a big scary message in your logs of course. We do not recommend to use it of course, but sometimes it might be useful to be able to “debug in production” without breaking your website.

Configuration file format

Options are chainable by using dots (.).

Some options have a string parameter, that must be quoted with double quotes, e.g. "string".

Comments are prefixed either with #, or ;.

Some rules apply in a specific function (context) on a specific variable (data), like disable_function. Others can only be enabled/disabled, like harden_random.

Most of the features can be used in simulation mode by appending the .simulation() or .sim() option to them (eg. sp.readonly_exec.simulation().enable();) to see whether or not they could break your website. The simulation mode won’t block the request, but will write a warning in the log.

The rules are evaluated in the order that they are written, the first one to match will terminate the evaluation (except for rules in simulation mode).

Rules can be split into lines and contain whitespace for easier readability and maintenance: (This feature is available since version 0.8.0.)

sp.disable_function.function("mail")
  .param("to").value_r("\\n")
  .alias("newline in mail() To:")
  .drop();

The terminating ; is optional for now, but it should be used for future compatibility.

Rules, including comments, needs to be written in ASCII, other encodings aren’t supported and might cause syntax errors and related issues like making all rules after non-ASCII symbols not considered for execution and silently discarded.

Miscellaneous

conditions

It’s possible to use conditions to have configuration portable across several setups.

@condition PHP_VERSION_ID < 80000;
  # some rules
@condition PHP_VERSION_ID >= 80000;
  # some other rules
@end_condition;

Conditions accept variables and the special function extension_loadod().

@condition extension_loaded("sqlite3");
sp.ini.key("sqlite3.extension_dir").ro();
@end_condition;

Conditions cannot be nested, but arithmetic and logical operations can be applied.

@condition extension_loaded("session") && PHP_VERSION_ID <= 80200;
set whitelist "my_fun,cos"
sp.eval_whitelist.list(whitelist).simulation().dump("/tmp/dump_result/");
@end_condition;

variables

You may set a configuration variable using the set keyword (or @set) and use it instead of arguments.

@set CMD "ls"
sp.disable_function.function("system").pos("0").value(CMD).allow();

global

This configuration variable contains parameters that are used by multiple features:

  • secret_key: A secret key used by various cryptographic features, like cookies protection or unserialize protection, please ensure the length and complexity is sufficient. You can generate it with functions such as: head -c 256 /dev/urandom | tr -dc 'a-zA-Z0-9'.

sp.global.secret_key("44239bd400aa82e125337c9d4eb8315767411ccd");

log_media

This configuration variable allows to specify how logs should be written, either via php or syslog.

sp.log_media("php");
sp.log_media("syslog");

The default value for sp.log_media is php, to respect the principle of least astonishment. But since it’s possible to modify php’s logging system via php, it’s heavily recommended to use the syslog option instead.

log_max_len

This configuration variable allows to specify (roughly) the size of the log.

sp.log_max_len("16");

The default value for sp.log_max_len is 255.

Bugclass-killer features

global_strict

global_strict, disabled by default, will enable the strict mode globally, forcing PHP to throw a TypeError exception if an argument type being passed to a function does not match its corresponding declared parameter type.

It can either be enabled or disabled.

sp.global_strict.disable();
sp.global_strict.enable();

harden_random

harden_random, enabled by default, will silently replace the insecure rand and mt_rand functions with the secure PRNG random_int.

It can either be enabled or disabled.

sp.harden_random.enable();
sp.harden_random.disable();

Prevent sloppy comparison

Sloppy comparison prevention, disabled by default, will prevent php type juggling (==): two values with different types will always be different.

It can either be enabled or disabled.

sp.sloppy_comparison.enable();
sp.sloppy_comparison.disable();

unserialize_noclass

unserialize_noclass, available only on PHP8+ and disabled by default, will disable the deserialization of objects via unserialize. It’s equivalent to setting the options parameter of unserialize to false, on every call. It can either be enabled or disabled.

sp.unserialize_noclass.enable();
sp.unserialize_noclass.disable();

unserialize_hmac

unserialize_hmac, disabled by default, will add an integrity check to unserialize calls, preventing arbitrary code execution in their context.

It can either be enabled or disabled and can be used in simulation mode.

sp.unserialize_hmac.enable();
sp.unserialize_hmac.disable();

Warning

This feature breaks web applications doing checks on the serialized representation of data on their own, like WordPress.

INI Settings Protection

INI settings can be forced to a value, limited by min/max value or regular expression and set read-only mode.

First, this feature can be enabled or disabled:

sp.ini_protection.enable();
sp.ini_protection.disable();

The INI protection feature can be set to simulation mode, where violations are only reported, but rules are not enforced:

sp.ini_protection.simulation();

Rule violations can be set to drop as a global policy, or alternatively be set on individual rules using .drop().

sp.ini_protection.policy_drop();

Rules can be set to fail silently without logging anything:

sp.ini_protection.policy_silent_fail();
## or write sp.ini_protection.policy_no_log(); as an alias

Read-only settings are implemented in a way that the PHP system itself can block the setting, which is very efficient. If you do not need to log read-only violations, these can be set to silent separately:

sp.ini_protection.policy_silent_ro();

A global access policy can be set to either read-only or read-write. Individual entries can be set to read-only/read-write as well using .ro()/.rw().

sp.ini_protection.policy_readonly();
sp.ini_protection.policy_readwrite();

Individual rules are specified using sp.ini. These entries can have the following attributes:

  • .key("..."): mandatory ini name.

  • .set("..."): set the initial value. This overrides php.ini. checks are not performed for this initial value.

  • .min("...") / .max("..."): value must be an integer between .min and .max. shorthand notation (e.g. 1k = 1024) is allowed

  • .regexp("..."): value must match the regular expression

  • .allow_null(): allow setting a NULL-value

  • .msg("..."): message is shown in logs on rule violation instead of default message

  • .readonly() / .ro() / .readwrite() / .rw(): set entry to read-only or read-write respectively. If no access keyword is provided, the entry inherits the default policy set by sp.ini_protection.policy_*-rules.

  • .drop(): drop request on rule violation for this entry

  • .simulation(): only log rule violation for this entry

Examples:

sp.ini.key("display_errors").set("0").ro();
sp.ini.key("default_socket_timeout").min("1").max("300").rw();
sp.ini.key("highlight.comment").regexp("^#[0-9a-fA-F]{6}$");

For more examples, check out the config directory.

readonly_exec

readonly_exec, disabled by default, will prevent the execution of writeable PHP files.

It can either be enabled or disabled and can be used in simulation mode. extended_checks can be specified to abort the execution if the executed file or the folder containing it is owned by the user the PHP process is running under.

Extended checks, enabled by default, can be explicitly enabled via extended_checks and disabled via no_extended_checks. The checks include:

  • verifying the effective user id;

  • verifying that the current folder isn’t writable;

  • verifying the current folder effective user id.

sp.readonly_exec.enable();

upload_validation

upload_validation, disabled by default, will call a given script upon a file upload, with the path to the file being uploaded as argument and various information about it in the environment:

  • SP_FILENAME: the name of the uploaded file

  • SP_FILESIZE: the size of the file being uploaded

  • SP_REMOTE_ADDR: the ip address of the uploader

  • SP_CURRENT_FILE: the current file being executed

This feature can be used, for example, to check if an uploaded file contains php code, using vld, via a python script, or a php one.

The upload will be allowed if the script returns the value 0. Every other value will prevent the file from being uploaded.

It can either be enabled or disabled and can be used in simulation mode.

sp.upload_validation.script("/var/www/is_valid_php.py").enable();

xxe_protection

xxe_protection, disabled by default, will prevent XXE attacks by disabling the loading of external entities (libxml_disable_entity_loader) in the XML parser.

sp.xxe_protection.enable();
sp.xxe_protection.disable();

Whitelist of stream-wrappers

Stream-wrapper whitelist allows to explicitly whitelist some stream wrappers.

sp.wrappers_whitelist.list("file,php,phar");

Eval white and blacklist

eval_whitelist and eval_blacklist, disabled by default, allow to respectively specify functions allowed and forbidden from being called inside eval. The functions names are comma-separated.

sp.eval_blacklist.list("system,exec,shell_exec");
sp.eval_whitelist.list("strlen,strcmp").simulation();

The whitelist comes before the black one: if a function is both whitelisted and blacklisted, it’ll be allowed.

Virtual-patching

Snuffleupagus provides virtual-patching via the disable_function directive, allowing you to stop or control dangerous behaviours. In the situation where you have a call to system() that lacks proper user-input validation, this could cause issues as it would lead to an RCE. The virtual-patching would allow this to be prevented.

# Allow `id.php` to restrict system() calls to `id`
sp.disable_function.function("system").filename("/var/www/html/id.php").param("cmd").value("id").allow();
sp.disable_function.function("system").filename("/var/www/html/id.php").drop()

Of course, this is a trivial example, a lot can be achieved with this feature, as you will see below.

Filters

  • alias(description): human-readable description of the rule

  • cidr(ip/mask): match on the client’s cidr

  • filename(name): exact match on the file’s name

  • filename_r(regexp): file name matching the regexp

  • function(name): exact match on function name

  • function_r(regexp): function name matching the regexp

  • hash(sha256): exact match on the file’s sha256 sum

  • line(line_number): exact match on the file’s line.

  • param(name): exact match on the function’s parameter name

  • param_r(regexp): match on the function’s parameter regexp

  • param_type(type): exact match on the function’s parameter type

  • pos(nth_argument): exact match on the nth argument, starting from 0

  • ret(value): exact match on the function’s return value

  • ret_r(regexp): match with a regexp on the function’s return

  • ret_type(type_name): match on the type_name of the function’s return value

  • value(value): exact match on a literal value

  • value_r(regexp): match on a value matching the regexp

  • var(name): exact match on a local variable name

  • key(name): exact match on the presence of name as a key in the hashtable

  • key_r(regexp): match with regexp on keys in the hashtable

The type must be one of the following values:

  • FALSE: for boolean false

  • TRUE: for boolean true

  • NULL: for the null value

  • LONG: for a long (also know as integer) value

  • DOUBLE: for a double (also known as float) value

  • STRING: for a string

  • OBJECT: for a object

  • ARRAY: for an array

  • RESOURCE: for a resource

Actions

Every rule must have one action.

  • allow(): allow the request if the rule matches

  • drop(): drop the request if the rule matches

Modifications

  • dump(directory): dump the request in the directory if it matches the rule

  • simulation(): enabled the simulation mode

Details

The function filter is able to do various dereferencing:

  • function("AwesomeClass::my_method") will match the method my_method in the class AwesomeClass

  • function("AwesomeNamespace\\my_function") will match the function my_function in the namespace AwesomeNamespace

It’s also able to have calltrace constrains: function(func1>func2) will match only if func2 is called inside of func1. Do note that their might be other functions called between them.

The param filter is able to do some dereferencing as well:

  • param($foo[bar]) will get a match on the value corresponding to the bar key in the hashtable foo. Remember that in PHP, almost every data structure is a hashtable. You can of course nest this like param($foo[bar][$object->array['123']][$batman]).

  • The var filter will walk the calltrace until it finds the variable name, or the end of the calltrace, allowing the filter to match global variables: .var("$_GET[\"param\"]") will match on the GET parameter param.

The filename filter requires a leading /, since paths are absolutes (like /var/www/mywebsite/lib/parse.php). If you would like to have only one configuration file for several vhost in different folders, you can use the filename_r directive to match on the filename (like /lib/parse\.php). Please do note that this filter matches on the file where the function is defined, not the one where the function is called from.

For clarity, the presence of the allow or drop action is mandatory.

In the logs, the parameters and the return values of function are url-encoded, to accommodate fragile log processors.

Warning

When you’re writing rules, please do keep in mind that the order matters. For example, if you’re denying a call to system() and then allowing it in a more narrowed way later, the call will be denied, because it’ll match the deny first.

If you’re paranoid, we’re providing a php script to automatically generate hash of files containing dangerous functions, and blacklisting them everywhere else.

Limitations

It’s currently not possible to:

  • Hook every language construct, because each of them requires a specific implementation. It’s also not possible to hook them via regular expression.

  • Use extra-convoluted rules for matching, like ${$A}$$B->${'}[1], because if you’re writing things like this, odds are that you’re doing something wrong anyway.

  • Hooks on echo and on print are equivalent: there is no way to hook one without hooking the other, at least for now). This is why hooked print will be displayed as echo in the logs.

  • Hook strlen, since in latest PHP versions, this function is usually optimized away by the compiler.

Examples

Evaluation order of rules

The following rules will:

  1. Allow calls to system("id")

  2. Issue a trace in the logs on calls to system with its parameters starting with ping, and pursuing evaluation of the remaining rules.

  3. Drop calls to system.

sp.disable_function.function("system").param("cmd").value("id").allow();
sp.disable_function.function("system").param("cmd").value_r("^ping").drop().simulation();
sp.disable_function.function("system").param("cmd").drop();

Miscellaneous examples

# This is the default configuration file for Snuffleupagus (https://snuffleupagus.rtfd.io).
# It contains "reasonable" defaults that won't break your websites,
# and a lot of commented directives that you can enable if you want to 
# have a better protection.

# Harden the PRNG
sp.harden_random.enable();

# Enable XXE protection
@condition extension_loaded("xml");
sp.xxe_protection.enable();
@end_condition;

# Global configuration variables
# sp.global.secret_key("YOU _DO_ NEED TO CHANGE THIS WITH SOME RANDOM CHARACTERS.");

# Globally activate strict mode
# https://www.php.net/manual/en/language.types.declarations.php#language.types.declarations.strict
# sp.global_strict.enable();

# Prevent unserialize-related exploits
# sp.unserialize_hmac.enable();

# Only allow execution of read-only files. This is a low-hanging fruit that you should enable.
# sp.readonly_exec.enable();

# PHP has a lot of wrappers, most of them aren't usually useful, you should
# only enable the ones you're using.
# sp.wrappers_whitelist.list("file,php,phar");

# Prevent sloppy comparisons.
# sp.sloppy_comparison.enable();

# Use SameSite on session cookie
# https://snuffleupagus.readthedocs.io/features.html#protection-against-cross-site-request-forgery
sp.cookie.name("PHPSESSID").samesite("lax");

# Harden the `chmod` function (0777 (oct = 511, 0666 = 438)
sp.disable_function.function("chmod").param("mode").value("438").drop();
sp.disable_function.function("chmod").param("mode").value("511").drop();

# Prevent various `mail`-related vulnerabilities
sp.disable_function.function("mail").param("additional_parameters").value_r("\\-").drop();

# Since it's now burned, me might as well mitigate it publicly
sp.disable_function.function("putenv").param("setting").value_r("LD_").drop()
sp.disable_function.function("putenv").param("setting").value("PATH").drop()

# This one was burned in Nov 2019 - https://gist.github.com/LoadLow/90b60bd5535d6c3927bb24d5f9955b80
sp.disable_function.function("putenv").param("setting").value_r("GCONV_").drop()

# Since people are stupid enough to use `extract` on things like $_GET or $_POST, we might as well mitigate this vector
sp.disable_function.function("extract").pos("0").value_r("^_").drop()
sp.disable_function.function("extract").pos("1").value("0").drop()

# This is also burned:
# ini_set('open_basedir','..');chdir('..');…;chdir('..');ini_set('open_basedir','/');echo(file_get_contents('/etc/passwd'));
# Since we have no way of matching on two parameters at the same time, we're
# blocking calls to open_basedir altogether: nobody is using it via ini_set anyway.
# Moreover, there are non-public bypasses that are also using this vector ;)
sp.disable_function.function("ini_set").param("varname").value_r("open_basedir").drop()

# Prevent various `include`-related vulnerabilities
sp.disable_function.function("require_once").value_r("\.(inc|phtml|php)$").allow();
sp.disable_function.function("include_once").value_r("\.(inc|phtml|php)$").allow();
sp.disable_function.function("require").value_r("\.(inc|phtml|php)$").allow();
sp.disable_function.function("include").value_r("\.(inc|phtml|php)$").allow();
sp.disable_function.function("require_once").drop()
sp.disable_function.function("include_once").drop()
sp.disable_function.function("require").drop()
sp.disable_function.function("include").drop()

# Prevent `system`-related injections
sp.disable_function.function("system").param("command").value_r("[$|;&`\\n\\(\\)\\\\]").drop();
sp.disable_function.function("shell_exec").pos("0").value_r("[$|;&`\\n\\(\\)\\\\]").drop();
sp.disable_function.function("exec").param("command").value_r("[$|;&`\\n\\(\\)\\\\]").drop();
sp.disable_function.function("proc_open").param("command").value_r("[$|;&`\\n\\(\\)\\\\]").drop();

# Prevent runtime modification of interesting things
sp.disable_function.function("ini_set").param("varname").value("assert.active").drop();
sp.disable_function.function("ini_set").param("varname").value("zend.assertions").drop();
sp.disable_function.function("ini_set").param("varname").value("memory_limit").drop();
sp.disable_function.function("ini_set").param("varname").value("include_path").drop();
sp.disable_function.function("ini_set").param("varname").value("open_basedir").drop();

# Detect some backdoors via environment recon
sp.disable_function.function("ini_get").param("varname").value("allow_url_fopen").drop();
sp.disable_function.function("ini_get").param("varname").value("open_basedir").drop();
sp.disable_function.function("ini_get").param("varname").value_r("suhosin").drop();
sp.disable_function.function("function_exists").param("function_name").value("eval").drop();
sp.disable_function.function("function_exists").param("function_name").value("exec").drop();
sp.disable_function.function("function_exists").param("function_name").value("system").drop();
sp.disable_function.function("function_exists").param("function_name").value("shell_exec").drop();
sp.disable_function.function("function_exists").param("function_name").value("proc_open").drop();
sp.disable_function.function("function_exists").param("function_name").value("passthru").drop();
sp.disable_function.function("is_callable").param("var").value("eval").drop();
sp.disable_function.function("is_callable").param("var").value("exec").drop();
sp.disable_function.function("is_callable").param("var").value("system").drop();
sp.disable_function.function("is_callable").param("var").value("shell_exec").drop();
sp.disable_function.function("is_callable").param("var").value("proc_open").drop();
sp.disable_function.function("is_callable").param("var").value("passthru").drop();

# Ghetto error-based sqli detection
# sp.disable_function.function("mysql_query").ret("FALSE").drop();
# sp.disable_function.function("mysqli_query").ret("FALSE").drop();
# sp.disable_function.function("PDO::query").ret("FALSE").drop();

# Ensure that certificates are properly verified
sp.disable_function.function("curl_setopt").param("value").value("1").allow();
sp.disable_function.function("curl_setopt").param("value").value("2").allow();
# `81` is SSL_VERIFYHOST and `64` SSL_VERIFYPEER
sp.disable_function.function("curl_setopt").param("option").value("64").drop().alias("Please don't turn CURLOPT_SSL_VERIFYCLIENT off.");
sp.disable_function.function("curl_setopt").param("option").value("81").drop().alias("Please don't turn CURLOPT_SSL_VERIFYHOST off.");

# File upload
# On old PHP7 versions
#sp.disable_function.function("move_uploaded_file").param("destination").value_r("\\.ph").drop();
#sp.disable_function.function("move_uploaded_file").param("destination").value_r("\\.ht").drop();
# On PHP7.4+
sp.disable_function.function("move_uploaded_file").param("new_path").value_r("\\.ph").drop();
sp.disable_function.function("move_uploaded_file").param("new_path").value_r("\\.ht").drop();

# Logging lockdown
sp.disable_function.function("ini_set").param("varname").value_r("error_log").drop()
sp.disable_function.function("ini_set").param("varname").value_r("error_reporting").drop()
sp.disable_function.function("ini_set").param("varname").value_r("display_errors").drop()