I wrote profiling applications over SSL recently and this is my attempt at doing so in Bro. I haven't written a Bro script before this one so I'm betting I've got a bunch of things wrong here. The code comes in two parts. The first is the main script which has the core logic. The second part is the "local" script which defines the application profiles you are interested in.
@load base/protocols/conn
@load base/protocols/ssl
@load base/frameworks/notice
module SSLProfiler;
export {
type profile: record {
orig: vector of count;
resp: vector of count;
name: string;
orig_idx: count &default=0 &optional;
resp_idx: count &default=0 &optional;
skip: bool &default=F &optional;
orig_match: bool &default=F &optional;
resp_match: bool &default=F &optional;
};
type profiles: vector of profile;
type profile_table: table[string] of profiles;
redef enum Notice::Type += {
SSL_Application_Profile
};
# Applications will look the same across given groups of ciphers. For
# example, a reverse shell with TLS_RSA_WITH_AES_128_CBC_SHA will look the
# same as a reverse shell with TLS_DH_DSS_WITH_AES_128_CBC_SHA.
const cipher_groups: table[string] of string = {
["TLS_RSA_WITH_RC4_128_SHA"] = "STREAM_SHA",
["TLS_RSA_WITH_RC4_128_MD5"] = "STREAM_MD5",
["TLS_RSA_WITH_AES_256_CBC_SHA"] = "BLOCK_16_SHA"
} &redef;
const ssl_profiles: profile_table = {
} &redef;
}
redef SSL::disable_analyzer_after_detection = F;
redef record SSL::Info += {
application_data_count: count &default = 0;
found_ssl_profile: bool &default = F;
prof_table: profile_table &default = ssl_profiles;
};
event ssl_encrypted_data(c: connection, is_orig: bool, content_type: count, length: count) {
c$ssl$application_data_count += 1;
# Skip the first two records as they are the encrypted handshake, not
# relevant to our profiling.
if (!(c ?$ ssl) ||
!(c$ssl ?$ cipher) ||
c$ssl$found_ssl_profile ||
c$ssl$application_data_count <= 2 ||
c$ssl$cipher !in cipher_groups) {
return;
}
local group: string = cipher_groups[c$ssl$cipher];
if (group !in c$ssl$prof_table) {
return;
}
local app_profiles: profiles = c$ssl$prof_table[group];
for (i in app_profiles) {
local app_profile = app_profiles[i];
# Skip the profile if we can't possibly match it.
if (app_profile$skip ||
(is_orig && app_profile$orig_match) ||
(!is_orig && app_profile$resp_match)) {
next;
}
local idx: count;
local vect: vector of count;
if (is_orig) {
idx = app_profile$orig_idx;
vect = app_profile$orig;
} else {
idx = app_profile$resp_idx;
vect = app_profile$resp;
}
if (vect[idx] == 0 || vect[idx] == length) {
if (is_orig) {
app_profile$orig_idx += 1;
if (app_profile$orig_idx >= |app_profile$orig|) {
app_profile$orig_match = T;
}
} else {
app_profile$resp_idx += 1;
if (app_profile$resp_idx >= |app_profile$resp|) {
app_profile$resp_match = T;
}
}
if (app_profile$orig_match && app_profile$resp_match) {
NOTICE([$note=SSL_Application_Profile,
$msg=fmt("Possible SSL application profile: %s",
app_profile$name),
$conn=c]);
c$ssl$found_ssl_profile = T;
}
} else {
app_profile$skip = T;
}
}
}
The export
section is used to define the various data structures in use. The most basic of which is called profile
, which is a Bro record
(think of it like a struct in C). The profile
consists of three required parts: orig
, resp
, and name
. The orig
and resp
members are vectors of type count. These are where you can put in the sizes of application records you are interested in for your application profile. The name
is just a string which you can fill in to make notices more useful. The other parts of the record are not required to be filled in, and are used for internal state tracking.
The next two types are straight-forward: profiles
are a vector of profile
records and profile_table
maps a given string to profiles
.
All of the data structures mentioned above are used in the ssl_profiles
table, which is of type profile_table
. This is the main data structure used throughout the event handler.
The last table is cipher_groups
which maps the cipher string to a shortened version. This table is used as a way to group cipher suites.
The ssl_encrypted_data event is called once per encrypted message (in TLS terms it is an Application Data Record). The first message sent after a Change Cipher Spec message must be an Encrypted Handshake message. Because this is not relevant to our profiling needs we can simply discard it. This is why we track the application_data_count
and only handle the event if it is > 2.
Once we have determined that the application_data_count
has passed the Encrypted Handshake messages we must look up the negotiated cipher in the cipher_groups
table. This is the table which maps the cipher suite (TLS_RSA_WITH_AES_256_CBC_SHA
) to a generic grouping (BLOCK_16_SHA
). This step is useful because applications encrypted with different cipher suites can have the same profiles if the cipher suite is similar enough. A good example of this is an application encrypted TLS_RSA_WITH_AES_256_CBC_SHA
will look the same as TLS_DH_WITH_AES_256_CBC_SHA
because they are the same symmetric cipher, just the key exchange is different. This allows us to group similar profiles easily.
With the grouping key we can look up the profiles from the ssl_profiles
table. This gives us a vector of profile
objects (the records discussed before) which we can iterate over to determine if we have a matching profile. To determine a matching profile we use orig_idx
and resp_idx
as indices into the orig
and resp
vectors, respectively. If a value in the vector is 0 or matches the length of the current application data record then corresponding index is incremented. The special value of 0 is used to indicate that the length of the application record in that spot is not relevant to the profile. If the end of the vector in a particular direction is reached then that direction is "matched". When both vectors are matched then the entire profile is matched and a NOTICE
is raised.
If at any point the length of the current application data record does not match the current index into the appropriate array the entire profile is marked as a failure and skipped in the future.
@load ./ssl-profiling.bro
const reverse_shell_stream_sha = SSLProfiler::profile(
$orig = vector(148),
$resp = vector(34),
$name = "Reverse Shell (STREAM SHA)"
);
const reverse_shell_block_16_cbc_sha = SSLProfiler::profile(
$orig = vector(32, 160),
$resp = vector(32, 48),
$name = "Reverse Shell (BLOCK 16 SHA)"
);
const reverse_shell_stream_md5 = SSLProfiler::profile(
$orig = vector(144),
$resp = vector(30),
$name = "Reverse Shell (STREAM MD5)"
);
redef SSLProfiler::ssl_profiles: SSLProfiler::profile_table = {
["STREAM_SHA"] = vector(reverse_shell_stream_sha),
["STREAM_MD5"] = vector(reverse_shell_stream_md5),
["BLOCK_16_SHA"] = vector(reverse_shell_block_16_cbc_sha)
};
The above illustrates profiles for a reverse shell with a typical banner as discussed in my earlier post, followed by executing "ipconfig /all" within three different cipher suites.
Here is my execution of the ssl-profiling-local.bro
script on a PCAP which contains two reverse shells, each with a different cipher suite.
wxs@psh bro % ls
reverseshell-both.pcap ssl-profiling-local.bro ssl-profiling.bro
wxs@psh bro % bro -b -r reverseshell-both.pcap ssl-profiling-local.bro
wxs@psh bro % ls -l
total 768
-rw-r--r-- 1 wxs staff 788 Jun 30 13:46 conn.log
-rw-r--r-- 1 wxs staff 965 Jun 30 13:46 files.log
-rw-r--r-- 1 wxs staff 1124 Jun 30 13:46 notice.log
-rw-r--r-- 1 wxs staff 360558 Jun 29 11:07 reverseshell-both.pcap
-rw------- 1 wxs staff 729 Jun 30 13:23 ssl-profiling-local.bro
-rw------- 1 wxs staff 3901 Jun 30 13:23 ssl-profiling.bro
-rw-r--r-- 1 wxs staff 1093 Jun 30 13:46 ssl.log
-rw-r--r-- 1 wxs staff 1244 Jun 30 13:46 x509.log
wxs@psh bro % cat notice.log
#separator \x09
#set_separator ,
#empty_field (empty)
#unset_field -
#path notice
#open 2015-06-30-13-46-13
#fields ts uid id.orig_h id.orig_p id.resp_h id.resp_p fuid file_mime_type file_desc proto note msg sub src dst p n peer_descr actions suppress_for droppedremote_location.country_code remote_location.region remote_location.city remote_location.latitude remote_location.longitude
#types time string addr port addr port string string string enum enum string string addr addr port count string set[enum] interval bool string string string double double
1434735285.374838 CFvgM51TNJJtpi6dj9 192.168.1.214 65411 192.168.1.209 9999 - - - tcp SSLProfiler::SSL_Application_Profile Possible SSL application profile: Reverse Shell (STREAM SHA) - 192.168.1.214 192.168.1.209 9999 - bro Notice::ACTION_LOG 3600.000000 F - - - - -
1434744729.751189 CgmPbH3O1D42wtt4xh 192.168.1.214 49533 192.168.1.209 9999 - - - tcp SSLProfiler::SSL_Application_Profile Possible SSL application profile: Reverse Shell (BLOCK 16 SHA) - 192.168.1.214 192.168.1.209 9999 - bro Notice::ACTION_LOG 3600.000000 F - - - - -
#close 2015-06-30-13-46-13
wxs@psh bro %
In some cases it is reasonable to express a range for a length. Currently you have to know the exact length of a given application data record, which is rather limiting. For example, in the case of a reverse shell the prompt may not always be C:\windows\system32 >
, which would mean our profile for stream ciphers would be incorrect, and depending upon the length of the path may be incorrect for block ciphers. Changing the code to use a vector for each length element would be one way to solve this. The first element in the vector would be a minimum length and the second could be a maximum. In python this would look something like:
orig = [(150, 160)]
There are also other things that can be done to make this nicer, like being able to specify the negotiated TLS version or automatically handling block ciphers in CBC mode in TLS1.2 where the first block is discarded, thereby inflating otherwise similar profiles.
If you like this idea and want to contribute PCAPs of applications inside SSL so they can be profiled please get in touch with me. In particular I'm interested in malicious protocols and detecting those using this technique.