21 Commits

Author SHA1 Message Date
KrakenTech d2f7e7595b Revise KrakenTech Proprietary License
Updated the proprietary license terms and conditions.
2026-03-27 16:07:45 -04:00
KrakenTech 3935efb6ed Revise MIT License terms and conditions
Updated the MIT License to include restrictions on distribution and derivative works.
2026-03-27 16:05:30 -04:00
Evan Hosinski 88c5afba9c "Add documentation for new web-based interface, including usage, features, GIFs, and updated elimination module details" 2025-10-13 12:14:18 -04:00
KrakenTech 852b061b40 Merge pull request #2 from KrakenTech-LLC/webserver
Webserver
2025-10-13 11:47:11 -04:00
Evan Hosinski b4db6c19b9 new gifs 2025-10-13 07:49:15 -04:00
Evan Hosinski 3f50f20892 Enhance API error responses with JSON format, improve suspicious directory detection with worker pool implementation, and refine elimination logic with better index validation and data flow updates. Update UI for active report indicators, item expansion, and eliminated state tracking. 2025-10-12 21:58:11 -04:00
Evan Hosinski 25d99c265d Add elimination API handler, update browser logic for process tracking, and refine UI animations and modal handling. 2025-10-12 20:58:53 -04:00
Evan Hosinski 0b09092973 Refine comments in index.html for clarity, update alert message in elimination workflow, and adjust UI text for improved consistency. 2025-10-12 20:07:16 -04:00
Evan Hosinski adcad167df Add support for dynamic hosts file management, browser opening, and new favicon handling in web server. Add elimination workflow UI enhancements with better state management and design.
TODO: Test elimination per finding type in web view

Figure out where RustDesk registry persistence is located. The installer is aware of it somehow
2025-10-12 20:02:49 -04:00
Evan Hosinski 15fb9eb510 Add web-based user interface with hunting, reporting, and elimination workflow for RMM-Hunter 2025-10-12 18:53:07 -04:00
Evan Hosinski 01113551fb Add web server implementation for RMM-Hunter with API endpoints and WebSocket support 2025-10-12 18:46:59 -04:00
KrakenTech e6f91d0bc7 Update README.md 2025-10-11 23:39:32 -04:00
KrakenTech 615c129376 Update README.md 2025-10-11 23:39:06 -04:00
KrakenTech bf63fb83bf Update README.md 2025-10-11 23:32:21 -04:00
KrakenTech 976b45043b Add files via upload 2025-10-11 23:32:05 -04:00
KrakenTech e578a8fd97 Update README.md 2025-10-11 23:30:21 -04:00
KrakenTech 58b521987d Add files via upload 2025-10-11 23:29:32 -04:00
KrakenTech 69657e4c46 Update README.md
Added Any.Run results
2025-10-11 23:23:58 -04:00
Evan Hosinski d349b38047 Update README to replace banner image and add new rmm-hunter.png asset 2025-10-11 21:56:44 -04:00
Evan Hosinski 3e498365a3 Expand README with detailed elimination module documentation, add new GIF assets, and refine feature descriptions for clarity. 2025-10-11 21:45:08 -04:00
Evan Hosinski b47351f4ee Remove placeholder text from action descriptions in UI and clarify performEliminate function behavior. 2025-10-11 21:08:58 -04:00
32 changed files with 2580 additions and 173 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

+21 -16
View File
@@ -1,22 +1,27 @@
MIT License
KrakenTech Proprietary License
Copyright (c) 2025 KrakenTech LLC
Copyright (c) 2025 KrakenTech LLC. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Permission is hereby granted to view and use this software for personal,
educational, or internal business purposes only, subject to the following
restrictions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. You may NOT distribute, sublicense, publish, sell, resell, or otherwise
make this software available to any third party, in whole or in part,
in source or binary form.
2. You may NOT create derivative works for distribution or commercial use.
3. You may NOT use this software to provide a competing product or service.
4. Modifications for personal or internal use are permitted, provided they
are not distributed.
5. This copyright notice and license must be included in all copies.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
IMPLIED. IN NO EVENT SHALL KRAKENTECH LLC BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY ARISING FROM THE USE OF THIS SOFTWARE.
For commercial licensing, redistribution rights, or other inquiries,
contact: licensing@krkn.tech
+140 -38
View File
@@ -1,15 +1,35 @@
# RMM-Hunter
A comprehensive Windows security tool designed to detect and analyze Remote Monitoring and Management (RMM) software deployments across enterprise environments.
A comprehensive Windows security tool designed to detect and analyze Remote Monitoring and Management (RMM) software deployments.
![](.img/banner-metadata.png)
![](.img/rmm-hunter.png)
## Overview
RMM-Hunter is a forensic analysis tool that identifies potentially malicious or unauthorized Remote Monitoring and Management software on Windows systems. Built on a proprietary detection framework called **Scurvy** (private repository), RMM-Hunter provides security teams with comprehensive visibility into RMM installations that may pose security risks or compliance concerns.
RMM-Hunter is an analysis tool that identifies potentially malicious or unauthorized Remote Monitoring and Management software/connections on Windows systems. Built on **Scurvy**, a custom low-level OS exploitation repository (private), RMM-Hunter provides security teams with comprehensive visibility into RMM installations that may pose security risks or compliance concerns.
## Features
### Web Interface
RMM-Hunter now includes a modern web-based interface for both hunting and elimination operations. Simply double-click the executable to launch the web server, which automatically:
- **Starts a local web server** on port 80 (http://rmm-hunter)
- **Adds a DNS entry** to your Windows hosts file for easy access via `http://rmm-hunter`
- **Requests UAC elevation** if administrator privileges are not already granted
- **Opens your default browser** automatically to the web interface
- **Cleans up the hosts entry** when the application exits
![](.gif/web_execute.gif)
The web interface provides:
- Real-time hunt execution with live log streaming via WebSockets
- Interactive elimination interface with visual feedback
- Previous hunt report browsing and analysis
- Modern, responsive UI accessible from any browser on the local machine
![](.gif/web_hunt.gif)
### Hunt Module
The hunt module performs deep system analysis across multiple detection vectors:
@@ -21,8 +41,10 @@ The hunt module performs deep system analysis across multiple detection vectors:
- **Network Connection Monitoring** - Identifies active outbound connections to known RMM infrastructure
- **Scheduled Task Detection** - Discovers RMM-related scheduled tasks used for persistence
- **Directory Scanning** - Searches for RMM installation directories and artifacts
- **AutoRun Analysis** - Searches for RMM persistence via Windows AutoRuns utilzing COM Services and Registry Keys
![](.img/hunt.png)
![](.gif/sample_hunt.gif)
### Detection Capabilities
@@ -42,15 +64,13 @@ RMM-Hunter generates comprehensive reports in multiple formats:
- **JSON** - Machine-readable format for integration with SIEM and automation platforms
- **HTML** - Interactive web-based report with filtering and search capabilities
![](.img/findings.png)
The HTML report includes:
- Executive summary with detection statistics
- Detailed findings across all detection categories
- Metadata including detection time and system information
- Built-in search and filter functionality for large result sets
![](.img/search-filter.png)
![](.gif/html_report.gif)
## Installation
@@ -58,6 +78,7 @@ The HTML report includes:
- Windows Operating System (Windows 10/11 or Windows Server 2016+)
- Administrator privileges (required for service and process enumeration)
- The application will automatically request UAC elevation if not running as administrator
- Go 1.24+ (for building from source)
### Binary Download
@@ -74,9 +95,31 @@ The Scurvy Library is not publicly accessible making building this tool from sou
## Usage
### Hunt Mode
### Web Interface (Recommended)
Execute a comprehensive system scan:
Launch the web interface by simply running the executable without arguments:
```powershell
.\rmm-hunter.exe
```
This will:
1. Check for administrator privileges and request UAC elevation if needed
2. Start a web server on port 80
3. Add `rmm-hunter` to your hosts file (pointing to 127.0.0.1)
4. Automatically open your browser to `http://rmm-hunter`
From the web interface, you can:
- Execute hunts with real-time progress monitoring
- View and analyze previous hunt reports
- Perform elimination operations on detected RMM software
- Access all functionality through an intuitive browser-based UI
The hosts file entry is automatically cleaned up when you exit the application.
### Hunt Mode (CLI)
Execute a comprehensive system scan from the command line:
```powershell
powershell .\rmm-hunter.exe hunt
@@ -86,7 +129,7 @@ With custom output file:
```powershell
powershell .\rmm-hunter.exe hunt --output custom-report.json
```
```
Exclude specific RMM tools from detection:
```powershell
@@ -95,29 +138,92 @@ powershell .\rmm-hunter.exe hunt --exclude TeamViewer,AnyDesk
### Eliminate Mode
**Status: Under Construction**
The elimination module provides both web-based and command-line interfaces for removing detected RMM installations from your system. Both interfaces automatically request UAC elevation if administrator privileges are required.
The elimination module is currently under active development. This functionality will provide automated remediation capabilities for detected RMM installations.
#### Web Interface
Planned features:
- Service termination and removal
- Process termination
- Binary deletion
- Registry cleanup
- Scheduled task removal
- Backup and rollback capabilities
The web interface provides a modern, browser-based elimination experience:
```powershell
.\rmm-hunter.exe
```
Or explicitly launch the web-based elimination interface:
```powershell
powershell .\rmm-hunter.exe eliminate --web
```
The web interface offers:
- Visual representation of all detected RMM components
- Real-time elimination with progress feedback
- Dependency checking to prevent system instability
- Interactive browsing of previous hunt reports
- Live log streaming during operations
#### CLI Component
Launch the interactive CLI elimination interface:
```powershell
powershell .\rmm-hunter.exe eliminate --cli
```
The CLI component operates through a multi-stage interactive workflow designed to provide granular control over the elimination process. When launched, the interface guides you through the following stages:
**Stage 1: Report Selection**
The interface scans the current directory for JSON hunt reports and presents them in a navigable list. You can browse available reports using arrow keys and select one by pressing Enter. The file picker automatically filters for valid JSON files generated by previous hunt operations.
**Stage 2: Category Selection**
After loading a report, you are presented with seven elimination categories corresponding to the detection vectors from the hunt module. Each category is accessible via numeric keys (1-7):
1. AutoRuns - Registry-based persistence mechanisms
2. Binaries - Executable files on disk
3. Connections - Active network connections
4. Directories - Installation directories
5. Processes - Running processes
6. Scheduled Tasks - Task Scheduler entries
7. Services - Windows services
**Stage 3: Item List View**
Upon selecting a category, the interface displays all detected items within that category. Each item shows relevant identifying information such as process names, file paths, service names, or connection details. Items that have already been eliminated are marked with a checkmark and displayed in green to provide visual feedback on remediation progress. You can navigate through the list using arrow keys and select an item for detailed inspection by pressing Enter.
**Stage 4: Detail View and Elimination**
The detail view presents comprehensive information about the selected item, including all metadata collected during the hunt phase. For each item type, the interface displays specific details:
For processes, you see the process name, PID, parent PID, command-line arguments, creation time, and executable path. For services, the display includes service name, display name, service type, start type, binary path, start account, and description. For autoruns, you see the entry name, launch string, registry location, image path, arguments, and file hashes (MD5, SHA1, SHA256). For binaries and directories, the full path is shown. For network connections, local and remote addresses, remote hostname, connection state, associated PID, and process name are displayed. For scheduled tasks, the name, author, state, enabled status, last result, next run time, last run time, and task path are presented.
From the detail view, pressing the exclamation mark (!) key initiates the elimination process for that specific item. The system performs intelligent dependency checking before elimination to prevent system instability.
**Dependency Validation**
Before eliminating binaries or directories, the system checks whether any active processes or enabled services are currently using those resources. If a dependency is detected, a warning modal appears explaining the conflict and suggesting the proper elimination order. For example, if you attempt to delete a binary that is currently in use by a running process, the system will warn you to eliminate the process first. Similarly, if a directory contains binaries used by active services, you must stop and remove those services before the directory can be deleted.
![](.gif/binary_order_of_operations_removal.gif)
**Elimination Actions**
Each category type performs specific elimination operations:
Processes are terminated using their PID. Services are stopped and then deleted from the service control manager. Binaries are removed from the filesystem. Directories are recursively deleted along with all contents. AutoRun entries are removed from their respective registry locations. Scheduled tasks are disabled and then deleted from the Task Scheduler. Network connections result in the creation of Windows Firewall outbound block rules for the remote host, preventing future connections to that destination.
![](.gif/kill_conn.gif)
**State Persistence**
After each successful elimination, the system updates the JSON report file to mark the item as eliminated. This ensures that if you exit and restart the elimination interface, previously eliminated items remain marked and visually distinguished. The persistent state allows you to work through large result sets across multiple sessions without losing track of your progress.
**Navigation**
Throughout the interface, you can navigate backward using the left arrow key to return to the previous screen. Pressing 'q', 'Esc', or 'Ctrl+C' at any point will exit the application. The interface provides contextual help at each stage, displaying available keyboard shortcuts and actions.
## Architecture
RMM-Hunter is built on **Scurvy**, a proprietary Windows system analysis framework (private repository). Scurvy provides the core capabilities for:
- Low-level Windows API interactions
- Process and service management
- Registry operations
- Network connection enumeration
- WMI query execution
The modular architecture allows for extensible detection capabilities while maintaining performance and stability.
RMM-Hunter is built on **Scurvy**, a custom low-level OS exploitation repository (private). Scurvy provides the core capabilities for low-level Windows API interactions, process and service management, registry operations, network connection enumeration, and WMI query execution. The modular architecture allows for extensible detection capabilities while maintaining performance and stability.
## Output Formats
@@ -144,17 +250,11 @@ RMM-Hunter employs multiple detection strategies:
## Limitations
- Requires administrative privileges for complete system visibility
- May generate false positives in environments with legitimate RMM deployments
- Network detection requires active connections at scan time
- Elimination functionality not yet available
Requires administrative privileges for complete system visibility (UAC elevation prompt will appear if needed). May generate false positives in environments with legitimate RMM deployments. Network detection requires active connections at scan time. The web server requires port 80 to be available on the local machine.
## Contributing
Contributions are welcome. Please submit pull requests with:
- Detailed description of changes
- Test coverage for new detection signatures
- Documentation updates
Contributions are welcome. Please submit pull requests with detailed descriptions of changes, test coverage for new detection signatures, and documentation updates.
## License
@@ -180,7 +280,9 @@ This tool is intended for authorized security assessments and forensic analysis
For issues, questions, or feature requests, please open an issue on the GitHub repository.
---
**Note**: The underlying Scurvy repository is a custom low-level OS exploitation framework that is not publicly accessible and is maintained privately.
**Note**: The underlying Scurvy framework is not publicly accessible and is maintained in a private repository.
## Any.Run Submission
v1.2.0: https://app.any.run/tasks/03b6afcd-308c-4056-bafc-e6514185d922
![](.img/any.run.graph.png)
+3 -3
View File
@@ -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
+2
View File
@@ -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
+4
View File
@@ -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=
+125 -75
View File
@@ -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())
+25 -3
View File
@@ -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)
}
+1 -1
View File
@@ -185,7 +185,7 @@ func (m AppModel) View() string {
}
}
// performEliminate routes to placeholder eliminate functions without removing items from data
// performEliminate routes to eliminate functions without removing items from data
func (m *AppModel) performEliminate(typeKey string, idx int) error {
switch typeKey {
case "autoruns":
+4 -4
View File
@@ -108,7 +108,7 @@ func (m DetailViewModel) renderDetails() string {
return "Item no longer available"
}
c := m.data.OutboundConnections[m.index]
return fmt.Sprintf("Local: %s\nRemote: %s\nHost: %s\nState: %s\nPID: %s\nProcess: %s\nAction: add firewall block (placeholder)", c.LocalAddr, c.RemoteAddr, c.RemoteHost, c.State, c.PID, c.Process)
return fmt.Sprintf("Local: %s\nRemote: %s\nHost: %s\nState: %s\nPID: %s\nProcess: %s\nAction: add firewall block", c.LocalAddr, c.RemoteAddr, c.RemoteHost, c.State, c.PID, c.Process)
case "directories":
if m.index >= len(m.data.Directories) {
return "Item no longer available"
@@ -120,19 +120,19 @@ func (m DetailViewModel) renderDetails() string {
return "Item no longer available"
}
p := m.data.Processes[m.index]
return fmt.Sprintf("Name: %s\nPID: %d\nPPID: %d\nParent: %s\nArgs: %s\nCreated: %s\nPath: %s\nAction: stop then delete (placeholder)", p.Name, p.PID, p.PPID, p.Parent, p.Args, p.Created, p.Path)
return fmt.Sprintf("Name: %s\nPID: %d\nPPID: %d\nParent: %s\nArgs: %s\nCreated: %s\nPath: %s\nAction: stop then delete", p.Name, p.PID, p.PPID, p.Parent, p.Args, p.Created, p.Path)
case "scheduledTasks":
if m.index >= len(m.data.ScheduledTasks) {
return "Item no longer available"
}
t := m.data.ScheduledTasks[m.index]
return fmt.Sprintf("Name: %s\nAuthor: %s\nState: %s\nEnabled: %v\nLastResult: %s\nNextRun: %s\nLastRun: %s\nPath: %s\nAction: disable then delete (placeholder)", t.Name, t.Author, t.State, t.Enabled, t.LastResult, t.NextRun, t.LastRun, t.Path)
return fmt.Sprintf("Name: %s\nAuthor: %s\nState: %s\nEnabled: %v\nLastResult: %s\nNextRun: %s\nLastRun: %s\nPath: %s\nAction: disable then delete", t.Name, t.Author, t.State, t.Enabled, t.LastResult, t.NextRun, t.LastRun, t.Path)
case "services":
if m.index >= len(m.data.Services) {
return "Item no longer available"
}
s := m.data.Services[m.index]
return fmt.Sprintf("Name: %s\nDisplay: %s\nType: %s\nStartType: %s\nBinPath: %s\nStartName: %s\nDescription: %s\nAction: stop then delete (placeholder)", s.Name, s.DisplayName, s.ServiceType, s.StartType, s.BinaryPathName, s.ServiceStartName, s.Description)
return fmt.Sprintf("Name: %s\nDisplay: %s\nType: %s\nStartType: %s\nBinPath: %s\nStartName: %s\nDescription: %s\nAction: stop then delete", s.Name, s.DisplayName, s.ServiceType, s.StartType, s.BinaryPathName, s.ServiceStartName, s.Description)
}
return ""
}
+181
View File
@@ -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
}
+129
View File
@@ -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")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because it is too large Load Diff
+1
View File
@@ -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"}
+571 -2
View File
@@ -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
}
+14
View File
@@ -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()
}