SentinelScan-WSS / backend /scanners /nikto /plugins /nikto_core.plugin
larxius's picture
Deploy SentinelScan WSS to HF Spaces
d543fc1 verified
Raw
History Blame Contribute Delete
193 kB
###############################################################################
# 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 = <FI>;
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, <IN>); next; }
# all the other files require per-line processing
else {
my @file;
# Cleanup
while (<IN>) {
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 (<IN>) {
$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 (<IN>) {
$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 (<IN>) {
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 (<IN>) {
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 (<IN>) {
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 (<IN>) {
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 (<IN>) {
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 (<IN>) {
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 (<IN>) {
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 (<IN>) {
$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 = <PF>;
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'} !~ /(?<![x])$tune/gi) {
$excludes .= $tune;
}
else {
$includes .= $tune;
}
}
# now load checks
foreach my $line (@DBFILE) {
if ($line =~ /^\"/) # check
{
chomp($line);
my @item = parse_csv($line);
my $add = 0;
# check tuning options
# $item[2] contains the test's types
if (($CLI{'tuning'} ne "") && (defined $item[2])) {
# Work out the required tuning from the CLI string
if ($includes ne "") {
foreach $tune (split(//, $includes)) {
if ($item[2] =~ /$tune/i) {
$add = 1;
last;
}
}
}
if ($excludes ne "") {
# if includes is null and excludes is not null, add all but excludes
foreach $tune (split(//, $excludes)) {
if ($item[2] =~ /$tune/i) {
$add = 0;
last;
}
else {
$add = 1;
}
}
}
}
else {
$add = 1;
}
# Skip list
if ($add && exists $SKIPLIST{ $item[0] }) {
$add = 0;
}
# If $add is still true, then add it
if ($add) {
my $ext = get_ext($item[3]);
$db_extensions{$ext} = 1;
$COUNTERS{total_checks}++;
$TESTS{ $item[0] }{'references'} = $item[1];
$TESTS{ $item[0] }{'tuning'} = $item[2];
$TESTS{ $item[0] }{'uri'} = $item[3];
$TESTS{ $item[0] }{'method'} = $item[4];
$TESTS{ $item[0] }{'dsl'} = $item[5];
$TESTS{ $item[0] }{'message'} = $item[6];
$TESTS{ $item[0] }{'data'} = $item[7];
$TESTS{ $item[0] }{'headers'} = $item[8];
$TESTS{ $item[0] }{'matcher'} = build_matcher($item[5], $item[0]);
}
}
}
nprint("- $COUNTERS{'total_checks'} server checks loaded", "v", "Init");
if ($COUNTERS{'total_checks'} eq 0 && !defined $CLI{'tuning'}) {
nprint("+ Unable to load valid checks!");
$mark->{'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 /(?<!\\)\|/, $or_content;
# Parse each alternative as a separate DSL expression
my @or_parsed = ();
foreach my $alt (@alternatives) {
$alt =~ s/^\s+|\s+$//g;
next unless $alt;
# Recursively parse this alternative
my $alt_parsed = parse_dsl($alt);
push @or_parsed, $alt_parsed;
}
# Store the OR group
push @{ $compiled{or_groups} }, \@or_parsed;
next;
}
else {
# Token looks like an OR pattern but has unbalanced parentheses
die "Invalid DSL token: OR pattern has unbalanced parentheses: $token";
}
}
elsif ($token =~ /^\(/) {
# Token starts with ( but doesn't match OR pattern format
# Show token details for debugging
my $token_len = length($token);
my $first_char = substr($token, 0, 1);
my $last_char = substr($token, -1);
die
"Invalid DSL token: token starts with '(' but is not a properly formed OR pattern (must start with '(' and end with ')'): length=$token_len, first='$first_char', last='$last_char', token='$token'";
}
# Check negation
my $neg = 0;
if ($token =~ /^!/) {
$neg = 1;
$token = substr($token, 1);
}
# Skip @LFI() tokens - they are placeholders that will be expanded at runtime
# This can happen if variables aren't loaded yet during validation
if ($token =~ /^@?LFI\(\)$/) {
next;
}
# TYPE:VALUE split (only first colon)
my ($type, $pattern) = split /:/, $token, 2;
die "Invalid DSL token: $token" unless defined $type && defined $pattern;
$type = uc($type);
if ($type eq 'CODE') {
push @{ $neg ? $compiled{code_neg} : $compiled{code_pos} }, compile_regex($pattern);
}
elsif ($type eq 'BODY') {
die "Empty BODY pattern in DSL" if $pattern eq '';
my $compiled = compile_regex($pattern);
push @{ $neg ? $compiled{body_neg} : $compiled{body_pos} }, $compiled;
}
elsif ($type eq 'HEADER') {
my ($hname, $hval) = split /:/, $pattern, 2;
die "Missing HEADER name: $token" unless defined $hname && length $hname;
$hname =~ s/^\s+|\s+$//g;
$hval = '' unless defined $hval;
$hval =~ s/^\s+//;
my $re = length($hval) ? compile_regex($hval) : undef;
push @{ $neg ? $compiled{header_neg} : $compiled{header_pos} },
{ name => 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 (<IN>) {
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 (<IN>) {
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;