自宅サーバーへの辞書攻撃対策

最近自宅サーバーへの辞書攻撃が目立つようになってきたので対策を行いました。

[blacklistd](https://man.freebsd.org/cgi/man.cgi?query=blacklistd&sektion=8&format=html)に対応しているサービスはそちらに任せて、[TCP Wrappers](https://docs.freebsd.org/ja/books/handbook/security/#tcpwrappers)に対応しているサービスについて自動的に攻撃を遮断するための仕組みを導入します。

# maxloginsの改造
syslogをモニターしてsshへの攻撃を遮断する[maxlogins](https://www.teamits.com/resources/maxlogins.html)をベースに、ssh以外への攻撃を検知できるように、ログのマッチングルールを設定ファイルでサービス別に複数記述できるように改造します。

設定ファイルはコマンドラインオプション(-c)で指定、またはスクリプトと同じパスで拡張子を.plから.confに置き換えたファイルを検索します。

更に、下記を参考に出力する遮断リストの可読性を改善する修正を加えています。

http://matsup.blogspot.com/2009/03/blog-post.html

## 設定ファイルの構造
手っ取り早く設定ファイルもperlスクリプトで実装します。

また、オリジナルのmaxloginsは攻撃を検知した際に対象のプロセスをkillするかどうかをコマンドラインオプションで指定していましたが、サービスによって切り替えたいので設定ファイルで個別に設定できるようにします。

```perl
{
    サービス名1 => {
        kill => 0または1(1ならkillする),
        rules => [
            '正規表現でsyslogのマッチングパターンを定義',
            '複数のパターンを定義可能',
        ]
    },
    サービス名2 => {
        :
        :
    }
}
```

ssh用の設定例は下記の通りです。
```perl:ssh用の設定
{
    sshd => {
        kill => 1,
        rules => [
            'sshd\[$PID\]: Failed password .* from $IP_ADDR',
            'sshd\[$PID\]: Invalid user .* from $IP_ADDR',
            'sshd\[$PID\]: Did not receive identification string .* from $IP_ADDR',
            'sshd\[$PID\]: Connection closed by .*$IP_ADDR port .* \[preauth\]',
        ]
    }
}
```

syslogのマッチングパターンに```$PID```や```$IP_ADDR```という記述がありますが、これはそれぞれプロセスIDとIPアドレスを名前付きでキャプチャするパターンと置き換えられます。

|キーワード|置換される正規表現|
|:--------|:---------------|
|$PID     |```\b(?<pid>\d*)```     |
|$IP_ADDR |```\b(?<ip>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})```|

# maxlogins.pl の修正パッチ

```patch:maxlogins.patch
--- maxlogins.txt	2023-11-19 12:45:40.256893000 +0900
+++ ../maxlogins.pl	2023-11-19 15:42:21.897012000 +0900
@@ -6,11 +6,16 @@
 
 use strict;
 use Getopt::Long;
+use FindBin;
+use File::Basename;
 
+my $conf_file = $FindBin::Bin . '/' . basename($0);
+$conf_file =~ s/\.[^.]*$/.conf/;
+
 my %option = (
 	badipfile => '/var/log/maxlogins',
+	conf_file => $conf_file,
 	expire => '12h',
-	kill => 1,
 	loglevel => 1,
 	maxattempts => 3,
 	maxsuspects => 3,
@@ -18,9 +23,9 @@
 GetOptions(
 	\%option,
 	'badipfile|b=s',
+	'conf_file|c=s',
 	'expire|e=s',
 	'help|h',
-	'kill|k=i',
 	'loglevel|l=i',
 	'maxattempts|a=i',
 	'maxsuspects|s=i',
@@ -48,8 +53,13 @@
 my $LOG_VERBOSE = 9;
 my @suspects = ();
 my $num_expired = 0;
-my ($rin,$rout,$nfound,$sshdpid,$logline,$bytes,$buf,$bol,$eol,$halfline);
+my ($rin,$rout,$nfound,$logline,$bytes,$buf,$bol,$eol,$halfline);
+my %CONF;
+my $PID='\b(?<pid>\d*)';
+my $IP_ADDR='\b(?<ip>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})';
 
+read_conf_file();
+
 $rin = $halfline = '';
 vec($rin,fileno(STDIN),1) = 1;
 $nfound = select($rout=$rin, undef, undef, 3);
@@ -85,10 +95,9 @@
 			$bol = $eol+1;
 			$halfline = "";
 	
-			if ($logline =~ /sshd\[\d*\]: Failed password/){
-				my $ip;
-				($sshdpid, $ip) = $logline =~ (/sshd\[(\d*)\].*from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}) port/);
-				write_log($LOG_VERBOSE, "Bad login attempt from: $ip (PID $sshdpid)");
+			my ($pid, $ip, $kill) = log_match($logline);
+			if (defined $pid) {
+				write_log($LOG_VERBOSE, "Bad login attempt from: $ip (PID $pid)");
 			
 
 				if ($option{loglevel}==$LOG_VERBOSE) {
@@ -105,11 +114,14 @@
 					if ($ip eq $suspects[$i][0]) {
 						$newcount = ++$suspects[$i][1];
 						if ($newcount >= $option{maxattempts}) { # block IP
-							push @blacklist, $ip."	##expires: ".($time + expiration())."\n";
+							my $expirationtime = $time + expiration();
+							push @blacklist, $ip."	##expires: ".($expirationtime)." ".
+								&GetDateStr(localtime($expirationtime))." ".
+								&GetTimeStr(localtime($expirationtime))."\n";
 							write_log($LOG_INFO, "Blocking $ip");
-							if ($option{kill} && (defined $sshdpid) && ($sshdpid != '00000')) {
-								kill ('TERM',$sshdpid);
-								write_log($LOG_VERBOSE, "Killing process $sshdpid");
+							if ($kill && ($pid != '00000')) {
+								kill ('TERM',$pid);
+								write_log($LOG_VERBOSE, "Killing process $pid");
 							}
 						}
 						remove_suspect($i);
@@ -144,6 +156,32 @@
 ## Subroutines
 ##
 
+sub read_conf_file {
+	if (!(-e $option{conf_file})) {
+		die "Could not open ".$option{conf_file}.":$!\n";
+	}
+	my $conf = do $option{conf_file} or die "$!$@";
+
+	%CONF = %$conf;
+}
+
+sub log_match {
+	my ($line) = @_;
+
+	while (my ($service, $conf) = each(%CONF)) {
+		foreach my $rule(@{$conf->{rules}}) {
+			my $_rule = $rule;
+			$_rule =~ s/\$PID\b/$PID/;
+			$_rule =~ s/\$IP_ADDR\b/$IP_ADDR/;
+			if ($line =~ /${_rule}/) {
+				return ($+{pid}, $+{ip}, $conf->{kill});
+			}
+		}
+	}
+	return (undef, undef, undef);
+}
+
+
 sub read_badip_file {
 	if (!(-e $option{badipfile})) {
 		open (BADIP,'>',$option{badipfile}) or die "Could not create ".$option{badipfile}.":$!\n";
@@ -231,6 +269,35 @@
 	}
 }
 
+sub GetDateStr {
+	my(@GTS_date_array) = @_;
+	if ($GTS_date_array[1] eq '') {
+		@GTS_date_array = localtime(time);
+	}
+	my($dum);
+	$dum=($GTS_date_array[5]+1900)."/".&ZeroPadding($GTS_date_array[4]+1)."/".
+		&ZeroPadding($GTS_date_array[3]);
+	return $dum;
+}
+
+sub GetTimeStr {
+	my(@GTS_date_array) = @_;
+	if ($GTS_date_array[1] eq '') {
+		@GTS_date_array = localtime(time);
+	}
+	my($dum);
+	$dum=&ZeroPadding($GTS_date_array[2]).":".&ZeroPadding($GTS_date_array[1]).":".
+		&ZeroPadding($GTS_date_array[0]);
+	return $dum;
+}
+
+sub ZeroPadding {
+	my($dum)=sprintf("%d",@_);
+	$dum="00".$dum;
+	$dum=substr($dum,(length($dum)-2));
+	return $dum;
+}
+
 __END__
 
 #----------------------------DOCUMENTATION------------------------------
@@ -360,12 +427,6 @@
 Note: blocks expire and are removed when B<maxlogins> runs, i.e., when
 something writes to F<auth.log>.  You can also run maxlogins from cron at any
 desired interval (> 5 seconds) to clean out expired IPs on a regular basis.
-
-=item B<kill (-k)>
-
-Set to 1 to kill the sshd process being used by the cracker, to prevent
-additional login attempts on the already-open connection.  Set to
-0 if you do not want to do this.  Default: 1.
 
 =item B<loglevel (-l)>
 
```