Published on

Analyzing Zeek's SSH Brute Force Detection Script

Authors
  • avatar
    Name
    Morphy Chan
    Twitter

The script is located at /opt/zeek/share/zeek/policy/protocols/ssh/detect-bruteforcing.zeek. It was last updated in 2015 — quite dated.

$ ls -lh
total 16K
-rw-r--r-- 1 root root 3.2K Jan 28  2015 detect-bruteforcing.zeek
-rw-r--r-- 1 root root 1.5K Jan 28  2015 geo-data.zeek
-rw-r--r-- 1 root root 1.6K Jan 28  2015 interesting-hostnames.zeek
-rw-r--r-- 1 root root 1008 Jan 28  2015 software.zeek

1. Parameter Definitions

The script starts with a series of parameter definitions:

export {
	redef enum Notice::Type += {
		## ...
		Password_Guessing,
		## ...
		Login_By_Password_Guesser,
	};

	redef enum Intel::Where += {
		## An indicator of the login for the intel framework.
		SSH::SUCCESSFUL_LOGIN,
	};

	## The number of failed SSH connections before a host is designated as
	## guessing passwords.
	const password_guesses_limit: double = 30 &redef;

	## The amount of time to remember presumed non-successful logins to
	## build a model of a password guesser.
	const guessing_timeout = 30 mins &redef;

	## ...
	const ignore_guessers: table[subnet] of subnet &redef;
}
  • Two new Notice types are defined, as the detection results are ultimately written to notice.log. The names are self-explanatory: Password_Guessing for brute force events, and Login_By_Password_Guesser for successful logins after brute forcing.
  • A common industry approach to detecting brute force attacks is checking whether the number of attempts within a time window exceeds a preset threshold. This method is simple and efficient, though it may miss attempts spanning across time windows. This script follows the same logic. password_guesses_limit defines the attempt frequency threshold (default: 30); guessing_timeout defines the time window (default: 30 minutes).
  • ignore_guessers defines an IP whitelist.

2. Detection Logic Using the SumStats Framework

Zeek provides a statistics framework called SumStats, primarily used for aggregating various network data metrics. It's well-suited for implementing frequency-based detection scripts. The SumStats framework 1 involves three steps:

  • Observation: Collecting data — in this script, collecting each SSH login attempt.
  • Reducer: Processing the collected data — e.g., summation, aggregation.
  • SumStat: Responding to processing results — i.e., the action, such as generating a notice when SSH login attempt frequency exceeds the threshold.

Note that Zeek's architecture is distributed, so the SumStats framework likely draws on Map-Reduce principles.

Let's examine how the detection script implements these three steps.

2.1 Observing ssh_auth_failed Events

ssh_auth_failed(c: connection) is a Zeek event triggered when an SSH login failure is detected:

event ssh_auth_failed(c: connection)
	{
	local id = c$id;

	# Add data to the FAILED_LOGIN metric unless this connection should
	# be ignored.
	if ( ! (id$orig_h in ignore_guessers &&
	        id$resp_h in ignore_guessers[id$orig_h]) )
		SumStats::observe("ssh.login.failure", [$host=id$orig_h], [$str=cat(id$resp_h)]);
	}

When triggered, the observe method is called for IPs not in the whitelist. The observe method takes three parameters:

  • Id: A string identifier for the observed data. Since observe may be called in multiple scripts for different data, each data stream needs a unique identifier — here "ssh.login.failure" identifies SSH login failure events.
  • Key: A SumStats::Key record containing str and host fields. Here $host is used with the source IP of the failed SSH login as the key.
  • Obs: A SumStats::Observation record with num, dbl, and str fields. Here the $str type stores the destination IP of the failed SSH login.

2.2 Reduce and SumStat

Data reduction and the sumstat action are handled in the zeek_init() event.

2.2.1 Reducer

First, a SumStats::Reducer is defined:

event zeek_init()
	{
	local r1: SumStats::Reducer = [$stream="ssh.login.failure", $apply=set(SumStats::SUM, SumStats::SAMPLE), $num_samples=5];
	...
	}

The Reducer record includes:

  • stream: The data stream identifier, matching the corresponding observe method's Id.

  • apply: A set of SumStats::Calculation values defining what processing to apply. Here two methods are used — SUM and SAMPLE:

    Calculate the sum of the values. For string values, this will be the number of strings.

    Since the observation phase stores destination IPs as strings, SUM here counts the number of strings — i.e., the number of failed SSH attempts.

    Get uniquely distributed random samples from the observation stream.

    Samples the observed SSH login destination IPs, with the sample count based on num_samples.

2.2.2 SumStat

Next comes the action processing after reduction, using the sumstat method:

event zeek_init()
	{
	...
		SumStats::create([$name="detect-ssh-bruteforcing",
	                  $epoch=guessing_timeout,
	                  $reducers=set(r1),
	                  $threshold_val(key: SumStats::Key, result: SumStats::Result) =
	                  	{
	                  	return result["ssh.login.failure"]$sum;
	                  	},
	                  $threshold=password_guesses_limit,
	                  $threshold_crossed(key: SumStats::Key, result: SumStats::Result) =
	                  	{
	                  	local r = result["ssh.login.failure"];
	                  	local sub_msg = fmt("Sampled servers: ");
	                  	local samples = r$samples;
	                  	for ( i in samples )
	                  		{
	                  		if ( samples[i]?$str )
	                  			sub_msg = fmt("%s%s %s", sub_msg, i==0 ? "":",", samples[i]$str);
	                  		}
	                  	# Generate the notice.
	                  	NOTICE([$note=Password_Guessing,
	                  	        $msg=fmt("%s appears to be guessing SSH passwords (seen in %d connections).", key$host, r$num),
	                  	        $sub=sub_msg,
	                  	        $src=key$host,
	                  	        $identifier=cat(key$host)]);
	                  	}]);
	}

SumStats::create includes:

  • name: The name of this statistical analysis — here, SSH brute force detection.
  • epoch: The time period — the detection time window defined in the parameters.
  • threshold_val: Since we need to compare against the preset threshold, we need the count of observed SSH login failures within the time window. The SUM method from the reducer provides this.
  • threshold: The preset threshold defined in the parameters.
  • threshold_crossed: A Zeek-provided callback triggered when the statistical result exceeds the threshold:
    • Results are stored in result["ssh.login.failure"]
    • Since the reducer also uses SAMPLE, the sampled data is formatted into sub_msg
    • The relevant information is output to notice.log

3. Script Verification

Running this script against a pcap file containing SSH brute force traffic successfully generates an alert in notice.log:

$ cat notice.log | jq
{
  "ts": 1617095241.708715,
  "note": "SSH::Password_Guessing",
  "msg": "192.168.153.6 appears to be guessing SSH passwords (seen in 30 connections).",
  "sub": "Sampled servers:  192.168.153.18, 192.168.153.18, 192.168.153.18, 192.168.153.18, 192.168.153.18",
  "src": "192.168.153.6",
  "actions": [
    "Notice::ACTION_LOG"
  ],
  "suppress_for": 3600
}

Footnotes

  1. Summary Statistics