Mercurial > hg > maltfilter
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 } |