Skip to content

Instantly share code, notes, and snippets.

@lifeforms
Last active June 30, 2016 13:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lifeforms/28086fbdd17a2504faf488fe217380ea to your computer and use it in GitHub Desktop.
Save lifeforms/28086fbdd17a2504faf488fe217380ea to your computer and use it in GitHub Desktop.
my ModSecurity syntax dream

A better ModSecurity rule syntax?

Major usability problems with the ModSecurity rules

  1. A big ruleset with serious conventions contains many code mantras, which are repeated heavily in hard-to-maintain fashion. Examples:

    # Variables to match:
    REQUEST_COOKIES|!REQUEST_COOKIES:/__utm/|REQUEST_COOKIES_NAMES|ARGS_NAMES|ARGS|XML:/*
    
    # Anomaly score increases:
    setvar:'tx.msg=%{rule.msg}',\
    setvar:tx.php_injection_score=+%{tx.critical_anomaly_score},\
    setvar:tx.anomaly_score=+%{tx.critical_anomaly_score},\
    
  2. Lack of rule defaults. You can't apply settings to a group of rules, but rather you have to apply the settings to each rule. This creates lots of noise in the rules, and makes rules scary to read and harder to maintain. Example:

    tag:'application-multi',\
    tag:'language-PHP',\
    tag:'platform-multi',\
    tag:'attack-PHP injection',\
    tag:'OWASP_CRS/WEB_ATTACK/PHP_INJECTION',\
    tag:'OWASP_TOP_10/A1',\
    
  3. Lack of Boolean OR operator. Example, you can't write if A or B then Action. You can only create two rules and copy the 'action':

    if A then
    	Action
    
    if B then
    	Action
    
  4. The chain action is unintuitive. To do if A and B then Action, you have to write the following, which is hard to remember, hard to read, and easy to get wrong:

    if A then
    	chain
    	Action
    if B
    
  5. The Apache directive format is arguably annoying for complex, multi-line rules with its \ continuations.

  6. All config files are concatenated into one big global ball of mud, which makes it hard to modularize (and share) rulesets.

Small improvements

  • No functionality should be introduced which cannot be expressed in current ModSecurity rules.
  • Default rule settings could solve the problem of actions and metadata being typed again.
  • Repetitive incantations could be resolved with a simple macro system, which would be expanded in-place. The macro system can look like #define in C, doing simple textual replacement, and taking arguments.
  • Rule files could follow a Python-like syntax.
  • A rule file could declare a package for its defaults and defines. This enables modularization and safe code reuse across files. All rule files which share a certain package name, have access to the same set of defaults and defines.
  • Boolean OR logic can be added, and implemented within the current ModSecurity engine by generating multiple ModSecurity rules. The only tricky thing would be that this requires multiple rule ids (possibly auto-incremented if not specified by the user), but that's something one could check and enforce.

Example

Let's try to see what could happen to the CRS3 rule file REQUEST-33-APPLICATION-ATTACK-PHP.conf if we'd use a Python-like syntax, combined with packages, default settings, and a macro construct.

# Declare that this file belongs to the 'crs' package.
# All files in 'crs' package have access to eachother's defaults and macros.
package crs

# First, set some default settings for the rules.
# Consider the set of defaults to be inserted into every rule after it.
# Previous defaults may be forgotten by specifying 'clear' in a rule or
# in a new default section.
default:
	phase request
	severity CRITICAL
	rev 1
	maturity 1
	accuracy 9
	ver OWASP_CRS/3.0.0
	tag attack-injection-php
	tag language-php
	tag OWASP_CRS/WEB_ATTACK/PHP_INJECTION
	tag platform-multi
	tag OWASP_TOP_10/A1

# Create some named defaults.
# A named default can be ignored by 'clear name', ex: 'clear application'.
# Named defaults are useful if you have a default that's used in 90% of the
# rules, but not all of them.
default application:
	tag application-multi

default logparts:
	ctl auditLogParts=+E

# Create some macros for often returning expressions.
# In practice, we'd put these in a separate 'definitions file' for the package.
define PARAMETERS:
	REQUEST_COOKIES|!REQUEST_COOKIES:/__utm/|REQUEST_COOKIES_NAMES|ARGS_NAMES|ARGS|XML:/*

define META_HEADERS:
	REQUEST_HEADERS:Host|REQUEST_HEADERS:Referer|REQUEST_HEADERS:User-Agent

define UPLOAD_FILENAMES:
	FILES|REQUEST_HEADERS:X-Filename|REQUEST_HEADERS:X_Filename|REQUEST_HEADERS:X-File-Name

define ANY:
	PARAMETERS|META_HEADERS|UPLOAD_FILENAMES

# That was easy. Now it gets a little more technical.
# This macro would make our Accept/User-Agent header issue trivial:
define unset_or_empty(variable):
	&${variable} eq 0 or ${variable} streq ''

# Here is a macro for a repeated incantation that we use in every rule:
define block_anomaly(severity, attack):
	block
	capture
	logdata 'Matched Data: %{TX.0} found within %{MATCHED_VAR_NAME}: %{MATCHED_VAR}'
	setvar tx.msg=%{rule.msg}
    setvar tx.${attack}_score=+%{tx.${severity}_anomaly_score}
    setvar tx.anomaly_score=+%{tx.${severity}_anomaly_score}

# Another thing often copypasted is the paranoia level check.
# Those rules aren't that bad, but just for fun, a macro could generate them.
# The macro could also change the defaults for the following rules!
define paranoia_level(level, rule1, rule2, skipAfter):
	rule ${rule1}:
		clear # we don't want the defaults to apply to this rule
		phase 1
		if tx:paranoia_level lt ${level}:
			nolog
			pass
			skipAfter ${skipAfter}

	rule ${rule2}:
		clear
		phase 2
		if tx:paranoia_level lt ${level}:
			clear
			nolog
			pass
			skipAfter ${skipAfter}

	default paranoia:
		tag:paranoia-level/${level}

# Let's get down to the rules.

# -= Paranoia Level 1 =- (apply only when tx.paranoia_level is sufficiently high: 1 or higher)
#
paranoia_level 1 933011 933012 END-REQUEST-33-APPLICATION-ATTACK-PHP

# [ Opening/Closing PHP Tag Found ]
#
# http://www.php.net/manual/en/language.basic-syntax.phptags.php
#
rule 933100:
	if PARAMETERS pm '<? <?php ?> [php] [\php]':
		msg 'PHP Injection Attack: Opening/Closing Tag Found'
		block_anomaly critical php_injection

# [ PHP Script Uploads ]
#
# Block file uploads with PHP extensions (.php, .php5, .phtml etc).
#
rule 933110:
	if lowercase(UPLOAD_FILENAMES) rx '.*\.(?:php\d*|phtml)\.*$':
		msg 'PHP Injection Attack: PHP Script File Upload Found'
		block_anomaly critical php_injection

#
# [ PHP Configuration Directives ]
#
rule 933120:
	if PARAMETERS pmf 'php-config-directives.data'
	and MATCHED_VARS pm '=':
		msg 'PHP Injection Attack: Configuration Directive Found'
		block_anomaly critical php_injection

#
# [ PHP Variables ]
# It's much nicer to just state the transformations in the operand.
# t:none is implied.
#
rule 933130:
	if normalisePath(PARAMETERS) pmf 'php-variables.data':
		msg 'PHP Injection Attack: Variables Found'
		block_anomaly php_injection critical

#
# [ PHP I/O Streams ]
#
rule 933140:
	if PARAMETERS rx '(?i)php://(std(in|out|err)|(in|out)put|fd|memory|temp|filter)':
		msg 'PHP Injection Attack: I/O Stream Found'
		block_anomaly php_injection critical

#
# [ PHP Functions: High-Risk PHP Function Names ]
#
rule 933150:
	if PARAMETERS pmf 'php-function-names-933150.data':
		msg 'PHP Injection Attack: High-Risk PHP Function Name Found'
		block_anomaly php_injection critical

#
# [ PHP Functions: High-Risk PHP Function Calls ]
#
rule 933160:
	if PARAMETERS rx "(?i)\b(?:s(?:e(?:t_(?:e(?:xception|rror)_handler|magic_quotes_runtime|include_path)|ssion_start)|qlite_(?:(?:(?:unbuffered|single|array)_)?query|p?open|exec)|tr(?:eam_(?:context_create|socket_client)|ipc?slashes|rev)|implexml_load_(?:string|file)|ocket_c(?:onnect|reate)|ystem)|p(?:r(?:eg_(?:replace(?:_callback(?:_array)?)?|match(?:_all)?|split)|oc_open|int_r)|o(?:six_(?:get(?:(?:e[gu]|g)id|login|pwnam)|mknod)|pen)|g_(?:(?:execut|prepar)e|connect|query)|hp(?:version|_uname|info)|assthru|utenv)|o(?:b_(?:get_(?:c(?:ontents|lean)|flush)|end_(?:clean|flush)|clean|flush|start)|dbc_(?:result(?:_all)?|exec(?:ute)?|connect)|pendir)|m(?:b_ereg(?:_(?:replace(?:_callback)?|match)|i(?:_replace)?)?|ove_uploaded_file|ethod_exists|ysql_query|kdir)|g(?:z(?:(?:(?:defla|wri)t|encod|fil)e|compress|open|read)|et(?:(?:myui|cw)d|env))|f(?:i(?:le(?:_exists)?|nfo_open)|(?:unction_exis|pu)ts|tp_connect|write|open)|i(?:s_(?:(?:(?:execut|write?|read)ab|fi)le|dir)|ni_(?:get(?:_all)?|set))|h(?:tml(?:specialchars(?:_decode)?|_entity_decode|entities)|ex2bin)|e(?:scapeshell(?:arg|cmd)|rror_reporting|val|xec)|r(?:ead(?:(?:gz)?file|dir)|awurl(?:de|en)code)|b(?:(?:son_(?:de|en)|ase64_en)code|zopen)|c(?:url_(?:exec|init)|onvert_uuencode|hr)|u(?:n(?:serialize|pack)|rl(?:de|en)code)|(?:json_(?:de|en)cod|debug_backtrac)e|var_dump)(?:\s|/\*.*\*/|//.*|#.*)*\(.*\)":
		msg 'PHP Injection Attack: High-Risk PHP Function Call Found'
		block_anomaly php_injection critical

# -= Paranoia Level 2 =- (apply only when tx.paranoia_level is sufficiently high: 2 or higher)
#
paranoia_level 2 933013 933014 END-REQUEST-33-APPLICATION-ATTACK-PHP

rule 933151:
	if PARAMETERS pmf 'php-function-names-933151.data'
	and MATCHED_VARS pm '(':
		msg 'PHP Injection Attack: Medium-Risk PHP Function Name Found'
		block_anomaly critical php_injection

# -= Paranoia Level 3 =- (apply only when tx.paranoia_level is sufficiently high: 3 or higher)
#
paranoia_level 3 933015 933016 END-REQUEST-33-APPLICATION-ATTACK-PHP

rule 933161:
	if PARAMETERS rx "(?i)\b(?:i(?:s(?:_(?:in(?:t(?:eger)?|finite)|n(?:u(?:meric|ll)|an)|(?:calla|dou)ble|s(?:calar|tring)|f(?:inite|loat)|re(?:source|al)|l(?:ink|ong)|a(?:rray)?|object|bool)|set)|(?:mplod|dat)e|nt(?:div|val)|conv)|s(?:t(?:r(?:(?:le|sp)n|coll)|at)|(?:e(?:rializ|ttyp)|huffl)e|i(?:milar_text|zeof|nh?)|p(?:liti?|rintf)|(?:candi|ubst)r|y(?:mlink|slog)|o(?:undex|rt)|leep|rand|qrt)|c(?:h(?:o(?:wn|p)|eckdate|root|dir|mod)|o(?:(?:(?:nsta|u)n|mpac)t|sh?|py)|lose(?:dir|log)|(?:urren|ryp)t|eil)|f(?:ile(?:(?:siz|typ)e|owner|pro)|l(?:o(?:atval|ck|or)|ush)|(?:rea|mo)d|t(?:ell|ok)|close|gets|stat|eof)|e(?:x(?:(?:trac|i)t|p(?:lode)?)|a(?:ster_da(?:te|ys)|ch)|r(?:ror_log|egi?)|mpty|cho|nd)|l(?:o(?:g(?:1[0p])?|caltime)|i(?:nk(?:info)?|st)|(?:cfirs|sta)t|evenshtein|trim)|d(?:i(?:(?:skfreespac)?e|r(?:name)?)|e(?:fined?|coct)|(?:oubleva)?l|ate)|m(?:b(?:split|ereg)|i(?:crotime|n)|a(?:i[ln]|x)|etaphone|y?sql|hash)|r(?:e(?:(?:cod|nam)e|adlin[ek]|wind|set)|an(?:ge|d)|ound|sort|trim)|t(?:e(?:xtdomain|mpnam)|(?:mpfil|im)e|a(?:int|nh?)|ouch|rim)|u(?:n(?:(?:tain|se)t|iqid|link)|s(?:leep|ort)|cfirst|mask)|a(?:s(?:(?:se|o)rt|inh?)|r(?:sort|ray)|tan[2h]?|cosh?|bs)|h(?:e(?:ader(?:s_(?:lis|sen)t)?|brev)|ypot|ash)|p(?:a(?:thinfo|ck)|r(?:intf?|ev)|close|o[sw]|i)|g(?:et(?:t(?:ext|ype)|date)|mdate|lob)|o(?:penlog|ctdec|rd)|b(?:asename|indec)|n(?:atsor|ex)t|k(?:sort|ey)|quotemeta|wordwrap|virtual|join)(?:\s|/\*.*\*/|//.*|#.*)*\(.*\)"
		msg 'PHP Injection Attack: Low-Value PHP Function Call Found'
		block_anomaly critical php_injection

rule 933111:
	if lowercase(UPLOAD_FILENAMES) rx ".*\.(?:php\d*|phtml)\..*$":
		msg 'PHP Injection Attack: PHP Script File Upload Found'
		block_anomaly critical php_injection

# -= Paranoia Level 4 =- (apply only when tx.paranoia_level is sufficiently high: 4 or higher)
#
paranoia_level 4 933017 933018 END-REQUEST-33-APPLICATION-ATTACK-PHP

# Keep this marker at the bottom of the file
marker END-REQUEST-33-APPLICATION-ATTACK-PHP

A rule without if will become a SecAction:

rule 6660080:
	ctl ruleRemoveTargetById 950120 ARGS:url

Fun project idea

Create a parser for the above format, and have it generate ModSecurity 2/3 rules.

CRS3 can serve as a test case: The resulting rules should be the same as the original CRS3 (except for spacing and ordering) and pass the regression tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment