############################################################################### # SPDX-License-Identifier: GPL-3.0-only # PURPOSE: Scan for exposed Spring Boot Actuator endpoints and basic info leaks ############################################################################### use JSON; sub nikto_springboot_init { my $id = { name => "springboot", full_name => "Spring Boot Actuator endpoint check", author => "Sullo", description => "Detects exposed Spring Boot Actuator endpoints and basic info leaks", hooks => { scan => { method => \&nikto_springboot, } }, copyright => "2025 Chris Sullo" }; return $id; } sub nikto_springboot { my ($mark) = @_; my $root = $mark->{'root'} || ''; $root =~ s/\/$//; # Remove trailing slash if present my @endpoints = qw( /actuator /actuator/health /actuator/info /actuator/env /actuator/mappings /actuator/metrics /actuator/beans /actuator/configprops /actuator/loggers /actuator/threaddump /actuator/auditevents /actuator/httptrace /actuator/scheduledtasks /actuator/heapdump /actuator/jolokia /actuator/prometheus ); my $host = $mark->{'hostname'}; my $port = $mark->{'port'}; my $proto = $mark->{'ssl'} ? 'https' : 'http'; my $base_url = "$proto://$host:$port"; foreach my $ep (@endpoints) { my $path = $root . $ep; nprint("Checking $path", "v", "springboot"); my ($res, $content, $error, $request, $response) = nfetch($mark, $path, 'GET', '', '', undef, 'springboot'); my $ct = $response->{'content-type'} || ''; # Quick exits if ($res == 404) { next; } elsif ($res != 200 && $res != 404) { nprint("$path: Non-200 ($res) - possibly restricted endpoint", "v", "springboot"); next; } # resposnes should be JSON next unless ($ct =~ /application\/json/i || $content =~ /^\s*\{/); my $json; eval { $json = decode_json($content); }; if ($@ || !$json) { nprint("$path: 200 but invalid JSON", "v", "springboot"); next; } # /actuator special handling if ($ep eq '/actuator') { if (exists $json->{'_links'} && ref($json->{'_links'}) eq 'HASH') { my $links = $json->{'_links'}; my @found; foreach my $k (keys %$links) { my $href = $links->{$k}{'href'}; next unless $href; my $report_val; # If on same host, report only the path; otherwise, report full URL if ($href =~ m{^$proto://$host(?::$port)?(/.*)$}) { $report_val = $1; } else { $report_val = $href; } push @found, $report_val; add_vulnerability( $mark, "$report_val: Spring Boot Actuator endpoint discovered via /actuator _links.", 750001, "https://docs.spring.io/spring-boot/docs/current/actuator-api/html/", 'GET', $report_val ); } } else { nprint("/actuator: 200 but no _links", "v", "springboot"); } next; } # /actuator/health if ($ep eq '/actuator/health') { if (exists $json->{'status'} && $json->{'status'} eq 'UP') { add_vulnerability( $mark, "$path: Spring Boot Actuator health endpoint exposed", 750002, "https://docs.spring.io/spring-boot/docs/current/actuator-api/html/#health", 'GET', $path ); } next; } # /actuator/info if ($ep eq '/actuator/info') { if ( exists $json->{'build'} && ref($json->{'build'}) eq 'HASH' && exists $json->{'build'}{'version'}) { add_vulnerability( $mark, "$path: Spring Boot Actuator info endpoint exposed (build version: $json->{'build'}{'version'})", 750003, "https://docs.spring.io/spring-boot/docs/current/actuator-api/html/#info", 'GET', $path ); } next; } # Other endpoints: report if valid, non-empty JSON if (ref($json) eq 'HASH' && scalar(keys %$json) > 0) { add_vulnerability( $mark, "$path: Spring Boot Actuator endpoint exposed (valid JSON response)", 750004, "https://docs.spring.io/spring-boot/docs/current/actuator-api/html/", 'GET', $path ); } } } 1;