SentinelScan-WSS / backend /scanners /nikto /plugins /nikto_tests.plugin
larxius's picture
Deploy SentinelScan WSS to HF Spaces
d543fc1 verified
Raw
History Blame Contribute Delete
12.5 kB
###############################################################################
# SPDX-License-Identifier: GPL-3.0-only
# PURPOSE: Perform the full database of nikto tests against a target
###############################################################################
sub nikto_tests_init {
my $id = { name => "tests",
full_name => "Nikto Tests",
author => "Sullo, Tautology",
description => "Test host with the standard Nikto tests",
copyright => "2008 Chris Sullo",
hooks => {
scan => { method => \&nikto_tests,
weight => 99,
},
},
options => {
passfiles => "Flag to indicate whether to check for common password files",
all => "Flag to indicate whether to check all files with all directories",
report => "Report a status after the passed number of tests",
}
};
return $id;
}
sub nikto_tests {
my ($mark, $parameters) = @_;
return if $mark->{'terminate'};
my $data;
# this is the actual the looped code for all the checks
foreach my $checkid (sort keys %TESTS) {
return if $mark->{'terminate'};
# replace variables in the uri
my @urilist = change_variables($TESTS{$checkid}{'uri'}, $mark, $checkid);
# Now repeat for each uri
URI: foreach my $uri (@urilist) {
return if $mark->{'terminate'};
my (%headrs, %flags, $data);
if ($TESTS{$checkid}{'headers'} ne '') {
my $header = unslash($TESTS{$checkid}{'headers'});
# Apply variable replacement to headers
my @header_lines = split /\r\n/, $header;
foreach my $h (@header_lines) {
my ($key, $value) = split(/: /, $h);
$key = lc($key);
# Apply change_variables to the value part
my @replaced_values = change_variables($value, $mark, $checkid);
foreach my $replaced_value (@replaced_values) {
$headrs{$key} = $replaced_value;
}
}
$headrs{'host'} = $mark->{'hostname'}
unless ($headrs{'host'}); # Kludge not to override host injection vectors
$flags{'noclean'} = 1;
}
if ($TESTS{$checkid}{'data'} ne '') {
$data = unslash($TESTS{$checkid}{'data'});
$headrs{'content-length'} = length($data)
unless grep(/^(transfer-encoding|content-length)$/i, keys %headrs);
}
my ($res, $content, $error, $request, $response) =
nfetch($mark, $uri, $TESTS{$checkid}{'method'}, $data, \%headrs, \%flags, $checkid);
# DSL matcher expects response headers as a hashref
my $response_string = rebuild_response($response, 0);
my %response_headers;
foreach my $line (split(/\r?\n/, $response_string)) {
next if $line =~ /^HTTP\//; # Skip status line
last if $line =~ /^\s*$/; # Stop at blank line (end of headers)
if ($line =~ /^([^:]+):\s*(.*)$/) {
my ($name, $value) = (lc($1), $2);
if (exists $response_headers{$name}) {
$response_headers{$name} .= ', ' . $value;
}
else {
$response_headers{$name} = $value;
}
}
}
my $response_headers = \%response_headers;
# Extract cookies as a hashref for matcher
my %cookies = ();
if (ref $response->{'whisker'}->{'cookies'} eq 'ARRAY') {
foreach my $cookie (@{ $response->{'whisker'}->{'cookies'} }) {
if ($cookie =~ /^([^=]+)=([^;]*)/) {
$cookies{ lc($1) } = $2;
}
}
}
# Use the DSL matcher - now returns (match_status, captured_groups)
my ($positive, $reason, $captures) = (0, '', []);
my @matcher_result =
$TESTS{$checkid}{'matcher'}->($res, $content, $response_headers, \%cookies);
my ($match_status, $captured_groups) = @matcher_result;
if ($match_status) {
$positive = 1;
$reason = 'DSL Match';
$captures = $captured_groups || [];
}
# matched on something, check fails/ands
if ($positive) {
# if it's an index.php, check for normal /index.php to see if it's a FP
# if ($uri =~ /^\/index.php\?/i) {
# my $clean_content = rm_active_content($content, $mark->{'root'} . $uri);
# if (LW2::md5($clean_content) eq $mark->{'FoF'}{'index.php'}{'match'}) {
# next;
# }
# }
# Check user-specified error codes/strings first (highest priority, always wins)
# This must be checked before any other 404 detection logic
if (defined $VARIABLES{'ERRCODES'} && ref($VARIABLES{'ERRCODES'}) eq 'HASH') {
my $code_str = "$res";
if (exists $VARIABLES{'ERRCODES'}->{$code_str}) {
next URI; # Skip this test, it's a 404
}
}
if (defined $VARIABLES{'ERRSTRINGS'} && ref($VARIABLES{'ERRSTRINGS'}) eq 'HASH') {
foreach my $pattern (keys %{ $VARIABLES{'ERRSTRINGS'} }) {
if ($content =~ /$pattern/) {
next URI; # Skip this test, it's a 404
}
}
}
# Lastly check for a false positive based on file extension or type.
# We check is_404 when the actual response code is 200, because 404 error pages
# can return 200 status codes. However, we skip the check if:
# 1. There's a positive BODY match (specific body content indicates real content)
# 2. The DSL has an explicit non-200 CODE match (like CODE:404, CODE:403)
# without including 200, as those are intentional matches for specific status codes.
my $has_code_match = ($TESTS{$checkid}{'dsl'} =~ /CODE:/i);
# Check if DSL has an explicit non-200 CODE match that doesn't include 200
# (e.g., CODE:404, CODE:403, but not CODE:200 or CODE:200|404)
my $has_explicit_non200_code = 0;
if ($has_code_match) {
my $includes_code_200 = ($TESTS{$checkid}{'dsl'} =~ /\bCODE:\s*200(\D|$)/i);
# If there's a CODE match but it doesn't include 200, skip is_404 check
$has_explicit_non200_code = !$includes_code_200;
}
# Check if DSL has positive BODY patterns (BODY: but not !BODY:)
my $has_body_match = ($TESTS{$checkid}{'dsl'} =~ /(?:^|[^!])BODY:/i);
if ( $res == 200
&& !$has_body_match
&& !$has_explicit_non200_code
&& is_404($mark, $mark->{'root'} . $uri, $response)) {
next;
}
# Process message with captured groups (if any)
my $message = $TESTS{$checkid}{'message'};
if (@$captures && $message =~ /\$\d+/) {
$message = process_captured_groups($message, $captures);
}
# All checks passed, add vulnerability
add_vulnerability($mark, "$mark->{'root'}$uri: $message",
$checkid, $TESTS{$checkid}{'references'},
$TESTS{$checkid}{'method'}, $mark->{'root'} . $uri,
$request, $response,
$reason
);
}
}
# Percentages
if ( $OUTPUT{'progress'}
&& $parameters->{'report'}
&& ($COUNTERS{'totalrequests'} % $parameters->{'report'}) == 0) {
status_report();
}
} # end check loop
# Perform mutation tests
passchecks($mark) if $parameters->{'passfiles'};
allchecks($mark) if $parameters->{'all'};
return;
}
sub passchecks {
my ($mark) = @_;
my @DIRS = (split(/ /, $VARIABLES{"\@PASSWORDDIRS"}));
my @PFILES = (split(/ /, $VARIABLES{"\@PASSWORDFILES"}));
my @EXTS = qw(asp bak dat data dbc dbf exe htm html htx ini lst txt xml php php3);
nprint("- Performing passfiles mutation.", "v", "tests");
# Update total requests for status reports
my @CGIS = split(/ /, $VARIABLES{'@CGIDIRS'});
$COUNTERS{'total_checks'} =
$COUNTERS{'total_checks'} +
(scalar(@DIRS) * scalar(@PFILES)) +
(scalar(@DIRS) * scalar(@PFILES) * scalar(@EXTS)) +
((scalar(@DIRS) * scalar(@PFILES) * scalar(@EXTS) * scalar(@CGIS)) * 2);
foreach my $dir (@DIRS) {
return if $mark->{'terminate'};
foreach my $file (@PFILES) {
next if ($file eq "");
# dir/file
testfile($mark, "$dir$file", "passfiles", "299998");
foreach my $ext (@EXTS) {
return if $mark->{'terminate'};
# dir/file.ext
testfile($mark, "$dir$file.$ext", "passfiles", "299998");
foreach my $cgi (@CGIS) {
$cgi =~ s/\/$//;
# dir/file.ext
testfile($mark, "$cgi$dir$file.$ext", "passfiles", "299998");
# dir/file
testfile($mark, "$cgi$dir$file", "passfiles", "299998");
}
}
}
}
}
sub allchecks {
my ($mark) = @_;
# Hashes to temporarily store files/dirs in
# We're using hashes to ensure that duplicates are removed
my (%FILES, %DIRS);
# build the arrays
nprint("- Loading root level files.", "v", "tests");
foreach my $checkid (keys %TESTS) {
# Expand out vars so we get full matches
my @uris = change_variables($TESTS{$checkid}{'uri'}, $mark, $checkid);
foreach my $uri (@uris) {
my $dir = LW2::uri_get_dir($uri);
my $file = $uri;
if ($dir ne "") {
$DIRS{$dir} = "";
$dir =~ s/([^a-zA-Z0-9])/\\$1/g;
$file =~ s/$dir//;
}
if (($file ne "") && ($file !~ /^\?/)) {
$FILES{$file} = "";
}
}
}
# Update total requests for status reports
$COUNTERS{'total_checks'} = $COUNTERS{'total_checks'} + (keys(%DIRS) * keys(%FILES));
# Now do a check for each item - just check the return status, nothing else
foreach my $dir (keys %DIRS) {
foreach my $file (keys %FILES) {
return if $mark->{'terminate'};
testfile($mark, "$dir$file", "all checks", 299999);
}
}
}
sub testfile {
my ($mark, $uri, $name, $tid) = @_;
return if $mark->{'terminate'};
my ($res, $content, $error, $request, $response) =
nfetch($mark, $uri, "GET", "", "", "", "Tests: $name");
nprint("- $res for $uri (error: $error)",
"v", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'}));
if ($error) {
$mark->{'total_errors'}++;
nprint("+ ERROR: $uri returned an error: $error",
"e", ($mark->{'hostname'}, $mark->{'ip'}, $mark->{'displayname'}));
return;
}
if ($res == 200) {
add_vulnerability($mark, "$uri: file found during $name mutation",
$tid, "", "GET", $uri, $request, $response);
}
}
1;