adcad167df
TODO: Test elimination per finding type in web view Figure out where RustDesk registry persistence is located. The installer is aware of it somehow
230 lines
5.8 KiB
Go
230 lines
5.8 KiB
Go
package services
|
|
|
|
import (
|
|
"fmt"
|
|
"rmm-hunter/internal/pkg/hunt/detect/common"
|
|
. "rmm-hunter/internal/suspicious"
|
|
"strings"
|
|
|
|
"github.com/Kraken-OffSec/Scurvy/core/service"
|
|
)
|
|
|
|
// Whitelist for our own tool and legitimate system components
|
|
var whitelist = []string{
|
|
"rmm-hunter",
|
|
}
|
|
|
|
func isWhitelisted(config service.ServiceConfig) bool {
|
|
allText := strings.ToLower(strings.Join([]string{
|
|
config.DisplayName, config.ServiceStartName, config.BinaryPathName, config.Description,
|
|
}, "|"))
|
|
for _, w := range whitelist {
|
|
if strings.Contains(allText, w) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func Detect() []*Service {
|
|
fmt.Printf("[*] Enumerating Services \n")
|
|
|
|
scm, err := service.Connect()
|
|
if err != nil {
|
|
fmt.Printf("[-] Error getting Service Manager: %s\n", err.Error())
|
|
return []*Service{}
|
|
}
|
|
// Note: The service manager handle is managed by the Scurvy library
|
|
// and should not be manually closed here to avoid invalid handle errors
|
|
|
|
services, err := scm.ListServices()
|
|
if err != nil {
|
|
fmt.Printf("[-] Error enumerating services: %s\n", err.Error())
|
|
return []*Service{}
|
|
}
|
|
|
|
fmt.Printf(" [>] Dispositioning %d Services\n", len(services))
|
|
|
|
return compareServices(services, scm)
|
|
}
|
|
|
|
func compareServices(serviceStrings []string, scm *service.Mgr) []*Service {
|
|
var suspiciousServices []*Service
|
|
|
|
for _, serviceString := range serviceStrings {
|
|
svc, err := scm.OpenService(serviceString)
|
|
if err != nil {
|
|
fmt.Printf(" [>-] Error opening service %s: %s\n", serviceString, err.Error())
|
|
continue
|
|
}
|
|
// Note: Individual service handles are also managed by Scurvy library
|
|
|
|
config, err := svc.Config()
|
|
if err != nil {
|
|
fmt.Printf(" [>-] Error getting service config %s: %s\n", serviceString, err.Error())
|
|
continue
|
|
}
|
|
|
|
// Skip whitelisted services (our own tool)
|
|
if isWhitelisted(config) {
|
|
continue
|
|
}
|
|
|
|
if isSuspiciousService(config) {
|
|
fmt.Printf(" [?] Found %s\n", config.DisplayName)
|
|
suspiciousServices = append(suspiciousServices, &Service{
|
|
Name: serviceString,
|
|
DisplayName: config.DisplayName,
|
|
ServiceTypeRaw: config.ServiceType,
|
|
ServiceType: getServiceType(config.ServiceType),
|
|
StartTypeRaw: config.StartType,
|
|
StartType: getStartType(config.StartType),
|
|
ErrorControlRaw: config.ErrorControl,
|
|
ErrorControl: getErrorControl(config.ErrorControl),
|
|
BinaryPathName: config.BinaryPathName,
|
|
LoadOrderGroup: config.LoadOrderGroup,
|
|
TagId: config.TagId,
|
|
Dependencies: config.Dependencies,
|
|
ServiceStartName: config.ServiceStartName,
|
|
Password: config.Password,
|
|
Description: config.Description,
|
|
SidType: config.SidType,
|
|
DelayedAutoStart: config.DelayedAutoStart,
|
|
})
|
|
}
|
|
}
|
|
|
|
fmt.Printf("[+] Found %d Suspicious Services\n", len(suspiciousServices))
|
|
return suspiciousServices
|
|
}
|
|
|
|
// isSuspiciousService uses multi-indicator scoring to detect RMM services
|
|
// Requires at least 2 independent indicators to flag as suspicious
|
|
func isSuspiciousService(config service.ServiceConfig) bool {
|
|
score := 0
|
|
|
|
// Build searchable text from all service fields
|
|
allText := strings.ToLower(strings.Join([]string{
|
|
config.DisplayName, config.ServiceStartName, config.BinaryPathName, config.Description,
|
|
}, "|"))
|
|
|
|
// Indicator 1: Known RMM vendor name match (CommonRMMs)
|
|
rmmNameHit := false
|
|
for _, rmm := range common.CommonRMMs {
|
|
if strings.Contains(allText, strings.ToLower(rmm)) {
|
|
rmmNameHit = true
|
|
break
|
|
}
|
|
}
|
|
if rmmNameHit {
|
|
score++
|
|
}
|
|
|
|
// Indicator 2: Known RMM executable/binary pattern in service binary path (CommonImageSuffixes)
|
|
binaryPatternHit := false
|
|
binaryPathLower := strings.ToLower(config.BinaryPathName)
|
|
for _, pattern := range common.CommonImageSuffixes {
|
|
patternLower := strings.ToLower(pattern)
|
|
if strings.Contains(binaryPathLower, patternLower) {
|
|
binaryPatternHit = true
|
|
break
|
|
}
|
|
}
|
|
if binaryPatternHit {
|
|
score++
|
|
}
|
|
|
|
// Indicator 3: Known RMM DNS/domain in binary path or description (CommonDNS)
|
|
dnsHit := false
|
|
for _, dns := range common.CommonDNS {
|
|
dnsLower := strings.ToLower(dns)
|
|
// Handle wildcard patterns: *.example.com should match anything.example.com
|
|
if strings.HasPrefix(dnsLower, "*.") {
|
|
// Match the domain suffix (e.g., ".example.com")
|
|
domainSuffix := dnsLower[1:] // Remove the * but keep the dot
|
|
if strings.Contains(allText, domainSuffix) {
|
|
dnsHit = true
|
|
break
|
|
}
|
|
} else if strings.HasSuffix(dnsLower, ".*") {
|
|
// Handle patterns like example.* - match the prefix
|
|
domainPrefix := dnsLower[:len(dnsLower)-2] // Remove the .*
|
|
if strings.Contains(allText, domainPrefix) {
|
|
dnsHit = true
|
|
break
|
|
}
|
|
} else {
|
|
// Exact domain match (no wildcard)
|
|
if strings.Contains(allText, dnsLower) {
|
|
dnsHit = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if dnsHit {
|
|
score++
|
|
}
|
|
|
|
// Indicator 4: Suspicious installation path (temp, public, programdata)
|
|
pathSuspicious, _ := common.AnalyzeExecutablePath(config.BinaryPathName)
|
|
if pathSuspicious {
|
|
score++
|
|
}
|
|
|
|
// Require at least 2 independent Indicators to reduce false positives
|
|
return score >= 2
|
|
}
|
|
|
|
func getServiceType(raw uint32) string {
|
|
switch raw {
|
|
case 1:
|
|
return "KernelDriver"
|
|
case 2:
|
|
return "FileSystemDriver"
|
|
case 4:
|
|
return "Adapter"
|
|
case 8:
|
|
return "RecognizerDriver"
|
|
case 16:
|
|
return "Win32OwnProcess"
|
|
case 32:
|
|
return "Win32ShareProcess"
|
|
case 256:
|
|
return "InteractiveProcess"
|
|
default:
|
|
return "Unknown"
|
|
}
|
|
}
|
|
|
|
func getStartType(raw uint32) string {
|
|
switch raw {
|
|
case 0:
|
|
return "Boot"
|
|
case 1:
|
|
return "System"
|
|
case 2:
|
|
return "Automatic"
|
|
case 3:
|
|
return "Manual"
|
|
case 4:
|
|
return "Disabled"
|
|
default:
|
|
return "Unknown"
|
|
}
|
|
}
|
|
|
|
func getErrorControl(raw uint32) string {
|
|
switch raw {
|
|
case 0:
|
|
return "Ignore"
|
|
case 1:
|
|
return "Normal"
|
|
case 2:
|
|
return "Severe"
|
|
case 3:
|
|
return "Critical"
|
|
default:
|
|
return fmt.Sprintf("Unknown %d", raw)
|
|
}
|
|
}
|