#!/usr/bin/perl -w use strict; use strict 'refs'; use Data::Dumper; use Getopt::Long; =head1 NAME B - A "Web Knocker" application. =head1 SYNOPSIS B -s|--sequence=SEQUENCE [OPTIONS] =head1 DESCRIPTION This application extrapolates the concept of "port knocking" to URLs, where a pre-chosen sequence of paths must be accessed on the current server to activate a script. The paths themselves do not need to exist in the server, as the program constantly "tails" the access.log output from Apache looking for the hits. Arguments =over 4 =item B<-s, --sequence=SEQ> Set the sequence to be used for web knocking. Sequence items can be separated by colons. Only the path part of the URI matters here. S =item B<-t, --timeout=NUM> Timeout (in seconds) to expire the sequence. The user has a total time of NUM seconds to continue since the previous token in the sequence. S =item B<-l, --logfile=LOGFILE> The name of apache's "access.log" logfile. S =item B<--ttl=NUM> Number of seconds after which the "close" command is executed. Essentially, if you're using iptables as your "open" command (the default), it will close the port after this time. S =item B<-o, --open=CMD> =item B<-c, --close=CMD> Commands that will be executed when a successful knock is detected and after 'TTL' seconds have elapsed, respectively. The '%HOST%' string will be substituted with the IP of the knocking client. Defaults to iptables commands opening and closing port 22 for the client's IP, respectively. =item B<-i, --ignore> List of CIDR addresses to ignore, separated by colons. Example: S<"127.0.0.1/8:192.168.0.0/16".> =item B<-v, --verbose> Increases verbosity. Can be used up to three times. =item B<-h, --help> Guess? :) =back =head1 AUTHOR Marco Paganini . See also http://www.paganini.net for more info. =head1 BUGS Not exactly a bug, but the program expects the "standard" LCF as the input from Apache. =head1 VERSION $Id: webknock 21 2005-09-06 18:24:01Z paganini $ =cut ## No debugging, initially. my $Debug = 0; use constant TIMEOUT => 120; my $wkd = { sequence => [], timeout => 120, # Total time to hit the entire sequence logfile => "/var/log/apache/access.log", # default logfile (Debian) ttl => 86400, # How long should the port remain open (secs) open_cmd => "iptables -I INPUT -p tcp -s \%HOST\% --dport 22 -j ACCEPT", # CMD to open the port close_cmd => "iptables -D INPUT -p tcp -s \%HOST\% --dport 22 -j ACCEPT", # CMD to close the port ignore => [], status => {}, }; my $filesize; my $newsize; my $ip; my $url; cmdline($wkd, $ARGV); ## Change $0 to hide knock sequence $0 = "webknock: Running..."; #----------------------------------------------------------------------------- open(FH, $wkd->{logfile}) || die "Cannot open $wkd->{logfile}: $!"; $filesize = (stat(FH))[7]; ## Ignore previous information by reading file until the end while () { }; for (;;) { ## Tail the file while () { chomp; ($ip, $url) = parse_line($_); if ($ip && !ignore_ip($wkd, $ip)) { if (check_status($wkd, $ip, $url)) { if (exec_cmd($wkd->{open_cmd}, $ip) != 0) { debug(1, "IP: $ip. Error executing command. Sequence reset."); delete($wkd->{status}->{$ip}); } } } } ## Expire old entries in the 'status' hash (and close ports) expire_status($wkd); ## If file was removed, renamed, or size is smaller than previous pass, ## close and reopen it (making sure the file exists before we reopen) $newsize = (stat(FH))[7]; if ((stat(FH))[3] == 0 || ## Removed? $newsize < $filesize || ## Truncated? (stat(FH))[1] != (stat($wkd->{logfile}))[1]) ## moved to another name? { ## nlinks == 0? debug(2, "File truncated, removed or renamed...\n"); close(FH); while ( ! -s $wkd->{logfile} ) { sleep 1 }; open(FH, $wkd->{logfile}) || die "Cannot open $wkd->{logfile}: $!"; $newsize = $filesize = (stat(FH))[7]; } $filesize = $newsize; sleep 1; seek(FH, 0, 1); } #----------------------------------------------------------------------------- # parse_line(line) - Parses the incoming lines # # Parses the incoming line and returns the array (ip, url), if found. # Otherwise, returns (undef,undef). # # This function expects a Common Logfile Format input line. #----------------------------------------------------------------------------- sub parse_line { my $str = shift; # 127.0.0.1 - - [26/Aug/2005:17:03:57 -0400] "GET /favicon.ico # HTTP/1.1" 404 285 "-" "Mozilla/5.0 (X11 ; U; Linux i686; en-US; # rv:1.7.8) Gecko/20050517 Firefox/1.0.4 StumbleUpon/1.9995 (Debian # package 1. 0.4-2)" if ($str =~ m/((\d{0,3}\.){3}\d{0,3}) (.*) (.*) \[(.*)\] \"GET ([^\s]+) .*/o) { debug(3, "Found IP=\"$1\", URL=\"$6\""); return($1, $6); } else { return(undef, undef); } } #----------------------------------------------------------------------------- # check_status(line) - Checks and maintains the 'status' hash. # # The 'status' hash is used by the state machine to control the state # machine inside this program. This function uses the passed IP and URL # to maintain the hash. # # Returns: # 1 - A successful knock has been identified for the given IP. # 0 - No successful knocks have been identified. #----------------------------------------------------------------------------- sub check_status { my $wkd = shift; my $ip = shift; my $url = shift; my $status = $wkd->{status}; my $sequence = $wkd->{sequence}; ## Rules: ## - If the status is established, reset its time to now. ## - Sequence must be hit within a 2 minute interval ## - Anything not on sequence will be ignored. ## - Wrong item on sequence will reset it. if (exists($status->{$ip})) { ## Already an 'established' connection? if ($status->{$ip}->{established}) { if ($$sequence[0] eq $url) { debug(1, "$ip: URL=$url. Connection refreshed."); $status->{$ip}->{timestamp} = time; } } ## Status matches the next expected one? elsif ($status->{$ip}->{next} ne $url) { debug(2, "$ip: URL=$url. I got \"$url\", but I was expecting \"$status->{$ip}->{next})\". Ignoring..."); } ## Too old? elsif (($status->{$ip}->{timestamp} + TIMEOUT) < time) { debug(1, "$ip: URL=$url. Sequence is too old. Resetting."); delete($status->{$ip}); } else { ## At this point, the sequence matches and the timeout is OK. ## If we're at the end of the sequence, return "true" and ## remove this status entry. Otherwise, just advance the status if ($status->{$ip}->{seqnum} == (scalar(@$sequence) - 1)) { debug(1, "$ip: URL=\"$url\". Sequence match."); $status->{$ip}->{established} = 1; return 1; } else { $status->{$ip}->{seqnum}++; $status->{$ip}->{next} = $$sequence[$status->{$ip}->{seqnum}]; $status->{$ip}->{timestamp} = time; debug(1, "$ip: URL=\"$url\". Sequence advanced. Next: \"$status->{$ip}->{next}\""); } } } else { ## We create a new status if the initial "knock" is right. if ($$sequence[0] eq $url) { $status->{$ip}->{seqnum} = 1; $status->{$ip}->{next} = $$sequence[1]; $status->{$ip}->{timestamp} = time; $status->{$ip}->{established} = 0; debug(1, "$ip: URL=\"$url\". New sequence created. Next \"$status->{$ip}->{next}\""); } } return 0; } #----------------------------------------------------------------------------- # expire_status - Expire connections older than TTL # # This function reads the 'status' hash and issues the "close_cmd" for # every established connection that is older than TTL (usually, a day). #----------------------------------------------------------------------------- sub expire_status { my $wkd = shift; my $status = $wkd->{status}; my $ttl = $wkd->{ttl}; foreach my $ip (keys %$status) { if ($status->{$ip}->{established} && $status->{$ip}->{timestamp} < (time - $ttl)) { debug(1, "$ip: Expired after $ttl seconds."); exec_cmd($wkd->{close_cmd}, $ip); delete($status->{$ip}); } } #sleep 2; } #----------------------------------------------------------------------------- # exec_cmd(command, ip) # # Executes the command passed, substituting %HOST% for the real IP. # Returns the return code from the command, shifted right 8 bits # (the retcode as seen from the shell). #----------------------------------------------------------------------------- sub exec_cmd { my $cmd = shift; my $ip = shift; $cmd =~ s/\%HOST\%/$ip/g; debug(1, "About to execute: \"$cmd\""); system($cmd); if ($? == -1) { debug(1, "Failed to execute command: $!"); } elsif ($? & 127) { debug(1, sprintf("Child died with signal %d, %s coredump\n", ($? & 127), ($? & 128) ? 'with' : 'without')); } else { debug(1, sprintf("Child exited with value %d", $? >> 8)); } return($? >> 8); } #----------------------------------------------------------------------------- # ignore_ip - Checks if a given IP should be ignored. # # Checks for the passed IP against the list of IPs to be ignored. # Returns 1 if a match is found, 0 otherwise. #----------------------------------------------------------------------------- sub ignore_ip { my $wkd = shift; my $ip = shift; foreach my $var (@{$wkd->{ignore}}) { print "Found: [$var]\n"; if (ipmatch($var, $ip)) { debug(2, "$ip: matches \"$var\" in the list of IPs to be ignored."); return 1; } } return 0; } #----------------------------------------------------------------------------- # ipmatch - Matches a CIDR and an IP # # This function will match a CIDR (a.b.c.d/nn) with a given IP. # Returns 1 if there is a match, 0 otherwise. #----------------------------------------------------------------------------- sub ipmatch { my $cidr = shift; my $ip = shift; my $long_ip; my $long_cidr; my $bitmask; my $numbits; ## CIDR if ($cidr =~ m/^(([3-9]\d?|[01]\d{0,2}|2\d?|2[0-4]\d|25[0-5])\.){3} ([3-9]\d?|[01]\d{0,2}|2\d?|2[0-4]\d|25[0-5])\/ ([3-9]|[12]\d?|3[12])/gox) { ## Convert to numerical form if ($cidr =~ m/(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)/o) { $long_cidr = $1 + ($2 << 8) + ($3 << 16) + ($4 << 24); $numbits = $5; } else { die "Cannot parse CIDR. Looks like a problem in the code"; } } else { die "CIDR should be in the format a.b.c.d/nn"; } ## IP if ($ip =~ m/^(([3-9]\d?|[01]\d{0,2}|2\d?|2[0-4]\d|25[0-5])\.){3} ([3-9]\d?|[01]\d{0,2}|2\d?|2[0-4]\d|25[0-5])/gox) { ## Convert to numerical form if ($ip =~ m/(\d+)\.(\d+)\.(\d+)\.(\d+)/o) { $long_ip = $1 + ($2 << 8) + ($3 << 16) + ($4 << 24); } else { die "Cannot parse IP. Looks like a problem in the code"; } } else { die "IP should be in the format a.b.c.d"; } ## Generate bitmask $bitmask = ((2**($numbits)) - 1); ## CIDR will contain network bits only $long_cidr &= $bitmask; #printf "Long CIDR = %08.8x\n", $long_cidr; #printf "Long IP = %08.8x\n", $long_ip; #printf "Long IP = %08.8x\n", ($long_ip & $bitmask); #printf "Bitmask = %08.8x\n", $bitmask; if (($long_ip & $bitmask) == $long_cidr) { return 1; } else { return 0; } } #----------------------------------------------------------------------------- sub cmdline { my $wkd = shift; my $sequence = undef; my $ignore = undef; my $help = 0; my $res = GetOptions( "s|sequence=s" => \$sequence, "t|timeout=i" => \$wkd->{timeout}, "l|logfile=s" => \$wkd->{logfile}, "ttl=i" => \$wkd->{ttl}, "o|open=s" => \$wkd->{open_cmd}, "c|close=s" => \$wkd->{close_cmd}, "i|ignore=s" => \$ignore, "v|verbose+" => \$Debug, "h|help" => \$help); if (! $res || $help) { usage(); exit(1); } ## Split strings into proper arrays. @{$wkd->{sequence}} = split(/:/,$sequence) if defined($sequence); @{$wkd->{ignore}} = split(/:/,$ignore) if defined($ignore); ## Sequence is mandatory... if (! $sequence) { print STDERR "ERROR: Sequence MUST be specified! Try \"--help\" for more details.\n"; exit 1; } } #----------------------------------------------------------------------------- sub usage { print STDERR <= $level) { print STDERR "webknock($$): ",scalar(localtime(time)),": $str\n"; } } # vim: ts=4:sw=4