############################################################################### # SPDX-License-Identifier: GPL-3.0-only # PURPOSE: Nikto core functionality ############################################################################### sub change_variables { my ($line, $mark, $checkid, $skip_lfi) = @_; # If no mark provided, use global mark $mark = $mark || $::mark; # If no variables to expand, check for LFI before returning my $at_index = index($line, '@'); if ($at_index == -1) { return ($line); } # Use cached $shname for efficiency my $shname = $mark->{'hostname'} || $mark->{'ip'}; my @subtests; # Replace JUNK(n) with random string $line =~ s/\@JUNK\((\d+)\)/LW2::utils_randstr($1)/e; # Replace static variables $line =~ s/\@IP/$mark->{'ip'}/g; $line =~ s/\@HOSTNAME/$shname/g; # Phase 1: Expand all non-LFI variables recursively # Keep expanding until no more non-LFI variables remain if (index($line, '@') == -1) { push @subtests, $line; } else { # Check for non-LFI variables and expand them my $found_variable = 0; foreach my $varname (keys %VARIABLES) { next if $varname =~ /^\@LFI/; next unless index($line, $varname) != -1; if ($line =~ /\Q$varname\E/) { # Expand this variable: split by whitespace and recursively process each value foreach my $value (split(/\s+/, $VARIABLES{$varname})) { my $cooked = $line; $cooked =~ s/\Q$varname\E/$value/g; # Recursively expand variables in the cooked line (skip LFI processing in recursive calls) my @expanded = change_variables($cooked, $mark, $checkid, 1); push @subtests, @expanded; } $found_variable = 1; last; # Break out of foreach loop after handling first variable found } } if (!$found_variable) { push(@subtests, $line); } } # Phase 2: Handle LFI (only if not skipping) if ($skip_lfi) { # In recursive call, just return the subtests without processing LFI return @subtests; } # Now that other expansions are done, handle LFI my @tests; foreach my $subtest (@subtests) { if ($subtest =~ /\@LFI\(([^)]*)\)/) { my $args = $1 || ''; my @temp_tests = lfi_function($subtest, $args, $checkid, $mark); push @tests, @temp_tests; } else { push @tests, $subtest; } } return @tests; } sub lfi_function { my ($test, $args, $checkid, $mark) = @_; my @options = split(/,/, $args); my @lfitests; # Get detected/forced platform (default to 'all' if not set) my $detected_platform = ($mark && $mark->{'platform'}) ? $mark->{'platform'} : 'all'; # Determine platform and path settings from test options my $is_nix = grep(/nix/i, @options); my $is_win = grep(/win/i, @options); my $use_url = grep(/url/i, @options); my $use_abs = grep(/abs/i, @options); # Determine which platforms to generate tests for my @platforms; my $test_specifies_platform = ($is_nix || $is_win); if ($test_specifies_platform) { # Test explicitly specifies platform(s) # Only run if platform matches or platform is 'all' if ($detected_platform eq 'all') { # Platform is 'all' - respect test's specification if ($is_nix && !$is_win) { push @platforms, 'nix'; } elsif ($is_win && !$is_nix) { push @platforms, 'win'; } else { # Both specified in test - generate both push @platforms, 'nix', 'win'; } } elsif ( ($is_nix && $detected_platform eq 'nix') || ($is_win && $detected_platform eq 'win')) { # Platform matches test specification - use test's choice if ($is_nix && !$is_win) { push @platforms, 'nix'; } elsif ($is_win && !$is_nix) { push @platforms, 'win'; } else { # Both specified in test - but only one matches platform push @platforms, $detected_platform; } } else { # Platform doesn't match test specification - return empty (no tests) return @lfitests; # Return empty array } } else { # Test doesn't specify platform - use detected platform if ($detected_platform eq 'all') { push @platforms, 'nix', 'win'; } elsif ($detected_platform eq 'nix') { push @platforms, 'nix'; } elsif ($detected_platform eq 'win') { push @platforms, 'win'; } else { # Unknown platform, default to both push @platforms, 'nix', 'win'; } } # Get depth (same for both platforms) my $depth = $VARIABLES{'@LFIDEPTH'} || 5; $depth =~ s/^\s+|\s+$//g; $depth = int($depth) if $depth =~ /^\s*\d+\s*$/; $depth = 5 if $depth < 1 || $depth > 20; # Sanity check # Generate tests for each platform foreach my $platform (@platforms) { my $is_nix_platform = ($platform eq 'nix'); # Get LFI variables (trim whitespace) my $target = $is_nix_platform ? ($VARIABLES{'@LFITGTNIX'} || '/etc/hosts') : ($VARIABLES{'@LFITGTWIN'} || '\\Windows\\win.ini'); $target =~ s/^\s+|\s+$//g; # Build the full path my $full_path; if ($use_abs) { # 'abs' means skip traversal, use absolute path directly $full_path = $target; # Ensure it starts with / for nix or \ for win if ($is_nix_platform) { $full_path = '/' . $full_path unless $full_path =~ /^\//; } else { # For Windows, ensure it starts with \ (unless it has a drive letter) $full_path = '\\' . $full_path unless $full_path =~ /^[A-Za-z]:|^\\/; } } else { # Build traversal sequence my $path = $is_nix_platform ? ($VARIABLES{'@LFIPATHNIX'} || '../') : ($VARIABLES{'@LFIPATHWIN'} || '..\\'); $path =~ s/^\s+|\s+$//g; my $traversal = $path x $depth; # Fix double slash if test is exactly @LFI(...) if ($test =~ /^\@LFI\([^)]*\)$/) { # Remove trailing / from traversal or leading / from target to avoid // if ($is_nix_platform && $traversal =~ /\/$/ && $target =~ /^\//) { $traversal =~ s/\/$//; } elsif (!$is_nix_platform && $traversal =~ /\\$/ && $target =~ /^\\/) { $traversal =~ s/\\$//; } } $full_path = $traversal . $target; } # Apply URL encoding if requested (do this after absolute path handling) if ($use_url) { $full_path =~ s/([^A-Za-z0-9])/sprintf("%%%02X", ord($1))/seg; } # Replace @LFI(...) in the test string with the generated path my $expanded_test = $test; $expanded_test =~ s/\@LFI\([^)]*\)/$full_path/g; push @lfitests, $expanded_test; } return @lfitests; } ############################################################################### sub unslash { my $line = $_[0] || return; # $line is the slash-escaped variable # Early return for empty strings return $line if $line eq ''; # Use a single regex with eval to handle all escape sequences at once $line =~ s/\\([abefnrt])|\\x([[:xdigit:]]{2})/defined($1) ? eval("qq{\\$1}") : chr(hex($2))/ge; return $line; } ############################################################################### sub is_404 { my ($mark, $uri, $response) = @_; return 0 unless defined $uri; return 0 unless defined $mark; return 0 unless defined $response; my $debug_404 = 0; my $code = $response->{'whisker'}{'code'}; if ($debug_404) { print "--------------------------------\n"; print "404: URI is: $uri\n"; print "404: URI code is: $code\n"; } # Check user-specified error codes first (highest priority) if (defined $VARIABLES{'ERRCODES'} && ref($VARIABLES{'ERRCODES'}) eq 'HASH') { if (exists $VARIABLES{'ERRCODES'}->{$code}) { print "404: 1\n" if $debug_404; return 1; } } # Check user-specified error strings (second priority, before expensive checks) if (defined $VARIABLES{'ERRSTRINGS'} && ref($VARIABLES{'ERRSTRINGS'}) eq 'HASH') { foreach my $pattern (keys %{ $VARIABLES{'ERRSTRINGS'} }) { if ($response->{'content'} =~ /$pattern/) { print "404: 2\n" if $debug_404; return 1; } } } # Trust 404, 406, and 410 codes if ($code =~ /^40[46]$/ || $code eq '410') { print "404: 3\n" if $debug_404; return 1; } # remove and capture the query string my $query_string = ""; if ($uri =~ /\?(.*)$/) { $query_string = $1; $uri =~ s/\?(.*)$//; } my @uri_parts = split("/", $uri); # If we don't have a real path (probably /) just return 0 # Filter out empty URI, just "/", or all empty parts if ($uri eq "" || $uri eq "/" || (scalar(@uri_parts) > 0 && !grep { $_ ne "" } @uri_parts)) { return 0; } my $ext = get_ext($uri); # Build base path (all parts except the last one) my @base_parts = @uri_parts; pop @base_parts if scalar(@base_parts) > 0; my $base_path = join("/", @base_parts); # Ensure base_path ends with / if it's not empty (for proper path construction) if ($base_path ne "" && $base_path ne "/") { $base_path .= "/"; } elsif ($base_path eq "") { $base_path = "/"; } # Determine the suffix pattern based on extension type my $suffix_pattern = ""; if ($ext eq "DIRECTORY") { $suffix_pattern = "/"; } elsif ($ext eq "DOTFILE") { $base_path .= "."; } elsif ($ext eq "NONE") { } else { $suffix_pattern = "." . $ext; } # Build cache key using "RAND" placeholder my $cache_key = $base_path . "RAND" . $suffix_pattern; # Check cache before making request my $cached_entry = $mark->{'nf_cache'}{$cache_key}; my ($err_res, $err_content, $err_error, $err_request, $err_response); if (defined $cached_entry) { print "404: Cached Entry\n" if $debug_404; # Use cached response data $err_res = $cached_entry->{'code'}; $err_content = ""; # Content not cached $err_error = ""; $err_request = {}; $err_response = { 'code' => $cached_entry->{'code'}, 'location' => exists $cached_entry->{'location'} ? $cached_entry->{'location'} : '' }; } else { print "404: No Cached Entry\n" if $debug_404; # Build nf_path by replacing "RAND" with actual random string my $rand_str = LW2::utils_randstr(8); my $nf_path = $base_path . $rand_str . $suffix_pattern; $nf_path .= "?" . $query_string if $query_string ne ""; ($err_res, $err_content, $err_error, $err_request, $err_response) = nfetch($mark, $nf_path, "GET", "", "", "", "is_404"); # Determine mode, type, match, and location my %response_map = (200 => "OK", 300 => "REDIR", 301 => "REDIR", 302 => "REDIR", 303 => "REDIR", 307 => "REDIR", 401 => "STD", 403 => "STD", 404 => "STD", 406 => "STD", 410 => "STD" ); my $mode = $response_map{$err_res} || "OTHER"; my $cache_entry = { 'code' => $err_res, 'mode' => $mode }; print "404: $nf_path returned code: $err_res\n" if $debug_404; print "404: mode is: $mode\n" if $debug_404; # Handle redirects - store location if present if ($err_response && $err_response->{'location'} ne '') { $cache_entry->{'location'} = get_base_host($err_response->{'location'}); } # Only determine type for OK/OTHER modes (STD and REDIR don't need type) if ($mode eq "OK" || $mode eq "OTHER") { if (length($err_content) == 0) { print "404: Type is BLANK\n" if $debug_404; $cache_entry->{'type'} = "BLANK"; $cache_entry->{'match'} = ""; } else { print "404: Type is HASH\n" if $debug_404; $cache_entry->{'type'} = "HASH"; $cache_entry->{'match'} = LW2::md5(rm_active_content($err_content, $nf_path)); print "404: Match is: $cache_entry->{'match'}\n" if $debug_404; } } # Store in cache $mark->{'nf_cache'}{$cache_key} = $cache_entry; } # Now determine if the actual response matches the "not found" pattern # Get the cached entry (should always be defined at this point) my $nf_entry = $mark->{'nf_cache'}{$cache_key}; return 0 unless defined $nf_entry; my $nf_mode = $nf_entry->{'mode'}; my $actual_code = $response->{'whisker'}{'code'}; # From the actual response being checked my $actual_content = $response->{'whisker'}{'data'} || ""; my $actual_location = $response->{'location'} || ""; # Check STD mode first (most common case - fastest check) if ($nf_mode eq "STD") { if ($actual_code =~ /^4\d\d$/) { print "404: 4\n" if $debug_404; return 1; } return 0; } # Check REDIR mode if ($nf_mode eq "REDIR") { if ($actual_location ne '' && exists $nf_entry->{'location'}) { my $actual_base = get_base_host($actual_location); if ($actual_base eq $nf_entry->{'location'}) { print "404: 5\n" if $debug_404; return 1; } } return 0; } # Check BLANK type (for OK/OTHER modes) if (exists $nf_entry->{'type'} && $nf_entry->{'type'} eq "BLANK") { if (length($actual_content) == 0) { print "404: 6\n" if $debug_404; return 1; } return 0; } # Check HASH type (most expensive, check last) if (exists $nf_entry->{'type'} && $nf_entry->{'type'} eq "HASH") { print "404: Checking HASH type\n" if $debug_404; print "404: nf_entry hash is: $nf_entry->{'match'}\n" if $debug_404; if (exists $nf_entry->{'match'} && $nf_entry->{'match'} ne '') { print "404: in exists\n" if $debug_404; if (length($actual_content) > 0) { print "404: length is > 0\n" if $debug_404; my $clean_content = rm_active_content($actual_content, $uri); print "404: Hash comparison is: \n" if $debug_404; print " " . LW2::md5($clean_content) . "\n" if $debug_404; print " " . $nf_entry->{'match'} . "\n" if $debug_404; if (LW2::md5($clean_content) eq $nf_entry->{'match'}) { print "404: 7\n" if $debug_404; return 1; } } else { print "length is 0\n" if $debug_404; } } print "404: 8\n" if $debug_404; return 0; } # If we get here, the cached entry doesn't match any known pattern print "404: 9\n" if $debug_404; return 0; } ############################################################################### sub scrub { # line to scrub my $line = shift; for my $val (@_) { next if $val eq ""; # Create a copy to avoid modifying read-only values my $val_copy = $val; # remove IPv6 brackets if present $val_copy =~ s/^\[([^\]]+)\]$/$1/; $val_copy = validate_and_fix_regex($val_copy); my ($validip, $internal, $loopback) = is_ip($val_copy); if ($validip) { if ($val_copy =~ /^$LW2::IPv6_re$/) { $line =~ s/$val_copy/\:\:/g; } else { $line =~ s/$val_copy/0.0.0.0/g; } } else { $line =~ s/$val_copy/example.com/ig; } } return $line; } ############################################################################### sub nprint { my ($line, $mode, $testid) = @_; chomp($line); # Deferred output? if ($VARIABLES{'deferout'}) { push @{ $VARIABLES{'defertxt'} }, $mode . "::" . ($testid // '') . "::" . $line; return; } # scrub values - only pass scrub values (everything after $line and $mode) if ($OUTPUT{'scrub'}) { my @scrub_values = @_[ 3 .. $#_ ]; $line = scrub($line, @scrub_values); } # don't print debug & verbose to output file... if ($mode ne '') { my %output_flags = ('d' => 'debug', 'v' => 'verbose', 'e' => 'errors'); if (exists $output_flags{$mode} && $OUTPUT{ $output_flags{$mode} }) { my $prefix = $mode eq 'd' ? "D:" : $mode eq 'v' ? "V:" : "E:"; my $output = $mode eq 'd' ? \*STDERR : \*STDOUT; my $testid_str = defined $testid ? "[$testid]" : "[000000]"; print $output $prefix . localtime() . " $testid_str - $line\n"; } return; } # print errors to STDERR if ($line =~ /^\t?\+ ERROR:/) { print STDERR "$line\n"; return; } # don't print to STDOUT if output file is "-" return if defined $CLI{'file'} && $CLI{'file'} eq "-"; $line =~ s/(CVE\-[12][0-9]{3}-[0-9]{4,5})/https:\/\/cve.mitre.org\/cgi-bin\/cvename.cgi?name\=$1/g; $line =~ s/(CA\-[12][0-9]{3}-[0-9]{2})/https:\/\/www.cert.org\/advisories\/$1.html/g; $line =~ s/(MS([0-9]{2})\-[0-9]{3})/https\:\/\/docs\.microsoft\.com\/en-us\/security-updates\/securitybulletins\/20$2\/$1/gi; print $line . "\n"; return; } ############################################################################### sub get_ext { my $uri = $_[0] || return; return "DIRECTORY" if $uri =~ /\/$/; $uri =~ s/^.*\///; return "DOTFILE" if $uri =~ /^\.[^.%]/; $uri =~ s/[?&%;\|].*$//; return "NONE" if index($uri, '.') == -1; $uri =~ s/\@[A-Z]+(\([^\)]*\))?//; # remove variables and functions $uri =~ s/".*$//; $uri =~ s/^.*\.//; return $uri; } ############################################################################### sub status_report { my ($mark) = shift; my $line; # without this we could face a div by 0 error if ( $COUNTERS{'totalrequests'} eq 0 || $COUNTERS{'total_checks'} eq 0 || $COUNTERS{'total_targets'} eq 0) { nprint("- STATUS: Starting up!"); return; } my $secleft = ((time() - $COUNTERS{'scan_start'}) / $COUNTERS{'totalrequests'}) * (($COUNTERS{'total_checks'} * $COUNTERS{'total_targets'}) - $COUNTERS{'totalrequests'}); my $timeleft; if ($secleft > 60) { my $minleft = $secleft / 60; $timeleft = sprintf("%.1f minutes", $minleft); if ($minleft > 60) { my $hrsleft = $minleft / 60; $timeleft = sprintf("%.1f hours", $hrsleft); } } else { $timeleft = sprintf("%.0f seconds", $secleft); } my $perc_compl = ($COUNTERS{'totalrequests'} / ($COUNTERS{'total_checks'} * $COUNTERS{'total_targets'}) * 100); $line = "- STATUS: Completed $COUNTERS{'totalrequests'} requests"; if ($COUNTERS{'total_targets'} > 1) { $line .= " (target " . ($COUNTERS{'hosts_completed'} + 1) . "/$COUNTERS{'total_targets'})"; } if (($perc_compl < 100) && ($secleft > 0)) { $line .= sprintf(" (~%.0f%% complete, ~$timeleft left)", $perc_compl); } if ($NIKTO{'current_plugin'} ne '') { $line .= ": currently in plugin '$NIKTO{'current_plugin'}'"; } nprint($line); nprint("- STATUS: " . running_average_print($mark)); return; } ############################################################################### sub date_disp { my $t = $_[0] || return; my @time = localtime($t); my $result = sprintf("%d-%02d-%02d %02d:%02d:%02d", $time[5] + 1900, $time[4] + 1, $time[3], $time[2], $time[1], $time[0]); return $result; } ############################################################################### sub get_base_host { my $uri = $_[0] || return; # uri, protocol, host, port, params, frag, user, password. my @hd = LW2::uri_split($uri); my $base = $hd[1] . "://" . $hd[2]; if (($hd[3] != 80) && ($hd[3] != 443)) { $base .= ":" . $hd[3]; } $base .= "/"; return $base; } ############################################################################### sub rm_active_content { # Try to remove active content which could mess up the file's signature my ($cont, $file) = @_; return "" if (length($cont) == 0); # Dates/Times $cont =~ s/[12]\d{3}[-.\/][1-3]?\d[-.\/][1-3]?\d//g; # 2001-12-12 $cont =~ s/[1-3]?\d[-.\/][1-3]?\d\d[-.\/][12]\d{3}//g; # 12-12-2002 $cont =~ s/\d{8,14}//g; # timestamp $cont =~ s/\d{6}//g; # timestamp $cont =~ s/\d{2}:\d{2}(?::\d{2})?//g; # 12:11:33 $cont =~ s/(?:mon|tue|wed|thu|fri|sat|sun)(?:day)?,? [1-3]?[0-9] (?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)//ig; $cont =~ s/[12][0-9]{3}\s?(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s?[1-3]?[0-9]//gi ; # 2009 jan 29 $cont =~ s/[1-3]?[0-9]\s?(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[, ]?(?:[12][0-9]{3})?//gi ; # 29 Jan 2009 $cont =~ s/[\d.]+ (?:second|queries)//gi; # page load time # URI, if provided, plus encoded versions of it # $_[1] has unescaped file name, and $file has escaped. use appropriate one! if ($file ne '') { $file = quotemeta($file); $cont =~ s/$file//g; # base 64 my $e = LW2::encode_base64($_[1]); $cont =~ s/$e//gs; # hex encoded $e = LW2::encode_uri_hex($_[1]); $cont =~ s/$e//gs; # unicode encoded $e = LW2::encode_unicode($_[1]); $e = quotemeta($e); $cont =~ s/$e//gs; # url encoding, full url $e = $_[1]; $e =~ s/([^A-Za-z0-9])/sprintf("%%%02X", ord($1))/seg; $cont =~ s/$e//gs; # url encoding, query portion if ($file =~ /\?(.*$)/) { my $qs = $1; # match pages which link to themselves w/diff args $cont =~ s/$qs//gs; # url encoded $qs =~ s/([^A-Za-z0-9])/sprintf("%%%02X", ord($1))/seg; $cont =~ s/$qs//gs; } } return $cont; } ############################################################################### sub dump_target_info { my ($mark) = @_; my $sslprint = ""; if ($mark->{ssl}) { $sslprint = "$VARIABLES{'DIV'}\n"; $sslprint .= "+ SSL Info: Subject: $mark->{'ssl_cert_subject'}\n"; # Extract and display CN separately my $cn = ''; if ($mark->{'ssl_cert_subject'} =~ /CN=([^$ \/]+)/) { $cn = $1; $sslprint .= " CN: $cn\n"; } # Display SAN if present if ($mark->{'ssl_cert_altnames'} ne '') { $sslprint .= " SAN: $mark->{'ssl_cert_altnames'}\n"; } $sslprint .= " Ciphers: $mark->{'ssl_cipher'}\n"; $sslprint .= " Issuer: $mark->{'ssl_cert_issuer'}"; } if ($CLI{'plugins'} ne '@@NONE') { if ($mark->{ip} =~ /^$LW2::IPv4_re$/ || $mark->{ip} =~ /^$LW2::IPv6_re_inc_zoneid$/) { nprint("+ Target IP: $mark->{ip}", "", ($mark->{'ip'})); } else { nprint("+ Target IP: (proxied)", "", ($mark->{'ip'})); } nprint("+ Target Hostname: $mark->{hostname}", "", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); nprint("+ Target Port: $mark->{port}"); if (defined $CLI{'root'}) { nprint("+ Target Path: $CLI{'root'}", "", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); } if ((defined $CLI{'vhost'}) && ($CLI{'vhost'} ne $mark->{hostname})) { nprint("+ Virtual Host: $CLI{'vhost'}", "", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); } if ($request{'whisker'}->{'proxy_host'} ne '') { nprint( "+ Proxy: $request{'whisker'}->{'proxy_host'}:$request{'whisker'}->{'proxy_port'}", "", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'}) ); } if ($mark->{ssl}) { nprint($sslprint, "", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); } if (defined $NIKTO{'anti_ids'} && defined $CLI{'evasion'}) { for (my $i = 1 ; $i <= (keys %{ $NIKTO{'anti_ids'} }) ; $i++) { if ($CLI{'evasion'} =~ /$i/) { nprint("+ Using Encoding: $NIKTO{'anti_ids'}{$i}"); } } } if (defined $NIKTO{'mutate_opts'} && defined $CLI{'mutate'}) { for (my $i = 1 ; $i <= (keys %{ $NIKTO{'mutate_opts'} }) ; $i++) { if ($CLI{'mutate'} =~ /$i/) { nprint("+ Using Mutation: $NIKTO{'mutate_opts'}{$i}"); } } } if (defined $mark->{'messages'}) { my @msgs = @{ $mark->{'messages'} }; foreach my $m (@msgs) { nprint("+ Message: $m", "", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); } } if (defined $mark->{'platform'}) { my %platform_names = ('nix' => 'Linux/Unix', 'win' => 'Windows', 'all' => 'Unknown' ); my $platform_display = $platform_names{ $mark->{'platform'} } || $mark->{'platform'}; nprint("+ Platform: $platform_display"); } my $time = date_disp($mark->{start_time}); nprint("+ Start Time: $time (GMT$VARIABLES{'GMTOFFSET'})"); nprint($VARIABLES{'DIV'}); } if ($mark->{banner} ne "") { nprint("+ Server: $mark->{banner}", "", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); } else { nprint("+ Server: No banner retrieved"); } return; } ############################################################################### sub general_config { ## gotta set these first $| = 1; # internal array, this should never be used outside this sub my @options; # This is used in dump_target_info(), not just help output $NIKTO{'anti_ids'}{'1'} = "Random URI encoding (non-UTF8)"; $NIKTO{'anti_ids'}{'2'} = "Directory self-reference (/./)"; $NIKTO{'anti_ids'}{'3'} = "Premature URL ending"; $NIKTO{'anti_ids'}{'4'} = "Prepend long random string"; $NIKTO{'anti_ids'}{'5'} = "Fake parameter"; $NIKTO{'anti_ids'}{'6'} = "TAB as request spacer"; $NIKTO{'anti_ids'}{'7'} = "Change the case of the URL"; $NIKTO{'anti_ids'}{'8'} = "Use Windows directory separator (\\)"; $NIKTO{'anti_ids'}{'A'} = "Use a carriage return (0x0d) as a request spacer"; $NIKTO{'anti_ids'}{'B'} = "Use binary value 0x0b as a request spacer"; # This is used in dump_target_info(), not just help output $NIKTO{'mutate_opts'}{'1'} = "Test all files with all root directories"; $NIKTO{'mutate_opts'}{'2'} = "Guess for password file names"; $NIKTO{'mutate_opts'}{'3'} = "Enumerate user names via Apache (/~user type requests)"; $NIKTO{'mutate_opts'}{'4'} = "Enumerate user names via cgiwrap (/cgi-bin/cgiwrap/~user type requests)"; $NIKTO{'mutate_opts'}{'6'} = "Attempt to guess directory names from the supplied dictionary file"; ### CLI STUFF $CLI{'pause'} = $CLI{'html'} = $OUTPUT{'verbose'} = $CLI{'skiplookup'} = $COUNTERS{'totalrequests'} = $OUTPUT{'debug'} = $OUTPUT{'scrub'} = $OUTPUT{'errors'} = 0; $CLI{'all_options'} = join(" ", @ARGV); $CLI{'all_options'} =~ s/(\-id?\s[^\s:]+:)[^\s]+/$1****/i; GetOptions("ask=s" => \$CLI{'ask'}, "Add-header=s" => \@{ $CLI{'headers'} }, "check6" => \$CLI{'check6'}, "Cgidirs=s" => \$CLI{'forcecgi'}, "config=s" => \$CLI{'config'}, "dbcheck" => \&check_dbs, "Display=s" => \$CLI{'display'}, "evasion=s" => \$CLI{'evasion'}, "followredirects" => \$CLI{'followredirects'}, "Format=s" => \$CLI{'format'}, "Help" => \&usage, "host=s" => \$CLI{'host'}, "id=s" => \$CLI{'hostauth'}, "key=s" => \$CLI{'key'}, "list-plugins" => \&list_plugins, "maxtime=s" => \$CLI{'maxtime'}, "mutate-options=s" => \$CLI{'mutate-options'}, "mutate=s" => \$CLI{'mutate'}, "nointeractive" => \$CLI{'nointeractive'}, "nolookup" => \$CLI{'skiplookup'}, "nossl" => \$CLI{'nossl'}, "Option=s" => \@options, "output=s" => \$CLI{'file'}, "Pause=f" => \$CLI{'pause'}, "Plugins=s" => \$CLI{'plugins'}, "Platform=s" => \$CLI{'platform'}, "RSAcert=s" => \$CLI{'cert'}, "port=s" => \$CLI{'ports'}, "root=s" => \$CLI{'root'}, "ssl" => \$CLI{'ssl'}, "noslash" => \$CLI{'noslash'}, "Save=s" => \$CLI{'saveresults'}, "timeout=i" => \$CLI{'timeout'}, "Tuning=s" => \$CLI{'tuning'}, "Userdbs:s" => \$CLI{'userdbs'}, "nocheck" => \$CLI{'nocheck'}, "nocookies" => \$CLI{'nocookies'}, "useproxy:s" => \$CLI{'useproxy'}, "useragent=s" => \$CLI{'useragent'}, "url=s" => \$CLI{'host'}, "Version" => \&version, "vhost=s" => \$CLI{'vhost'}, "404string=s" => \$CLI{'404string'}, "404code=s" => \$CLI{'404code'}, "ipv6" => \$CLI{'ipv6'}, "ipv4" => \$CLI{'ipv4'}, ) or usage(); # Validate that -followredirects doesn't have an argument, a common confusion with -Format if ($CLI{'followredirects'}) { if ($CLI{'all_options'} =~ /\-f(ollowredirects)?\s+[^-]/) { nprint( "+ ERROR: -f (-followredirects) does not accept arguments. Use -F for output format (e.g., -F html)" ); exit 1; } } # Run a test for IPv6 connectivity if ($CLI{'check6'}) { check_ipv6(); } # both -host and -url if (($CLI{'host'} ne '') && ($CLI{'url'} ne '')) { nprint("+ ERROR: Cannot use -url and -host at the same time"); exit 1; } # -ipv4 and -ipv6 validations if ($CLI{'ipv4'} && $CLI{'ipv6'}) { nprint("+ ERROR: Cannot use -ipv4 and -ipv6 at the same time"); exit 1; } if ($CLI{'ipv6'}) { $CLI{'ipv4'} = 0; } else { $CLI{'ipv4'} = 1; } # 404string if ($CLI{'404string'} ne '') { my $s = validate_and_fix_regex($CLI{'404string'}); $VARIABLES{'ERRSTRINGS'}->{$s} = 1; } # 404code if ($CLI{'404code'} ne '') { foreach my $code (split(/\s?,\s?/, $CLI{'404code'})) { $code =~ s/^\s+|\s+$//g; # Trim whitespace if ($code =~ /[^\d]/) { nprint("+ ERROR: Invalid 404code, must be an integer"); exit 1; } $VARIABLES{'ERRCODES'}->{$code} = 1; } } # Maxtime must be seconds if ($CLI{'maxtime'} ne '') { $CLI{'maxtime'} = time_to_seconds($CLI{'maxtime'}); if ($CLI{'maxtime'} eq '') { nprint("+ ERROR: Invalid maxtime value, must be a valid time (e.g., 3600s, 60m, 1h)"); exit 1; } } # options allows overriding of nikto.conf entries on command line foreach my $option (@options) { my @optione = split("=", $option, 2); $CONFIGFILE{ $optione[0] } = $optione[1]; } # Userdb type: blank is db_tests only, so 'all' is only valid option if (defined($CLI{'userdbs'})) { if ($CLI{'userdbs'} =~ /^all$/i) { $CLI{'userdbs'} = 'all'; } else { $CLI{'userdbs'} = 'tests'; } } # CLI proxy overrides nikto.conf if ((defined($CLI{'useproxy'})) && ($CLI{'useproxy'} ne '')) { if ($CLI{'useproxy'} !~ /^https?:\/\//) { $CLI{'useproxy'} = "http://$CLI{'useproxy'}"; } my @prox = LW2::uri_split($CLI{'useproxy'}); $CONFIGFILE{'PROXYHOST'} = $prox[2]; $CONFIGFILE{'PROXYPORT'} = $prox[3]; $CONFIGFILE{'PROXYUSER'} = $prox[6]; $CONFIGFILE{'PROXYPASS'} = $prox[7]; } elsif (defined($CLI{'useproxy'})) { $CLI{'useproxy'} = 1; } else { undef $CONFIGFILE{'PROXYHOST'}; undef $CONFIGFILE{'PROXYPORT'}; undef $CONFIGFILE{'PROXYUSER'}; undef $CONFIGFILE{'PROXYPASS'}; } # Save Results if (defined($CLI{'saveresults'})) { if ($CLI{'saveresults'} eq '') { nprint("+ ERROR: -Save must have a directory name or '.' for auto-generated"); exit 1; } eval "require JSON::PP"; if ($@) { nprint("+ ERROR: Module JSON::PP missing."); exit 1; } } # Parse comma-separated formats early (before validation) my @formats_raw = (); if (defined $CLI{'format'} && $CLI{'format'} ne '') { @formats_raw = split(/,/, $CLI{'format'}); } # If no format specified, try to infer from file extension later my @formats = (); my %formats_hash = (); foreach my $fmt (@formats_raw) { $fmt =~ s/^\s+|\s+$//g; # Trim whitespace $fmt = lc($fmt); $fmt = 'txt' if $fmt eq 'text'; $fmt = 'htm' if $fmt eq 'html'; if ($fmt !~ /^(?:txt|htm|csv|json|sql|sqld|xml|none)$/) { nprint("+ ERROR: Invalid output format: $fmt"); exit 1; } # Avoid duplicates if (!exists $formats_hash{$fmt}) { push(@formats, $fmt); $formats_hash{$fmt} = 1; } } # Store formats array for later use $CLI{'formats'} = \@formats; # Keep first format for backward compatibility with single-format code paths $CLI{'format'} = $formats[0] if @formats > 0; # Check XML dependencies (check if xml is in formats) if (grep { $_ eq 'xml' } @formats) { eval "require XML::Writer"; if ($@) { nprint("+ ERROR: Module XML::Writer missing. Install with: cpan XML::Writer"); exit 1; } } # port(s) if (defined $CLI{'ports'}) { $CLI{'ports'} =~ s/^\s+//; $CLI{'ports'} =~ s/\s+$//; if ($CLI{'ports'} =~ /[^0-9\-\, ]/) { nprint("+ ERROR: Invalid port option '$CLI{'ports'}'"); exit 1; } } # output file - infer format from extension if not specified if (@formats == 0) { # No format specified, try to infer from file if (defined $CLI{'file'} && $CLI{'file'} ne '' && $CLI{'file'} ne '.') { my $ext = lc($CLI{'file'}); $ext =~ s/(^.*\.)([^.]*$)/$2/g; $ext = 'txt' if $ext eq 'text'; $ext = 'htm' if $ext eq 'html'; if ($ext =~ /^(?:txt|htm|csv|json|sql|sqld|xml)$/) { push(@formats, $ext); $CLI{'formats'} = \@formats; $CLI{'format'} = $ext; } else { $CLI{'format'} = 'none'; push(@formats, 'none'); $CLI{'formats'} = \@formats; } } else { $CLI{'format'} = 'none'; push(@formats, 'none'); $CLI{'formats'} = \@formats; } } # Check if we need files for any format my $needs_file = 0; foreach my $fmt (@formats) { if (($fmt ne "none") && ($fmt ne "sqld")) { $needs_file = 1; last; } } # Initialize files hash $CLI{'files'} = {}; # File naming logic if ($CLI{'file'} eq '.') { # Auto-generate file names for each format if (@formats == 0 || ($formats[0] eq '')) { nprint("+ ERROR: Output format must be used with auto file naming"); exit 1; } my $hn = $CLI{'host'}; $hn =~ s/[^a-zA-Z0-9\.\-\_]/_/g; $hn =~ s/_+/_/g; my $port = $CLI{'ports'}; $port =~ s/,/\-/g; $port =~ s/[^a-zA-Z0-9\.\-\_]/_/g; my $now = date_disp(time()); $now =~ s/[^0-9-]/-/g; my $base_name = "nikto_" . $hn . "_" . $port . "_" . $now; $base_name =~ s/_+/_/g; # Generate file names for each format foreach my $fmt (@formats) { next if ($fmt eq "none" || $fmt eq "sqld"); # These don't need files my $file_name = $base_name . "." . $fmt; # Check if file exists and add counter if needed if (-e $file_name) { $file_name =~ /^(.*)(\.[a-z]{3,4})/; my $fn = $1; my $ext = $2; my $ctr = 0; my $exists = 1; while ($exists) { $ctr++; my $new_name = $fn . "_" . $ctr . $ext; if (!-e $new_name) { $file_name = $new_name; $exists = 0; } } } $CLI{'files'}{$fmt} = $file_name; nprint("- Auto-generated save file: $file_name", "v", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); } } elsif (defined $CLI{'file'} && $CLI{'file'} ne '' && $CLI{'file'} ne '.') { # Use filename as prefix, append format extensions my $prefix = $CLI{'file'}; # Generate file names for each format foreach my $fmt (@formats) { if ($fmt eq "none" || $fmt eq "sqld") { $CLI{'files'}{$fmt} = ""; # No file for these formats next; } # Append format extension to prefix only if not already present my $file_name; if ($prefix =~ /\.\Q$fmt\E$/i) { $file_name = $prefix; } else { $file_name = $prefix . "." . $fmt; } $CLI{'files'}{$fmt} = $file_name; } } else { # No file specified - only sqld/none formats allowed foreach my $fmt (@formats) { if ($fmt eq "none" || $fmt eq "sqld") { $CLI{'files'}{$fmt} = ""; } } } # Validation: If file-based formats are specified but no output file, default to auto-generation if ((!defined $CLI{'file'} || $CLI{'file'} eq '') && $needs_file) { $CLI{'file'} = '.'; # Re-run the auto-generation logic above (simplified, since formats are already parsed) my $hn = $CLI{'host'}; $hn =~ s/[^a-zA-Z0-9\.\-\_]/_/g; $hn =~ s/_+/_/g; my $port = $CLI{'ports'}; $port =~ s/,/\-/g; $port =~ s/[^a-zA-Z0-9\.\-\_]/_/g; my $now = date_disp(time()); $now =~ s/[^0-9-]+/-/g; my $base_name = "nikto_" . $hn . "_" . $port . "_" . $now; $base_name =~ s/_+/_/g; foreach my $fmt (@formats) { next if ($fmt eq "none" || $fmt eq "sqld"); my $file_name = $base_name . "." . $fmt; if (-e $file_name) { $file_name =~ /^(.*)(\.[a-z]{3,4})/; my $fn = $1; my $ext = $2; my $ctr = 0; my $exists = 1; while ($exists) { $ctr++; my $new_name = $fn . "_" . $ctr . $ext; if (!-e $new_name) { $file_name = $new_name; $exists = 0; } } } $CLI{'files'}{$fmt} = $file_name; nprint("- Auto-generated save file: $file_name", "v", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); } } # Validation checks if ((defined $CLI{'file'}) && (@formats == 0 || ($formats[0] eq ""))) { nprint("+ ERROR: Output file specified without a format"); exit 1; } if ((!defined $CLI{'file'} || $CLI{'file'} eq '') && $needs_file && @formats > 0) { nprint("+ ERROR: Output file format specified without a name"); exit 1; } # verify readable dtd (check all formats for xml) if (grep { $_ eq 'xml' } @formats) { if (!defined $CONFIGFILE{'NIKTODTD'} || $CONFIGFILE{'NIKTODTD'} eq '') { nprint("+ ERROR: DTD not defined in configuration"); exit 1; } # Check if DTD file exists my $dtd_path = $CONFIGFILE{'NIKTODTD'}; if ($dtd_path !~ /^\// && defined $CONFIGFILE{'EXECDIR'}) { $dtd_path = "$CONFIGFILE{'EXECDIR'}/$dtd_path"; } if (!-f $dtd_path) { nprint("+ ERROR: DTD file not found: $dtd_path"); nprint("+ Please check your nikto.conf configuration"); exit 1; } } # screen output if (defined $CLI{'display'}) { if ($CLI{'display'} =~ /d/i) { $OUTPUT{'debug'} = 1; } if ($CLI{'display'} =~ /v/i) { $OUTPUT{'verbose'} = 1; } if ($CLI{'display'} =~ /s/i) { $OUTPUT{'scrub'} = 1; } if ($CLI{'display'} =~ /e/i) { $OUTPUT{'errors'} = 1; } if ($CLI{'display'} =~ /p/i) { $OUTPUT{'progress'} = 1; } if ($CLI{'display'} =~ /1/i) { $OUTPUT{'show_redirects'} = 1; } if ($CLI{'display'} =~ /2/i) { $OUTPUT{'show_cookies'} = 1; } if ($CLI{'display'} =~ /3/i) { $OUTPUT{'show_ok'} = 1; } if ($CLI{'display'} =~ /4/i) { $OUTPUT{'show_auth'} = 1; } } # Fixup if (defined $CLI{'root'}) { $CLI{'root'} =~ s/\/$//; if (($CLI{'root'} !~ /^\//) && ($CLI{'root'} ne "")) { $CLI{'root'} = "/$CLI{'root'}"; } } if (defined $CLI{'evasion'}) { $CLI{'evasion'} =~ s/[^1-8AB]//g; } if (!defined $CLI{'plugins'} || $CLI{'plugins'} eq "") { $CLI{'plugins'} = '@@DEFAULT'; } # Mapping for mutate for plugins if (defined $CLI{'mutate'}) { if ($CLI{'mutate'} =~ /1/ || $CLI{'mutate'} =~ /2/) { my $parameters; $parameters = "passfiles" if ($CLI{'mutate'} =~ /2/); $parameters .= ",all" if ($CLI{'mutate'} =~ /1/); $CLI{'plugins'} .= ';tests(' . $parameters . ')'; } if ($CLI{'mutate'} =~ /3/ || $CLI{'mutate'} =~ /4/) { my $parameters; $parameters = "enumerate"; $parameters .= ",home" if ($CLI{'mutate'} =~ /3/); $parameters .= ",cgiwrap" if ($CLI{'mutate'} =~ /4/); $parameters .= ",dictionary:" . $CLI{'mutate-options'} if (defined $CLI{'mutate-options'}); $CLI{'plugins'} .= ';apacheusers(' . $parameters . ')'; } if ($CLI{'mutate'} =~ /6/) { $CLI{'plugins'} .= ';dictionary(dictionary:' . $CLI{'mutate-options'} . ')'; } nprint( "- Mutate is deprecated, use -Plugins instead. The following option can be used in future: -Plugin $CLI{'plugins'}" ); } # Asking questions? if ($CLI{'ask'} =~ /^(?:auto|yes|no)$/) { $CONFIGFILE{'UPDATES'} = $CLI{'ask'}; # override nikto.conf setting undef($CLI{'ask'}); } $CLI{'timeout'} = $CLI{'timeout'} || 10; # RFI URL -- push it to VARIABLES if (defined $CONFIGFILE{'RFIURL'}) { $VARIABLES{'@RFIURL'} = $CONFIGFILE{'RFIURL'}; } else { nprint("- ***** RFIURL is not defined in nikto.conf--no RFI tests will run *****"); } # SSL Test if (!LW2::ssl_is_available()) { nprint("- ***** TLS/SSL support not available (see docs for SSL install) *****"); if ($CLI{'ssl'} || ($CLI{'host'} =~ /^https/i)) { nprint("- ERROR: -ssl was specified but TLS/SSL is not available."); exit 1; } } # get core version open(FI, "<$CONFIGFILE{'PLUGINDIR'}/nikto_core.plugin"); my @F = ; close(FI); my @VERS = grep(/^#VERSION/, @F); $VARIABLES{'core_version'} = $VERS[0]; $VARIABLES{'core_version'} =~ s/\#VERSION,//; chomp($VARIABLES{'core_version'}); $VARIABLES{'TEMPL_HCTR'} = 0; if ($^O !~ /MSWin32/) { $NIKTO{'POSIX'}{'fd_stdin'} = fileno(STDIN); $NIKTO{'POSIX'}{'term'} = POSIX::Termios->new(); $NIKTO{'POSIX'}{'term'}->getattr($NIKTO{'POSIX'}{'fd_stdin'}); $NIKTO{'POSIX'}{'oterm'} = $NIKTO{'POSIX'}{'term'}->getlflag(); $NIKTO{'POSIX'}{'echo'} = ECHOE | ECHO | ECHOK | ICANON; $NIKTO{'POSIX'}{'noecho'} = $NIKTO{'POSIX'}{'oterm'} & ~$NIKTO{'POSIX'}{'echo'}; } if ($CLI{'pause'} > 0) { nprint("-***** Pausing $CLI{'pause'} second(s) per request"); } # Default values $COUNTERS{'totalrequests'} = 0; $COUNTERS{'total_checks'} = 0; $COUNTERS{'total_targets'} = 0; $VARIABLES{'GMTOFFSET'} = gmt_offset(); $VARIABLES{'DIV'} = "-" x 75; $VARIABLES{'deferout'} = 0; $VARIABLES{'defertxt'} = []; # Some Win versions can't use Time::HiRes correctly $VARIABLES{'MSWIN32'} = 0; if ($^O =~ /MSWin32/) { $VARIABLES{'MSWIN32'} = 1; } return; } ############################################################################### sub time_to_seconds { my $time = $_[0] || return; if ($time =~ /m$/i) { $time =~ s/m$//i; $time = ($time * 60); } elsif ($time =~ /h$/i) { $time =~ s/h$//i; $time = ($time * 3600); } elsif ($time =~ /s$/i) { $time =~ s/s$//i; } return $time; } ############################################################################### sub sleeper { sleep($CLI{'pause'}) if defined $CLI{'pause'}; } ############################################################################### sub safe_quit { my ($mark) = @_; # When called as a signal handler, $mark is the signal name (e.g. "INT"), not a hashref if (!ref($mark)) { $mark = $NIKTO{'current_mark'}; } if (ref($mark)) { $mark->{'end_time'} = time(); $mark->{'elapsed'} = $mark->{'end_time'} - $mark->{'start_time'}; $COUNTERS{'scan_elapsed'} = (time() - $COUNTERS{'scan_start'}); report_host_end($mark); report_summary($mark); report_close($mark); } $NIKTO{'POSIX'}{'term'}->setlflag($NIKTO{'POSIX'}{'oterm'}) if ($^O !~ /MSWin32/); exit 1; } ############################################################################### sub check_input { my ($mark) = @_; my $key = readkey(); return if $key eq ''; # Key to OUTPUT field mapping for toggles my %toggles = (v => 'verbose', d => 'debug', e => 'errors', p => 'progress', r => 'show_redirects', c => 'show_cookies', o => 'show_ok', a => 'show_auth', ); if ($key eq ' ') { status_report($mark); } elsif (exists $toggles{$key}) { $OUTPUT{ $toggles{$key} } = !$OUTPUT{ $toggles{$key} }; } elsif ($key eq 'q' || ord($key) == 3) { safe_quit($mark); } elsif ($key eq 'P') { status_report($mark); pause(); } elsif ($key eq 'N') { nprint("- Terminating host scan."); return 'term'; } return; } ############################################################################### sub pause { return if ($^O =~ /MSWin32/); nprint("- Pausing--press P to resume."); while (readkey() ne 'P') { sleep 1; } nprint("- Resuming."); } ############################################################################### sub readkey { return if $^O =~ /MSWin32/; # Early return for Windows my $key; $NIKTO{'POSIX'}{'term'}->setlflag($NIKTO{'POSIX'}{'noecho'}); $NIKTO{'POSIX'}{'term'}->setattr($NIKTO{'POSIX'}{'fd_stdin'}, TCSANOW); eval { local $SIG{ALRM} = sub { die; }; ualarm(1_000); sysread(STDIN, $key, 1); ualarm(0); }; $NIKTO{'POSIX'}{'term'}->setlflag($NIKTO{'POSIX'}{'oterm'}); $NIKTO{'POSIX'}{'term'}->setattr($NIKTO{'POSIX'}{'fd_stdin'}, TCSANOW); return $key; } ############################################################################### sub resolve { my $ident = $_[0] or return; my $report = defined $_[1] ? $_[1] : 1; my ($ip, $name, $ipcache) = ""; my (@addresses, @scrub); my $is6 = 0; if (($CONFIGFILE{'PROXYHOST'} ne '') && $CLI{'useproxy'}) { return $ident, $ident, $ident; } if ($ident =~ /^$LW2::IPv4_re$/) { # ident is IPv4 $ip = $name = $ident; } elsif ($ident =~ /^\[?($LW2::IPv6_re_inc_zoneid)\]?$/) { $ip = $1; $name = $ident; # HTTP host header uses [IPv6] rather than the raw IPv6 address } else # not an IP, assume name & resolve { if ($CLI{'skiplookup'}) { nprint("+ ERROR: -nolookup set, but given name\n"); exit 1; } if ($LW2::LW2_CAN_IPv6) { # IPv4/v6 resolve use Socket qw(:addrinfo SOCK_RAW); my ($err, @res) = Socket::getaddrinfo($ident, "", { socktype => SOCK_RAW }); if ($err) { my $msg = "ERROR: Cannot resolve hostname '$ident' because '$err'."; if ($ident =~ /^\[?($LW2::IPv6_re_inc_zoneid)\]?$/) { $msg .= " Use the -ipv6 flag if needed."; } if ($CLI{'ipv6'}) { $msg .= " Ensure you have IPv6 connectivity. Trying running Nikto with the '-check6' flag."; } return $ident, '', $ident, $msg; } foreach my $res (@res) { my ($err, $ip) = Socket::getnameinfo($res->{addr}, NI_NUMERICHOST, NIx_NOSERV); push @addresses, $ip unless $err; } } else { # Traditional IPv4 resolve if ($hent = gethostbyname($ident)) { my $addr_ref = $hent->addr_list; @addresses = map { inet_ntoa($_) } @$addr_ref; } } my @temp4_ipcache; my @temp6_ipcache; my %seen; foreach $temp_ip (@addresses) { if ($temp_ip =~ /:/) { push @temp6_ipcache, $temp_ip if !$seen{$temp_ip}++; } else { push @temp4_ipcache, $temp_ip if !$seen{$temp_ip}++; } } $ip = ($CLI{'ipv6'}) ? shift @temp6_ipcache : shift @temp4_ipcache; push(@scrub, $ip, @temp4_ipcache, @temp6_ipcache); $ipcache = join ", ", (@temp4_ipcache, @temp6_ipcache); if ($ip eq '') { if ($CLI{'ipv6'} && scalar @temp4_ipcache) { nprint( "+ ERROR: IPv6 scanning mode requested but only IPv4 addresses found ($ipcache)" ); } elsif ($CLI{'ipv4'} && scalar @temp6_ipcache) { nprint( "+ ERROR: IPv4 scanning mode requested but only IPv6 addresses found ($ipcache)" ); } exit 1; } if ($ipcache ne "" && $report) { nprint("+ Multiple IPs found: $ip, $ipcache", "", @scrub); } if ( $ip !~ /^$LW2::IPv4_re$/ && $ip !~ /^$LW2::IPv6_re$/) { nprint("+ ERROR: Invalid IP: $ip\n\n", "", ($ident, $ip, $ident)); exit 1; } $name = $ident; } my $displayname = ($name) ? $name : $ip; return $name, $ip, $displayname; } ############################################################################### sub set_targets { my ($hostlist, $portlist, $ssl, $root) = @_; my $host_ctr = 1; my @hosts = split(/,/, $hostlist); my @tempports = split(/,/, $portlist) if defined $portlist; my (@ports, @checkhosts, @results, @marks); my $defaultport = ($ssl) ? 443 : 80; nprint("- Getting targets", "v", "Init"); # Check for portlist and expand foreach my $port (@tempports) { if ($port =~ /-/) { my ($start, $end); my @temp = split(/-/, $port); $start = $temp[0]; $end = $temp[1]; if ($start eq "") { $start = 0; } if ($end eq "") { $end = 65535; } if ($start > $end) { nprint("+ ERROR port range $port doesn't make sense - assuming 80/tcp"); next; } for (my $i = $start ; $i <= $end ; $i++) { push(@ports, $i); } } else { push(@ports, $port); } } # no ports explicitly set, so use default port if (scalar(@ports) == 0) { push(@ports, $defaultport); } # check whether -h is a file or an entry foreach my $host (@hosts) { if (-f $host || $host eq "-") { @results = parse_hostfile($host); push(@checkhosts, @results); } else { push(@checkhosts, $host); } } # Now parse the list of checkhosts foreach my $host (@checkhosts) { $host =~ s/\s+//g; if ($host eq '') { next; } my $markhash = {}; $markhash->{'root'} = $root; $markhash->{'cookiejar'} = LW2::cookie_new_jar(); # is it a URL? if ($host =~ /^https?:\/\//) { if ($CLI{'ports'} ne '') { nprint("- ERROR: The -port option cannot be used with a full URI"); exit 1; } my @hostdata = LW2::uri_split($host); $markhash->{'ident'} = $hostdata[2]; $markhash->{'port'} = $hostdata[3]; if ($markhash->{'port'} eq '') { if ($host =~ /^https:/) { $markhash->{'port'} = 443; } else { $markhash->{'port'} = $defaultport; } } # If URL included a path, add that as the root unless -root was specified if (($hostdata[0] ne '/') && ($hostdata[0] ne '') && ($markhash->{'root'} eq '')) { $hostdata[0] =~ s/\/$//; $markhash->{'root'} = $hostdata[0]; nprint("- Added -root value of '$hostdata[0]' from URI", "v", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); } push(@marks, $markhash); } else { if ((index $host, '[') == 0) { # looks like accepted IPv6 format if ($host =~ /^(\[?$LW2::IPv6_re_inc_zoneid\]?)(?:[:](\d+))?$/) { $markhash->{'ident'} = $1; $markhash->{'port'} = $2; push(@marks, $markhash); } else { nprint("- ERROR: Unrecognised target host format: $host", "", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); } } else { my @h = split(/\:|\,/, $host); if (scalar @h > 2 || $h[0] eq '') { # Possible invalid IPv6 format has been supplied nprint( "- ERROR: Target host '$host' contains more than one colon (:). If specifying an IPv6 target, use the [IPv6] format.", "", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'}) ); } else { $markhash->{'ident'} = $h[0]; if ($h[1] !~ /[^0-9]/ && $h[1] ne '') { $markhash->{'port'} = $h[1]; push(@marks, $markhash); } else { # push unique array ref for each port my $ti = $markhash->{'ident'}; my $tr = $markhash->{'root'}; foreach my $p (@ports) { my $markhash = { 'port' => $p, 'root' => $tr, 'ident' => $ti }; $markhash->{'port'} = $p; push(@marks, $markhash); } } } } } } return @marks; } ############################################################################### sub platform_profiler { if (defined $CLI{'platform'}) { if ($CLI{'platform'} =~ /(nix|win|all)/i) { return $CLI{'platform'}; } else { $VARIABLES{'deferout'} = 0; nprint("+ ERROR: Invalid platform: $CLI{'platform'}"); exit 1; } } my ($mark) = @_; my @profile_pages = ("/", "/server-status", "/icons/", "/trace.axd", "/nosuchfile.asp", "/nosuchfile.aspx", "/localstart.asp", "/docs/", "/server" ); foreach my $file (@profile_pages) { my ($res, $content, $error, $request, $response) = nfetch($mark, $file, "GET", "", "", "", "platform_profiler"); # Look for indicators of the platform in the Server header if ( $mark->{'banner'} && $VARIABLES{'@PLATFORMNIX'} && $mark->{'banner'} =~ /$VARIABLES{'@PLATFORMNIX'}/i) { return 'nix'; } elsif ( $mark->{'banner'} && $VARIABLES{'@PLATFORMWIN'} && $mark->{'banner'} =~ /$VARIABLES{'@PLATFORMWIN'}/i) { return 'win'; } # check the response body if ($content && $content =~ /$VARIABLES{'@PLATFORMNIX'}/i) { return 'nix'; } elsif ($content && $content =~ /$VARIABLES{'@PLATFORMWIN'}/i) { return 'win'; } # check the response headers if ($response && ref($response) eq 'HASH') { foreach my $header (keys %$response) { my $value = $response->{$header}; next unless defined $value; # Check header name if ($header =~ /$VARIABLES{'@PLATFORMNIX'}/i) { return 'nix'; } elsif ($header =~ /$VARIABLES{'@PLATFORMWIN'}/i) { return 'win'; } # Check header value (handle arrays) my $header_value = ref($value) eq 'ARRAY' ? join(', ', @$value) : $value; if ($header_value =~ /$VARIABLES{'@PLATFORMNIX'}/i) { return 'nix'; } elsif ($header_value =~ /$VARIABLES{'@PLATFORMWIN'}/i) { return 'win'; } } } } return 'all'; } ############################################################################### sub load_databases { my @dbs = qw/db_useragents db_404_strings db_outdated db_variables db_headers_suggested/; my $prefix = $_[0] || ''; # Only load the right databases if -Userdbs is set if ((defined($CLI{'userdbs'})) && ($CLI{'userdbs'} eq 'all')) { if ($prefix eq '') { return; } else { push(@dbs, 'db_tests'); } } if (($prefix eq 'u') || (!defined($CLI{'userdbs'}))) { push(@dbs, 'db_tests'); } # verify required files for my $file (@dbs) { if (!-r "$CONFIGFILE{'DBDIR'}/$file") { nprint("+ ERROR: Can't find/read required file \"$CONFIGFILE{'DBDIR'}/$file\""); exit 1; } } for my $file (@dbs) { my $filename = $CONFIGFILE{DBDIR} . "/" . $prefix . $file; if (!-r $filename) { next; } nprint("- Loading DB: $filename", "d"); open(IN, "<$filename") || die nprint("+ ERROR: Can't open \"$filename\":$@\n"); # db_tests if ($file =~ /u?db_tests/) { push(@DBFILE, ); next; } # all the other files require per-line processing else { my @file; # Cleanup while () { chomp; $_ =~ s/#.*$//; $_ =~ s/\s+$//; $_ =~ s/^\s+//; if ($_ ne "") { push(@file, $_); } } # db_variables if ($file =~ /u?db_variables/) { foreach my $l (@file) { if ($l =~ /^@/) { next if $l eq ''; my @temp = split(/=/, $l, 2); # Limit to 2 parts to handle = in values if ( @temp >= 2 && defined($temp[0]) && defined($temp[1]) && $temp[0] ne '' && $temp[1] ne '') { $temp[0] =~ s/^\s+|\s+$//g; $VARIABLES{ $temp[0] } = $temp[1]; } } } } # db_headers_suggested elsif ($file =~ /u?db_headers_suggested/) { foreach my $l (@file) { my @T = parse_csv($l); next if $T[0] eq ''; $VARIABLES->{'SUGGESTED_HEADERS'}->{ $T[0] } = $T[1]; } } # db_404_strings elsif ($file =~ /u?db_404_strings/) { foreach my $l (@file) { if ($l =~ /^\@CODE=/) { $l =~ s/^\@CODE=//; $l = validate_and_fix_regex($l); $VARIABLES{'ERRCODES'}->{$l} = 1; } else { $l = validate_and_fix_regex($l); $VARIABLES{'ERRSTRINGS'}->{$l} = 1; } } } # db_outdated elsif ($file =~ /u?db_outdated/) { foreach my $l (@file) { my @T = parse_csv($l); next if $T[1] eq ''; $T[1] = validate_and_fix_regex($T[1]); $OVERS{ $T[1] }{ $T[2] } = $T[3]; $OVERS{ $T[1] }{'tid'} = $T[0]; } } # db_useragents elsif ($file =~ /u?db_useragents/) { $VARIABLES{'@USERAGENTS'} = []; foreach my $l (@file) { next if $l =~ /^\#/; next if $l eq ''; $l =~ s/^\s+//; $l =~ s/\s+$//; push @{ $VARIABLES{'@USERAGENTS'} }, $l; } } close(IN); } } return; } ############################################################################### # Get directory listing sub dirlist { my $DIR = $_[0] || return; my $PATTERN = $_[1] || ""; my @FILES_TMP = (); opendir(DIRECTORY, $DIR) || die print STDERR "+ ERROR: Can't open directory '$DIR': $@"; foreach my $file (readdir(DIRECTORY)) { if ($file =~ /^\./) { next; } # skip hidden files, '.' and '..' if ($PATTERN ne "") { if ($file =~ /$PATTERN/) { push(@FILES_TMP, $file); } } else { push(@FILES_TMP, $file); } } closedir(DIRECTORY); return @FILES_TMP; } ############################################################################### sub check_dbs { @dbs = dirlist($CONFIGFILE{'DBDIR'}, "^u?db_*"); my %ALL_IDS; for my $file (@dbs) { my $filename = $CONFIGFILE{DBDIR} . "/" . $prefix . $file; if (!-r $filename) { nprint("+ ERROR: Unable to read \"$filename\""); next; } open(IN, "<$filename") || die nprint("+ ERROR: Can't open \"$filename\":$@\n"); nprint("Syntax Check: $filename"); if ($file =~ /u?db_outdated/) { my $count = 0; my %BANNER; foreach $line () { $line =~ s/^\s+//; if ($line =~ /^\#/) { next; } chomp($line); if ($line eq "" || $line =~ /"nikto_id"/) { next; } $count++; my @L = parse_csv($line); if ($#L ne 3) { nprint("\t+ ERROR: Invalid syntax ($#L): $line"); next; } if (($L[0] ne 0) && exists($ALL_IDS{ $L[0] })) { nprint("\t+ ERROR: Duplicate Test ID: $L[0]"); } else { $ALL_IDS{ $L[0] } = 1; } if (exists($BANNER{ $L[1] }) && $L[0] !~ /(600067|600068|601085)/i) { nprint("\t+ ERROR: Duplicate Server Banner: $line"); nprint( "\t+ If this expected/needed: Please add the ID $L[0] at line " . (__LINE__- 2) . " in the nikto_core.plugin."); } else { $BANNER{ $L[1] } = 1; } } nprint("\t$count entries"); } elsif ($file =~ /u?db_favicon/ || $file =~ /u?db_domino/) { my $counter = 0; my %ENTRY; foreach $line () { $line =~ s/^\s+//; if ($line =~ /^\#/) { next; } chomp($line); if ($line eq "" || $line =~ /"nikto_id"/) { next; } $counter++; my @L = parse_csv($line); if ($#L ne 2) { nprint("\t+ ERROR: Invalid syntax ($#L): $line"); next; } if (($L[0] ne 0) && exists($ALL_IDS{ $L[0] })) { nprint("\t+ ERROR: Duplicate Test ID: $L[0]"); } else { $ALL_IDS{ $L[0] } = 1; } if (exists($ENTRY{ $L[1] })) { nprint("\t+ ERROR: Duplicate entry: $line"); } else { $ENTRY{ $L[1] } = 1; } } nprint("\t$counter entries"); } elsif ($file =~ /u?db_tests/) { my %ENTRIES; foreach my $line () { chomp($line); $line =~ s/^\s+//; if ($line =~ /^\#|^$/) { next; } my @L = parse_csv($line); # Validate field count (should be 9 fields) if ((count_fields($line, 1) ne 8) && (count_fields($line) ne '')) { nprint( "\t+ ERROR: Invalid syntax - expected 9 fields, got " . (scalar(@L)) . ": $line"); next; } # Validate method if ( ($L[4] !~ /(GET|POST|TRACE|TRACK|OPTIONS|SEARCH|INDEX)/i) && ($L[0] ne '006433')) { nprint("\t+ ERROR: Possibly invalid method: $L[4] on ($line)"); } # Validate DSL field is not empty if ($L[5] eq "") { nprint("\t+ ERROR: blank DSL field: $line"); next; } # Validate DSL syntax my $dsl_to_validate = $L[5]; if (defined $dsl_to_validate && length $dsl_to_validate) { # Expand @LFI() placeholder before validation, since it needs to be expanded to be valid DSL $dsl_to_validate = expand_lfi_dsl($dsl_to_validate); if (defined $dsl_to_validate && length $dsl_to_validate) { eval { parse_dsl($dsl_to_validate); }; if ($@) { nprint( "\t+ ERROR: Invalid DSL syntax in test $L[0] field 5: \"$L[5]\", error: $@" ); } } else { nprint("\t+ ERROR: Empty DSL field test $L[0]"); } } # Validate URI format if (($L[3] =~ /^\@CG/) && ($L[3] !~ /^\@CGIDIRS/)) { nprint("\t+ ERROR: Possible \@CGIDIRS misspelling: $line"); } if ($L[3] =~ /[\s]/) { nprint("\t+ ERROR: space in file portion test #$L[0]: '$L[3]'"); } # Validate CSV format if ($line =~ /[^\\]"\s/) { nprint("\t+ ERROR: space after quote #$L[0]: $line"); } if ($line =~ /\s"/) { nprint("\t+ ERROR: space before quote #$L[0]: $line"); } # Check for duplicate entries $ENTRIES{"$L[3],$L[4],$L[5],$L[6],$L[7],$L[8]"}++; # Validate Test ID if (($L[0] ne 0) && exists($ALL_IDS{ $L[0] })) { nprint("\t+ ERROR: Duplicate Test ID: $L[0]"); } else { $ALL_IDS{ $L[0] } = 1; } # Validate Tuning Type if ($L[2] eq "" || $L[2] =~ /[^a-f0-9]/) { nprint("\t+ ERROR: Invalid Tuning Type: $line"); } # Validate URI patterns if ( $L[3] =~ '^(/@(?!JUNK)|//)' && $L[0] !~ /(000396|000447|000543|000544|000545|000928|000929|001208|001373|001497|002761|002762|003029|007152)/i ) { nprint("\t+ ERROR: Possible incorrect slashes: $line"); nprint( "\t+ If two or more slashes are needed for this test: Please add the ID $L[0] at line " . (__LINE__- 2) . " in the nikto_core.plugin."); } if ($L[3] =~ '^@(?!JUNK)[A-Z]+/' && $L[0] !~ /(003348|003349)/i) { nprint("\t+ ERROR: Possible incorrect slash after \@VARIABLE: $line"); nprint( "\t+ If this slash is needed for this test: Please add the ID $L[0] at line " . (__LINE__- 2) . " in the nikto_core.plugin."); } # Validate POST data usage if ((($L[4] ne 'POST') && ($L[4] ne 'SEARCH')) && ($L[7] ne '')) { # Some test IDs need this if ($L[0] !~ /(006992|000126|000291|001153)/i) { nprint( "\t+ ERROR: Possible incorrect use of POST data without POST method on line: $line" ); nprint( "\t+ If the POST data is needed for this test: Please add the ID $L[0] at line " . (__LINE__- 2) . " in the nikto_core.plugin."); } } } foreach $entry (keys %ENTRIES) { if ($ENTRIES{$entry} > 1) { nprint("\t+ ERROR: Duplicate Check Syntax ($ENTRIES{$entry}): $entry"); } } nprint("\t" . keys(%ENTRIES) . " entries"); } elsif ($file =~ /u?db_variables/) { my $ctr = 0; foreach $line () { if ($line !~ /^\@/) { next; } if ($line !~ /^\@.+\=.+$/i) { nprint("\t+ ERROR: Invalid syntax: $line"); } $ctr++; } nprint("\t$ctr entries"); } elsif ($file =~ /u?db_404_strings/ || $file =~ /u?db_dictionary/) { my $ctr = 1; my %STRINGS; foreach $line () { chomp($line); $line =~ s/\#.*$//; next if $line eq ''; my ($result, $bad) = validate_and_fix_regex($line, 1); if ($bad) { nprint("\t+ ERROR: Invalid regex on line $ctr: \"$line\""); } if (exists($STRINGS{$line})) { nprint("\t+ ERROR: Duplicate String: $line"); } else { $STRINGS{$line} = 1; } $ctr++; } $ctr--; nprint("\t$ctr entries"); } elsif ($file =~ /u?db_headers_suggested/) { my $ctr = 0; my %HEADERS; foreach $line () { chomp($line); $line =~ s/\#.*$//; next if $line eq ''; my @fields = parse_csv($line); # Skip header line if present if ($fields[0] =~ /^header$/i) { next; } if (scalar(@fields) != 2) { nprint("\t+ ERROR: Invalid syntax (expected 2 fields): $line"); } if (exists($HEADERS{ $fields[0] })) { nprint("\t+ ERROR: Duplicate Header: $fields[0]"); } else { $HEADERS{ $fields[0] } = 1; } $ctr++; } nprint("\t$ctr entries"); } elsif ($file =~ /u?db_headers_common/) { my $ctr = 0; my %HEADERS; foreach $line () { chomp($line); $line =~ s/\#.*$//; next if $line eq ''; if ((count_fields($line) ne 0) && (count_fields($line) ne '')) { nprint("\t+ ERROR: Invalid syntax: $line"); } if (exists($HEADERS{$line})) { nprint("\t+ ERROR: Duplicate Header: $line"); } else { $HEADERS{$line} = 1; } $ctr++; } nprint("\t$ctr entries"); } elsif ($file =~ /u?db_multiple_index/) { my $ctr = 0; foreach $line () { if ((count_fields($line) ne 0) && (count_fields($line) ne '')) { nprint("\t+ ERROR: Invalid syntax: $line"); } $ctr++; } nprint("\t$ctr entries"); } elsif ($file =~ /u?db_useragents/) { my $ctr = 0; foreach $line () { chomp($line); next if $line =~ /^\#/; next if $line eq ''; if ($line !~ /^\"[^"]+\"/) { nprint("\t+ ERROR: Invalid syntax: $line"); } $ctr++; } nprint("\t$ctr entries"); } else { # It's a file of standard DB type, we can do this intelligently my (@headers, @regex_fields); my $ctr = 0, $fields = 0; foreach $line () { $line =~ s/^#.*//; next if $line eq ""; # first, grab the headers if ($fields == 0) { @headers = parse_csv($line); $fields = $#headers; # check regex fields for syntax for (my $i = 0 ; $i <= $#headers ; $i++) { if ( ($headers[$i] eq 'match') || ($headers[$i] eq 'matchstring') || ($headers[$i] eq 'server')) { push(@regex_fields, $i); } } next; } chomp($line); next if $line eq ""; my @entry = parse_csv($line); if ($regex_fields[0] ne '') { foreach my $f (@regex_fields) { my ($result, $bad) = validate_and_fix_regex($entry[$f], 1); if ($bad) { nprint("\t+ ERROR: Invalid regex in field $f on line $ctr: \"$line\""); } } } if ( (count_fields($line, 1) != $fields - 1) && (count_fields($line) ne '')) { nprint("\t+ ERROR: Invalid syntax: $line"); } if (($entry[0] ne 0) && exists($ALL_IDS{ $entry[0] })) { nprint("\t+ ERROR: Duplicate Test ID: $entry[0]"); } else { $ALL_IDS{ $entry[0] } = 1; } $ctr++; } nprint("\t$ctr entries"); } close(IN); } # Try to grab the test IDs from plugins to check for duplicates. Not foolproof. nprint("Checking plugins for duplicate test IDs"); my $found = 0; my @pluginlist = dirlist("$CONFIGFILE{'PLUGINDIR'}", '\.plugin$'); foreach my $pf (@pluginlist) { open(PF, "<$CONFIGFILE{'PLUGINDIR'}/$pf") || die print STDERR "+ ERROR: Unable to open '$pf': $@\n"; my @file = ; close(PF); my @adds = grep(/add_vulnerability\(/, @file); foreach my $addv (@adds) { chomp($addv); my @bits = parse_csv($addv); $bits[2] =~ s/\s+//g; $bits[2] =~ s/\"//g; if ($bits[2] =~ /^[\d]+$/) { if (($bits[2] ne 0) && exists($ALL_IDS{ $bits[2] })) { $found++; nprint("\t+ ERROR: Duplicate Test ID: $bits[2]"); } else { $ALL_IDS{ $bits[2] } = 1; } } } } nprint("\t$found entries"); # Bad practice here but this one won't parse right above ¯\_(ツ)_/¯ $ALL_IDS{'000137'} = 1; # TLS issues # Look for bad/invalid IDs foreach my $id (keys %ALL_IDS) { chomp($id); next if (($id eq 0) || ($id eq '') || ($id eq 'nikto_id')); if ($id =~ /[^\d]/) { nprint("+ ERROR: Invalid test ID: $id"); next; } if (length($id) < 6) { nprint("+WARNING: Possibly invalid test ID: $id"); } } # Suggest some open IDs my @open; my $id = '000001'; while ($#open < 6) { if (!exists($ALL_IDS{$id})) { push(@open, $id); } $id++; } nprint("\nSome (probably) open IDs: " . join(", ", @open)); nprint("\n"); exit 1; } ############################################################################### sub count_fields { my $line = $_[0] || return; my $checkid = $_[1] || 0; if ($line !~ /^\"/) { return; } chomp($line); $line =~ s/\s+$//; if ($line eq '') { return; } my @L = parse_csv($line); if ($checkid && ($L[0] ne 'nikto_id') && (($L[0] =~ /[^0-9]/) || ($L[0] eq ''))) { return -1; } return $#L; } ############################################################################### sub port_check { my ($start_time, $hostname, $ip, $port, $key, $cert, $vhost) = @_; my $m = {}; $m->{'start_time'} = $start_time; $m->{'hostname'} = $vhost || $hostname; $m->{'ip'} = $ip; $m->{'port'} = $port; $m->{'ssl'} = 0; my @checktypes; if ($CLI{'nossl'}) { @checktypes = ('HTTP'); } elsif ($CLI{'ssl'} || $CLI{'host'} =~ /^https/i) { @checktypes = ('HTTPS'); } else { @checktypes = ('HTTP', 'HTTPS'); } foreach my $method (split(/ /, $CONFIGFILE{'CHECKMETHODS'})) { $request{'whisker'}->{'method'} = $method; foreach my $checkssl (@checktypes) { nprint("- Checking for $checkssl on " . ($m->{'hostname'} || $m->{'ip'}) . ":$port, using $method", "v", "CheckSSL", ($m->{'hostname'}, $m->{'ip'}, $m->{'displayname'}) ); $m->{ssl} = ($checkssl eq "HTTP") ? 0 : 1; if ($m->{'ssl'}) { $m->{'key'} = $key; $m->{'cert'} = $cert; } proxy_check($m); my ($res, $content, $error, $request, $response) = nfetch($m, "/", $method, "", "", { noerror => 1, noprefetch => 1, nopostfetch => 1 }, "PortCheck"); if ($res) { # Some Apache servers are annoying and answer non-TLS requests on a TLS server. if (defined $content && ($content =~ /plain HTTP (?:to an SSL|request was sent to HTTPS)/)) { dump_var("Result Hash", \%result, ($m->{'hostname'}, $m->{'ip'}, $m->{'displayname'})); next; } nprint("- $checkssl server found: " . ($m->{'hostname'} || $m->{'ip'}) . ":$port \t$response->{server}", "d", ($m->{'hostname'}, $m->{'ip'}, $m->{'displayname'}) ); return $m->{'ssl'} + 1; } } } my $msg = "Unable to connect to " . ($hostname || $ip) . ":$port"; if ($CLI{'ipv6'}) { $msg .= ". Ensure you have IPv6 connectivity. Trying running Nikto with the '-check6' flag."; } nprint($VARIABLES{'DIV'}); return $msg; } ############################################################################### sub load_plugins { my @pluginlist = dirlist("$CONFIGFILE{'PLUGINDIR'}", '\.plugin$'); my @all_names; # populate plugin macros $CONFIGFILE{'@@NONE'} = ""; # Check if running plugins is NONE - if so, don't bother initializing plugins if ($CLI{'plugins'} eq '@@NONE') { return; } foreach my $plugin (@pluginlist) { my $plugin_name = $plugin; $plugin_name =~ s/\.plugin$//; my $plugin_init = $plugin_name . "_init"; eval { require "$CONFIGFILE{'PLUGINDIR'}/$plugin"; }; if ($@) { nprint("- Could not load or parse plugin: $plugin_name\n Error: "); warn $@; nprint("- The plugin could not be run."); } else { nprint("- Initializing plugin $plugin_name", "v", "Init"); # Call initialisation method if (defined &$plugin_init) { my $pluginhash = &$plugin_init; # Add default weights if not already assigned while (my ($hook, $hook_params) = each(%{ $pluginhash->{'hooks'} })) { $hook_params->{$hook}->{'weight'} = 50 unless (defined $hook_params->{$hook}->{'weight'}); } $pluginhash->{report_weight} = 50 unless (defined $pluginhash->{report_weight}); push(@all_names, $pluginhash->{name}); push(@PLUGINS, $pluginhash); nprint("- Loaded \"$pluginhash->{full_name}\" plugin.", "v", "Init"); } else { nprint("WARNING: No init found for $plugin_name\n", "d"); } } } $CONFIGFILE{'@@ALL'} = join(';', @all_names); my @torun = split(/;/, expand_pluginlist($CLI{'plugins'}, 0)); # Force-enable report plugins if needed if ($CLI{'plugins'} =~ /\@NONE/ && defined $CLI{'formats'} && ref($CLI{'formats'}) eq 'ARRAY') { my %format_map = ('csv' => 'report_csv', 'json' => 'report_json', 'htm' => 'report_html', 'html' => 'report_html', 'sql' => 'report_sqlg', 'sqld' => 'report_sqld', 'txt' => 'report_text', 'xml' => 'report_xml' ); foreach my $fmt (@{ $CLI{'formats'} }) { if (exists $format_map{$fmt}) { push(@torun, $format_map{$fmt}) unless grep { $_ eq $format_map{$fmt} } @torun; } } } # Second pass to ensure that @@ALL is configured foreach my $plugin (@PLUGINS) { # Check that the plugin is to be run # Perl doesn't allow us to use "in", pity foreach my $torun_plugin (@torun) { next if ($torun_plugin eq ""); # split up into parameters my $name = my $suffix = $torun_plugin; if ($torun_plugin =~ /\(/) { $name =~ s/(.*)(\(.*\))/$1/; $suffix =~ s/(.*)(\(.*\))/$2/; } else { $name = $torun_plugin; $suffix = ""; } if ($plugin->{'name'} =~ /$name/i) { $plugin->{'run'} = 1; # Create parameters if ($suffix ne "") { my $parameters = {}; $suffix =~ s/(\()(.*[^\)])(\)?)/$2/; foreach my $parameter (split(/,/, $suffix)) { if ($parameter !~ /:/) { $parameters->{$parameter} = 1; } else { my $key = my $value = $parameter; $key =~ s/:.*//; $value =~ s/.*://; $parameters->{$key} = $value; } } $plugin->{'parameters'} = $parameters; } } } } # first build a temporary hash of all known hooks my %hooks; foreach my $plugin (@PLUGINS) { foreach my $hook (keys(%{ $plugin->{'hooks'} })) { $hooks{$hook} = (); } } # now we know the types of hooks, look through each plugin for them foreach my $hook (keys(%hooks)) { foreach my $plugin (@PLUGINS) { if ($plugin->{'run'} == 1) { if (defined $plugin->{'hooks'}->{$hook}->{'method'}) { push(@{ $hooks{$hook} }, $plugin); } } } } # Now sort each array by weight foreach my $hook (keys(%hooks)) { my @sorted = sort { $a->{'hooks'}->{$hook}->{'weight'} <=> $b->{'hooks'}->{$hook}->{'weight'} } @{ $hooks{$hook} }; $PLUGINORDER{$hook} = \@sorted; } } ############################################################################### sub run_hooks { my ($mark, $type, $request, $response) = @_; # Cache plugin array reference to avoid repeated hash access my $plugins = $PLUGINORDER{$type}; return ($request, $response) unless $plugins; foreach my $plugin (@$plugins) { return ($request, $response) if $mark->{'terminate'}; # Cache hook reference to avoid repeated hash access my $hook = $plugin->{'hooks'}->{$type}; next unless $hook; # Check conditionals more efficiently my $run = 1; if (my $condition = $hook->{'cond'}) { $run = eval($condition); next unless $run; } # Cache plugin parameters and full_name my $parameters = $plugin->{'parameters'}; my $full_name = $plugin->{'full_name'}; # Save current output states my $oldverbose = $OUTPUT{'verbose'}; my $olddebug = $OUTPUT{'debug'}; my $olderrors = $OUTPUT{'errors'}; # Set output flags based on parameters $OUTPUT{'verbose'} = 1 if $parameters && $parameters->{'verbose'} == 1; $OUTPUT{'debug'} = 1 if $parameters && $parameters->{'debug'} == 1; # Print status unless it's a prefetch/postfetch hook unless ($type eq "prefetch" || $type eq "postfetch") { nprint("- Running $type for \"$full_name\" plugin", "v", "Plugins"); $NIKTO{'current_plugin'} = $full_name; } # Execute the hook method &{ $hook->{'method'} }($mark, $parameters, $request, $response); # Restore output states $OUTPUT{'verbose'} = $oldverbose; $OUTPUT{'debug'} = $olddebug; $OUTPUT{'errors'} = $olderrors; } return ($request, $response); } ############################################################################### sub report_head { # Support multiple formats: use formats array my @formats_to_process = (); my %files_to_process = (); if (defined $CLI{'formats'} && ref($CLI{'formats'}) eq 'ARRAY' && @{ $CLI{'formats'} } > 0) { # Multiple formats from comma-separated list @formats_to_process = @{ $CLI{'formats'} }; if (defined $CLI{'files'} && ref($CLI{'files'}) eq 'HASH') { %files_to_process = %{ $CLI{'files'} }; } } else { # Fallback: no formats specified (should not happen, but handle gracefully) nprint("+ WARNING: No formats specified for reporting", "v", "Reports"); return; } nprint("- Opening reports (" . join(',', @formats_to_process) . ")", "v", "Reports"); # Process each format separately to ensure unique handles foreach my $format_to_use (@formats_to_process) { my $file_to_use = $files_to_process{$format_to_use} || ''; # Skip file-based formats that don't have a file (unless sqld/none) if ($file_to_use eq '' && $format_to_use ne 'none' && $format_to_use ne 'sqld') { nprint("+ ERROR: No file specified for format: $format_to_use", "v", "Reports"); next; } foreach my $i (1 .. 100) { foreach my $plugin (@PLUGINS) { if ( $plugin->{run} && defined $plugin->{report_item} && $plugin->{report_weight} == $i) { my $run = 1; # Check if this plugin handles this format if (defined $plugin->{report_format}) { $run = ($format_to_use eq $plugin->{report_format}); } if ($run) { nprint( "- Opening report for \"$plugin->{full_name}\" plugin ($format_to_use) -> $file_to_use", "v", "Reports" ); my $handle; if (defined $plugin->{report_head}) { # Each plugin gets its own unique lexical handle $handle = &{ $plugin->{report_head} }($file_to_use); # Ensure autoflush is enabled (already done in plugin, but double-check) if (defined $handle && ref($handle) eq 'GLOB') { $handle->autoflush(1); } } # Store this report entry with its unique handle my $report_entry = { host_start => $plugin->{report_host_start}, host_end => $plugin->{report_host_end}, item => $plugin->{report_item}, close => $plugin->{report_close}, summary => $plugin->{report_summary}, ssl_info => $plugin->{report_ssl_info}, # SSL info hook handle => $handle, format => $format_to_use, # Store for debugging file => $file_to_use, # Store for debugging }; push(@REPORTS, $report_entry); } } } } } return; } ############################################################################### # Generate scanid for SQL reporting # Format: MD5 hash of "protocol://hostname:port/timestamp" # Example: LW2::md5("https://example.com:443/2025:12:16:03:30:44GMT") sub generate_scanid { my ($mark) = @_; # Determine protocol my $protocol = $mark->{'ssl'} ? 'https' : 'http'; # Get hostname (prefer vhost if present) my $hostname = $mark->{'vhost'} ? $mark->{'vhost'} : $mark->{'hostname'}; # Get port my $port = $mark->{'port'} || ($mark->{'ssl'} ? 443 : 80); # Get GMT timestamp in format YYYY:MM:DD:HH:MM:SSGMT my @gmt = gmtime(time); my $timestamp = sprintf("%04d:%02d:%02d:%02d:%02d:%02dGMT", $gmt[5] + 1900, # year $gmt[4] + 1, # month $gmt[3], # day $gmt[2], # hour $gmt[1], # minute $gmt[0] ); # second # Generate MD5 hash my $scanid = LW2::md5("$protocol://$hostname:$port/$timestamp"); return $scanid; } ############################################################################### sub report_host_start { my ($mark) = @_; # Generate scanid for SQL reporting plugins $mark->{'scanid'} = generate_scanid($mark); # Go through all reporting modules foreach my $reporter (@REPORTS) { if (defined $reporter->{host_start}) { &{ $reporter->{host_start} }($reporter->{handle}, $mark); } } } ############################################################################### sub report_host_end { my ($mark) = @_; # Go through all reporting modules foreach my $reporter (@REPORTS) { if (defined $reporter->{host_end}) { &{ $reporter->{host_end} }($reporter->{handle}, $mark); } } } ############################################################################### sub report_summary { my ($mark) = @_; # Go through all reporting modules foreach my $reporter (@REPORTS) { if (defined $reporter->{summary}) { &{ $reporter->{summary} }($reporter->{handle}, $mark); } } } ############################################################################### sub report_item { my ($mark, $item) = @_; if (($item->{'uri'} eq 'undef') || ($item->{'uri'} eq '')) { $item->{'uri'} = '/'; } # Go through all reporting modules foreach my $reporter (@REPORTS) { if (defined $reporter->{item}) { &{ $reporter->{item} }($reporter->{handle}, $mark, $item); } } } ############################################################################### sub report_ssl_info { my ($mark) = @_; # Only report SSL info if SSL is enabled and info is available return unless ($mark->{'ssl'} && defined $mark->{'ssl_cipher'}); # Go through all reporting modules foreach my $reporter (@REPORTS) { if (defined $reporter->{ssl_info}) { &{ $reporter->{ssl_info} }($reporter->{handle}, $mark); } } } ############################################################################### sub report_close { # Go through all reporting modules foreach my $reporter (@REPORTS) { # Explicitly flush the handle before closing to ensure all data is written if (defined $reporter->{handle}) { my $fh = $reporter->{handle}; # Only flush file handles (GLOB refs), not database handles or other types if (ref($fh) eq 'GLOB') { # Ensure autoflush is enabled $fh->autoflush(1); # Try to flush explicitly if method exists if ($fh->can('flush')) { eval { $fh->flush(); }; } } } # Call plugin's close function (it may close the handle itself) if (defined $reporter->{close}) { &{ $reporter->{close} }($reporter->{handle}); } # Explicitly close file handles after plugin's close (in case plugin didn't close it) # Only close GLOB file handles, not database handles (DBI) or STDOUT if (defined $reporter->{handle} && ref($reporter->{handle}) eq 'GLOB') { # Don't close STDOUT/STDERR if ($reporter->{handle} ne \*STDOUT && $reporter->{handle} ne \*STDERR) { eval { close($reporter->{handle}); }; } } } } ############################################################################### # portions of this sub were taken from the Term::ReadPassword module. # It has been modified to not require Term::ReadLine, but still requires # POSIX::Termios if it's a POSIX machine ############################################################################### sub read_data { if ($CONFIGFILE{PROMPTS} eq 'no') { return; } my ($prompt, $mode, $POSIX) = @_; my $input; my %SPECIAL = ("\x03" => 'INT', # Control-C, Interrupt "\x08" => 'DEL', # Backspace "\x7f" => 'DEL', # Delete "\x0d" => 'ENT', # CR, Enter "\x0a" => 'ENT', # LF, Enter ); local (*TTY, *TTYOUT); open TTY, "<&STDIN" or return; open TTYOUT, ">>&STDOUT" or return; # Don't buffer it! select((select(TTYOUT), $| = 1)[0]); print TTYOUT $prompt; # Remember where everything was my $fd_tty = fileno(TTY); my $term = POSIX::Termios->new(); $term->getattr($fd_tty); my $original_flags = $term->getlflag(); if ($mode eq "noecho") { my $new_flags = $original_flags & ~(ISIG | ECHO | ICANON); $term->setlflag($new_flags); } $term->setattr($fd_tty, TCSAFLUSH); KEYSTROKE: while (1) { my $new_keys = ''; my $count = sysread(TTY, $new_keys, 99); if ($count) { for my $new_key (split //, $new_keys) { if (my $meaning = $SPECIAL{$new_key}) { if ($meaning eq 'ENT') { last KEYSTROKE; } elsif ($meaning eq 'DEL') { chop $input; } elsif ($meaning eq 'INT') { last KEYSTROKE; } else { $input .= $new_key; } } else { $input .= $new_key; } } } else { last KEYSTROKE; } } # Done with waiting for input. Let's not leave the cursor sitting # there, after the prompt. print TTY "\n"; nprint("\n"); # Let's put everything back where we found it. $term->setlflag($original_flags); $term->setattr($fd_tty, TCSAFLUSH); close(TTY); close(TTYOUT); return $input; } ############################################################################### sub proxy_check { my ($mark) = @_; setup_hash(\%request, $mark, "Proxy Check"); if (($request{'whisker'}->{'proxy_host'} ne '') && ($CLI{'useproxy'})) # proxy is set up { LW2::http_close(\%request); # force-close any old connections $request{'whisker'}->{'method'} = "GET"; $request{'whisker'}->{'uri'} = "/"; LW2::http_fixup_request(\%request); sleeper(); LW2::http_do_request_timeout(\%request, \%response); $COUNTERS{'totalrequests'}++; dump_var("Request Hash", \%request, ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); dump_var("Response Hash", \%response, ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); # First check that we can connect to the proxy if (exists $response{'whisker'}{'error'}) { if ($response{'whisker'}{'error'} =~ /Transport endpoint is not connected/) { nprint("+ ERROR: Could not connect to the defined proxy $CONFIGFILE{PROXYHOST}"); } nprint("+ ERROR: Proxy error: $response{'whisker'}{'error'}"); exit 1; } if ($response{'whisker'}{'code'} eq "407") # proxy requires auth { # have id/pw? if ($CONFIGFILE{PROXYUSER} eq "") { $CONFIGFILE{PROXYUSER} = read_data("Proxy ID: ", ""); $CONFIGFILE{PROXYPASS} = read_data("Proxy Pass: ", "noecho"); } if ($response{'proxy-authenticate'} !~ /Basic/i) { my @x = split(/ /, $response{'proxy-authenticate'}); nprint( "+ Proxy server uses '$x[0]' rather than 'Basic' authentication. $VARIABLES{'name'} $VARIABLES{'version'} can't do that." ); exit 1; } # test it... LW2::http_close(\%request); # force-close any old connections LW2::auth_set("proxy-basic", \%request, $CONFIGFILE{PROXYUSER}, $CONFIGFILE{PROXYPASS}) ; # set auth LW2::http_fixup_request(\%request); sleeper(); LW2::http_do_request_timeout(\%request, \%response); $COUNTERS{'totalrequests'}++; dump_var("Request Hash", \%request, ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); dump_var("Response Hash", \%response, ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); if ($response{'proxy-authenticate'} ne "") { my @pauthinfo = split(/ /, $response{'proxy-authenticate'}); my @pauthinfo2 = split(/=/, $response{'proxy-authenticate'}); $pauthinfo2[1] =~ s/^\"//; $pauthinfo2[1] =~ s/\"$//; nprint( "+ Proxy requires authentication for '$pauthinfo[0]' realm '$pauthinfo2[1]', unable to authenticate." ); exit 1; } else { nprint("- Successfully authenticated to proxy.", "v", undef); } } } return; } ####################################################################### sub dump_var { return if !$OUTPUT{'debug'}; my $msg = $_[0]; my %hash_in = %{ $_[1] }; my @scrubs; for (my $i = 2 ; $i <= $#_ ; $i++) { push(@scrubs, $_[$i]); } my $display = LW2::dump('', \%hash_in); my $new; $display =~ s/^\$/'$msg'/; if ($OUTPUT{'scrub'}) { $new = ""; foreach my $line (split(/\n/, $display)) { $line = scrub($line, @scrubs); $new .= "$line\n"; } $display = $new; } nprint($display, "d"); return; } ####################################################################### sub get_ua { # Always honor command line if ($CLI{'useragent'}) { return $CLI{'useragent'}; } # Return a random User-Agent from @USERAGENTS array if ( defined($VARIABLES{'@USERAGENTS'}) && ref($VARIABLES{'@USERAGENTS'}) eq 'ARRAY' && @{ $VARIABLES{'@USERAGENTS'} }) { my $ua = $VARIABLES{'@USERAGENTS'}->[ rand(@{ $VARIABLES{'@USERAGENTS'} }) ]; $ua =~ s/^"//; $ua =~ s/"$//; return $ua; } return undef; } ####################################################################### sub setup_hash { my ($reqhash, $mark, $testid) = @_; # Clear the hash first (like LW2::http_init_request does) %$reqhash = (); # Initialize the whisker hash $reqhash->{'whisker'} = {}; # Cache whisker hash reference to avoid repeated dereferencing my $whisker = $reqhash->{'whisker'}; # Set all required whisker properties (matching LW2::http_init_request defaults) $whisker->{'http_space1'} = ' '; $whisker->{'http_space2'} = ' '; $whisker->{'version'} = $CONFIGFILE{'DEFAULTHTTPVER'} || '1.1'; $whisker->{'method'} = 'GET'; $whisker->{'protocol'} = 'HTTP'; $whisker->{'port'} = $mark->{'port'} || 80; $whisker->{'uri'} = '/'; $whisker->{'uri_prefix'} = ''; $whisker->{'uri_postfix'} = ''; $whisker->{'uri_param_sep'} = '?'; $whisker->{'host'} = $mark->{'hostname'} || $mark->{'ip'}; $whisker->{'timeout'} = $CLI{'timeout'} || 10; $whisker->{'include_host_in_uri'} = 0; $whisker->{'ignore_duplicate_headers'} = 0; $whisker->{'normalize_incoming_headers'} = 1; $whisker->{'lowercase_incoming_headers'} = 1; $whisker->{'require_newline_after_headers'} = 0; $whisker->{'invalid_protocol_return_value'} = 1; $whisker->{'ssl'} = $mark->{'ssl'} || 0; $whisker->{'ssl_save_info'} = 1; $whisker->{'http_eol'} = "\x0d\x0a"; $whisker->{'force_close'} = 0; $whisker->{'force_open'} = 0; $whisker->{'retry'} = 0; $whisker->{'trailing_slurp'} = 0; $whisker->{'force_bodysnatch'} = 0; $whisker->{'max_size'} = 750000; $whisker->{'MAGIC'} = 31339; # Set SSL-specific fields if needed if ($mark->{'ssl'}) { $whisker->{'ssl_rsacertfile'} = $mark->{'key'}; $whisker->{'ssl_certfile'} = $mark->{'cert'}; } # Set evasion only if needed $whisker->{'anti_ids'} = $CLI{'evasion'} if (length($CLI{'evasion'})); # Set default headers (like LW2::http_init_request does) $reqhash->{'Connection'} = 'Keep-Alive'; # Random User-Agent $reqhash->{'User-Agent'} = get_ua(); # Set Host header only if vhost is configured if ($mark->{'has_vhost'}) { $reqhash->{'Host'} = $mark->{'vhost'}; } # Proxy configuration if (length($CONFIGFILE{PROXYHOST}) && $CLI{'useproxy'}) { $whisker->{'proxy_host'} = $CONFIGFILE{'PROXYHOST'}; $whisker->{'proxy_port'} = $CONFIGFILE{'PROXYPORT'}; # Set proxy auth only if credentials are provided if (length($CONFIGFILE{'PROXYUSER'})) { LW2::auth_set("proxy-basic", $reqhash, $CONFIGFILE{'PROXYUSER'}, $CONFIGFILE{'PROXYPASS'}); } } return $reqhash; } ####################################################################### sub running_average { my $last = shift; my ($mark) = @_; # Use push instead of unshift for better performance push(@{ $mark->{'running_avg'} }, $last); # Only splice if we exceed the limit (more efficient than always splicing) if (@{ $mark->{'running_avg'} } > 100) { splice(@{ $mark->{'running_avg'} }, 0, @{ $mark->{'running_avg'} } - 100); } } ####################################################################### sub running_average_print { use List::Util qw(sum); my ($mark) = @_; my @data = @{ $mark->{'running_avg'} }; my $elements = @data; # More efficient than $#data + 1 return "Running average: Not enough data." if $elements == 0; my $message = ''; if ($elements == 100) { my $avg = sum(@data) / $elements; $message = sprintf("100 requests: %.5f sec, ", $avg); } if ($elements > 10) { my @recent_data = @data[ ($#data - 9) .. $#data ]; my $recent_count = @recent_data; my $avg = sum(@recent_data) / $recent_count; $message .= sprintf("10 requests: %.4f sec", $avg); } return "Running average: $message."; } ####################################################################### sub nfetch { my ($mark, $uri, $method, $data, $headers_send, $flags, $testid, $httpver) = @_; my (%request, %response); setup_hash(\%request, $mark, $testid); # Ensure $flags is a hash reference (handle cases where empty string is passed) if (!ref($flags) || ref($flags) ne 'HASH') { $flags = {}; } # Check for keyboard input & terminate flag if (!$CLI{'nointeractive'} && !(($COUNTERS{'totalrequests'} % 10))) { $mark->{'terminate'} = 1 if (check_input($mark) eq 'term'); } # Check execution time if (my $maxtime = $CLI{'maxtime'}) { # Cache start_time to avoid repeated hash access my $start_time = $mark->{'start_time'}; if ((time() - $start_time) > $maxtime) { nprint("+ ERROR: Host maximum execution time of $maxtime seconds reached"); $mark->{'terminate'} = 1; } } # Prepend -root option's value if set $request{'whisker'}->{'uri'} = $mark->{'root'} . $uri; # Remove trailing slash if requested $request{'whisker'}->{'uri'} =~ s/\/$// if ($CLI{'noslash'}); $request{'whisker'}->{'method'} = $method; # POST data? if (length($data)) { $data =~ s/\\\"/\"/g; $request{'whisker'}->{'data'} = $data; } # Default an unobtrusive headers to help WAF evasion $request{'whisker'}->{'Accept'} = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'; $request{'whisker'}->{'Accept-Language'} = 'en-US,en;q=0.5'; $request{'whisker'}->{'Cache-Control'} = 'max-age=0'; $request{'whisker'}->{'Connection'} = 'keep-alive'; $request{'whisker'}->{'Upgrade-Insecure-Requests'} = '1'; $request{'whisker'}->{'Sec-Fetch-Dest'} = 'document'; $request{'whisker'}->{'Sec-Fetch-Mode'} = 'navigate'; $request{'whisker'}->{'Sec-Fetch-Site'} = 'none'; $request{'whisker'}->{'Sec-Fetch-User'} = '?1'; # Check for extra HTTP headers if (ref($headers_send) eq "HASH") { # Use explicit hash assignment instead of slice assignment foreach my $key (keys %$headers_send) { $request{$key} = $headers_send->{$key}; } } # Add custom headers from CLI if any if (defined $CLI{'headers'} && @{ $CLI{'headers'} }) { foreach my $header (@{ $CLI{'headers'} }) { if ($header =~ /^([^:]+):\s*(.+)$/) { my ($headername, $value) = ($1, $2); $request{$headername} = $value; } } } # Set auth if (my $realm = $mark->{'realms'}{'default'}) { if (length($realm->{'authtype'})) { LW2::auth_set($realm->{'authtype'}, $request, $realm->{'id'}, $realm->{'password'}); } } # Set cookies LW2::cookie_write($mark->{'cookiejar'}, \%request, 1) if defined($mark->{'cookiejar'}); # Override HTTP version $request{'whisker'}->{'version'} = $httpver if ($httpver ne ''); $request{'whisker'}->{'host'} = $mark->{'ip'} if ($flags->{'nohost'}); LW2::http_fixup_request(\%request) unless ($flags->{'noclean'}); # Run pre hooks unless ($flags->{'noprefetch'}) { (%$request, %$response) = run_hooks($mark, "prefetch", \%request, \%response); } # Do the request sleeper(); my $time = [gettimeofday]; LW2::http_do_request_timeout(\%request, \%response); $COUNTERS{'totalrequests'}++; if (!$VARIABLES{'MSWIN32'}) { running_average(tv_interval($time, [gettimeofday]), $mark); } # If we got an error, do 1 retry - optimized if (my $whisker = $response{'whisker'}) { if (defined $whisker->{'error'} || $whisker->{'code'} eq '') { $mark->{'failures'}++; sleeper(); LW2::http_do_request_timeout(\%request, \%response); $COUNTERS{'totalrequests'}++; } } # Get cookies from response & add to jar if (!$CLI{'nocookies'}) { my $tmpjar = LW2::cookie_new_jar(); LW2::cookie_read(\%tmpjar, \%response, \%request); # Cache cookiejar reference to avoid repeated hash access my $cookiejar = $mark->{'cookiejar'}; # Use more efficient array construction foreach my $c (keys %tmpjar) { my $cookie_data = $tmpjar{$c}; $cookiejar->{$c} = [ $cookie_data->[0], $cookie_data->[1], $cookie_data->[2], undef, $cookie_data->[4] ]; } } # follow redirects if ($CLI{'followredirects'} && ($response{'whisker'}->{'code'} =~ /^30[1278]/)) { my $newlocation = $response{'location'}; my $port = $mark->{'port'}; # Is a full URL redirect the same host? # Pre-compute host alternatives for better performance my $host_re = join '|', map { quotemeta $_ } ($mark->{'ip'}, $mark->{'hostname'}, $mark->{'display_name'}); # Only build port regex if port is specified if ($port && $port != 80 && $port != 443) { my $port_re = '(?:\:' . quotemeta($port) . ')?'; if ($response{'location'} =~ /^https?:\/\/($host_re)$port_re\//i) { $newlocation =~ s{^https?:\/\/(?:$host_re)$port_re/}{/}i; } } else { # No port needed in regex for standard ports if ($response{'location'} =~ /^https?:\/\/($host_re)\//i) { $newlocation =~ s{^https?:\/\/(?:$host_re)/}{/}i; } } # Cache whisker reference to avoid repeated hash access my $whisker = $request{'whisker'}; $whisker->{'uri'} = $newlocation; # Make redirect request LW2::http_fixup_request(\%request) unless ($flags->{'noclean'}); sleeper(); LW2::http_do_request_timeout(\%request, \%response); $COUNTERS{'totalrequests'}++; } # Check failures my $fail_limit = $CONFIGFILE{'FAILURES'}; if ($fail_limit > 0 && $mark->{'failures'} >= $fail_limit) { nprint( "+ ERROR: *** Error limit ($CONFIGFILE{'FAILURES'}) reached for host, giving up. Last error: " . $response{'whisker'}->{'error'} . ". ***\n+ ERROR: *** Consider using mitmproxy to avoid TLS fingerprinting. ***", "", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'}) ); $mark->{'terminate'} = 1; status_report(); } if ($OUTPUT{'debug'}) { dump_var("Request Hash", \%request, ($mark->{'ip'}, $mark->{'hostname'}, $mark->{'displayname'})); dump_var("Response Hash", \%response, ($mark->{'ip'}, $mark->{'hostname'}, $mark->{'displayname'})); } # Snarf what we can from the whisker hash and put in mark my $banner = \$mark->{'banner'}; my $whisker = $response{'whisker'}; if (!exists $whisker->{'error'}) { # Banner processing if ($$banner eq "") { $$banner = $response{'server'}; } elsif ( exists $response{'server'} && !exists $mark->{'bannerchanged'} && ($$banner ne $response{'server'}) && ($response{'server'} ne 'Microsoft-HTTPAPI/2.0')) { $request->{'whisker'}->{'uri'} = "/" if ( !defined $request->{'whisker'}->{'uri'} || $request->{'whisker'}->{'uri'} eq "" || $request->{'whisker'}->{'uri'} eq "."); add_vulnerability($mark, $request->{'whisker'}->{'uri'} . ": Server banner changed from '$$banner' to '$response{server}'", 999962, "", $method, $uri, $request, $response ); $mark->{'bannerchanged'} = 1; } # Also check X-Powered-By header for outdated version checking # Only add to components if it contains version-like information (digits, slashes, or dots) if (exists $response{'x-powered-by'} && $response{'x-powered-by'} =~ /(?:\d|\/|\.)/) { my $xpb_value = $response{'x-powered-by'}; $xpb_value =~ s/\s+.*$//; # Strip any trailing whitespace/content if (!exists $mark->{'components'}->{$xpb_value}) { $mark->{'components'}->{$xpb_value} = 1; } } # TLS if (!exists $mark->{'ssl_cipher'} && $mark->{'ssl'}) { # Cache SSL certificate array reference my $altnames = $whisker->{'ssl_cert_altnames'}; # Grab ssl details $mark->{'ssl_cipher'} = $whisker->{'ssl_cipher'}; $mark->{'ssl_cert_issuer'} = $whisker->{'ssl_cert_issuer'}; $mark->{'ssl_cert_subject'} = $whisker->{'ssl_cert_subject'}; # Process altnames correctly - Net::SSLeay::X509_get_subjectAltNames returns # an array where even indices are type codes and odd indices are the actual names if ($altnames && @$altnames) { my @valid_names; for (my $i = 1 ; $i < @$altnames ; $i += 2) { my $name = $altnames->[$i]; # Only include DNS names (type 2) and skip numeric-only names if ($altnames->[ $i - 1 ] == 2 && $name !~ /^[\d]+$/ && $name ne '') { push(@valid_names, $name); } } $mark->{'ssl_cert_altnames'} = join(', ', @valid_names); } } } nprint("- $response{'whisker'}{'code'} for $method:\t$response{'whisker'}->{'uri_requested'}", "v", $testid, ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); # Check for errors to reduce false positives if (my $whisker = $response{'whisker'}) { if ((defined $whisker->{'error'} || $whisker->{'code'} eq '') && !exists $flags->{'noerror'}) { $mark->{'total_errors'}++; nprint("+ ERROR: $whisker->{'uri_requested'} returned an error: $whisker->{'error'}\n", "e", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); if ($whisker->{'code'} eq '502' && $CLI{'useproxy'}) { nprint("+ ERROR: Received 502 'Bad Gateway' from proxy\n"); } } } # Show cookies if ($OUTPUT{'show_cookies'} && (my $cookies = $response{'whisker'}->{'cookies'})) { # Cache frequently accessed values my $uri_requested = $response{'whisker'}->{'uri_requested'}; my $hostname = $mark->{'hostname'}; my $ip = $mark->{'ip'}; my $displayname = $mark->{'displayname'}; foreach my $c (@$cookies) { nprint("+ $uri_requested sent cookie: $c", "", ($hostname, $ip, $displayname)); } } # Run post hooks unless ($flags->{'nopostfetch'}) { ($request, %$response) = run_hooks($mark, "postfetch", \%request, \%response); } return $response{'whisker'}->{'code'}, $response{'whisker'}->{'data'}, $response{'whisker'}->{'error'}, \%request, \%response; } ####################################################################### sub set_scan_items { %TESTS = (); $COUNTERS{total_checks} = 0; my %SKIPLIST = (); if (defined $CONFIGFILE{SKIPIDS}) { foreach my $id (split(/ /, $CONFIGFILE{SKIPIDS})) { $SKIPLIST{$id} = 1; } } my ($includes, $excludes) = ""; foreach my $tune (split(//, $CLI{'tuning'})) { next if ($tune eq "x"); if ($CLI{'tuning'} !~ /(?{'terminate'} = 1; } return; } ####################################################################### # Check for updates to the program # Expects response like: # { "products": { "nikto": { "version": "2.6.0", "epoch": 1737935000 } } } sub check_updates { # Get epoch from program/.timestamp || 0 my $epoch = 0; my $timestamp_file = defined $CONFIGFILE{'EXECDIR'} ? "$CONFIGFILE{'EXECDIR'}/.timestamp" : 'program/.timestamp'; if (-f $timestamp_file) { if (open(my $fh, '<', $timestamp_file)) { $epoch = <$fh>; chomp($epoch); close($fh); } } # Request API to get manifest JSON using LibWhisker and configured proxy return unless defined $CONFIGFILE{'VERSION_API'} && $CONFIGFILE{'VERSION_API'} ne ''; eval "require JSON::PP"; return if $@; my %request; my %response; # Parse URL from config my @uridata = LW2::uri_split($CONFIGFILE{'VERSION_API'}); my $host = $uridata[2] || ''; my $port = $uridata[3] || ''; my $path = $uridata[0] || '/'; # Determine SSL and default port my $ssl = ($CONFIGFILE{'VERSION_API'} =~ /^https:/i) ? 1 : 0; if ($port eq '') { $port = $ssl ? 443 : 80; } # Build URI with query parameters my $uri = $path; $uri .= ($path =~ /\?/) ? '&' : '?'; $uri .= "p=nikto&v=$VARIABLES{'version'}&e=$epoch"; LW2::http_init_request(\%request); $request{'whisker'}->{'host'} = $host; $request{'whisker'}->{'port'} = $port; $request{'whisker'}->{'ssl'} = $ssl; $request{'whisker'}->{'uri'} = $uri; $request{'whisker'}->{'timeout'} = 5; $request{'User-Agent'} = "Nikto/$VARIABLES{'version'}"; # Configure proxy if enabled if (length($CONFIGFILE{PROXYHOST}) && $CLI{'useproxy'}) { $request{'whisker'}->{'proxy_host'} = $CONFIGFILE{'PROXYHOST'}; $request{'whisker'}->{'proxy_port'} = $CONFIGFILE{'PROXYPORT'}; if (length($CONFIGFILE{'PROXYUSER'})) { LW2::auth_set("proxy-basic", \%request, $CONFIGFILE{'PROXYUSER'}, $CONFIGFILE{'PROXYPASS'}); } } LW2::http_fixup_request(\%request); LW2::http_do_request_timeout(\%request, \%response); # Check if request succeeded if (($response{'whisker'}->{'code'} ne '200') || ($response{'whisker'}->{'data'} eq '')) { nprint("+ ERROR: Failed to check for updates: $response{'whisker'}->{'code'}"); return; } # Parse JSON response my $json_data; eval { $json_data = JSON::PP->new->utf8(1)->decode($response{'whisker'}->{'data'}); }; return if $@ || !$json_data; # Extract remote version and epoch my $remote_version = $json_data->{'products'}->{'nikto'}->{'version'} || ''; my $remote_epoch = $json_data->{'products'}->{'nikto'}->{'epoch'} || 0; # Compare epoch to manifest epoch # If remote epoch is greater OR remote version is greater: if ($remote_epoch > $epoch || ($remote_version ne '' && $remote_version ne $VARIABLES{'version'})) { my $defer = $VARIABLES{'deferout'}; $VARIABLES{'deferout'} = 0; # Check if git install my $is_git = (-d '.git' || -d '../.git' || -d '../../.git'); if ($is_git) { nprint( "+ Your Nikto installation is out of date. Please run 'git pull' to update to the latest version of Nikto." ); } else { nprint("+ Your Nikto installation is out of date."); } $VARIABLES{'deferout'} = $defer; } } ####################################################################### # Expand @LFI() in DSL with platform-specific matchers sub expand_lfi_dsl { my ($dsl) = @_; if ($dsl =~ /@?LFI\(\)/) { # Local variables for LFI matching my $lfi_match_win = $VARIABLES{'@LFIMATCHWIN'} || ''; my $lfi_match_nix = $VARIABLES{'@LFIMATCHNIX'} || ''; # If both are empty, return original DSL if (!$lfi_match_win && !$lfi_match_nix) { return $dsl; } # Build the OR pattern: (@LFIMATCHWIN|@LFIMATCHNIX) # Use string concatenation to preserve backslashes and avoid interpolation issues my $replacement; if ($lfi_match_win && $lfi_match_nix) { $replacement = '(' . $lfi_match_win . '|' . $lfi_match_nix . ')'; } elsif ($lfi_match_win) { $replacement = $lfi_match_win; } else { $replacement = $lfi_match_nix; } # Replace @LFI() with the expanded pattern # Use \Q...\E to quote the replacement and prevent any regex interpretation # But we need the | and && to work, so we can't quote everything # Instead, just do the substitution - the replacement side doesn't interpret regex $dsl =~ s/@?LFI\(\)/$replacement/g; } return $dsl; } sub build_matcher { my ($dsl, $checkid) = @_; # Return early if DSL is undefined or empty return sub { return (0, []); } unless defined $dsl && length $dsl; # Expand @LFI() if present my $expanded_dsl = expand_lfi_dsl($dsl); # Use cached parser for speed my $parsed = $DSL_CACHE{$expanded_dsl}; if (!$parsed) { $parsed = parse_dsl($expanded_dsl); $DSL_CACHE{$expanded_dsl} = $parsed; } return sub { my ($code, $body, $headers, $cookies) = @_; my @captured_groups = (); # Store captured groups for extraction # --- CODE NEGATIVES --- for my $re (@{ $parsed->{code_neg} }) { return (0, []) if $code =~ $re; } # --- CODE POSITIVES --- for my $re (@{ $parsed->{code_pos} }) { if ($code =~ $re) { # Capture groups if they exist push @captured_groups, $1, $2, $3, $4, $5, $6, $7, $8, $9; } else { return (0, []); } } # --- HEADER NEGATIVES --- for my $h (@{ $parsed->{header_neg} }) { if (exists $headers->{ $h->{name} }) { # If regex defined, header must NOT match return (0, []) if $h->{regex} && $headers->{ $h->{name} } =~ $h->{regex}; # If no regex, header must not exist return (0, []) unless $h->{regex}; } } # --- HEADER POSITIVES --- for my $h (@{ $parsed->{header_pos} }) { # Must exist return (0, []) unless exists $headers->{ $h->{name} }; # Must match regex if defined if ($h->{regex}) { if ($headers->{ $h->{name} } =~ $h->{regex}) { # Capture groups if they exist push @captured_groups, $1, $2, $3, $4, $5, $6, $7, $8, $9; } else { return (0, []); } } } # --- COOKIE NEGATIVES --- for my $c (@{ $parsed->{cookie_neg} }) { if (exists $cookies->{ $c->{name} }) { # If regex defined, cookie must NOT match return (0, []) if $c->{regex} && $cookies->{ $c->{name} } =~ $c->{regex}; # If no regex, cookie must not exist return (0, []) unless $c->{regex}; } } # --- COOKIE POSITIVES --- for my $c (@{ $parsed->{cookie_pos} }) { # Must exist return (0, []) unless exists $cookies->{ $c->{name} }; # Must match regex if defined if ($c->{regex}) { if ($cookies->{ $c->{name} } =~ $c->{regex}) { # Capture groups if they exist push @captured_groups, $1, $2, $3, $4, $5, $6, $7, $8, $9; } else { return (0, []); } } } # --- BODY NEGATIVES --- for my $re (@{ $parsed->{body_neg} }) { return (0, []) if $body =~ $re; } # --- BODY POSITIVES --- for my $re (@{ $parsed->{body_pos} }) { if ($body =~ $re) { # Capture groups if they exist push @captured_groups, $1, $2, $3, $4, $5, $6, $7, $8, $9; } else { return (0, []); } } # --- OR GROUPS --- # Each OR group must have at least one alternative that matches if (exists $parsed->{or_groups} && @{ $parsed->{or_groups} }) { for my $or_group (@{ $parsed->{or_groups} }) { my $or_matched = 0; foreach my $alt_parsed (@$or_group) { # Build a temporary matcher for this alternative my $alt_matcher = sub { my ($alt_code, $alt_body, $alt_headers, $alt_cookies) = @_; my @alt_captures = (); # Check all conditions in this alternative for my $re (@{ $alt_parsed->{code_neg} }) { return (0, []) if $alt_code =~ $re; } for my $re (@{ $alt_parsed->{code_pos} }) { return (0, []) unless $alt_code =~ $re; } for my $h (@{ $alt_parsed->{header_neg} }) { if (exists $alt_headers->{ $h->{name} }) { return (0, []) if $h->{regex} && $alt_headers->{ $h->{name} } =~ $h->{regex}; return (0, []) unless $h->{regex}; } } for my $h (@{ $alt_parsed->{header_pos} }) { return (0, []) unless exists $alt_headers->{ $h->{name} }; if ($h->{regex}) { return (0, []) unless $alt_headers->{ $h->{name} } =~ $h->{regex}; } } for my $c (@{ $alt_parsed->{cookie_neg} }) { if (exists $alt_cookies->{ $c->{name} }) { return (0, []) if $c->{regex} && $alt_cookies->{ $c->{name} } =~ $c->{regex}; return (0, []) unless $c->{regex}; } } for my $c (@{ $alt_parsed->{cookie_pos} }) { return (0, []) unless exists $alt_cookies->{ $c->{name} }; if ($c->{regex}) { return (0, []) unless $alt_cookies->{ $c->{name} } =~ $c->{regex}; } } for my $re (@{ $alt_parsed->{body_neg} }) { return (0, []) if $alt_body =~ $re; } for my $re (@{ $alt_parsed->{body_pos} }) { return (0, []) unless $alt_body =~ $re; } # Check nested OR groups in this alternative if (exists $alt_parsed->{or_groups} && @{ $alt_parsed->{or_groups} }) { for my $nested_or_group (@{ $alt_parsed->{or_groups} }) { my $nested_or_matched = 0; foreach my $nested_alt (@$nested_or_group) { my $nested_alt_matcher = sub { my ($n_code, $n_body, $n_headers, $n_cookies) = @_; for my $re (@{ $nested_alt->{code_neg} }) { return (0, []) if $n_code =~ $re; } for my $re (@{ $nested_alt->{code_pos} }) { return (0, []) unless $n_code =~ $re; } for my $h (@{ $nested_alt->{header_neg} }) { if (exists $n_headers->{ $h->{name} }) { return (0, []) if $h->{regex} && $n_headers->{ $h->{name} } =~ $h->{regex}; return (0, []) unless $h->{regex}; } } for my $h (@{ $nested_alt->{header_pos} }) { return (0, []) unless exists $n_headers->{ $h->{name} }; if ($h->{regex}) { return (0, []) unless $n_headers->{ $h->{name} } =~ $h->{regex}; } } for my $c (@{ $nested_alt->{cookie_neg} }) { if (exists $n_cookies->{ $c->{name} }) { return (0, []) if $c->{regex} && $n_cookies->{ $c->{name} } =~ $c->{regex}; return (0, []) unless $c->{regex}; } } for my $c (@{ $nested_alt->{cookie_pos} }) { return (0, []) unless exists $n_cookies->{ $c->{name} }; if ($c->{regex}) { return (0, []) unless $n_cookies->{ $c->{name} } =~ $c->{regex}; } } for my $re (@{ $nested_alt->{body_neg} }) { return (0, []) if $n_body =~ $re; } for my $re (@{ $nested_alt->{body_pos} }) { return (0, []) unless $n_body =~ $re; } return (1, []); }; my ($n_match, $n_caps) = $nested_alt_matcher->( $alt_code, $alt_body, $alt_headers, $alt_cookies ); if ($n_match) { $nested_or_matched = 1; push @alt_captures, @$n_caps if $n_caps; last; } } return (0, []) unless $nested_or_matched; } } return (1, \@alt_captures); }; # Test this alternative my ($alt_match, $alt_caps) = $alt_matcher->($code, $body, $headers, $cookies); if ($alt_match) { $or_matched = 1; push @captured_groups, @$alt_caps if $alt_caps; last; # One match is enough for OR } } # If no alternative matched, the OR group fails return (0, []) unless $or_matched; } } # Filter out undefined captured groups and build final array my @final_captures = (); for my $capture (@captured_groups) { push @final_captures, $capture if defined $capture; } return (1, \@final_captures); # All conditions passed, return captures }; } ####################################################################### sub path_matcher { my ($files_ref, $dirs_ref, $links_ref) = @_; # Map file patterns to variable names # var can be a string or array ref for multiple variables my @file_patterns = ({ pattern => qr/pass/i, var => '@PASSWORDFILES' },); # Process files if ($files_ref) { foreach my $file (keys %{$files_ref}) { my $raw = $file; $file = validate_and_fix_regex($file); foreach my $check (@file_patterns) { if ($file =~ $check->{pattern}) { my @vars = ref($check->{var}) eq 'ARRAY' ? @{ $check->{var} } : ($check->{var}); foreach my $var (@vars) { if (index(lc($VARIABLES{$var}), lc($file)) < 0) { $VARIABLES{$var} .= " $raw"; } } } } } } # Map directory patterns to variable names # var can be a string or array ref for multiple variables my @dir_patterns = ({ pattern => qr/cgi/i, var => '@CGIDIRS' }, { pattern => qr/forum/i, var => [ '@NUKE', '@VBULLETIN' ] }, { pattern => qr/pass/i, var => '@PASSWORDDIRS' }, { pattern => qr/nuke/i, var => '@NUKE' }, { pattern => qr/admin/i, var => '@ADMIN' }, { pattern => qr/phpmy/i, var => '@PHPMYADMIN' }, { pattern => qr/fck/i, var => '@FCKEDITOR' }, { pattern => qr/crystal/i, var => '@CRYSTALREPORTS' }, { pattern => qr/struts/i, var => '@STRUTSACTIONS' }, { pattern => qr/(wordpress|wp)/i, var => '@WORDPRESS' }, { pattern => qr/php/i, var => '@PHPINFODIRS' }, { pattern => qr/phpinfo/i, var => '@PHPINFOFILES' }, { pattern => qr/mantis/i, var => '@MANTIS' }, { pattern => qr/dokuwiki/i, var => '@DOKUWIKI' }, { pattern => qr/rockmongo/i, var => '@ROCKMONGO' }, { pattern => qr/magento/i, var => '@MAGENTO' }, { pattern => qr/(vb|vbulletin)/i, var => '@VBULLETIN' }, { pattern => qr/jenkins/i, var => '@JENKINS' }, { pattern => qr/symphony|contrib/i, var => '@SYMPHONY' }, ); # Process directories if ($dirs_ref) { foreach my $dir (keys %{$dirs_ref}) { # Directory is already validated before being stored, just use it directly foreach my $check (@dir_patterns) { if ($dir =~ $check->{pattern}) { my @vars = ref($check->{var}) eq 'ARRAY' ? @{ $check->{var} } : ($check->{var}); foreach my $var (@vars) { if (index(lc($VARIABLES{$var}), lc($dir)) < 0) { $VARIABLES{$var} .= " $dir"; } } } } } } # Map link patterns to variable names # var can be a string or array ref for multiple variables my @link_patterns = ({ pattern => qr/\.action(\?|$)/i, var => '@STRUTSACTIONS' },); # Process full links if ($links_ref) { foreach my $link (keys %{$links_ref}) { $link = validate_and_fix_regex($link); foreach my $check (@link_patterns) { if ($link =~ $check->{pattern}) { my @vars = ref($check->{var}) eq 'ARRAY' ? @{ $check->{var} } : ($check->{var}); foreach my $var (@vars) { if ($VARIABLES{$var} !~ /$link/i) { $VARIABLES{$var} .= " $link"; } } } } } } } ####################################################################### # extract IP like strings and return an array sub get_ips { my $string = shift || return; my $ip_regex = qr/(?:\b|[^0-9v])($LW2::IPv4_re|$LW2::IPv6_re_inc_zoneid)(?:\b|[^0-9])/; return $string =~ /$ip_regex/g; } ####################################################################### # Check an IP's validity. Returns booleans for: validity, internal, loopback sub is_ip { my $ip = $_[0] || return 0, 0, 0; my $internal = 0; my $loopback = 0; # This is a little hacky but prevents Cloudflare cookies and headers from reporting if ($ip eq '1.0.1.1') { return 0, 0, 0; } if ($ip =~ /^$LW2::IPv4_re$/) { # check for internal if ($ip =~ /^(?:10|192\.168|172\.(?:1[6-9]|2\d|3[01]))\./) { $internal = 1; } # check for loopback if ($ip eq '127.0.0.1') { $loopback = 1; } } elsif ($ip =~ /^$LW2::IPv6_re_inc_zoneid(?:\/[0-9]+)?$/) { # check for internal if ($ip =~ /^(?:10|192\.168|172\.(?:1[6-9]|2\d|3[01]))\./) { $internal = 1; } if ($ip =~ /^(?:fe80 # is a link local unicast address |ff0[1-8] # is a multicast address |fc00 # private network ):/ix ) { $internal = 1; } # lastly, loopback? # This is a bit rough 'n' ready, could do with some finesse if ($ip =~ /^[01:]+(?:\/[0-9]+)?$/) { $loopback = 1; } } else { return 0, $internal, $loopback; } return 1, $internal, $loopback; } ####################################################################### sub parse_dsl { my ($dsl) = @_; die "Empty DSL string" unless defined $dsl && length $dsl; # Split top-level conditions on && and ||, but not inside parentheses # This preserves OR patterns like (PATTERN1|PATTERN2&&PATTERN3) # First, check if there's a top-level || (OR operator) my $has_top_level_or = 0; my $depth = 0; for (my $i = 0 ; $i < length($dsl) ; $i++) { my $char = substr($dsl, $i, 1); my $next_char = ($i < length($dsl) - 1) ? substr($dsl, $i + 1, 1) : ''; my $prev_char = ($i > 0) ? substr($dsl, $i - 1, 1) : ''; my $is_escaped = ($prev_char eq '\\'); if (!$is_escaped) { if ($char eq '(') { $depth++; } elsif ($char eq ')') { $depth--; } elsif ($char eq '|' && $next_char eq '|' && $depth == 0) { $has_top_level_or = 1; last; } } } # If we have a top-level ||, split on it and wrap in OR group if ($has_top_level_or) { my @or_alternatives = (); $depth = 0; my $current_alt = ''; for (my $i = 0 ; $i < length($dsl) ; $i++) { my $char = substr($dsl, $i, 1); my $next_char = ($i < length($dsl) - 1) ? substr($dsl, $i + 1, 1) : ''; my $prev_char = ($i > 0) ? substr($dsl, $i - 1, 1) : ''; my $is_escaped = ($prev_char eq '\\'); if (!$is_escaped) { if ($char eq '|' && $next_char eq '|' && $depth == 0) { push @or_alternatives, $current_alt if $current_alt ne ''; $current_alt = ''; $depth = 0; # Reset depth for new alternative $i += 1; # Skip first |, loop will auto-increment to skip second | next; # Continue to next iteration (will auto-increment i, skipping second |) } elsif ($char eq '(') { $depth++; $current_alt .= $char; } elsif ($char eq ')') { $depth--; $current_alt .= $char; } else { $current_alt .= $char; } } else { $current_alt .= $char; } } push @or_alternatives, $current_alt if $current_alt ne ''; # Parse each alternative and create an OR group my @or_parsed = (); foreach my $alt (@or_alternatives) { $alt =~ s/^\s+|\s+$//g; next unless $alt; # If alternative doesn't start with a known type prefix or OR group, assume it's a BODY pattern if ($alt !~ /^(CODE|BODY|HEADER|COOKIE|!CODE|!BODY|!HEADER|!COOKIE):/i && $alt !~ /^\(/) { $alt = "BODY:$alt"; } my $alt_parsed = parse_dsl($alt); push @or_parsed, $alt_parsed; } # Return a compiled structure with just the OR group my %compiled = (code_pos => [], code_neg => [], body_pos => [], body_neg => [], header_pos => [], header_neg => [], cookie_pos => [], cookie_neg => [], or_groups => [ \@or_parsed ], ); return \%compiled; } # No top-level ||, proceed with normal && splitting my @tokens = (); $depth = 0; my $current_token = ''; my $i = 0; my $len = length($dsl); while ($i < $len) { my $char = substr($dsl, $i, 1); my $next_char = ($i < $len - 1) ? substr($dsl, $i + 1, 1) : ''; my $prev_char = ($i > 0) ? substr($dsl, $i - 1, 1) : ''; # Check if && is escaped (previous char is backslash, similar to original regex) my $is_escaped = ($prev_char eq '\\'); if (!$is_escaped) { if ($char eq '(') { $depth++; $current_token .= $char; } elsif ($char eq ')') { $depth--; $current_token .= $char; } elsif ($char eq '&' && $next_char eq '&' && $depth == 0) { # Found top-level && delimiter push @tokens, $current_token if $current_token ne ''; $current_token = ''; $i += 2; # Skip both & characters next; } else { $current_token .= $char; } } else { # Escaped character, just add it $current_token .= $char; } $i++; } push @tokens, $current_token if $current_token ne ''; my %compiled = (code_pos => [], code_neg => [], body_pos => [], body_neg => [], header_pos => [], header_neg => [], cookie_pos => [], cookie_neg => [], or_groups => [], # Store OR patterns for special handling ); foreach my $token (@tokens) { $token =~ s/^\s+|\s+$//g; # trim whitespace $token =~ s/,\s*$// if length($token) > 1; # trim trailing comma next unless $token; # Check if this is an OR pattern (starts with ( and ends with )) # Use regex to check if it's a properly formed OR pattern if ($token =~ /^\((.*)\)$/) { # Verify balanced parentheses in the content (the outer parens are already matched) my $or_content = $1; my $depth = 0; my $valid_or = 1; for my $i (0 .. length($or_content) - 1) { my $char = substr($or_content, $i, 1); my $prev_char = ($i > 0) ? substr($or_content, $i - 1, 1) : ''; my $is_escaped = ($prev_char eq '\\'); if (!$is_escaped) { if ($char eq '(') { $depth++; } elsif ($char eq ')') { $depth--; if ($depth < 0) { $valid_or = 0; last; } } } } # Content must have balanced parentheses if ($valid_or && $depth == 0) { # Split on | but not escaped | my @alternatives = split /(? lc($hname), regex => $re, }; } elsif ($type eq 'COOKIE') { my ($cname, $cval) = split /:/, $pattern, 2; die "Missing COOKIE name: $token" unless defined $cname && length $cname; $cname =~ s/^\s+|\s+$//g; $cval = '' unless defined $cval; $cval =~ s/^\s+//; my $re = length($cval) ? compile_regex($cval) : undef; push @{ $neg ? $compiled{cookie_neg} : $compiled{cookie_pos} }, { name => lc($cname), regex => $re, }; } else { die "Unknown DSL type: $type"; } } return \%compiled; } ####################################################################### sub compile_regex { my ($pattern) = @_; # Remove escapes for our delimiters (but preserve other escapes like \. \d etc) # However, don't unescape pipes inside escaped parentheses \( \) as those are literal # Protect pipes inside \( \) blocks by temporarily replacing them my @protected_blocks = (); my $block_idx = 0; while ($pattern =~ /(\\\([^)]*\\\))/g) { my $block = $1; my $placeholder = "___PROTECTED_BLOCK_${block_idx}___"; $protected_blocks[$block_idx] = $block; $pattern =~ s/\Q$block\E/$placeholder/; $block_idx++; } # Now unescape delimiters outside protected blocks $pattern =~ s/\\([|!&:])/$1/g; # Restore protected blocks (with their escaped pipes intact) for (my $i = 0 ; $i < @protected_blocks ; $i++) { my $placeholder = "___PROTECTED_BLOCK_${i}___"; $pattern =~ s/\Q$placeholder\E/$protected_blocks[$i]/; } # Fix escaped quotes - convert \" to " (escaped quotes in CSV become literal quotes in regex) # This prevents "Trailing \ in regex" errors when patterns end with \" $pattern =~ s/\\"/"/g; # Fix trailing backslashes - if pattern ends with \ (not part of an escape sequence), double it # This prevents "Trailing \ in regex" errors (e.g., "c:\" becomes "c:\\") # Only do this if the backslash is not already escaped (not "\\") if ($pattern =~ /[^\\]\\$/) { $pattern =~ s/([^\\])\\$/$1\\\\/; # Double the trailing backslash } elsif ($pattern =~ /^\\$/) { # Pattern is just a single backslash $pattern = '\\\\'; } # Handle case-insensitive flag my $mod = ''; if ($pattern =~ s/^\(\?i\)//) { $mod = '(?i)'; } # Compile the regex pattern my $re = eval { qr/$mod$pattern/ }; die "Invalid regex '$pattern': $@" if $@; return $re; } ####################################################################### sub parse_csv { my $text = $_[0] || return; my @new = (); push(@new, $+) while $text =~ m{ "([^\"\\]*(?:\\.[^\"\\]*)*)",? | ([^,]+),? | , }gx; push(@new, undef) if substr($text, -1, 1) eq ','; return @new; } ####################################################################### sub check_ssl_support { LW2::init_ssl_engine(); my ($avail, $lib, $ver) = LW2::ssl_is_available(); if (!$avail) { nprint("+ WARNING: SSL: support not available."); } } ####################################################################### sub version { nprint("$VARIABLES{'name'} $VARIABLES{'version'} (LW $LW2::VERSION)"); exit 0; } ####################################################################### sub send_updates { return if ($CONFIGFILE{'UPDATES'} !~ /yes|auto/i); my (@MARKS) = @_; my ($updated_version, $answer, $code, $upd_enc); my $have_updates = 0; foreach my $mark (@MARKS) { foreach my $component (keys %{ $mark->{'components'} }) { if ($mark->{'components'}->{$component} eq 2) { if ($component !~ /\d/) { next; } elsif ($component =~ /^(?:\(?Win32\)?|Linux-Mandrake$)/) { next; } elsif ($component eq "") { next; } $have_updates = 1; $updated_version .= "$component "; } } } if ((!$have_updates) || ($updated_version eq "")) { return; } $updated_version =~ s/\s+$//; $updated_version =~ s/^\s+//; if ($CONFIGFILE{'UPDATES'} eq "auto") { $answer = "y"; } else { $answer = read_data( "\n ********************************************************************* Portions of the server's headers ($updated_version) are not in the Nikto " . $VARIABLES{'version'} . " database or are newer than the known string. Would you like to submit this information (*no server specific data*) to CIRT.net for a Nikto update (or you may email to sullo\@cirt.net) (y/n)? ", "" ); } if ($answer !~ /y/i) { return; } # set up our mark my %mark = ('ident' => $CONFIGFILE{CIRT}, 'ssl' => 1, 'port' => 443 ); ($mark{'hostname'}, $mark{'ip'}, $mark{'display_name'}) = resolve($CONFIGFILE{CIRT}, 0); $upd_enc = LW2::encode_base64($updated_version); chomp($upd_enc); # Use libwhisker directly instead of nfetch to avoid reporting on update target my (%request, %response); setup_hash(\%request, \%mark, ""); $request{'whisker'}->{'uri'} = "/nikto-updates.php?version=$upd_enc"; $request{'whisker'}->{'method'} = "GET"; $request{'whisker'}->{'host'} = $mark{'hostname'}; $request{'Accept'} = '*/*'; $request{'User-Agent'} = get_ua(); LW2::http_fixup_request(\%request); LW2::http_do_request_timeout(\%request, \%response); $code = $response{'whisker'}->{'code'}; $content = $response{'whisker'}->{'data'}; if ($code eq 407) { if ($CONFIGFILE{PROXYUSER} eq "") { $CONFIGFILE{PROXYUSER} = read_data("Proxy ID: ", ""); $CONFIGFILE{PROXYPASS} = read_data("Proxy Pass: ", "noecho"); } LW2::http_do_request_timeout(\%request, \%response); $code = $response{'whisker'}->{'code'}; $content = $response{'whisker'}->{'data'}; } if ($code eq "") { LW2::http_close(\%request); # Use CIRT config hostname for fallback my ($fallback_hostname, $fallback_ip, $fallback_display) = resolve($CONFIGFILE{CIRT}, 0); $mark{'ip'} = $fallback_ip; $request{'whisker'}->{'host'} = $fallback_hostname; LW2::http_fixup_request(\%request); LW2::http_do_request_timeout(\%request, \%response); $code = $response{'whisker'}->{'code'}; $content = $response{'whisker'}->{'data'}; } if (($code != 200) || ($content !~ /SUCCESS/)) { nprint("+ ERROR: $code -> " . $response{'location'} . "\n+ ERROR: Update failed, please notify sullo\@cirt.net of the previous line.", "", ($mark{'hostname'}, $mark{'ip'}, $mark{'display_name'}) ); } else { nprint("- Sent updated info to cirt.net -- Thank you!"); } return; } ####################################################################### sub usage { print " Options: -Add-header Add HTTP headers (can be used multiple times, one per header pair) -ask+ Whether to ask about submitting updates yes Ask about each (default) no Don't ask, don't send auto Don't ask, just send -check6 Check if IPv6 is working (connects to ipv6.google.com or value set in nikto.conf) -Cgidirs+ Scan these CGI dirs: \"none\", \"all\", or values like \"/cgi/ /cgi-a/\" -config+ Use this config file -Display+ Turn on/off display outputs: 1 Show redirects 2 Show cookies received 3 Show all 200/OK responses 4 Show URLs which require authentication D Debug output E Display all HTTP errors P Print progress to STDOUT S Scrub output of IPs and hostnames V Verbose output -dbcheck Check database and other key files for syntax errors -evasion+ Encoding technique:\n"; foreach my $k (sort keys %{ $NIKTO{'anti_ids'} }) { print " $k $NIKTO{'anti_ids'}{$k}\n"; } print " -followredirects Follow 3xx redirects to new location -Format+ Save file (-o) format: csv Comma-separated-value json JSON Format htm HTML Format sql Generic SQL (see docs for schema) txt Plain text xml XML Format (if not specified the format will be taken from the file extension passed to -output) -Help This help information -host+ Target host/URL -id+ Host authentication to use, format is id:pass or id:pass:realm -ipv4 IPv4 Only -ipv6 IPv6 Only -key+ Client certificate key file -list-plugins List all available plugins, perform no testing -maxtime+ Maximum testing time per host (e.g., 1h, 60m, 3600s) -mutate+ Guess additional file names:\n"; foreach my $k (sort keys %{ $NIKTO{'mutate_opts'} }) { print " $k $NIKTO{'mutate_opts'}{$k}\n"; } print " -mutate-options Provide information for mutates -nocheck Don't check for updates on startup -nocookies Do not use cookies from responses in requests -nointeractive Disables interactive features -nolookup Disables DNS lookups -nossl Disables the use of SSL -noslash Strip trailing slash from URL (e.g., '/admin/' to '/admin') -no404 Disables nikto attempting to guess a 404 page -Option Over-ride an option in nikto.conf, can be issued multiple times -output+ Write output to this file ('.' for auto-name) -Pause+ Pause between tests (seconds) -Platform+ Platform of target (nix, win, all) -Plugins+ List of plugins to run (default: ALL) -port+ Port to use (default 80) -RSAcert+ Client certificate file -root+ Prepend root value to all requests, format is /directory -Save Save positive responses to this directory ('.' for auto-name) -ssl Force ssl mode on port -Tuning+ Scan tuning: 1 Interesting File / Seen in logs 2 Misconfiguration / Default File 3 Information Disclosure 4 Injection (XSS/Script/HTML) 5 Remote File Retrieval - Inside Web Root 6 Denial of Service 7 Remote File Retrieval - Server Wide 8 Command Execution / Remote Shell 9 SQL Injection 0 File Upload a Authentication Bypass b Software Identification c Remote Source Inclusion d WebService e Administrative Console x Reverse Tuning Options (i.e., include all except specified) -timeout+ Timeout for requests (default 10 seconds) -Userdbs Load only user databases, not the standard databases all Disable standard dbs and load only user dbs tests Disable only db_tests and load udb_tests -useragent Force User-Agent instead of pulling from database -url+ Target host/URL (alias of -host) -useproxy Use the proxy defined in nikto.conf, or argument http://server:port -Version Print plugin and database versions -vhost+ Virtual host (for Host header) -404code Ignore these HTTP codes as negative responses (always). Format is \"302,301\". -404string Ignore this string in response body content as negative response (always). Can be a regular expression. + requires a value\n\n"; exit 0; } ####################################################################### sub init_db { my $dbname = shift; return if $dbname eq ""; my $filename = "$CONFIGFILE{'DBDIR'}/" . $dbname; my (@dbarray, @headers); my $hashref = {}; if ($CLI{'userdbs'} ne 'all') { # Check that the database exists unless (open(IN, "<$filename")) { nprint("+ ERROR: Unable to open database file $dbname: $@."); return $dbarray; } # Now read the header values while () { chomp; s/\#.*$//; if ($_ eq "") { next } unless (@headers) { @headers = parse_csv($_); } else { # contents; so split them up and apply to hash my @contents = parse_csv($_); my $hashref = {}; for (my $i = 0 ; $i <= $#contents ; $i++) { $hashref->{ $headers[$i] } = $contents[$i]; } push(@dbarray, $hashref); } } close(IN); } # And the udb_* file $filename = "$CONFIGFILE{'DBDIR'}/u" . $dbname; if (open(IN, "<$filename")) { while () { chomp; s/\#.*$//; if ($_ eq "") { next; } # contents; so split them up and apply to hash my @contents = parse_csv($_); my $hashref = {}; for (my $i = 0 ; $i <= $#contents ; $i++) { $hashref->{ $headers[$i] } = $contents[$i]; } push(@dbarray, $hashref); } } close(IN); return \@dbarray; } ####################################################################### sub add_vulnerability { my ($mark, $message, $nikto_id, $refs, $method, $uri, $request, $response, $reason) = @_; # Also normalize the response URI so it's consistent when used later if (defined $response && defined $response->{whisker}) { if ( !defined $response->{whisker}->{uri_requested} || $response->{whisker}->{uri_requested} eq "" || $response->{whisker}->{uri_requested} eq ".") { $response->{whisker}->{uri_requested} = "/"; } } $method = "GET" unless (defined $method); # Grammar matters if ($message !~ /\.$/) { $message .= "."; } # check to see if we've alerted already (can be from content search, etc.) foreach my $r (@RESULTS) { if ( ($uri eq $r->{'uri'}) && ($message eq $r->{'message'}) && ($method eq $r->{'method'}) && (${ $r->{'mark'} }{'ident'} eq $mark->{'ident'}) && (${ $r->{'mark'} }{'port'} eq $mark->{'port'})) { return; } } my $result = ""; if (defined $_[7]) { $result = $_[7]->{'whisker'}->{'data'}; } my $resulthash; %$resulthash = (mark => $mark, message => $message, nikto_id => $nikto_id, refs => $refs, method => $method, uri => $response->{whisker}->{uri_requested} || '/', result => $result, request => $request, response => $response, reason => $reason, ); push(@RESULTS, $resulthash); $mark->{total_vulns}++; if ($refs ne "") { $message .= " See: $refs"; } nprint("+ [$nikto_id] $message", "", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'})); # Save it if ($CLI{'saveresults'} ne '') { save_item($resulthash, $message, $request, $response); } # Now report it report_item($mark, $resulthash); } ############################################################################### sub rebuild_request { my ($req, $include_body, $truncate_length) = @_; return '' unless ref($req) eq 'HASH'; # Default to including body for backward compatibility $include_body = 1 unless defined $include_body; my $w = (ref($req->{whisker}) eq 'HASH') ? $req->{whisker} : {}; my $method = $w->{method} || 'GET'; my $uri = defined $w->{uri} ? $w->{uri} : '/'; my $proto = $w->{protocol} || 'HTTP'; my $ver = $w->{version} || '1.1'; my $eol = (defined $w->{http_eol} && $w->{http_eol} ne '') ? $w->{http_eol} : "\r\n"; # Things that are NOT HTTP headers in LW2 request structure my %NOT_HEADERS = map { $_ => 1 } qw( whisker data MAGIC host port ssl max_size method uri protocol version timeout http_eol http_space1 http_space2 retry uri_param_sep uri_prefix uri_postfix include_host_in_uri force_bodysnatch force_open force_close trailing_slurp ignore_duplicate_headers lowercase_incoming_headers normalize_incoming_headers require_newline_after_headers invalid_protocol_return_value ssl_certfile ssl_rsacertfile ssl_save_info ); # Allowlist pattern for header names my $is_header_name = sub { my ($k) = @_; return 0 if !defined $k; return 0 if $NOT_HEADERS{$k}; # hard stop return ($k =~ /^[A-Za-z][A-Za-z0-9-]*$/) ? 1 : 0; }; # Merge headers: whisker first, then top-level overrides my %h; for my $k (keys %$w) { next unless $is_header_name->($k); next unless defined $w->{$k}; next if ref($w->{$k}); $h{$k} = $w->{$k}; } for my $k (keys %$req) { next unless $is_header_name->($k); next unless defined $req->{$k}; next if ref($req->{$k}); $h{$k} = $req->{$k}; } # Determine Host header value (prefer explicit Host; else whisker 'host') my $host_val = ''; if (exists $h{Host} && defined $h{Host}) { $host_val = $h{Host}; } elsif (defined $w->{host} && $w->{host} ne '') { $host_val = $w->{host}; } # Build request line my $out = "$method $uri $proto/$ver$eol"; # Host header must be immediately after request line if ($host_val ne '') { $host_val =~ s/\r|\n/ /g; $out .= "Host: $host_val$eol"; delete $h{Host}; # avoid duplicate } # Other headers: stable order (case-insensitive) my @keys = sort { lc($a) cmp lc($b) } keys %h; for my $k (@keys) { my $v = $h{$k}; next unless defined $v; $v =~ s/\r|\n/ /g; $out .= "$k: $v$eol"; } $out .= $eol; # Include body if flag is set if ($include_body && defined $w->{data} && length($w->{data})) { $out .= $w->{data}; } # Truncate if length specified if (defined $truncate_length && $truncate_length > 0 && length($out) > $truncate_length) { my $original_length = length($out); $out = substr($out, 0, $truncate_length); $out .= "\n[Request truncated - original size: $original_length bytes]"; } return $out; } ############################################################################### sub rebuild_response { my ($resp, $include_body, $truncate_length) = @_; return '' unless ref($resp) eq 'HASH'; # Default to including body for backward compatibility $include_body = 1 unless defined $include_body; my $w = (ref($resp->{whisker}) eq 'HASH') ? $resp->{whisker} : {}; my $proto = $w->{protocol} || 'HTTP'; my $ver = $w->{version} || '1.1'; my $code = defined $w->{code} ? $w->{code} : '200'; my $msg = defined $w->{message} ? $w->{message} : 'OK'; my $eol = (defined $w->{http_eol} && $w->{http_eol} ne '') ? $w->{http_eol} : "\r\n"; # Build status line my $out = "$proto/$ver $code $msg$eol"; # Use header_order from whisker hash to maintain original header order if (defined $w->{header_order} && ref($w->{header_order}) eq 'ARRAY') { my %header_output; # Track which header values we've already output foreach my $header_name (@{ $w->{header_order} }) { next if ($header_name eq '' || $header_name eq 'whisker'); next unless defined $resp->{$header_name}; # Handle multiple values for the same header (array reference) if (ref($resp->{$header_name}) eq 'ARRAY') { my $value_idx = $header_output{$header_name} || 0; # Output one value per header_order entry to maintain order if ($value_idx < @{ $resp->{$header_name} }) { my $value = $resp->{$header_name}->[$value_idx]; if (defined $value) { $value =~ s/\r|\n/ /g; $out .= "$header_name: $value$eol"; $header_output{$header_name} = $value_idx + 1; } } } else { # Single value header - only output once even if in header_order multiple times unless ($header_output{$header_name}) { my $value = $resp->{$header_name}; $value =~ s/\r|\n/ /g; $out .= "$header_name: $value$eol"; $header_output{$header_name} = 1; } } } } $out .= $eol; # Include body if flag is set if ($include_body && defined $w->{data} && length($w->{data})) { $out .= $w->{data}; } # Truncate if length specified if (defined $truncate_length && $truncate_length > 0 && length($out) > $truncate_length) { my $original_length = length($out); $out = substr($out, 0, $truncate_length); $out .= "\n[Response truncated - original size: $original_length bytes]"; } return $out; } ############################################################################### sub list_plugins { # Just do a load_plugins, then loop through the array and print out name, # description and copyright load_plugins(); foreach my $plugin (@PLUGINS) { nprint("Plugin: $plugin->{'name'}"); nprint(" $plugin->{'full_name'} - $plugin->{'description'}"); nprint(" Written by $plugin->{'author'}, Copyright (C) $plugin->{'copyright'}"); if (defined $plugin->{'options'}) { nprint(" Options:"); while (my ($option, $description) = each(%{ $plugin->{'options'} })) { nprint(" $option: $description"); } } nprint("\n"); } # Plugin macros nprint("Defined plugin macros:"); foreach my $macro (keys %CONFIGFILE) { if ($macro =~ /^@@/) { nprint(" $macro = \"" . $CONFIGFILE{$macro} . "\""); if ($CONFIGFILE{$macro} =~ /@@/) { nprint(" (expanded) = \"" . expand_pluginlist($CONFIGFILE{$macro}, 0) . "\""); } } } exit 0; } ############################################################################### # This is overly complicated and jumps a lot between scalars and arrays. The REs are # probably dodgy, but it works! W00! sub expand_pluginlist { my ($pluginlist, $parent) = @_; my @macros; foreach my $config (keys %CONFIGFILE) { if ($config =~ /^@@/) { push(@macros, $config); } } # Now loop through each member of the list and expand it my $count = 0; my $npluginlist = $pluginlist; do { $count++; my @raw = split(/;/, $npluginlist); # cooked contains the processed list my @cooked; foreach my $entry (@raw) { # Is it +; if so remap to @@DEFAULT if ($entry eq "+") { $entry = '@@DEFAULT'; } # result contains the processed entry my $result = $original = $entry; # Is it a macro if ($entry =~ /^-?@@/) { # break up into components $prefix = ($entry =~ /^-/) ? "-" : ""; $name = $suffix = $entry; $name =~ s/(^-?)(@@[[:alpha:]]+)(\(?.*\)?$)/$2/; $suffix =~ s/(.*)(\(.*\))/$2/; if ($suffix eq $entry) { $suffix = ""; } foreach my $macro (@macros) { if ($entry =~ /-?$macro/) { # It's a macro, so replace the contents with the macro # Add prefix and suffix to each member of the macro my @temp; foreach my $child (split(/;/, $CONFIGFILE{$macro})) { push(@temp, "$prefix$child$suffix"); } $result = join(';', @temp); # stop an infinite loop last; } } } if ($result =~ /^-?@@/ && $result eq $original) { # macro not found or is itself - ignore $result = ""; } if ($count > 100) { # check for recurstion nprint("ERROR: Recursion found whilst expanding macros"); $result = ""; last; } push(@cooked, $result); } $npluginlist = join(';', @cooked); } while ($npluginlist =~ /@@/ && $count <= 100); #use re 'debug'; # Now we've expanded out macros, deal with duplicates and - my @raw = split(/;/, $npluginlist); # hash so we don't have to mess with duplicates my %cooked; foreach my $plugin (@raw) { # break out components my $minus; my $name = my $suffix = $plugin; $minus = (substr($plugin, 0, 1) eq '-'); $name =~ s/(^-?)([^\(]+)(\(?.*\)?$)/$2/; $suffix =~ s/(.*)(\(.*\))/$2/; if ($suffix eq $plugin) { $suffix = ""; } if ($minus) { # it's a minus - remove any previous entry if (exists $cooked{$name}) { delete $cooked{$name}; } } else { # else add it with the parameters as the value of the hash $cooked{$name} = $suffix; } } # Now rejoin into one happy whole my $output; foreach my $plugin (keys %cooked) { $output .= "$plugin" . $cooked{$plugin} . ";"; } # remove the last ; $output =~ s/;$//g; return $output; } ############################################################################### # Check a regex for validation & fix. If mode=1, return a flag which indicates # whether the regex was changed sub validate_and_fix_regex { my ($regex, $mode) = @_; my $fixed = 0; eval { qr/$regex/ }; if ($@) { $fixed = 1; $regex = rquote($regex); } return $mode ? ($regex, $fixed) : $regex; } ############################################################################### # Process captured groups in message strings # Replaces $1, $2, etc. with captured group values sub process_captured_groups { my ($message, $captures) = @_; # Quick exit if no message or captures return $message unless $message && $captures && @$captures; # Quick exit if message has no placeholder patterns return $message unless $message =~ /\$\d+/; # Replace $1, $2, etc. with captured values for my $i (1 .. @$captures) { my $value = $captures->[ $i - 1 ]; # Replace with captured value or empty string if undefined $value = '' unless defined $value; $message =~ s/\$$i(?!\d)/$value/g; } return $message; } ############################################################################### sub rquote { my $string = $_[0] || return; $string =~ s/([^A-Za-z_0-9 "'\\])/\\$1/g; return $string; } ############################################################################### sub gmt_offset { my @t = localtime(time); return (timegm(@t) - timelocal(@t)) / 3600; } ############################################################################### sub expand_range { local $" = '..'; my (@range); sort { $a <=> $b } map { map { ((@range = split /-/) == 2) ? eval('map {$_} ' . "@range") : $_ } split /\s/ } @_; } ############################################################################### sub check_ipv6 { nprint("Performing IPv6 connectivity tests:"); # Perform a series of tests and: exit 1 or 0 # Does the version of Socket even support IPv6? if (!$LW2::LW2_CAN_IPv6) { nprint( "+ ERROR: This version of Socket ($Socket::VERSION) has insufficient (or no) IPv6 support" ); exit 1; } nprint("+ This version of Socket ($Socket::VERSION) does support IPv6"); # Ensure switches are in a known state $CLI{'ipv6'} = 1; $CLI{'ipv4'} = 0; if ($CONFIGFILE{'CHECK6HOST'} eq "") { $CONFIGFILE{'CHECK6HOST'} = 'ipv6.google.com'; $CONFIGFILE{'CHECK6PORT'} = '443'; } # Try to resolve a known IPv6 hostname my ($name, $ip, $displayname) = resolve($CONFIGFILE{'CHECK6HOST'}); if (!$ip) { nprint("- DNS resolution of '$CONFIGFILE{'CHECK6HOST'}' using AF_INET6 failed"); nprint( "\t(Perhaps no DNS server set or server is incapable of resolving an IPv6 address for $CONFIGFILE{'CHECK6HOST'})" ); exit 1; } nprint("+ Successful DNS resolution of '$CONFIGFILE{'CHECK6HOST'}': $ip"); # Try to connect to the host my $res = LW2::utils_port_open($CONFIGFILE{'CHECK6HOST'}, $CONFIGFILE{'CHECK6PORT'}); if (!$res) { nprint( "+ ERROR: TCP connection to '$CONFIGFILE{'CHECK6HOST'}:$CONFIGFILE{'CHECK6PORT'}' using AF_INET6 failed" ); nprint("\t(Likely either no IPv6 connectivity or firewall blocking)"); exit 1; } nprint("+ Successful TCP connection to '$CONFIGFILE{'CHECK6HOST'}:$CONFIGFILE{'CHECK6PORT'}'"); nprint("----> All tests successful"); exit 0; } ############################################################################### sub nikto_core { return; } # trap for this plugin being called to run. lame. ############################################################################### 1;