There are some general concepts to keep in mind when developing Snort rules to maximize efficiency and speed.
Snort groups rules by protocol (ip, tcp, udp, icmp), then by ports (ip and icmp use slightly different logic), then by those with content and those without. For rules with content, a multi-pattern matcher is used to select rules that have a chance at matching based on a single content. Selecting rules for evaluation via this "fast" pattern matcher was found to increase performance, especially when applied to large rule groups like HTTP. The longer and more unique a content is, the less likely that rule and all of its rule options will be evaluated unnecessarily - it's safe to say there is generally more "good" traffic than "bad". Rules without content are always evaluated (relative to the protocol and port group in which they reside), potentially putting a drag on performance. While some detection options, such as pcre and byte_test, perform detection in the payload section of the packet, they are not used by the fast pattern matching engine. If at all possible, try and have at least one content (or uricontent) rule option in your rule.
Try to write rules that target the vulnerability, instead of a specific exploit.
For example, look for a the vulnerable command with an argument that is too large, instead of shellcode that binds a shell.
By writing rules for the vulnerability, the rule is less vulnerable to evasion when an attacker changes the exploit slightly.
Many services typically send the commands in upper case letters. FTP is a good example. In FTP, to send the username, the client sends:
user username_here
A simple rule to look for FTP root login attempts could be:
alert tcp any any -> any any 21 (content:"user root";)
While it may seem trivial to write a rule that looks for the username root, a good rule will handle all of the odd things that the protocol might handle when accepting the user command.
For example, each of the following are accepted by most FTP servers:
user root user root user root user root user<tab>root
To handle all of the cases that the FTP server might handle, the rule needs more smarts than a simple string match.
A good rule that looks for root login on ftp would be:
alert tcp any any -> any 21 (flow:to_server,established; \ content:"root"; pcre:"/user\s+root/i";)
There are a few important things to note in this rule:
The content matching portion of the detection engine has recursion to handle a few evasion cases. Rules that are not properly written can cause Snort to waste time duplicating checks.
The way the recursion works now is if a pattern matches, and if any of the detection options after that pattern fail, then look for the pattern again after where it was found the previous time. Repeat until the pattern is not found again or the opt functions all succeed.
On first read, that may not sound like a smart idea, but it is needed. For example, take the following rule:
alert ip any any -> any any (content:"a"; content:"b"; within:1;)
This rule would look for “a”, immediately followed by “b”. Without recursion, the payload “aab” would fail, even though it is obvious that the payload “aab” has “a” immediately followed by “b”, because the first "a" is not immediately followed by “b”.
While recursion is important for detection, the recursion implementation is not very smart.
For example, the following rule options are not optimized:
content:"|13|"; dsize:1;
By looking at this rule snippet, it is obvious the rule looks for a packet with a single byte of 0x13. However, because of recursion, a packet with 1024 bytes of 0x13 could cause 1023 too many pattern match attempts and 1023 too many dsize checks. Why? The content 0x13 would be found in the first byte, then the dsize option would fail, and because of recursion, the content 0x13 would be found again starting after where the previous 0x13 was found, once it is found, then check the dsize again, repeating until 0x13 is not found in the payload again.
Reordering the rule options so that discrete checks (such as dsize) are moved to the beginning of the rule speed up Snort.
The optimized rule snipping would be:
dsize:1; content:"|13|";
A packet of 1024 bytes of 0x13 would fail immediately, as the dsize check is the first option checked and dsize is a discrete check without recursion.
The following rule options are discrete and should generally be placed at the beginning of any rule:
The rule options byte_test and byte_jump were written to support writing rules for protocols that have length encoded data. RPC was the protocol that spawned the requirement for these two rule options, as RPC uses simple length based encoding for passing data.
In order to understand why byte_test and byte_jump are useful, let's go through an exploit attempt against the sadmind service.
This is the payload of the exploit:
89 09 9c e2 00 00 00 00 00 00 00 02 00 01 87 88 ................ 00 00 00 0a 00 00 00 01 00 00 00 01 00 00 00 20 ............... 40 28 3a 10 00 00 00 0a 4d 45 54 41 53 50 4c 4f @(:.....metasplo 49 54 00 00 00 00 00 00 00 00 00 00 00 00 00 00 it.............. 00 00 00 00 00 00 00 00 40 28 3a 14 00 07 45 df ........@(:...e. 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 00 00 00 00 00 00 00 06 00 00 00 00 00 00 00 00 ................ 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00 04 ................ 7f 00 00 01 00 01 87 88 00 00 00 0a 00 00 00 04 ................ 7f 00 00 01 00 01 87 88 00 00 00 0a 00 00 00 11 ................ 00 00 00 1e 00 00 00 00 00 00 00 00 00 00 00 00 ................ 00 00 00 00 00 00 00 3b 4d 45 54 41 53 50 4c 4f .......;metasplo 49 54 00 00 00 00 00 00 00 00 00 00 00 00 00 00 it.............. 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 00 00 00 00 00 00 00 06 73 79 73 74 65 6d 00 00 ........system.. 00 00 00 15 2e 2e 2f 2e 2e 2f 2e 2e 2f 2e 2e 2f ....../../../../ 2e 2e 2f 62 69 6e 2f 73 68 00 00 00 00 00 04 1e ../bin/sh....... <snip>
Let's break this up, describe each of the fields, and figure out how to write a rule to catch this exploit.
There are a few things to note with RPC:
89 09 9c e2 - the request id, a random uint32, unique to each request 00 00 00 00 - rpc type (call = 0, response = 1) 00 00 00 02 - rpc version (2) 00 01 87 88 - rpc program (0x00018788 = 100232 = sadmind) 00 00 00 0a - rpc program version (0x0000000a = 10) 00 00 00 01 - rpc procedure (0x00000001 = 1) 00 00 00 01 - credential flavor (1 = auth\_unix) 00 00 00 20 - length of auth\_unix data (0x20 = 32 ## the next 32 bytes are the auth\_unix data 40 28 3a 10 - unix timestamp (0x40283a10 = 1076378128 = feb 10 01:55:28 2004 gmt) 00 00 00 0a - length of the client machine name (0x0a = 10) 4d 45 54 41 53 50 4c 4f 49 54 00 00 - metasploit 00 00 00 00 - uid of requesting user (0) 00 00 00 00 - gid of requesting user (0) 00 00 00 00 - extra group ids (0) 00 00 00 00 - verifier flavor (0 = auth\_null, aka none) 00 00 00 00 - length of verifier (0, aka none)
The rest of the packet is the request that gets passed to procedure 1 of sadmind.
However, we know the vulnerability is that sadmind trusts the uid coming from the client. sadmind runs any request where the client's uid is 0 as root. As such, we have decoded enough of the request to write our rule.
First, we need to make sure that our packet is an RPC call.
content:"|00 00 00 00|"; offset:4; depth:4;
Then, we need to make sure that our packet is a call to sadmind.
content:"|00 01 87 88|"; offset:12; depth:4;
Then, we need to make sure that our packet is a call to the procedure 1, the vulnerable procedure.
content:"|00 00 00 01|"; offset:20; depth:4;
Then, we need to make sure that our packet has auth_unix credentials.
content:"|00 00 00 01|"; offset:24; depth:4;
We don't care about the hostname, but we want to skip over it and check a number value after the hostname. This is where byte_test is useful. Starting at the length of the hostname, the data we have is:
00 00 00 0a 4d 45 54 41 53 50 4c 4f 49 54 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
We want to read 4 bytes, turn it into a number, and jump that many bytes forward, making sure to account for the padding that RPC requires on strings. If we do that, we are now at:
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
which happens to be the exact location of the uid, the value we want to check.
In English, we want to read 4 bytes, 36 bytes from the beginning of the packet, and turn those 4 bytes into an integer and jump that many bytes forward, aligning on the 4 byte boundary. To do that in a Snort rule, we use:
byte_jump:4,36,align;
then we want to look for the uid of 0.
content:"|00 00 00 00|"; within:4;
Now that we have all the detection capabilities for our rule, let's put them all together.
content:"|00 00 00 00|"; offset:4; depth:4; content:"|00 01 87 88|"; offset:12; depth:4; content:"|00 00 00 01|"; offset:20; depth:4; content:"|00 00 00 01|"; offset:24; depth:4; byte_jump:4,36,align; content:"|00 00 00 00|"; within:4;
The 3rd and fourth string match are right next to each other, so we should combine those patterns. We end up with:
content:"|00 00 00 00|"; offset:4; depth:4; content:"|00 01 87 88|"; offset:12; depth:4; content:"|00 00 00 01 00 00 00 01|"; offset:20; depth:8; byte_jump:4,36,align; content:"|00 00 00 00|"; within:4;
If the sadmind service was vulnerable to a buffer overflow when reading the client's hostname, instead of reading the length of the hostname and jumping that many bytes forward, we would check the length of the hostname to make sure it is not too large.
To do that, we would read 4 bytes, starting 36 bytes into the packet, turn it into a number, and then make sure it is not too large (let's say bigger than 200 bytes). In Snort, we do:
byte_test:4,>,200,36;
Our full rule would be:
content:"|00 00 00 00|"; offset:4; depth:4; content:"|00 01 87 88|"; offset:12; depth:4; content:"|00 00 00 01 00 00 00 01|"; offset:20; depth:8; byte_test:4,>,200,36;