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");
cookie_env_var
: A environment variable used as part of cookies encryption. See the relevant documentation
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 bysp.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 fileSP_FILESIZE
: the size of the file being uploadedSP_REMOTE_ADDR
: the ip address of the uploaderSP_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-readabledescription
of the rulecidr(ip/mask)
: match on the client’s cidrfilename(name)
: match in the filename
filename_r(regexp)
: the file name matching theregexp
function(name)
: match on functionname
function_r(regexp)
: the function matching theregexp
hash(sha256)
: match on the file’s sha256 sumline(line_number)
: match on the file’s line.param(name)
: match on the function’s parametername
param_r(regexp)
: match on the function’s parameterregexp
param_type(type)
: match on the function’s parametertype
pos(nth_argument)
: match on the nth argument, starting from0
ret(value)
: match on the function’s returnvalue
ret_r(regexp)
: match with aregexp
on the function’s returnret_type(type_name)
: match on thetype_name
of the function’s return valuevalue(value)
: match on a literalvalue
value_r(regexp)
: match on a value matching theregexp
var(name)
: match on a local variablename
key(name)
: match on the presence ofname
as a key in the hashtablekey_r(regexp)
: match withregexp
on keys in the hashtable
The type
must be one of the following values:
FALSE
: for boolean falseTRUE
: for boolean trueNULL
: for the null valueLONG
: for a long (also know asinteger
) valueDOUBLE
: for a double (also known asfloat
) valueSTRING
: for a stringOBJECT
: for a objectARRAY
: for an arrayRESOURCE
: for a resource
Actions¶
Every rule must have one action.
allow()
: allow the request if the rule matchesdrop()
: drop the request if the rule matches
Modifications¶
dump(directory)
: dump the request in thedirectory
if it matches the rulesimulation()
: enabled the simulation mode
Details¶
The function
filter is able to do various dereferencing:
function("AwesomeClass::my_method")
will match the methodmy_method
in the classAwesomeClass
function("AwesomeNamespace\\my_function")
will match the functionmy_function
in the namespaceAwesomeNamespace
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 thebar
key in the hashtablefoo
. Remember that in PHP, almost every data structure is a hashtable. You can of course nest this likeparam($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 parameterparam
.
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 onprint
are equivalent: there is no way to hook one without hooking the other, at least for now). This is why hookedprint
will be displayed asecho
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:
- Allow calls to
system("id")
- Issue a trace in the logs on calls to
system
with its parameters starting withping
, and pursuing evaluation of the remaining rules. - 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()