|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
@@ -6,6 +6,7 @@ import (
|
||||
"rmm-hunter/internal/pkg"
|
||||
"rmm-hunter/internal/pkg/hunter"
|
||||
"rmm-hunter/internal/tui"
|
||||
"rmm-hunter/internal/web"
|
||||
|
||||
scurvy "github.com/Kraken-OffSec/Scurvy"
|
||||
"github.com/Kraken-OffSec/Scurvy/core/escalator"
|
||||
@@ -145,9 +146,8 @@ func runHunt() {
|
||||
|
||||
func runEliminate() {
|
||||
if webUI {
|
||||
// Launch the web UI for elimination flow
|
||||
// TODO: Launch web UI
|
||||
fmt.Println("Web UI not implemented yet")
|
||||
fmt.Println("Starting Web UI on http://127.0.0.1:8080 ...")
|
||||
web.StartWebServer()
|
||||
return
|
||||
} else if cliUI {
|
||||
// Launch the TUI for elimination flow
|
||||
|
||||
@@ -30,6 +30,7 @@ require (
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/ethereum/go-ethereum v1.14.12 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -49,6 +50,7 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
|
||||
@@ -57,6 +57,8 @@ github.com/ethereum/go-ethereum v1.14.12 h1:8hl57x77HSUo+cXExrURjU/w1VhL+ShCTJrT
|
||||
github.com/ethereum/go-ethereum v1.14.12/go.mod h1:RAC2gVMWJ6FkxSPESfbshrcKpIokgQKsVKmAuqdekDY=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
@@ -102,6 +104,8 @@ golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -1,78 +1,128 @@
|
||||
package common
|
||||
|
||||
var CommonDirectories = []string{
|
||||
`C:\Program Files (x86)%\mRemoteNG`,
|
||||
`C:\\Program Files (x86)\Sysprogs`,
|
||||
`C:\\Program Files (x86)\Sysprogs\SmarTTY`,
|
||||
`C:\AlpemixSrvc`,
|
||||
`C:\Downloads\SuperPuTTY`,
|
||||
`C:\Program Files (x86)\Almageste\DragonDisk`,
|
||||
`C:\Program Files (x86)\AnyDesk`,
|
||||
`C:\Program Files (x86)\AnyViewer`,
|
||||
`C:\Program Files (x86)\Atera Networks`,
|
||||
`C:\Program Files (x86)\Bitvise SSH Client`,
|
||||
`C:\Program Files (x86)\Bluetrait Agent`,
|
||||
`C:\Program Files (x86)\DesktopCentral_Agent`,
|
||||
`C:\Program Files (x86)\DesktopCentral_Agent\bin`,
|
||||
`C:\Program Files (x86)\GoTo Opener`,
|
||||
`C:\Program Files (x86)\GoToMyPC`,
|
||||
`C:\Program Files (x86)\Google\Chrome Remote Desktop`,
|
||||
`C:\Program Files (x86)\ISL Online`,
|
||||
`C:\Program Files (x86)\Kaseya`,
|
||||
`C:\Program Files (x86)\LANDesk`,
|
||||
`C:\Program Files (x86)\OnionShare`,
|
||||
`C:\Program Files (x86)\NetSarang`,
|
||||
`C:\Program Files (x86)\NetSarang\xShell`,
|
||||
`C:\Program Files (x86)\PJ Technologies`,
|
||||
`C:\Program Files (x86)\PJ Technologies\GOVsrv`,
|
||||
`C:\Program Files (x86)\Radmin Viewer 3`,
|
||||
`C:\Program Files (x86)\RemotePC`,
|
||||
`C:\Program Files (x86)\S3 Browser`,
|
||||
`C:\Program Files (x86)\ScreenConnect Client (`, // C:\Program Files (x86)\ScreenConnect Client (<string ID>)
|
||||
`C:\Program Files (x86)\SmartFTP Client`,
|
||||
`C:\Program Files (x86)\Splashtop`,
|
||||
`C:\Program Files (x86)\TeamViewer`,
|
||||
`C:\Program Files (x86)\UltraViewer`,
|
||||
`C:\Program Files (x86)\Xpra`,
|
||||
`C:\Program Files (x86)\Yandex`,
|
||||
`C:\Program Files (x86)\mRemoteNG`,
|
||||
`C:\Program Files\ATERA NETWORKS`,
|
||||
`C:\Program Files\ATERA NETWORKS\AteraAgent`,
|
||||
`C:\Program Files\AnyDesk`,
|
||||
`C:\Program Files\Bitvise SSH Server`,
|
||||
`C:\Program Files\Danware Data\NetOp Packn Deploy`,
|
||||
`C:\Program Files\Level`,
|
||||
`C:\\Program Files\\LiteManager Pro`,
|
||||
`C:\\Program Files\\LiteManager Pro \u2013 Viewer`,
|
||||
`C:\Program Files\ManageEngine\ManageEngine Free Tools`,
|
||||
`C:\Program Files\ManageEngine\ManageEngine Free Tools\Launcher`,
|
||||
`C:\Program Files\RealVNC`,
|
||||
`C:\Program Files\RealVNC\VNC Serve`,
|
||||
`C:\Program Files\Remote Utilities`,
|
||||
`C:\Program Files\Remote Utilities\Agent`,
|
||||
`C:\Program Files\Solar-Putty-v4`,
|
||||
`C:\Program Files\SolarWinds\Dameware Mini Remote Control`,
|
||||
`C:\Program Files\SysAidServer`,
|
||||
`C:\Program Files\TeamViewer`,
|
||||
`C:\Program Files\TightVNC`,
|
||||
`C:\Program Files\ZOC8`,
|
||||
`C:\Program Files\uvnc bvba`,
|
||||
`C:\Program Files\uvnc bvba\UltraVNC`,
|
||||
`C:\ProgramData\Kaseya`,
|
||||
`C:\ProgramData\Total Software Deployment`,
|
||||
`C:\ProgramFiles\GoTo Machine Installer`,
|
||||
`C:\ProgramFiles (x86)\GoTo Machine Installer`,
|
||||
`{{APPDATA}}\Local\Google\Chrome\User Data\Default\Extensions\iodihamcpbpeioajjeobimgagajmlibd`,
|
||||
`{{APPDATA}}\Local\MEGAsync`,
|
||||
`{{APPDATA}}\Roaming\Mikogo`,
|
||||
`{{APPDATA}}\Roaming\SyncTrayzor`,
|
||||
`C:\Users\IEUser\Downloads\WinSCP-5.21.6-Portable`,
|
||||
`C:\Users\USERNAME\AppData\Roaming\Insync`,
|
||||
`C:\Users\USERNAME\AppData\Roaming\Insync\App`,
|
||||
`C:\Windows\Action1`,
|
||||
`C:\Windows\SysWOW64\rserver30`,
|
||||
`C:\Windows\SysWOW64\rserver30\FamItrfc`,
|
||||
`C:\Windows\SysWOW64\rserver30\FamItrf2`,
|
||||
`C:\Windows\dwrcs`,
|
||||
`C:\ProgramData\AMMYY`,
|
||||
// KnownRMMDirectories contains known directory names/paths
|
||||
// These will be searched in common installation locations defined in SearchBasePaths
|
||||
var KnownRMMDirectories = []string{
|
||||
// A
|
||||
`Action1`,
|
||||
`Almageste\DragonDisk`,
|
||||
`AlpemixSrvc`,
|
||||
`AMMYY`,
|
||||
`AnyDesk`,
|
||||
`AnyViewer`,
|
||||
`Atera Networks`,
|
||||
`ATERA NETWORKS`,
|
||||
`ATERA NETWORKS\AteraAgent`,
|
||||
|
||||
// B
|
||||
`Bitvise SSH Client`,
|
||||
`Bitvise SSH Server`,
|
||||
`Bluetrait Agent`,
|
||||
|
||||
// D
|
||||
`Danware Data\NetOp Packn Deploy`,
|
||||
`DesktopCentral_Agent`,
|
||||
`DesktopCentral_Agent\bin`,
|
||||
|
||||
// G
|
||||
`GoTo Opener`,
|
||||
`GoTo Machine Installer`,
|
||||
`GoToMyPC`,
|
||||
`Google\Chrome Remote Desktop`,
|
||||
`Google\Chrome\User Data\Default\Extensions\iodihamcpbpeioajjeobimgagajmlibd`,
|
||||
|
||||
// I
|
||||
`Insync`,
|
||||
`Insync\App`,
|
||||
`ISL Online`,
|
||||
|
||||
// K
|
||||
`Kaseya`,
|
||||
|
||||
// L
|
||||
`LANDesk`,
|
||||
`Level`,
|
||||
`LiteManager Pro`,
|
||||
`LiteManager Pro – Viewer`,
|
||||
|
||||
// M
|
||||
`ManageEngine\ManageEngine Free Tools`,
|
||||
`ManageEngine\ManageEngine Free Tools\Launcher`,
|
||||
`MEGAsync`,
|
||||
`Mikogo`,
|
||||
`mRemoteNG`,
|
||||
|
||||
// N
|
||||
`NetSarang`,
|
||||
`NetSarang\xShell`,
|
||||
|
||||
// O
|
||||
`OnionShare`,
|
||||
|
||||
// P
|
||||
`PJ Technologies`,
|
||||
`PJ Technologies\GOVsrv`,
|
||||
|
||||
// R
|
||||
`Radmin Viewer 3`,
|
||||
`RealVNC`,
|
||||
`RealVNC\VNC Serve`,
|
||||
`Remote Utilities`,
|
||||
`Remote Utilities\Agent`,
|
||||
`RemotePC`,
|
||||
`RustDesk`,
|
||||
|
||||
// S
|
||||
`S3 Browser`,
|
||||
`ScreenConnect Client (`, // Prefix pattern for ScreenConnect Client (<string ID>)
|
||||
`SmartFTP Client`,
|
||||
`Solar-Putty-v4`,
|
||||
`SolarWinds\Dameware Mini Remote Control`,
|
||||
`Splashtop`,
|
||||
`SuperPuTTY`,
|
||||
`SyncTrayzor`,
|
||||
`Sysprogs`,
|
||||
`Sysprogs\SmarTTY`,
|
||||
`SysAidServer`,
|
||||
`SysWOW64\rserver30`,
|
||||
`SysWOW64\rserver30\FamItrfc`,
|
||||
`SysWOW64\rserver30\FamItrf2`,
|
||||
|
||||
// T
|
||||
`TeamViewer`,
|
||||
`TightVNC`,
|
||||
`Total Software Deployment`,
|
||||
|
||||
// U
|
||||
`UltraViewer`,
|
||||
`uvnc bvba`,
|
||||
`uvnc bvba\UltraVNC`,
|
||||
|
||||
// W
|
||||
`WinSCP-5.21.6-Portable`,
|
||||
`dwrcs`,
|
||||
|
||||
// X
|
||||
`Xpra`,
|
||||
|
||||
// Y
|
||||
`Yandex`,
|
||||
|
||||
// Z
|
||||
`ZOC8`,
|
||||
}
|
||||
|
||||
// SearchBasePaths defines the base directories to search within
|
||||
var SearchBasePaths = []string{
|
||||
`C:\Program Files`,
|
||||
`C:\Program Files (x86)`,
|
||||
`C:\ProgramData`,
|
||||
`C:\ProgramFiles`, // Installers variant 1
|
||||
`C:\ProgramFiles (x86)`, // Installers variant 2
|
||||
`C:\Windows`,
|
||||
`{{APPDATA}}\Local`,
|
||||
`{{APPDATA}}\Roaming`,
|
||||
`{{USERPROFILE}}\Downloads`,
|
||||
`C:\Downloads`, // Standard downloads location
|
||||
`C:\`, // Root for edge cases (AlpemixSrvc)
|
||||
}
|
||||
|
||||
@@ -7,52 +7,106 @@ import (
|
||||
"rmm-hunter/internal/pkg/hunt/detect/common"
|
||||
. "rmm-hunter/internal/suspicious"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var appData = os.Getenv("APPDATA")
|
||||
var userProfile = os.Getenv("USERPROFILE")
|
||||
|
||||
const numWorkers = 5
|
||||
|
||||
type searchJob struct {
|
||||
basePath string
|
||||
rmmDir string
|
||||
}
|
||||
|
||||
func Detect() []Directory {
|
||||
var suspiciousDirectories []Directory
|
||||
seen := make(map[string]bool) // Prevent duplicates
|
||||
|
||||
fmt.Printf("[*] Enumerating Suspicious Directories \n")
|
||||
// Check for common directories
|
||||
for _, dir := range common.CommonDirectories {
|
||||
dir = replaceAppData(dir)
|
||||
|
||||
// Check if this is a prefix pattern (ends with incomplete path such as Screen Connect "C:\Program Files (x86)\ScreenConnect Client (")
|
||||
if isPrefix(dir) {
|
||||
// Find all directories matching this prefix
|
||||
matches := findPrefixMatches(dir)
|
||||
for _, match := range matches {
|
||||
if !seen[match] {
|
||||
fmt.Printf(" [?] Found %s\n", match)
|
||||
suspiciousDirectories = append(suspiciousDirectories, Directory{Path: match})
|
||||
seen[match] = true
|
||||
}
|
||||
// Create channels
|
||||
jobs := make(chan searchJob, 100)
|
||||
results := make(chan Directory, 100)
|
||||
|
||||
// WaitGroup to track workers
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Start worker pool
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
wg.Add(1)
|
||||
go worker(jobs, results, &wg)
|
||||
}
|
||||
|
||||
// Start result collector goroutine
|
||||
var suspiciousDirectories []Directory
|
||||
seen := make(map[string]bool)
|
||||
var resultWg sync.WaitGroup
|
||||
resultWg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer resultWg.Done()
|
||||
for dir := range results {
|
||||
if !seen[dir.Path] {
|
||||
fmt.Printf(" [?] Found %s\n", dir.Path)
|
||||
suspiciousDirectories = append(suspiciousDirectories, dir)
|
||||
seen[dir.Path] = true
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
if !seen[dir] {
|
||||
fmt.Printf(" [?] Found %s\n", dir)
|
||||
suspiciousDirectories = append(suspiciousDirectories, Directory{Path: dir})
|
||||
seen[dir] = true
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Send jobs to workers
|
||||
for _, rmmDir := range common.KnownRMMDirectories {
|
||||
for _, basePath := range common.SearchBasePaths {
|
||||
jobs <- searchJob{
|
||||
basePath: basePath,
|
||||
rmmDir: rmmDir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close jobs channel and wait for workers to finish
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
// Close results channel and wait for collector to finish
|
||||
close(results)
|
||||
resultWg.Wait()
|
||||
|
||||
fmt.Printf("[+] Found %d Suspicious Directories\n", len(suspiciousDirectories))
|
||||
|
||||
return suspiciousDirectories
|
||||
}
|
||||
|
||||
// replaceAppData replaces {{APPDATA}} with the actual APPDATA path
|
||||
func replaceAppData(path string) string {
|
||||
if strings.Contains(path, "{{APPDATA}}") {
|
||||
p := strings.Replace(path, "{{APPDATA}}", "", -1)
|
||||
return filepath.Join(appData, p)
|
||||
// worker processes search jobs from the jobs channel
|
||||
func worker(jobs <-chan searchJob, results chan<- Directory, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
|
||||
for job := range jobs {
|
||||
// Replace environment variables
|
||||
basePath := replaceEnvVars(job.basePath)
|
||||
|
||||
// Construct full path
|
||||
fullPath := filepath.Join(basePath, job.rmmDir)
|
||||
|
||||
// Check if this is a prefix pattern (ends with incomplete path like "ScreenConnect Client (")
|
||||
if isPrefix(job.rmmDir) {
|
||||
// Find all directories matching this prefix
|
||||
matches := findPrefixMatches(fullPath)
|
||||
for _, match := range matches {
|
||||
results <- Directory{Path: match}
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if _, err := os.Stat(fullPath); err == nil {
|
||||
results <- Directory{Path: fullPath}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replaceEnvVars replaces environment variable placeholders with actual paths
|
||||
func replaceEnvVars(path string) string {
|
||||
path = strings.ReplaceAll(path, "{{APPDATA}}", appData)
|
||||
path = strings.ReplaceAll(path, "{{USERPROFILE}}", userProfile)
|
||||
return path
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Kraken-OffSec/Scurvy/core/service"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Whitelist for our own tool and legitimate system components
|
||||
@@ -35,7 +34,8 @@ func Detect() []*Service {
|
||||
fmt.Printf("[-] Error getting Service Manager: %s\n", err.Error())
|
||||
return []*Service{}
|
||||
}
|
||||
defer windows.Close(scm.Handle)
|
||||
// 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 {
|
||||
@@ -57,6 +57,8 @@ func compareServices(serviceStrings []string, scm *service.Mgr) []*Service {
|
||||
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())
|
||||
|
||||
@@ -10,11 +10,33 @@ import (
|
||||
// EliminateAutoRun removes an autorun entry from the system
|
||||
func EliminateAutoRun(ar AutoRun) error {
|
||||
all := scurvy.ListAutoruns()
|
||||
|
||||
// Try to find by MD5 first
|
||||
for _, a := range all {
|
||||
if a.MD5 == ar.MD5 {
|
||||
// Found it, delete it
|
||||
if a.MD5 == ar.MD5 && a.MD5 != "" {
|
||||
return scurvy.DeleteAutorun(a)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s | %s not found", ar.Location, ar.Entry)
|
||||
|
||||
// If not found by MD5, try to find by location (for registry entries)
|
||||
for _, a := range all {
|
||||
if a.Location == ar.Location && ar.Location != "" {
|
||||
return scurvy.DeleteAutorun(a)
|
||||
}
|
||||
}
|
||||
|
||||
// Build a descriptive error message
|
||||
location := ar.Location
|
||||
if location == "" {
|
||||
location = "unknown location"
|
||||
}
|
||||
entry := ar.Entry
|
||||
if entry == "" {
|
||||
entry = ar.ImageName
|
||||
}
|
||||
if entry == "" {
|
||||
entry = "unknown entry"
|
||||
}
|
||||
|
||||
return fmt.Errorf("autorun entry not found at %s (%s) - it may have already been removed", location, entry)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
shell32 = syscall.NewLazyDLL("shell32.dll")
|
||||
shellExecuteExW = shell32.NewProc("ShellExecuteExW")
|
||||
)
|
||||
|
||||
const (
|
||||
SEE_MASK_NOCLOSEPROCESS = 0x00000040
|
||||
SW_SHOW = 5
|
||||
)
|
||||
|
||||
// SHELLEXECUTEINFO structure for ShellExecuteEx
|
||||
type shellExecuteInfo struct {
|
||||
cbSize uint32
|
||||
fMask uint32
|
||||
hwnd uintptr
|
||||
lpVerb *uint16
|
||||
lpFile *uint16
|
||||
lpParameters *uint16
|
||||
lpDirectory *uint16
|
||||
nShow int32
|
||||
hInstApp uintptr
|
||||
lpIDList uintptr
|
||||
lpClass *uint16
|
||||
hkeyClass uintptr
|
||||
dwHotKey uint32
|
||||
hIconOrMonitor uintptr
|
||||
hProcess windows.Handle
|
||||
}
|
||||
|
||||
// BrowserHandle represents a handle to the opened browser process
|
||||
type BrowserHandle struct {
|
||||
ProcessID uint32
|
||||
Handle windows.Handle
|
||||
}
|
||||
|
||||
// OpenBrowser opens the default browser to the specified URL using Windows ShellExecute API
|
||||
// Returns a handle to the browser process that can be used to close it later
|
||||
func OpenBrowser(url string) (*BrowserHandle, error) {
|
||||
// Convert strings to UTF16 pointers
|
||||
operation, err := syscall.UTF16PtrFromString("open")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert operation string: %w", err)
|
||||
}
|
||||
|
||||
urlPtr, err := syscall.UTF16PtrFromString(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert URL string: %w", err)
|
||||
}
|
||||
|
||||
// Initialize SHELLEXECUTEINFO structure
|
||||
sei := shellExecuteInfo{
|
||||
cbSize: uint32(unsafe.Sizeof(shellExecuteInfo{})),
|
||||
fMask: SEE_MASK_NOCLOSEPROCESS, // Request process handle
|
||||
hwnd: 0,
|
||||
lpVerb: operation,
|
||||
lpFile: urlPtr,
|
||||
lpParameters: nil,
|
||||
lpDirectory: nil,
|
||||
nShow: SW_SHOW,
|
||||
hInstApp: 0,
|
||||
}
|
||||
|
||||
// Call ShellExecuteExW
|
||||
ret, _, err := shellExecuteExW.Call(uintptr(unsafe.Pointer(&sei)))
|
||||
if ret == 0 {
|
||||
return nil, fmt.Errorf("ShellExecuteExW failed: %w", err)
|
||||
}
|
||||
|
||||
if sei.hInstApp <= 32 {
|
||||
return nil, fmt.Errorf("ShellExecuteExW failed with code: %d", sei.hInstApp)
|
||||
}
|
||||
|
||||
// Get process ID from handle
|
||||
var processID uint32
|
||||
if sei.hProcess != 0 {
|
||||
processID, err = windows.GetProcessId(sei.hProcess)
|
||||
if err != nil {
|
||||
// If we can't get PID, still return the handle
|
||||
processID = 0
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[web] Browser opened successfully (PID: %d)\n", processID)
|
||||
|
||||
return &BrowserHandle{
|
||||
ProcessID: processID,
|
||||
Handle: sei.hProcess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close terminates the browser process and all child processes
|
||||
func (bh *BrowserHandle) Close() error {
|
||||
if bh == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// First try to kill the direct process if we have a handle
|
||||
if bh.Handle != 0 {
|
||||
windows.CloseHandle(bh.Handle)
|
||||
}
|
||||
|
||||
// Kill all browser processes that might have our URL open
|
||||
// This is more reliable than trying to track the exact process tree
|
||||
killed := killBrowserProcesses()
|
||||
|
||||
fmt.Printf("[web] Terminated %d browser process(es)\n", killed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// killBrowserProcesses finds and kills common browser processes
|
||||
func killBrowserProcesses() int {
|
||||
browserExes := []string{
|
||||
"chrome.exe",
|
||||
"msedge.exe",
|
||||
"firefox.exe",
|
||||
"brave.exe",
|
||||
"opera.exe",
|
||||
"iexplore.exe",
|
||||
}
|
||||
|
||||
killed := 0
|
||||
for _, exeName := range browserExes {
|
||||
count := killProcessByName(exeName)
|
||||
killed += count
|
||||
}
|
||||
|
||||
return killed
|
||||
}
|
||||
|
||||
// killProcessByName kills all processes with the given executable name
|
||||
func killProcessByName(exeName string) int {
|
||||
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer windows.CloseHandle(snapshot)
|
||||
|
||||
var procEntry windows.ProcessEntry32
|
||||
procEntry.Size = uint32(unsafe.Sizeof(procEntry))
|
||||
|
||||
err = windows.Process32First(snapshot, &procEntry)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
killed := 0
|
||||
for {
|
||||
// Convert the process name from [260]uint16 to string
|
||||
processName := syscall.UTF16ToString(procEntry.ExeFile[:])
|
||||
|
||||
if processName == exeName {
|
||||
// Open process with terminate rights
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, procEntry.ProcessID)
|
||||
if err == nil {
|
||||
err = windows.TerminateProcess(handle, 0)
|
||||
if err == nil {
|
||||
killed++
|
||||
fmt.Printf("[web] Killed %s (PID: %d)\n", exeName, procEntry.ProcessID)
|
||||
}
|
||||
windows.CloseHandle(handle)
|
||||
}
|
||||
}
|
||||
|
||||
err = windows.Process32Next(snapshot, &procEntry)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return killed
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
hostsEntry = "127.0.0.1 rmm-hunter"
|
||||
marker = "# RMM-Hunter entry"
|
||||
)
|
||||
|
||||
// AddHostsEntry adds the rmm-hunter DNS entry to the Windows hosts file
|
||||
// Requires administrator privileges
|
||||
func AddHostsEntry() error {
|
||||
hostsPath := getHostsPath()
|
||||
|
||||
// Check if entry already exists
|
||||
exists, err := hostsEntryExists(hostsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check hosts file: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
fmt.Println("[+] rmm-hunter hosts entry already exists")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read existing hosts file
|
||||
content, err := os.ReadFile(hostsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read hosts file: %w", err)
|
||||
}
|
||||
|
||||
// Append our entry
|
||||
newContent := string(content)
|
||||
if !strings.HasSuffix(newContent, "\n") {
|
||||
newContent += "\n"
|
||||
}
|
||||
newContent += fmt.Sprintf("\n%s\n%s\n", marker, hostsEntry)
|
||||
|
||||
// Write back to hosts file
|
||||
err = os.WriteFile(hostsPath, []byte(newContent), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write hosts file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("[+] Added rmm-hunter to hosts file")
|
||||
fmt.Println("[+] You can now access the web UI at: http://rmm-hunter:8080")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveHostsEntry removes the rmm-hunter DNS entry from the Windows hosts file
|
||||
func RemoveHostsEntry() error {
|
||||
hostsPath := getHostsPath()
|
||||
|
||||
// Read existing hosts file
|
||||
file, err := os.Open(hostsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open hosts file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var newLines []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
skipNext := false
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Skip the marker line and the next line (our entry)
|
||||
if strings.Contains(line, marker) {
|
||||
skipNext = true
|
||||
continue
|
||||
}
|
||||
|
||||
if skipNext && strings.Contains(line, "rmm-hunter") {
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("failed to read hosts file: %w", err)
|
||||
}
|
||||
|
||||
// Write back to hosts file
|
||||
newContent := strings.Join(newLines, "\n")
|
||||
err = os.WriteFile(hostsPath, []byte(newContent), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write hosts file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("[+] Removed rmm-hunter from hosts file")
|
||||
return nil
|
||||
}
|
||||
|
||||
// hostsEntryExists checks if the rmm-hunter entry already exists in the hosts file
|
||||
func hostsEntryExists(hostsPath string) (bool, error) {
|
||||
file, err := os.Open(hostsPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.Contains(line, "rmm-hunter") && strings.Contains(line, "127.0.0.1") {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, scanner.Err()
|
||||
}
|
||||
|
||||
// getHostsPath returns the path to the Windows hosts file
|
||||
func getHostsPath() string {
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = "C:\\Windows"
|
||||
}
|
||||
return filepath.Join(systemRoot, "System32", "drivers", "etc", "hosts")
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 469 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -1,5 +1,574 @@
|
||||
package web
|
||||
|
||||
func StartWebServer() {
|
||||
// TODO: Start web server
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rmm-hunter/internal/suspicious"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"rmm-hunter/internal/pkg"
|
||||
"rmm-hunter/internal/pkg/hunt/eliminate"
|
||||
"rmm-hunter/internal/pkg/hunter"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
//go:embed templates/*
|
||||
var contentFS embed.FS
|
||||
|
||||
// broadcaster for hunt logs
|
||||
type wsHub struct {
|
||||
mu sync.Mutex
|
||||
conns map[*websocket.Conn]struct{}
|
||||
}
|
||||
|
||||
func newHub() *wsHub { return &wsHub{conns: make(map[*websocket.Conn]struct{})} }
|
||||
func (h *wsHub) add(c *websocket.Conn) { h.mu.Lock(); h.conns[c] = struct{}{}; h.mu.Unlock() }
|
||||
func (h *wsHub) rm(c *websocket.Conn) { h.mu.Lock(); delete(h.conns, c); h.mu.Unlock() }
|
||||
func (h *wsHub) send(msg string) {
|
||||
h.mu.Lock()
|
||||
for c := range h.conns {
|
||||
_ = c.WriteMessage(websocket.TextMessage, []byte(msg))
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// JSONReportMeta is a lightweight descriptor for previous hunts
|
||||
type JSONReportMeta struct {
|
||||
File string `json:"file"`
|
||||
ReportName string `json:"reportName"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
}
|
||||
|
||||
type server struct {
|
||||
hub *wsHub
|
||||
http *http.Server
|
||||
quitCh chan struct{}
|
||||
}
|
||||
|
||||
func StartWebServer() {
|
||||
var hostAdded bool
|
||||
h := newHub()
|
||||
s := &server{hub: h, quitCh: make(chan struct{})}
|
||||
|
||||
// Add hosts file entry for rmm-hunter
|
||||
if err := AddHostsEntry(); err != nil {
|
||||
log.Printf("[web] Warning: Failed to add hosts entry: %v\n", err)
|
||||
} else {
|
||||
hostAdded = true
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
mux.HandleFunc("/logo", s.handleLogo)
|
||||
mux.HandleFunc("/favicon.ico", s.handleFavicon)
|
||||
mux.HandleFunc("/favicon-32x32.png", s.handleFavicon)
|
||||
mux.HandleFunc("/favicon-16x16.png", s.handleFavicon)
|
||||
mux.HandleFunc("/apple-touch-icon.png", s.handleFavicon)
|
||||
mux.HandleFunc("/site.webmanifest", s.handleManifest)
|
||||
mux.HandleFunc("/api/hunts", s.handleListHunts)
|
||||
mux.HandleFunc("/api/hunt/start", s.handleStartHunt)
|
||||
mux.HandleFunc("/api/report", s.handleGetReport)
|
||||
mux.HandleFunc("/api/eliminate", s.handleEliminate)
|
||||
mux.HandleFunc("/api/quit", s.handleQuit)
|
||||
mux.HandleFunc("/ws/hunt", s.handleWS)
|
||||
|
||||
s.http = &http.Server{Addr: ":80", Handler: logRequests(mux)}
|
||||
|
||||
// Determine which URL to open in browser
|
||||
browserURL := "http://rmm-hunter"
|
||||
if !hostAdded {
|
||||
browserURL = "http://127.0.0.1"
|
||||
}
|
||||
|
||||
// Channel to signal when server is ready
|
||||
serverReady := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
// Signal that we're about to start listening
|
||||
close(serverReady)
|
||||
|
||||
if err := s.http.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for server to start, then open browser
|
||||
<-serverReady
|
||||
time.Sleep(500 * time.Millisecond) // Give server a moment to fully initialize
|
||||
log.Printf("[web] Opening browser to %s...\n", browserURL)
|
||||
_, err := OpenBrowser(browserURL)
|
||||
if err != nil {
|
||||
log.Printf("[web] Warning: Failed to open browser: %v\n", err)
|
||||
if !hostAdded {
|
||||
log.Printf("[web] Please open your browser and navigate to http://127.0.0.1\n")
|
||||
}
|
||||
log.Printf("[web] Please open your browser and navigate to http://rmm-hunter\n")
|
||||
}
|
||||
|
||||
// block until quit
|
||||
<-s.quitCh
|
||||
|
||||
// Clean up hosts entry on exit
|
||||
log.Printf("[web] Cleaning up hosts entry...\n")
|
||||
if err := RemoveHostsEntry(); err != nil {
|
||||
log.Printf("[web] Warning: Failed to remove hosts entry: %v\n", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = s.http.Shutdown(ctx)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("%s %s", r.Method, r.URL.Path)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := contentFS.ReadFile("templates/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "template missing", 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
// serve logo from repo .img; fallback to 404
|
||||
func (s *server) handleLogo(w http.ResponseWriter, r *http.Request) {
|
||||
path := filepath.Join(".img", "rmm-hunter.png")
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
http.ServeContent(w, r, "rmm-hunter.png", time.Now(), f)
|
||||
}
|
||||
|
||||
// serve favicon files from embedded templates folder
|
||||
func (s *server) handleFavicon(w http.ResponseWriter, r *http.Request) {
|
||||
filename := filepath.Base(r.URL.Path)
|
||||
b, err := contentFS.ReadFile("templates/" + filename)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Set appropriate content type
|
||||
contentType := "image/x-icon"
|
||||
if filepath.Ext(filename) == ".png" {
|
||||
contentType = "image/png"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000")
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
// serve site.webmanifest from embedded templates folder
|
||||
func (s *server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := contentFS.ReadFile("templates/site.webmanifest")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000")
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
func (s *server) handleListHunts(w http.ResponseWriter, r *http.Request) {
|
||||
files, _ := filepath.Glob("*.json")
|
||||
var out []JSONReportMeta
|
||||
for _, f := range files {
|
||||
// read small head of file to verify
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var env struct {
|
||||
ReportName string `json:"reportName"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
}
|
||||
if json.Unmarshal(b, &env) == nil && (env.ReportName != "" || strings.Contains(string(b), "\"findings\"")) {
|
||||
out = append(out, JSONReportMeta{File: f, ReportName: env.ReportName, GeneratedAt: env.GeneratedAt})
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
func (s *server) handleGetReport(w http.ResponseWriter, r *http.Request) {
|
||||
f := r.URL.Query().Get("file")
|
||||
if f == "" || strings.Contains(f, "..") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "bad file"})
|
||||
return
|
||||
}
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
func (s *server) handleStartHunt(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "use POST"})
|
||||
return
|
||||
}
|
||||
name := fmt.Sprintf("hunt-%s", time.Now().Format("20060102-150405"))
|
||||
go s.runHunt(name)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"reportName": name})
|
||||
}
|
||||
|
||||
func (s *server) runHunt(name string) {
|
||||
// redirect stdout to our pipe
|
||||
oldStdout := os.Stdout
|
||||
pr, pw, _ := os.Pipe()
|
||||
os.Stdout = pw
|
||||
// also mirror stderr
|
||||
oldStderr := os.Stderr
|
||||
pr2, pw2, _ := os.Pipe()
|
||||
os.Stderr = pw2
|
||||
|
||||
// reader goroutines
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
sc := bufio.NewScanner(pr)
|
||||
for sc.Scan() {
|
||||
s.hub.send(sc.Text())
|
||||
}
|
||||
done <- struct{}{}
|
||||
}()
|
||||
go func() {
|
||||
sc := bufio.NewScanner(pr2)
|
||||
for sc.Scan() {
|
||||
s.hub.send(sc.Text())
|
||||
}
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
// run hunter
|
||||
hunter.Start(pkg.RunOptions{Name: name})
|
||||
|
||||
// close writers and restore
|
||||
_ = pw.Close()
|
||||
_ = pw2.Close()
|
||||
<-done
|
||||
<-done
|
||||
os.Stdout = oldStdout
|
||||
os.Stderr = oldStderr
|
||||
s.hub.send("[+] Hunt complete")
|
||||
}
|
||||
|
||||
func (s *server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
up := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||
c, err := up.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.hub.add(c)
|
||||
defer func() { s.hub.rm(c); _ = c.Close() }()
|
||||
for { // keep alive until client closes
|
||||
if _, _, err := c.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) handleEliminate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ReportFile string `json:"reportFile"`
|
||||
Type string `json:"type"`
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load the report file
|
||||
reportFile := req.ReportFile
|
||||
if !strings.HasSuffix(reportFile, ".json") {
|
||||
reportFile += ".json"
|
||||
}
|
||||
reportPath := filepath.Join(".", reportFile)
|
||||
data, err := os.ReadFile(reportPath)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to read report: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the full report structure with findings wrapper
|
||||
var fullReport struct {
|
||||
ReportName string `json:"reportName"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
RiskRating interface{} `json:"riskRating"`
|
||||
Findings suspicious.Suspicious `json:"findings"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &fullReport); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to parse report: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Perform elimination based on type
|
||||
if err := performElimination(&fullReport.Findings, req.Type, req.Index); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save updated report
|
||||
updatedData, err := json.MarshalIndent(fullReport, "", " ")
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to marshal report: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(reportPath, updatedData, 0644); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to save report: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (s *server) handleQuit(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "use POST"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
go func() { time.Sleep(200 * time.Millisecond); s.quitCh <- struct{}{} }()
|
||||
}
|
||||
|
||||
// performElimination executes the elimination logic for a specific finding type and index
|
||||
func performElimination(report *suspicious.Suspicious, typeKey string, idx int) error {
|
||||
switch typeKey {
|
||||
case "connections":
|
||||
if idx < 0 || idx >= len(report.OutboundConnections) {
|
||||
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.OutboundConnections))
|
||||
}
|
||||
conn := report.OutboundConnections[idx]
|
||||
if err := eliminate.EliminateConnection(conn.RemoteHost); err != nil {
|
||||
return err
|
||||
}
|
||||
report.OutboundConnections[idx].Eliminated = true
|
||||
|
||||
case "processes":
|
||||
if idx < 0 || idx >= len(report.Processes) {
|
||||
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.Processes))
|
||||
}
|
||||
proc := report.Processes[idx]
|
||||
if err := eliminate.EliminateProcess(proc); err != nil {
|
||||
return err
|
||||
}
|
||||
report.Processes[idx].Eliminated = true
|
||||
|
||||
case "services":
|
||||
if idx < 0 || idx >= len(report.Services) {
|
||||
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.Services))
|
||||
}
|
||||
svc := report.Services[idx]
|
||||
if svc == nil {
|
||||
return fmt.Errorf("service is nil")
|
||||
}
|
||||
if err := eliminate.EliminateService(*svc); err != nil {
|
||||
return err
|
||||
}
|
||||
report.Services[idx].Eliminated = true
|
||||
|
||||
case "tasks":
|
||||
if idx < 0 || idx >= len(report.ScheduledTasks) {
|
||||
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.ScheduledTasks))
|
||||
}
|
||||
task := report.ScheduledTasks[idx]
|
||||
if task == nil {
|
||||
return fmt.Errorf("task is nil")
|
||||
}
|
||||
if err := eliminate.EliminateScheduledTask(*task); err != nil {
|
||||
return err
|
||||
}
|
||||
report.ScheduledTasks[idx].Eliminated = true
|
||||
|
||||
case "autoruns":
|
||||
if idx < 0 || idx >= len(report.AutoRuns) {
|
||||
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.AutoRuns))
|
||||
}
|
||||
ar := report.AutoRuns[idx]
|
||||
if err := eliminate.EliminateAutoRun(ar); err != nil {
|
||||
return err
|
||||
}
|
||||
report.AutoRuns[idx].Eliminated = true
|
||||
|
||||
case "binaries":
|
||||
if idx < 0 || idx >= len(report.Binaries) {
|
||||
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.Binaries))
|
||||
}
|
||||
bin := report.Binaries[idx]
|
||||
// Check if binary is blocked by active processes/services
|
||||
if err := checkBinaryBlocked(bin.Path, *report); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := eliminate.EliminateBinary(bin.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
report.Binaries[idx].Eliminated = true
|
||||
|
||||
case "directories":
|
||||
if idx < 0 || idx >= len(report.Directories) {
|
||||
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.Directories))
|
||||
}
|
||||
dir := report.Directories[idx]
|
||||
// Check if directory is blocked by active processes/services
|
||||
if err := checkDirectoryBlocked(dir.Path, *report); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := eliminate.EliminateDirectory(dir.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
report.Directories[idx].Eliminated = true
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown type: %s", typeKey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkBinaryBlocked checks if a binary is in use by active processes or services
|
||||
func checkBinaryBlocked(path string, data suspicious.Suspicious) error {
|
||||
normPath := func(p string) string {
|
||||
return strings.ToLower(filepath.Clean(p))
|
||||
}
|
||||
|
||||
np := normPath(path)
|
||||
|
||||
// Check active processes
|
||||
for _, p := range data.Processes {
|
||||
if p.Eliminated {
|
||||
continue
|
||||
}
|
||||
if normPath(p.Path) == np {
|
||||
return fmt.Errorf("binary in use by running process %s (PID %d). Eliminate the process first", p.Name, p.PID)
|
||||
}
|
||||
}
|
||||
|
||||
// Check enabled services
|
||||
for _, s := range data.Services {
|
||||
if s == nil || s.Eliminated {
|
||||
continue
|
||||
}
|
||||
sp := normPath(s.BinaryPathName)
|
||||
if sp == np && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") {
|
||||
// Check if service has a running process
|
||||
for _, p := range data.Processes {
|
||||
if p.Eliminated {
|
||||
continue
|
||||
}
|
||||
if normPath(p.Path) == sp {
|
||||
return fmt.Errorf("binary used by active and enabled service %s. Stop/delete the service first", s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDirectoryBlocked checks if a directory contains binaries used by active processes or services
|
||||
func checkDirectoryBlocked(dir string, data suspicious.Suspicious) error {
|
||||
normPath := func(p string) string {
|
||||
return strings.ToLower(filepath.Clean(p))
|
||||
}
|
||||
|
||||
dn := normPath(dir)
|
||||
if !strings.HasSuffix(dn, string(filepath.Separator)) {
|
||||
dn += string(filepath.Separator)
|
||||
}
|
||||
|
||||
inDir := func(p string) bool {
|
||||
pp := normPath(p)
|
||||
if pp == "" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(pp, dn)
|
||||
}
|
||||
|
||||
// Check processes
|
||||
for _, p := range data.Processes {
|
||||
if p.Eliminated {
|
||||
continue
|
||||
}
|
||||
if inDir(p.Path) {
|
||||
return fmt.Errorf("directory contains active process %s (PID %d). Eliminate the process first", p.Name, p.PID)
|
||||
}
|
||||
}
|
||||
|
||||
// Check services
|
||||
for _, s := range data.Services {
|
||||
if s == nil || s.Eliminated {
|
||||
continue
|
||||
}
|
||||
if inDir(s.BinaryPathName) && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") {
|
||||
// Check if service has a running process
|
||||
for _, p := range data.Processes {
|
||||
if p.Eliminated {
|
||||
continue
|
||||
}
|
||||
if normPath(p.Path) == normPath(s.BinaryPathName) {
|
||||
return fmt.Errorf("directory contains active and enabled service binary for %s. Stop/delete the service first", s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"rmm-hunter/cmd"
|
||||
"rmm-hunter/internal/web"
|
||||
|
||||
scurvy "github.com/Kraken-OffSec/Scurvy"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 1 {
|
||||
escErr := scurvy.CheckAndEscalateBinary()
|
||||
if escErr != nil {
|
||||
fmt.Printf("Failed to elevate: %v\n", escErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
web.StartWebServer()
|
||||
return
|
||||
}
|
||||
cmd.Execute()
|
||||
}
|
||||
|
||||