diff --git a/.gif/web_execute.gif b/.gif/web_execute.gif new file mode 100644 index 0000000..a79bd7f Binary files /dev/null and b/.gif/web_execute.gif differ diff --git a/.gif/web_hunt.gif b/.gif/web_hunt.gif new file mode 100644 index 0000000..aff15d7 Binary files /dev/null and b/.gif/web_hunt.gif differ diff --git a/cmd/root.go b/cmd/root.go index 5efa5bd..5f03673 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 diff --git a/go.mod b/go.mod index 0cb8b12..944169c 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 0295d34..be8cd78 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/pkg/hunt/detect/common/directories.go b/internal/pkg/hunt/detect/common/directories.go index d5cab91..05eea07 100644 --- a/internal/pkg/hunt/detect/common/directories.go +++ b/internal/pkg/hunt/detect/common/directories.go @@ -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 () - `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 () + `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) } diff --git a/internal/pkg/hunt/detect/directory/directories.go b/internal/pkg/hunt/detect/directory/directories.go index 41c7f3b..16afaeb 100644 --- a/internal/pkg/hunt/detect/directory/directories.go +++ b/internal/pkg/hunt/detect/directory/directories.go @@ -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 } diff --git a/internal/pkg/hunt/detect/services/services.go b/internal/pkg/hunt/detect/services/services.go index 13e0b7a..cba30b8 100644 --- a/internal/pkg/hunt/detect/services/services.go +++ b/internal/pkg/hunt/detect/services/services.go @@ -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()) diff --git a/internal/pkg/hunt/eliminate/autorun.go b/internal/pkg/hunt/eliminate/autorun.go index e151d83..1a09858 100644 --- a/internal/pkg/hunt/eliminate/autorun.go +++ b/internal/pkg/hunt/eliminate/autorun.go @@ -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) } diff --git a/internal/web/browser.go b/internal/web/browser.go new file mode 100644 index 0000000..6759c73 --- /dev/null +++ b/internal/web/browser.go @@ -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 +} diff --git a/internal/web/hosts.go b/internal/web/hosts.go new file mode 100644 index 0000000..5c2688d --- /dev/null +++ b/internal/web/hosts.go @@ -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") +} diff --git a/internal/web/templates/android-chrome-192x192.png b/internal/web/templates/android-chrome-192x192.png new file mode 100644 index 0000000..9f9fff0 Binary files /dev/null and b/internal/web/templates/android-chrome-192x192.png differ diff --git a/internal/web/templates/android-chrome-512x512.png b/internal/web/templates/android-chrome-512x512.png new file mode 100644 index 0000000..e5605af Binary files /dev/null and b/internal/web/templates/android-chrome-512x512.png differ diff --git a/internal/web/templates/apple-touch-icon.png b/internal/web/templates/apple-touch-icon.png new file mode 100644 index 0000000..4039879 Binary files /dev/null and b/internal/web/templates/apple-touch-icon.png differ diff --git a/internal/web/templates/favicon-16x16.png b/internal/web/templates/favicon-16x16.png new file mode 100644 index 0000000..cfffb9f Binary files /dev/null and b/internal/web/templates/favicon-16x16.png differ diff --git a/internal/web/templates/favicon-32x32.png b/internal/web/templates/favicon-32x32.png new file mode 100644 index 0000000..bb3d71e Binary files /dev/null and b/internal/web/templates/favicon-32x32.png differ diff --git a/internal/web/templates/favicon.ico b/internal/web/templates/favicon.ico new file mode 100644 index 0000000..5da1b38 Binary files /dev/null and b/internal/web/templates/favicon.ico differ diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html new file mode 100644 index 0000000..79b2133 --- /dev/null +++ b/internal/web/templates/index.html @@ -0,0 +1,1183 @@ + + + + + + RMM Hunter Web UI + + + + + + + + + +
+ +
RMM HUNTER
+
POWERED BY KRAKENTECH
+
+
+ + +
+
+
Shutting Down
+
RMM Hunter is closing...
+
✓ You can now close this browser tab
+
+ + + + +
+ +
+ +
+
+

Hunt

+

Click Start Hunt to scan for Remote Monitoring & Management (RMM) software. Progress logs will appear below in real-time.

+
+ + + +
+
+
+ +
+

Use Previous Hunt

+
+
+ + + + +
+ + + + + + + diff --git a/internal/web/templates/site.webmanifest b/internal/web/templates/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/internal/web/templates/site.webmanifest @@ -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"} \ No newline at end of file diff --git a/internal/web/webserver.go b/internal/web/webserver.go index 212ae57..0cca693 100644 --- a/internal/web/webserver.go +++ b/internal/web/webserver.go @@ -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 } diff --git a/main.go b/main.go index ec9ba86..e2d7719 100644 --- a/main.go +++ b/main.go @@ -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() }