diff --git a/go.mod b/go.mod index 656c390..0d3456d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module rmm-hunter go 1.24.7 require ( - github.com/Kraken-OffSec/Scurvy v0.0.0-20251011184544-e9265efd21c6 + github.com/Kraken-OffSec/Scurvy v0.0.0-20251011211525-6bf6bee1b100 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 diff --git a/go.sum b/go.sum index 0f42f3b..680e093 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ github.com/Kraken-OffSec/Scurvy v0.0.0-20251010192328-967933276439 h1:n/B4+1K6vp github.com/Kraken-OffSec/Scurvy v0.0.0-20251010192328-967933276439/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ= github.com/Kraken-OffSec/Scurvy v0.0.0-20251011184544-e9265efd21c6 h1:CRH0t964ocRHXspOo8cB0DPcSfEtsGh8FenjML252HI= github.com/Kraken-OffSec/Scurvy v0.0.0-20251011184544-e9265efd21c6/go.mod h1:0pPwYHy+r8KGzXZ8vBgyYd6qy3vX+AMRo9XLiGc8WGE= +github.com/Kraken-OffSec/Scurvy v0.0.0-20251011204529-faafd6327395 h1:5VcLiLUs33Hvqp5Jiyft+ZzzhfjTVb6fOB3MWiIDp1M= +github.com/Kraken-OffSec/Scurvy v0.0.0-20251011204529-faafd6327395/go.mod h1:0pPwYHy+r8KGzXZ8vBgyYd6qy3vX+AMRo9XLiGc8WGE= +github.com/Kraken-OffSec/Scurvy v0.0.0-20251011211525-6bf6bee1b100 h1:Om4wnKb+fpfYi3uRfc27Pz8uG/3CNrM2G3sSBwerSXA= +github.com/Kraken-OffSec/Scurvy v0.0.0-20251011211525-6bf6bee1b100/go.mod h1:0pPwYHy+r8KGzXZ8vBgyYd6qy3vX+AMRo9XLiGc8WGE= github.com/alwindoss/morse v1.0.1 h1:PkUh5m1UHMcZ1Upvl7CmSIBMxdEBejWoQ4rQQtgJsCQ= github.com/alwindoss/morse v1.0.1/go.mod h1:qAqJOep3jEpIpiLgqSGgLk5Zh4BZKsyzMQHuAwVPMXc= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= diff --git a/internal/pkg/hunt/detect/autorun/autorun.go b/internal/pkg/hunt/detect/autorun/autorun.go index 80d7889..cbb2d8c 100644 --- a/internal/pkg/hunt/detect/autorun/autorun.go +++ b/internal/pkg/hunt/detect/autorun/autorun.go @@ -9,58 +9,22 @@ import ( "github.com/Kraken-OffSec/Scurvy/core/autoruns" ) -// basic allow/deny helpers kept local to keep changes scoped -var systemDirs = []string{"\\windows\\system32\\", "\\windows\\syswow64\\", "\\windows\\", "\\program files\\windowsapps\\"} -var safeSystemBinaries = []string{ - "svchost.exe", "lsass.exe", "services.exe", "winlogon.exe", "explorer.exe", - "ctfmon.exe", "spoolsv.exe", "dwm.exe", "smss.exe", "csrss.exe", - "runtimebroker.exe", "shellexperiencehost.exe", "searchui.exe", "sihost.exe", - "taskhostw.exe", "wininit.exe", "rdpclip.exe", -} -var genericTokens = map[string]struct{}{ - "remote": {}, "control": {}, "support": {}, "assist": {}, "viewer": {}, - "server": {}, "service": {}, "manager": {}, "desktop": {}, "host": {}, - "client": {}, "agent": {}, "connect": {}, "access": {}, "admin": {}, - "vpn": {}, "ssh": {}, "vnc": {}, "rdp": {}, "microsoft": {}, "windows": {}, +// Whitelist for our own tool and legitimate system components +var whitelist = []string{ + "rmm-hunter", } -func inSlice(slice []string, v string) bool { - v = strings.ToLower(v) - for _, s := range slice { - if strings.ToLower(s) == v { +func isWhitelisted(ar AutoRun) bool { + allText := strings.ToLower(strings.Join([]string{ + ar.ImageName, ar.ImagePath, ar.Entry, ar.LaunchString, + }, "|")) + for _, w := range whitelist { + if strings.Contains(allText, w) { return true } } return false } -func containsAny(haystack string, needles []string) bool { - h := strings.ToLower(haystack) - for _, n := range needles { - if strings.Contains(h, strings.ToLower(n)) { - return true - } - } - return false -} -func isInSystemDir(p string) bool { - pl := strings.ToLower(p) - for _, d := range systemDirs { - if strings.Contains(pl, d) { - return true - } - } - return false -} -func isNonGenericToken(t string) bool { - t = strings.ToLower(strings.TrimSpace(t)) - if len(t) < 4 { - return false - } - if _, ok := genericTokens[t]; ok { - return false - } - return true -} func Detect() []AutoRun { var suspiciousAutoRuns []AutoRun @@ -86,6 +50,11 @@ func Detect() []AutoRun { LaunchString: ar.LaunchString, } + // Skip whitelisted entries (our own tool) + if isWhitelisted(sar) { + continue + } + if isSuspiciousAutoRunEntry(sar) { fmt.Printf(" [?] Found %s | %s | %s\n", sar.Location, sar.Entry, sar.ImagePath) suspiciousAutoRuns = append(suspiciousAutoRuns, sar) @@ -96,51 +65,107 @@ func Detect() []AutoRun { return suspiciousAutoRuns } -// isSuspiciousAutoRunEntry determines if an autorun appears to be an RMM by -// checking image path/name, location, entry and launch string against -// common RMM indicators and suspicious image suffixes. It also flags -// suspicious installation paths. +// isSuspiciousAutoRunEntry uses multi-Indicator scoring to detect RMMs +// Requires at least 2 independent Indicators to flag as suspicious +// Hash match alone is sufficient (high confidence) func isSuspiciousAutoRunEntry(ar AutoRun) bool { - // Build a single string of fields we care about - joined := strings.ToLower(strings.Join([]string{ar.ImageName, ar.ImagePath, ar.Entry, ar.LaunchString}, "|")) + score := 0 - // 1) Vendor token hit (filter out generic words) - vendorHit := false - for _, tok := range common.CommonRMMs { - if !isNonGenericToken(tok) { - continue + // Build searchable text from all fields + allText := strings.ToLower(strings.Join([]string{ + ar.ImageName, ar.ImagePath, ar.Entry, ar.LaunchString, ar.Location, ar.Arguments, + }, "|")) + + // Indicator 0: Known RMM hash match (SHA256 or SHA1) - HIGHEST CONFIDENCE + // A hash match alone is sufficient to flag as suspicious + if ar.SHA256 != "" { + sha256Lower := strings.ToLower(ar.SHA256) + for _, hash := range common.CommonRMMHashes { + if strings.ToLower(hash) == sha256Lower { + return true // Hash match is definitive + } } - if strings.Contains(joined, strings.ToLower(tok)) { - vendorHit = true - break + } + if ar.SHA1 != "" { + sha1Lower := strings.ToLower(ar.SHA1) + for _, hash := range common.CommonRMMHashesSHA1 { + if strings.ToLower(hash) == sha1Lower { + return true // Hash match is definitive + } } } - // 2) Known image suffix/file pattern hit (robust to registry naming) - suffixHit := false + // 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 (CommonImageSuffixes) + binaryPatternHit := false imgPathLower := strings.ToLower(ar.ImagePath) imgNameLower := strings.ToLower(ar.ImageName) - for _, suf := range common.CommonImageSuffixes { - s := strings.ToLower(suf) - if strings.Contains(imgPathLower, s) || strings.Contains(imgNameLower, s) { - suffixHit = true + launchLower := strings.ToLower(ar.LaunchString) + for _, pattern := range common.CommonImageSuffixes { + patternLower := strings.ToLower(pattern) + if strings.Contains(imgPathLower, patternLower) || + strings.Contains(imgNameLower, patternLower) || + strings.Contains(launchLower, patternLower) { + binaryPatternHit = true break } } + if binaryPatternHit { + score++ + } - // 3) Known vendor DNS in launch string/command + // Indicator 3: Known RMM DNS/domain in command line or launch string (CommonDNS) dnsHit := false - ls := strings.ToLower(ar.LaunchString) - for _, d := range common.CommonDNS { - if strings.Contains(ls, strings.ToLower(d)) { - dnsHit = true - break + argsLower := strings.ToLower(ar.Arguments) + 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(launchLower, domainSuffix) || strings.Contains(argsLower, 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(launchLower, domainPrefix) || strings.Contains(argsLower, domainPrefix) { + dnsHit = true + break + } + } else { + // Exact domain match (no wildcard) + if strings.Contains(launchLower, dnsLower) || strings.Contains(argsLower, dnsLower) { + dnsHit = true + break + } } } - - // Require two independent signals to reduce false positives - if (vendorHit && (suffixHit || dnsHit)) || (suffixHit && dnsHit) { - return true + if dnsHit { + score++ } - return false + + // Indicator 4: Suspicious installation path (temp, public, programdata) + pathSuspicious, _ := common.AnalyzeExecutablePath(ar.ImagePath) + if !pathSuspicious && ar.LaunchString != "" { + pathSuspicious, _ = common.AnalyzeExecutablePath(ar.LaunchString) + } + if pathSuspicious { + score++ + } + + // Require at least 2 independent Indicator to reduce false positives + return score >= 2 } diff --git a/internal/pkg/hunt/detect/common/rmmHashes.go b/internal/pkg/hunt/detect/common/rmmHashes.go new file mode 100644 index 0000000..86d29cf --- /dev/null +++ b/internal/pkg/hunt/detect/common/rmmHashes.go @@ -0,0 +1,15 @@ +package common + +// CommonRMMHashes contains known SHA256 hashes of RMM executables +// These are high-confidence indicators - a hash match is a strong signal +// Sources: VirusTotal, LOLRMM, threat intelligence reports +var CommonRMMHashes = []string{ + // TODO: Add hashes here + +} + +// CommonRMMHashesSHA1 contains known SHA1 hashes of RMM executables +var CommonRMMHashesSHA1 = []string{ + // TODO: Add hashes here + // SHA256 is preferred for collision resistance +} diff --git a/internal/pkg/hunt/detect/processes/processes.go b/internal/pkg/hunt/detect/processes/processes.go index 936e569..f787aa1 100644 --- a/internal/pkg/hunt/detect/processes/processes.go +++ b/internal/pkg/hunt/detect/processes/processes.go @@ -9,6 +9,23 @@ import ( "github.com/Kraken-OffSec/Scurvy/core/process" ) +// Whitelist for our own tool and legitimate system components +var whitelist = []string{ + "rmm-hunter", +} + +func isWhitelisted(proc process.Process) bool { + allText := strings.ToLower(strings.Join([]string{ + proc.Executable(), proc.Path(), + }, "|")) + for _, w := range whitelist { + if strings.Contains(allText, w) { + return true + } + } + return false +} + func Detect() []Process { fmt.Printf("[*] Enumerating Processes \n") @@ -27,6 +44,11 @@ func compareProcesses(processes []process.Process) []Process { var suspiciousProcesses []Process for _, proc := range processes { + // Skip whitelisted processes (our own tool) + if isWhitelisted(proc) { + continue + } + procName := proc.Executable() procNameLower := strings.ToLower(procName) diff --git a/internal/pkg/hunt/detect/services/services.go b/internal/pkg/hunt/detect/services/services.go index 302d09d..13e0b7a 100644 --- a/internal/pkg/hunt/detect/services/services.go +++ b/internal/pkg/hunt/detect/services/services.go @@ -10,23 +10,21 @@ import ( "golang.org/x/sys/windows" ) -// ignore overly-generic tokens when matching vendor names -var genericTokens = map[string]struct{}{ - "remote": {}, "control": {}, "support": {}, "assist": {}, "viewer": {}, - "server": {}, "service": {}, "manager": {}, "desktop": {}, "host": {}, - "client": {}, "agent": {}, "connect": {}, "access": {}, "admin": {}, - "vpn": {}, "ssh": {}, "vnc": {}, "rdp": {}, "microsoft": {}, "windows": {}, +// Whitelist for our own tool and legitimate system components +var whitelist = []string{ + "rmm-hunter", } -func isNonGenericToken(t string) bool { - t = strings.ToLower(strings.TrimSpace(t)) - if len(t) < 4 { - return false +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 + } } - if _, ok := genericTokens[t]; ok { - return false - } - return true + return false } func Detect() []*Service { @@ -64,25 +62,13 @@ func compareServices(serviceStrings []string, scm *service.Mgr) []*Service { fmt.Printf(" [>-] Error getting service config %s: %s\n", serviceString, err.Error()) continue } - svcStartName := strings.ToLower(config.ServiceStartName) - svcDisplayName := strings.ToLower(config.DisplayName) - svcBinaryPath := strings.ToLower(config.BinaryPathName) - // Check against known RMMs - isRMMMatch := false - for _, rmm := range common.CommonRMMs { - if !isNonGenericToken(rmm) { - continue - } - rmmLower := strings.ToLower(rmm) - if strings.Contains(svcDisplayName, rmmLower) || strings.Contains(svcStartName, rmmLower) || strings.Contains(svcBinaryPath, rmmLower) { - isRMMMatch = true - break - } + // Skip whitelisted services (our own tool) + if isWhitelisted(config) { + continue } - // Only flag when there is a positive RMM vendor token match - if isRMMMatch { + if isSuspiciousService(config) { fmt.Printf(" [?] Found %s\n", config.DisplayName) suspiciousServices = append(suspiciousServices, &Service{ Name: serviceString, @@ -110,6 +96,83 @@ func compareServices(serviceStrings []string, scm *service.Mgr) []*Service { 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: diff --git a/internal/pkg/hunt/eliminate/autorun.go b/internal/pkg/hunt/eliminate/autorun.go new file mode 100644 index 0000000..e151d83 --- /dev/null +++ b/internal/pkg/hunt/eliminate/autorun.go @@ -0,0 +1,20 @@ +package eliminate + +import ( + "fmt" + . "rmm-hunter/internal/suspicious" + + "github.com/Kraken-OffSec/Scurvy" +) + +// EliminateAutoRun removes an autorun entry from the system +func EliminateAutoRun(ar AutoRun) error { + all := scurvy.ListAutoruns() + for _, a := range all { + if a.MD5 == ar.MD5 { + // Found it, delete it + return scurvy.DeleteAutorun(a) + } + } + return fmt.Errorf("%s | %s not found", ar.Location, ar.Entry) +} diff --git a/internal/pkg/hunt/eliminate/binary.go b/internal/pkg/hunt/eliminate/binary.go new file mode 100644 index 0000000..1702eae --- /dev/null +++ b/internal/pkg/hunt/eliminate/binary.go @@ -0,0 +1,8 @@ +package eliminate + +import "os" + +// EliminateBinary removes a binary from the system +func EliminateBinary(path string) error { + return os.Remove(path) +} diff --git a/internal/pkg/hunt/eliminate/connection.go b/internal/pkg/hunt/eliminate/connection.go new file mode 100644 index 0000000..fc90116 --- /dev/null +++ b/internal/pkg/hunt/eliminate/connection.go @@ -0,0 +1,36 @@ +package eliminate + +import ( + "fmt" + + "github.com/Kraken-OffSec/Scurvy/core/firewall" +) + +// EliminateConnection adds an outbound block for the connection to the Windows firewall +func EliminateConnection(dst string) error { + // Create a new WindowsFirewall instance + fw, err := firewall.NewWindowsFirewall() + if err != nil { + return err + } + + // Check if firewall is enabled + if !fw.Enabled() { + return fmt.Errorf("windows firewall is currently disabled. please enable it and try again") + } + + // Add a block rule for the destination + return fw.AddRule(firewall.FirewallRule{ + Name: fmt.Sprintf("Block Outgoing %s", dst), + Direction: "outbound", + Protocol: "any", + LocalPort: "any", + RemotePort: "any", + LocalAddress: "", + RemoteAddress: "", + Action: "block", + Profile: "", + Destination: dst, + Source: "", + }) +} diff --git a/internal/pkg/hunt/eliminate/directory.go b/internal/pkg/hunt/eliminate/directory.go new file mode 100644 index 0000000..cd63db5 --- /dev/null +++ b/internal/pkg/hunt/eliminate/directory.go @@ -0,0 +1,7 @@ +package eliminate + +import "os" + +func EliminateDirectory(path string) error { + return os.RemoveAll(path) +} diff --git a/internal/pkg/hunt/eliminate/eliminate.go b/internal/pkg/hunt/eliminate/eliminate.go deleted file mode 100644 index cadbced..0000000 --- a/internal/pkg/hunt/eliminate/eliminate.go +++ /dev/null @@ -1 +0,0 @@ -package eliminate diff --git a/internal/pkg/hunt/eliminate/processes.go b/internal/pkg/hunt/eliminate/processes.go new file mode 100644 index 0000000..76ef14f --- /dev/null +++ b/internal/pkg/hunt/eliminate/processes.go @@ -0,0 +1,16 @@ +package eliminate + +import ( + . "rmm-hunter/internal/suspicious" + + scurvy "github.com/Kraken-OffSec/Scurvy" +) + +// EliminateProcess kills a process and removes its binary from the system +func EliminateProcess(p Process) error { + err, proc := scurvy.FindProcessByPID(p.PID) + if err != nil { + return err + } + return proc.Kill() +} diff --git a/internal/pkg/hunt/eliminate/scheduledTasks.go b/internal/pkg/hunt/eliminate/scheduledTasks.go new file mode 100644 index 0000000..3fdc83d --- /dev/null +++ b/internal/pkg/hunt/eliminate/scheduledTasks.go @@ -0,0 +1,11 @@ +package eliminate + +import ( + . "rmm-hunter/internal/suspicious" + + scurvy "github.com/Kraken-OffSec/Scurvy" +) + +func EliminateScheduledTask(t ScheduledTask) error { + return scurvy.DeleteScheduledTask(t.Name) +} diff --git a/internal/pkg/hunt/eliminate/services.go b/internal/pkg/hunt/eliminate/services.go new file mode 100644 index 0000000..0c69de2 --- /dev/null +++ b/internal/pkg/hunt/eliminate/services.go @@ -0,0 +1,12 @@ +package eliminate + +import ( + . "rmm-hunter/internal/suspicious" + + scurvy "github.com/Kraken-OffSec/Scurvy" +) + +// EliminateService stops and removes a service from the system +func EliminateService(s Service) error { + return scurvy.RemoveService(s.Name) +} diff --git a/internal/suspicious/rmm.go b/internal/suspicious/rmm.go index 6e3c610..622f3ad 100644 --- a/internal/suspicious/rmm.go +++ b/internal/suspicious/rmm.go @@ -50,12 +50,6 @@ AutoRun The object used to resemble the auto run methods used by the Suspicious software. */ type AutoRun struct { - //Name string `json:"name"` - //Command string `json:"command"` - //Location string `json:"location"` - //Enabled bool `json:"enabled"` - //Description string `json:"description"` - Type string `json:"type"` Location string `json:"location"` ImagePath string `json:"image_path"`