comparison maltfilter @ 0:fec14263801d

Initial import of maltfilter development version.
author Matti Hamalainen <ccr@tnsp.org>
date Thu, 13 Aug 2009 15:15:18 +0300
parents
children 3da95f3082d9
comparison
equal deleted inserted replaced
-1:000000000000 0:fec14263801d
1 #!/usr/bin/perl -w
2 #############################################################################
3 #
4 # Malicious Attack Livid Termination Filter daemon (maltfilter)
5 # Programmed by Matti 'ccr' Hämäläinen <ccr@tnsp.org>
6 # (C) Copyright 2009 Tecnic Software productions (TNSP)
7 #
8 #############################################################################
9 use strict;
10 use Date::Parse;
11 use Net::IP;
12
13 my $progbanner =
14 "Malicious Attack Livid Termination Filter daemon (maltfilter) v0.7\n".
15 "Programmed by Matti 'ccr' Hamalainen <ccr\@tnsp.org>\n".
16 "(C) Copyright 2009 Tecnic Software productions (TNSP)\n";
17
18 #############################################################################
19 ### Settings / configuration
20 #############################################################################
21 my %settings = (
22 "VERBOSITY" => 4,
23 "DRY_RUN" => 1,
24 "WEEDPERIOD" => 72,
25 "TRESHOLD" => 3,
26 "ACTION" => "DROP",
27 "LOGFILE" => "/var/log/maltfilter",
28 "IPTABLES" => "/sbin/iptables",
29 "NOBLOCK_HOSTS" => "127.0.0.1",
30
31 "CHK_SSHD" => 1,
32 "CHK_KNOWN_CGI" => 1,
33 "CHK_PHP_XSS" => 1,
34 "CHK_PROXY_SCAN" => 1,
35 "CHK_GOOD_HOSTS" => "",
36 );
37
38 # Default logfiles to monitor (SCANFILES setting of configuration overrides these)
39 my @scanfiles_def = (
40 "/var/log/auth.log",
41 "/var/log/httpd/error.log",
42 "/var/log/httpd/access.log"
43 );
44
45 my @scanfiles = ();
46
47 #############################################################################
48 ### Script code
49 #############################################################################
50 my %hitcount = ();
51 my %iplist = ();
52 my $pid_file = "";
53 my $LOGFILE;
54
55 sub mlog
56 {
57 my $level = shift;
58 my $msg = shift;
59 if (defined($LOGFILE)) {
60 print $LOGFILE "[".scalar localtime()."] ".$msg if ($settings{"VERBOSITY"} > $level);
61 } else {
62 print $msg if ($settings{"VERBOSITY"} > $level);
63 }
64 }
65
66 sub check_hosts($$)
67 {
68 my $host = $_[1];
69 my $ip = new Net::IP($host);
70 foreach my $test (split(/\s*\|\s*/, $_[0])) {
71 my $test_ip = new Net::IP($test);
72 if ($host eq $test) {
73 return 1;
74 }
75 if (defined($ip) && defined($test_ip)) {
76 if ($ip->binip() eq $test_ip->binip()) {
77 return 1;
78 }
79 }
80 }
81 return 0;
82 }
83
84 ### Execute iptables
85 sub exec_iptables(@)
86 {
87 my @args = ($settings{"IPTABLES"}, @_);
88 if ($settings{"DRY_RUN"}) {
89 log(3, ":: ".join(" ", @args)."\n");
90 } else {
91 system(@args) == 0 or print join(" ", @args)." failed: $?\n";
92 }
93 }
94
95 ### Get current Netfilter INPUT table entries we manage
96 sub update_iplist($)
97 {
98 open(STATUS, $settings{"IPTABLES"}." -v -n -L INPUT |") or
99 die("Could not execute ".$settings{"IPTABLES"}."\n");
100 while (<STATUS>) {
101 chomp;
102 if (/^\s*(\d+)\s+\d+\s+$settings{"ACTION"}\s+all\s+--\s+\*\s+\*\s+(\d+\.\d+\.\d+\.\d+)\s+0\.0\.0\.0\/0\s*$/) {
103 if (!defined($iplist{$2})) {
104 $hitcount{$2} = $settings{"TRESHOLD"};
105 $iplist{$2} = $_[0];
106 if ($_[0] >= 0) { mlog(2, "* $2 appeared in iptables, adding.\n"); }
107 }
108 }
109 }
110 close(STATUS);
111 }
112
113 ### Weed out old entries
114 sub check_time($)
115 {
116 return ($_[0] >= time() - ($settings{"WEEDPERIOD"} * 60 * 60));
117 }
118
119 sub weed_do($)
120 {
121 if (defined($iplist{$_[0]})) {
122 mlog(2, "* Weeding $_[0] ($iplist{$_[0]})\n");
123 exec_iptables("-D", "INPUT", "-s", $_[0], "-d", "0.0.0.0/0", "-j", $settings{"ACTION"});
124 undef($iplist{$_[0]});
125 }
126 }
127
128 sub weed_entries()
129 {
130 foreach my $mip (keys %iplist) {
131 if (defined($iplist{$mip})) {
132 if ($iplist{$mip} >= 0) {
133 if (!check_time($iplist{$mip})) { weed_do($mip); }
134 } else {
135 weed_do($mip);
136 }
137 }
138 }
139 }
140
141 ### Check if given "try count" exceeds treshold and if entry
142 ### is NOT in Netfilter already, then add it if so.
143 sub check_add_entry($$$$)
144 {
145 my $mip = $_[0];
146 my $mdate = str2time($_[1]);
147 my $mreason = $_[2];
148 my $mcond = $_[3];
149
150 my $cnt = $hitcount{$mip}++;
151 if ($cnt >= $settings{"TRESHOLD"} && check_time($mdate)) {
152 my $pat;
153 if (!$mcond) {
154 mlog(2, "* Ignoring $mip: $mreason\n");
155 return;
156 }
157 if (!defined($iplist{$mip})) {
158 if (!check_hosts($settings{"NOBLOCK_HOSTS"}, $mip)) {
159 # Add entry that has >= treshold hits and is not added yet
160 mlog(1, "* Adding $mip ($mdate): $mreason\n");
161 exec_iptables("-I", "INPUT", "1", "-s", $mip, "-j", $settings{"ACTION"});
162 }
163 $iplist{$mip} = $mdate;
164 } else {
165 # Over treshold, but is added, check if we can update the timedate
166 if ($iplist{$mip} >= 0) {
167 if ($mdate > $iplist{$mip}) {
168 $iplist{$mip} = $mdate;
169 }
170 } else {
171 # Empty date, set it now.
172 $iplist{$mip} = $mdate;
173 }
174 }
175 }
176 }
177
178 ### Check given logfile line for matches
179 sub check_log_line($)
180 {
181 # (1) SSH login scan attempts
182 if (/^(\S+\s+\d+\s+\d\d:\d\d:\d\d)\s+\S+\s+sshd\S*?: Failed password for invalid user \S+ from (\d+\.\d+\.\d+\.\d+)/) {
183 check_add_entry($2, $1, "SSHD", $settings{"CHK_SSHD"});
184 }
185 # (2) Common/known exploitable CGI/PHP software scans (like phpMyAdmin)
186 # NOTICE! This matches ERRORLOG, thus it only works if you DO NOT have
187 # any or some of these installed. Preferably none, or use uncommon
188 # paths and prefixes.
189 elsif (/^\[(.+?)\]\s+\[error\]\s+\[client\s+(\d+\.\d+\.\d+\.\d+)\]\s+(.+)$/) {
190 my $mdate = $1;
191 my $mip = $2;
192 my $merr = $3;
193 if ($merr =~ /^File does not exist: (.+)$/) {
194 my $tmp = $1;
195 if ($tmp =~ /\/mss2|\/pma|admin|sql|\/roundcube|\/webmail|\/bin|\/mail|xampp|zen|mailto:|appserv|cube|round|_vti_bin|wiki/i) {
196 check_add_entry($mip, $mdate, "CGI: $tmp", $settings{"CHK_KNOWN_CGI"});
197 }
198 }
199 }
200 # Match Apache common logging format GET requests here
201 elsif (/(\d+\.\d+\.\d+\.\d+)\s+-\s+-\s+\[(.+?)\]\s+\"GET (\S*?) HTTP\//) {
202 my $mdate = $2;
203 my $mip = $1;
204 my $merr = $3;
205
206 # (3) Simple match for generic PHP XSS vulnerability scans
207 # NOTICE! If your site genuinely uses (checked) PHP parameters with
208 # URIs, you should set CHK_GOOD_HOSTS to match your hostname(s)/IP(s)
209 # used in the URIs.
210 if ($merr =~ /\.php\?\S*?=http:\/\/([^\/]+)/) {
211 if (!check_hosts($settings{"CHK_GOOD_HOSTS"}, $1)) {
212 check_add_entry($mip, $mdate, "PHP XSS: $merr", $settings{"CHK_PHP_XSS"});
213 }
214 }
215 # (4) Try to match proxy scanning attempts
216 elsif ($merr =~ /^http:\/\/([^\/]+)/) {
217 if (!check_hosts($settings{"CHK_GOOD_HOSTS"}, $1)) {
218 check_add_entry($mip, $mdate, "Proxy scan: $merr", $settings{"CHK_PROXY_SCAN"});
219 }
220 }
221 }
222 }
223
224
225 ###
226 ### Utility functions
227 ###
228 my %logfile = ();
229
230 sub malt_init {
231 foreach my $logname (@scanfiles) {
232 local *INFILE;
233 mlog(0, "- Parsing ".$logname." ...\n");
234 open(INFILE, "<", $logname) or die("Could not open '".$logname."'!\n");
235 $logfile{$logname} = *INFILE;
236 while (<INFILE>) {
237 chomp;
238 check_log_line($_);
239 }
240 }
241
242 mlog(0, "- Weeding out old entries.\n");
243 weed_entries();
244 }
245
246 sub malt_cleanup {
247 mlog(0, "- Closing open filehandles.\n");
248 foreach my $logname (keys %logfile) {
249 close($logfile{$logname});
250 }
251 }
252
253 sub malt_scan {
254 ### Keep on reading
255 mlog(1, "- Entering main scanning loop.\n");
256 my $counter = 0;
257 while (1) {
258 my %logpos = ();
259 foreach my $logname (keys %logfile) {
260 for ($logpos{$logname} = tell($logfile{$logname}); $_ = <$logfile{$logname}>; $logpos{$logname} = tell($logfile{$logname})) {
261 chomp;
262 check_log_line($_);
263 }
264 }
265 sleep(5);
266 if ($counter++ >= 5) {
267 # Every once in a while, update known IP list from iptables
268 # (in case entries have appeared there from "outside")
269 # and perform weeding of old entries.
270 $counter = 0;
271 update_iplist(time());
272 weed_entries();
273 }
274 foreach my $logname (keys %logfile) {
275 seek($logfile{$logname}, $logpos{$logname}, 0);
276 }
277 }
278 }
279
280 sub malt_finish {
281 # Unlink pid-file
282 if ($pid_file ne "" && -e $pid_file) {
283 unlink $pid_file;
284 }
285 # Close logfile
286 close($LOGFILE) if (defined($LOGFILE));
287 undef($LOGFILE);
288 }
289
290 sub malt_int {
291 mlog(-1, "\nCaught Interrupt (^C), aborting.\n");
292 malt_cleanup();
293 malt_finish();
294 exit(1);
295 }
296
297 sub malt_term {
298 mlog(-1, "Receinved TERM, quitting.\n");
299 malt_cleanup();
300 malt_finish();
301 exit(1);
302 }
303
304 sub malt_hup {
305 mlog(-1, "Received HUP, reinitializing.\n");
306 malt_cleanup();
307 malt_init();
308 mlog(-1, "Reinitialization finished, resuming scanning.\n");
309 }
310
311
312 ###
313 ### Main program
314 ###
315 # Setup signal handlers
316 $SIG{'INT'} = 'malt_int';
317 $SIG{'TERM'} = 'malt_term';
318 $SIG{'HUP'} = 'malt_hup';
319
320 # Banner
321 my $argc = $#ARGV + 1;
322 if ($argc < 1) {
323 print $progbanner.
324 "\nUsage: maltfilter <pid filename> [config filename]\n";
325 exit;
326 }
327
328 # Test pid file existence
329 $pid_file = shift;
330 die("'$pid_file' already exists, not starting.\nIf the daemon is NOT running, remove the pid-file and re-start.\n") if (-e $pid_file);
331
332 # Read configuration file
333 if (defined(my $config_file = shift)) {
334 my $errors = 0;
335
336 # Let user define his/her own logfiles to scan
337 undef(@scanfiles_def);
338
339 open(CONFFILE, "<", $config_file) or die("Could not open configuration '".$config_file."'!\n");
340 while (<CONFFILE>) {
341 chomp;
342 if (/(^\s*#|^\s*$)/) {
343 # Ignore comments and empty lines
344 } elsif (/^\s*\"?([a-zA-Z0-9_]+)\"?\s*=>?\s*(\d+),?\s*$/) {
345 my $key = uc($1);
346 my $value = $2;
347 if (defined($settings{$key})) {
348 $settings{$key} = $value;
349 } else {
350 print STDERR "Unknown setting '$key' = $value\n";
351 $errors = 1;
352 }
353 } elsif (/^\s*\"?([a-zA-Z0-9_]+)\"?\s*=>?\s*\"(.*?)\",?\s*$/) {
354 my $key = uc($1);
355 my $value = $2;
356 if ($key eq "SCANFILE") {
357 push(@scanfiles_def, $value);
358 } elsif (defined($settings{$key})) {
359 $settings{$key} = $value;
360 } else {
361 print STDERR "Unknown setting '$key' = '$value'\n";
362 $errors = 1;
363 }
364 } else {
365 print STDERR "Syntax error: $_\n";
366 $errors = 1;
367 }
368 }
369 close(CONFFILE);
370 die("Errors in configuration file '$config_file', bailing out.\n") unless ($errors == 0);
371 }
372
373 # Clean up scanfiles from duplicate entries
374 my %saw = ();
375 @scanfiles = grep(!$saw{$_}++, @scanfiles_def);
376
377
378 # Open logfile
379 if ($settings{"DRY_RUN"}) {
380 print $progbanner.
381 "*********************************************\n".
382 "* NOTICE! DRY-RUN MODE ENABLED! No changes *\n".
383 "* will actually get committed to netfilter! *\n".
384 "*********************************************\n";
385 } elsif ($settings{"LOGFILE"} ne "") {
386 open($LOGFILE, ">>", $settings{"LOGFILE"}) or die("Could not open logfile '".$settings{"LOGFILE"}."' for writing!\n");
387 mlog(-1, "Log started\n");
388 }
389
390
391 # Initialize
392 update_iplist(-1);
393 malt_init();
394
395 # Fork to background, unless dry-running
396 if ($settings{"DRY_RUN"}) {
397 malt_scan();
398 malt_cleanup();
399 } else {
400 if (my $pid = fork) {
401 open(PIDFILE, ">", $pid_file) or die("Could not open pid file '".$pid_file."' for writing!\n");
402 print PIDFILE "$pid\n";
403 close(PIDFILE);
404 } else {
405 malt_scan();
406 malt_cleanup();
407 }
408 }