diff --git a/README.md b/README.md new file mode 100644 index 0000000..83a2e98 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# FERRUM + +Ferrum is a Windows-first vulnerability research and security auditing framework written in Go. It is designed as a single binary, `ferrum.exe`, with modules registered through a small core interface. + +## Build + +```sh +GOOS=windows GOARCH=amd64 go build -o ferrum.exe ./cmd +``` + +Or use the included script: + +```powershell +.\scripts\build-windows.ps1 +``` + +From Linux/macOS: + +```sh +./scripts/build-windows.sh +``` + +## Usage + +```cmd +ferrum.exe --HELP +ferrum.exe --ALL --VERBOSE +ferrum.exe --ALL --OUTPUT ferrum-reports +ferrum.exe --CLSID +ferrum.exe --CLSID --OUTPUT clsid.txt +ferrum.exe --TOKENS +ferrum.exe --REGISTRY +ferrum.exe --POLICY +ferrum.exe --DLLSEARCH +ferrum.exe --SERVICES +ferrum.exe --DRIVERS +ferrum.exe --PIPES +ferrum.exe --STARTUP +ferrum.exe --SCHEDULED +ferrum.exe --ENV +ferrum.exe --MITIGATIONS +ferrum.exe --AUTORUNS +ferrum.exe --IFEO +ferrum.exe --SILENTEXIT +ferrum.exe --WINLOGON +ferrum.exe --LSA +ferrum.exe --APPINIT +ferrum.exe --APPCERT +ferrum.exe --UAC +ferrum.exe --INSTALLER +ferrum.exe --POWERSHELL +ferrum.exe --APPLOCKER +ferrum.exe --WDAC +ferrum.exe --DEFENDER +ferrum.exe --FIREWALL +ferrum.exe --RDP +ferrum.exe --WMI +ferrum.exe --HOSTS +ferrum.exe --SHARES +ferrum.exe --SHELL +ferrum.exe --BROWSER +ferrum.exe --PROTOCOLS +ferrum.exe --COMLOCAL +ferrum.exe --KNOWNDLLS +ferrum.exe --SVCPATHS +ferrum.exe --DRIVERPATHS +ferrum.exe --CERTIFICATES +ferrum.exe --NETWORKPROVIDERS +ferrum.exe --PRINT +ferrum.exe --WINSOCK +ferrum.exe --ACCESSIBILITY +ferrum.exe --CLSID --VERBOSE +ferrum.exe --CLSID --QUIET +``` + +## Architecture + +- `cmd/` contains the CLI entry point. +- `core/` contains module registration, context, and banner code. +- `modules/` contains research modules. New modules implement `core.Module` and call `core.Register`. +- `windows/` contains build-tagged Windows API wrappers and non-Windows stubs. +- `output/` contains console logging. + +## Output + +Write a single module report: + +```cmd +ferrum.exe --CLSID --OUTPUT clsid.txt +``` + +Run every module and write one file per module: + +```cmd +ferrum.exe --ALL +ferrum.exe --ALL --OUTPUT ferrum-reports +``` + +Without `--OUTPUT`, `--ALL` creates a timestamped folder such as `ferrum-output-20260613-153000`. + +## CLSID ProcMon Filter Model + +`--CLSID` models this ProcMon workflow for COM hijack/LPE triage: + +- `User is NT AUTHORITY\SYSTEM` +- `Path contains HKCU\Software\Classes` +- `Path contains InprocServer32` +- `Path contains LocalServer32` +- `Result is NAME NOT FOUND` diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..ac0027d --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,335 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "ferrum/core" + _ "ferrum/modules/advanced" + _ "ferrum/modules/clsid" + _ "ferrum/modules/dllsearch" + _ "ferrum/modules/drivers" + _ "ferrum/modules/env" + _ "ferrum/modules/mitigations" + _ "ferrum/modules/pipes" + _ "ferrum/modules/policy" + _ "ferrum/modules/registry" + _ "ferrum/modules/scheduled" + _ "ferrum/modules/services" + _ "ferrum/modules/startup" + _ "ferrum/modules/tokens" + "ferrum/output" +) + +const build = "Development" + +// Module packages register these command flags through core.Register during init. +// Flags are parsed case-insensitively and displayed in uppercase by --HELP. +var registeredModuleFlags = []string{ + "--ACCESSIBILITY", + "--ACCESSTOKENS", + "--ACL", + "--ACTIVATIONCTX", + "--ALPC", + "--APPCERT", + "--APPINIT", + "--APPLOCKER", + "--APPCONTAINERBROKERS", + "--AUTORUNS", + "--AUTHPACKAGES", + "--BITS", + "--BROKERS", + "--BROWSER", + "--CERTIFICATES", + "--CLSID", + "--CLIPBOARD", + "--CLOUDAP", + "--COMDCOM", + "--COMELEVATION", + "--COMHIJACK", + "--COMLOCAL", + "--CONFUSEDDEPUTY", + "--CREDPROVIDERS", + "--CSRSS", + "--CUSTOMMARSHAL", + "--DCOMACTIVATION", + "--DDE", + "--DEFENDER", + "--DEVICEOBJECTS", + "--DLLHIJACKING", + "--DLLSEARCH", + "--DRAGDROP", + "--DRIVERPATHS", + "--DRIVERS", + "--EFSRPC", + "--ENDPOINTMAPPER", + "--ENV", + "--ENVINJECTION", + "--ETW", + "--EXPLOREREXT", + "--FIREWALL", + "--HANDLES", + "--HARDLINKS", + "--HOSTS", + "--HYPERV", + "--IFEO", + "--INSTALLER", + "--INSTALLERREPAIR", + "--IOCTLS", + "--JOBOBJECTS", + "--JUNCTIONS", + "--KNOWNDLLS", + "--LPC", + "--LSA", + "--LSAPLUGINS", + "--LSASSINTERFACES", + "--MITIGATIONS", + "--MINIFILTERS", + "--MMAP", + "--MOUNTPOINTS", + "--MSI", + "--NETWORKPROVIDERS", + "--NTOBJMGR", + "--OBJDIRS", + "--OLE", + "--OPLOCKS", + "--PATHCANON", + "--PIPES", + "--POLICY", + "--POWERSHELL", + "--PPL", + "--PREVIEWHANDLERS", + "--PRINT", + "--PROPERTYHANDLERS", + "--PROTOCOLS", + "--RDP", + "--RECOVERY", + "--REGSYMLINKS", + "--REGISTRY", + "--REPARSEPOINTS", + "--RPC", + "--SCHEDULED", + "--SCM", + "--SEARCHPOISON", + "--SECTIONOBJECTS", + "--SERVICES", + "--SESSIONISOLATION", + "--SHARES", + "--SHAREDMEMORY", + "--SHELL", + "--SILENTEXIT", + "--SMBIPC", + "--STARTUP", + "--SVCPATHS", + "--SXS", + "--SYMLINKS", + "--TASKRPC", + "--TEMPFILES", + "--THUMBNAILPROVIDERS", + "--TOKENS", + "--TOKENIMPERSONATION", + "--TOCTOU", + "--UAC", + "--UACAUTOELEVATION", + "--UPDATES", + "--URLMONIKERS", + "--USERPROFILESVC", + "--WDAC", + "--WDACPOLICY", + "--WINLOGON", + "--WIN32K", + "--WINRM", + "--WINSOCK", + "--WFP", + "--WINDOWSTATIONS", + "--WMI", + "--WSL", +} + +func main() { + fmt.Print(core.Banner(build)) + + modules := core.Modules() + flags, err := parseArgs(os.Args[1:], modules) + logger := output.NewConsoleLogger(os.Stdout, flags.Verbose, flags.Quiet) + + if err != nil { + logger.Error(err.Error()) + printHelp(modules) + os.Exit(2) + } + if flags.Help || len(flags.Selected) == 0 { + printHelp(modules) + return + } + + if flags.RunAll { + if err := runAllToDirectory(flags, modules); err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + return + } + + closeOutput, logger, err := outputLogger(flags) + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + defer closeOutput() + ctx := core.NewContext(logger, build) + + for _, module := range flags.Selected { + if err := module.Run(ctx); err != nil { + logger.Error(fmt.Sprintf("%s: %v", module.Name(), err)) + } + } +} + +type cliFlags struct { + Help bool + Quiet bool + Verbose bool + RunAll bool + OutputPath string + Selected []core.Module +} + +func parseArgs(args []string, modules []core.Module) (cliFlags, error) { + var flags cliFlags + byFlag := make(map[string]core.Module, len(modules)) + for _, module := range modules { + byFlag["--"+module.Name()] = module + } + + for i := 0; i < len(args); i++ { + arg := args[i] + normalized := strings.ToLower(arg) + switch { + case normalized == "--all": + flags.RunAll = true + flags.Selected = append(flags.Selected, modules...) + case normalized == "--output": + if i+1 >= len(args) { + return flags, fmt.Errorf("--OUTPUT requires a file path") + } + i++ + flags.OutputPath = args[i] + case strings.HasPrefix(normalized, "--output="): + flags.OutputPath = arg[len("--output="):] + if flags.OutputPath == "" { + return flags, fmt.Errorf("--OUTPUT requires a file path") + } + case normalized == "--help" || normalized == "-h" || normalized == "/?": + flags.Help = true + case normalized == "--verbose" || normalized == "-v": + flags.Verbose = true + case normalized == "--quiet" || normalized == "-q": + flags.Quiet = true + default: + module, ok := byFlag[normalized] + if !ok { + return flags, fmt.Errorf("unknown option: %s", arg) + } + flags.Selected = append(flags.Selected, module) + } + } + + if flags.Quiet { + flags.Verbose = false + } + return flags, nil +} + +func outputLogger(flags cliFlags) (func(), *output.ConsoleLogger, error) { + if flags.OutputPath == "" { + return func() {}, output.NewConsoleLogger(os.Stdout, flags.Verbose, flags.Quiet), nil + } + if err := ensureParent(flags.OutputPath); err != nil { + return nil, nil, err + } + file, err := os.Create(flags.OutputPath) + if err != nil { + return nil, nil, err + } + return func() { _ = file.Close() }, output.NewDualLogger(os.Stdout, file, flags.Verbose, flags.Quiet), nil +} + +func runAllToDirectory(flags cliFlags, modules []core.Module) error { + dir := flags.OutputPath + if dir == "" { + dir = fmt.Sprintf("ferrum-output-%s", time.Now().Format("20060102-150405")) + } + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + console := output.NewConsoleLogger(os.Stdout, flags.Verbose, flags.Quiet) + console.Info("Writing per-module reports to " + dir) + + for _, module := range modules { + path := filepath.Join(dir, strings.ToUpper(module.Name())+".txt") + file, err := os.Create(path) + if err != nil { + console.Error(fmt.Sprintf("%s: %v", module.Name(), err)) + continue + } + + logger := output.NewDualLogger(os.Stdout, file, flags.Verbose, flags.Quiet) + ctx := core.NewContext(logger, build) + logger.Info("Module: " + strings.ToUpper(module.Name())) + if err := module.Run(ctx); err != nil { + logger.Error(fmt.Sprintf("%s: %v", module.Name(), err)) + } + _ = file.Close() + } + + console.Info("Completed --ALL report directory: " + dir) + return nil +} + +func ensureParent(path string) error { + parent := filepath.Dir(path) + if parent == "." || parent == "" { + return nil + } + return os.MkdirAll(parent, 0755) +} + +func printHelp(modules []core.Module) { + fmt.Println("Usage:") + fmt.Println(" ferrum.exe [module] [options]") + fmt.Println(" ferrum.exe --ALL --VERBOSE") + fmt.Println(" ferrum.exe --CLSID --OUTPUT clsid.txt") + fmt.Println(" ferrum.exe --ALL --OUTPUT ferrum-reports") + fmt.Println() + fmt.Println("Modules:") + sort.Slice(modules, func(i, j int) bool { + return modules[i].Name() < modules[j].Name() + }) + width := 0 + for _, module := range modules { + flag := displayFlag(module.Name()) + if len(flag) > width { + width = len(flag) + } + } + for _, module := range modules { + fmt.Printf(" %-*s %s\n", width+2, displayFlag(module.Name()), module.Description()) + } + fmt.Println() + fmt.Println("Options:") + fmt.Println(" --VERBOSE Include additional context") + fmt.Println(" --QUIET Suppress banner and informational output") + fmt.Println(" --ALL Run every registered module") + fmt.Println(" --OUTPUT FILE Write a report file; with --ALL, use/create a report folder") + fmt.Println(" --HELP Show this help") +} + +func displayFlag(name string) string { + return "--" + strings.ToUpper(name) +} diff --git a/core/banner.go b/core/banner.go new file mode 100644 index 0000000..79dd498 --- /dev/null +++ b/core/banner.go @@ -0,0 +1,23 @@ +package core + +import "fmt" + +const banner = ` + _____ _____ _____ _____ _____ _____ +| __| __| __ | __ | | | | +| __| __| -| -| | | | | | +|__| |_____|__|__|__|__|_____|_|_|_| + + +Ferrum Windows Vulnerability Research Framework + +Build : %s +Author: Kernelstub + +######################################### + +` + +func Banner(build string) string { + return fmt.Sprintf(banner, build) +} diff --git a/core/context.go b/core/context.go new file mode 100644 index 0000000..499bfc5 --- /dev/null +++ b/core/context.go @@ -0,0 +1,17 @@ +package core + +type Logger interface { + Info(message string) + Success(message string) + Error(message string) + Verbose(message string) +} + +type Context struct { + Logger Logger + Build string +} + +func NewContext(logger Logger, build string) *Context { + return &Context{Logger: logger, Build: build} +} diff --git a/core/module.go b/core/module.go new file mode 100644 index 0000000..be41a37 --- /dev/null +++ b/core/module.go @@ -0,0 +1,47 @@ +package core + +import ( + "fmt" + "sort" + "strings" + "sync" +) + +type Module interface { + Name() string + Description() string + Run(ctx *Context) error +} + +var ( + mu sync.RWMutex + registry = make(map[string]Module) +) + +func Register(module Module) { + name := strings.ToLower(strings.TrimSpace(module.Name())) + if name == "" { + panic("module name cannot be empty") + } + + mu.Lock() + defer mu.Unlock() + if _, exists := registry[name]; exists { + panic(fmt.Sprintf("module already registered: %s", name)) + } + registry[name] = module +} + +func Modules() []Module { + mu.RLock() + defer mu.RUnlock() + + modules := make([]Module, 0, len(registry)) + for _, module := range registry { + modules = append(modules, module) + } + sort.Slice(modules, func(i, j int) bool { + return modules[i].Name() < modules[j].Name() + }) + return modules +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b178e88 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module ferrum + +go 1.26 diff --git a/internal/slices.go b/internal/slices.go new file mode 100644 index 0000000..b896869 --- /dev/null +++ b/internal/slices.go @@ -0,0 +1,8 @@ +package internal + +func Limit[T any](items []T, max int) []T { + if max <= 0 || len(items) <= max { + return items + } + return items[:max] +} diff --git a/modules/advanced/module.go b/modules/advanced/module.go new file mode 100644 index 0000000..cefd343 --- /dev/null +++ b/modules/advanced/module.go @@ -0,0 +1,155 @@ +package advanced + +import ( + "fmt" + + "ferrum/core" + win "ferrum/windows/facade" +) + +type definition struct { + name string + description string +} + +var definitions = []definition{ + {"comdcom", "Audit COM/DCOM registration and activation surface"}, + {"comelevation", "Audit COM elevation moniker and auto-elevated COM surface"}, + {"dcomactivation", "Audit DCOM activation and machine-wide COM security policy"}, + {"rpc", "Audit RPC endpoint and service registration surface"}, + {"alpc", "Audit ALPC-related object and broker surface"}, + {"lpc", "Audit legacy LPC and object namespace surface"}, + {"tokenimpersonation", "Audit token impersonation privilege and server process surface"}, + {"autoruns", "Audit extended autorun and logon execution locations"}, + {"ifeo", "Audit Image File Execution Options interception settings"}, + {"silentexit", "Audit SilentProcessExit monitor process settings"}, + {"winlogon", "Audit Winlogon shell, userinit, and notification surface"}, + {"lsa", "Audit LSA authentication, notification, and security packages"}, + {"appinit", "Audit AppInit DLL configuration"}, + {"appcert", "Audit AppCert DLL process creation hooks"}, + {"uac", "Audit UAC policy values"}, + {"uacautoelevation", "Audit UAC auto-elevation and consent policy surface"}, + {"installer", "Audit Windows Installer elevation policy"}, + {"msi", "Audit Windows Installer and repair abuse surface"}, + {"installerrepair", "Audit MSI repair and advertised shortcut abuse surface"}, + {"powershell", "Audit PowerShell policy and profile surface"}, + {"applocker", "Audit AppLocker and SRP policy presence"}, + {"wdac", "Audit Windows Defender Application Control policy presence"}, + {"wdacpolicy", "Audit WDAC and AppLocker policy interface surface"}, + {"defender", "Audit Microsoft Defender policy and exclusions"}, + {"firewall", "Audit Windows Firewall profile posture"}, + {"rdp", "Audit Remote Desktop exposure policy"}, + {"brokers", "Audit privileged broker process and COM broker surface"}, + {"appcontainerbrokers", "Audit AppContainer broker and capability surface"}, + {"wmi", "Audit WMI repository and AutoRecover MOF surface"}, + {"hosts", "Inspect hosts file overrides"}, + {"shares", "Audit configured LanmanServer shares"}, + {"shell", "Audit Explorer shell extension and delay-load surface"}, + {"browser", "Audit browser helper and native messaging extension surface"}, + {"protocols", "Audit custom URL protocol handlers"}, + {"urlmonikers", "Audit URL moniker and protocol activation surface"}, + {"comlocal", "Audit per-user COM local/inproc server registrations"}, + {"comhijack", "Audit COM registration hijacking surface"}, + {"custommarshal", "Audit COM custom marshaling registration surface"}, + {"knowndlls", "Inventory KnownDLL protected names"}, + {"dllhijacking", "Audit DLL hijacking and search-order abuse surface"}, + {"sxs", "Audit side-by-side assembly and activation context surface"}, + {"activationctx", "Audit manifest and activation context abuse surface"}, + {"ntobjmgr", "Audit NT Object Manager namespace surface"}, + {"objdirs", "Audit object directory namespace surface"}, + {"symlinks", "Audit symbolic link surface"}, + {"hardlinks", "Audit hard link research surface"}, + {"junctions", "Audit junction and directory link surface"}, + {"mountpoints", "Audit mount point surface"}, + {"reparsepoints", "Audit reparse point surface"}, + {"oplocks", "Audit opportunistic lock race research surface"}, + {"regsymlinks", "Audit registry symbolic link surface"}, + {"svcpaths", "Audit service image path risk at scale"}, + {"scm", "Audit Service Control Manager and service configuration surface"}, + {"driverpaths", "Audit driver image path risk at scale"}, + {"minifilters", "Audit file system minifilter driver surface"}, + {"ioctls", "Audit kernel driver IOCTL and device exposure surface"}, + {"deviceobjects", "Audit device object namespace exposure"}, + {"etw", "Audit Event Tracing for Windows provider surface"}, + {"win32k", "Audit Win32k and GUI subsystem boundary surface"}, + {"csrss", "Audit CSRSS and console subsystem boundary surface"}, + {"lsassinterfaces", "Audit LSASS interfaces and authentication package surface"}, + {"accesstokens", "Audit access token privilege and integrity surface"}, + {"handles", "Audit handle duplication and leak research surface"}, + {"jobobjects", "Audit job object namespace and process containment surface"}, + {"sectionobjects", "Audit section object and shared memory surface"}, + {"sharedmemory", "Audit shared memory object namespace surface"}, + {"mmap", "Audit memory-mapped file surface"}, + {"wfp", "Audit Windows Filtering Platform provider surface"}, + {"hyperv", "Audit Hyper-V component and service surface"}, + {"wsl", "Audit Windows Subsystem for Linux component surface"}, + {"certificates", "Inventory machine and user certificate store density"}, + {"networkproviders", "Audit credential and network provider load order"}, + {"print", "Audit print monitor, provider, and processor surface"}, + {"efsrpc", "Audit EFSRPC service and RPC exposure surface"}, + {"taskrpc", "Audit Task Scheduler RPC surface"}, + {"bits", "Audit Background Intelligent Transfer Service surface"}, + {"endpointmapper", "Audit DCOM/RPC Endpoint Mapper surface"}, + {"winrm", "Audit Windows Remote Management surface"}, + {"smbipc", "Audit SMB local IPC and LanmanServer surface"}, + {"credproviders", "Audit credential provider and filter surface"}, + {"authpackages", "Audit authentication package registration surface"}, + {"lsaplugins", "Audit LSA plugin registration surface"}, + {"cloudap", "Audit CloudAP and cloud authentication package surface"}, + {"ppl", "Audit Protected Process Light boundary indicators"}, + {"userprofilesvc", "Audit User Profile Service and profile path surface"}, + {"updates", "Audit update mechanism service and policy surface"}, + {"recovery", "Audit repair and recovery mechanism surface"}, + {"tempfiles", "Audit temporary file handling risk surface"}, + {"toctou", "Audit TOCTOU and race-prone filesystem surface"}, + {"pathcanon", "Audit path canonicalization risk surface"}, + {"confuseddeputy", "Audit confused deputy research surface"}, + {"acl", "Audit file and registry ACL misconfiguration surface"}, + {"envinjection", "Audit environment variable injection surface"}, + {"searchpoison", "Audit search path poisoning surface"}, + {"propertyhandlers", "Audit shell property handler surface"}, + {"explorerext", "Audit Explorer extension surface"}, + {"thumbnailproviders", "Audit thumbnail provider surface"}, + {"previewhandlers", "Audit preview handler surface"}, + {"winsock", "Audit Winsock catalog and namespace provider surface"}, + {"accessibility", "Audit accessibility binary interception surface"}, + {"sessionisolation", "Audit session isolation boundary surface"}, + {"windowstations", "Audit desktop and window station object surface"}, + {"clipboard", "Audit clipboard IPC surface"}, + {"dragdrop", "Audit drag-and-drop IPC surface"}, + {"dde", "Audit Dynamic Data Exchange surface"}, + {"ole", "Audit OLE automation and embedding surface"}, +} + +func init() { + for _, def := range definitions { + core.Register(module{definition: def}) + } +} + +type module struct { + definition +} + +func (m module) Name() string { return m.name } + +func (m module) Description() string { return m.description } + +func (m module) Run(ctx *core.Context) error { + ctx.Logger.Info("Running " + m.name + " audit...") + findings, err := win.EnumerateAdvancedFindings(m.name) + if err != nil { + return err + } + if len(findings) == 0 { + ctx.Logger.Info("No findings returned for " + m.name + ".") + return nil + } + for _, finding := range findings { + ctx.Logger.Success(fmt.Sprintf("%s %s > %s", finding.Severity, finding.Target, finding.Reason)) + if finding.Name != "" || finding.Value != "" { + ctx.Logger.Verbose(fmt.Sprintf("%s : %s = %s", finding.Area, finding.Name, finding.Value)) + } + } + return nil +} diff --git a/modules/clsid/module.go b/modules/clsid/module.go new file mode 100644 index 0000000..34d8ceb --- /dev/null +++ b/modules/clsid/module.go @@ -0,0 +1,247 @@ +package clsid + +import ( + "fmt" + "runtime" + "sort" + "strings" + "sync" + + "ferrum/core" + "ferrum/internal" + win "ferrum/windows/facade" +) + +const maxProcessWorkers = 16 +const maxVerboseCLSID = 40 +const maxProcMonCandidates = 120 + +func init() { + core.Register(Module{}) +} + +type Module struct{} + +func (Module) Name() string { + return "clsid" +} + +func (Module) Description() string { + return "Correlate privileged processes with HKCU COM registration surface" +} + +func (Module) Run(ctx *core.Context) error { + ctx.Logger.Info("Applying ProcMon-style CLSID filters: User is NT AUTHORITY\\SYSTEM; Path contains HKCU\\Software\\Classes; Path contains InprocServer32 or LocalServer32; Result is NAME NOT FOUND.") + ctx.Logger.Info("Enumerating running processes...") + processes, err := win.EnumerateProcesses() + if err != nil { + return err + } + + ctx.Logger.Info("Inspecting process security context...") + enriched := enrichProcesses(ctx, processes) + sortProcesses(enriched) + + interesting := 0 + for _, process := range enriched { + if !process.Interesting { + continue + } + interesting++ + ctx.Logger.Success(fmt.Sprintf("%s[%d] > %s", process.Name, process.PID, process.Label())) + if process.Detail != "" { + ctx.Logger.Verbose(fmt.Sprintf("%s[%d] : %s", process.Name, process.PID, process.Detail)) + } + } + if interesting == 0 { + ctx.Logger.Info("No privileged or elevated processes identified from accessible token data.") + } + + ctx.Logger.Info("Scanning HKLM COM registrations for missing HKCU InprocServer32/LocalServer32 lookups...") + candidates, err := win.EnumerateCLSIDProcMonCandidates() + if err != nil { + ctx.Logger.Error(fmt.Sprintf("CLSID ProcMon candidate scan: %v", err)) + } else { + reportProcMonCandidates(ctx, candidates, interestingProcesses(enriched)) + } + + ctx.Logger.Info("Inspecting HKCU\\Software\\Classes\\CLSID...") + entries, err := win.EnumerateHKCUCLSID() + if err != nil { + ctx.Logger.Error(fmt.Sprintf("HKCU CLSID enumeration: %v", err)) + return nil + } + + if len(entries) == 0 { + ctx.Logger.Info("No HKCU CLSID registrations found.") + return nil + } + + for _, entry := range summarizeCLSID(entries) { + ctx.Logger.Success(fmt.Sprintf("HKCU\\Software\\Classes\\CLSID\\%s > %s", entry.CLSID, entry.Kind)) + if entry.Value != "" { + ctx.Logger.Verbose(fmt.Sprintf("%s : %s", entry.CLSID, entry.Value)) + } + } + + if interesting > 0 { + ctx.Logger.Info("Privileged COM clients commonly search HKCU before HKLM for per-user COM classes; review user-controlled registrations above for unusual or hijack-prone behavior.") + } + return nil +} + +func reportProcMonCandidates(ctx *core.Context, candidates []win.CLSIDProcMonCandidate, processes []processFinding) { + if len(candidates) == 0 { + ctx.Logger.Info("No HKCU COM NAME NOT FOUND candidates found for InprocServer32 or LocalServer32.") + return + } + + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].Kind != candidates[j].Kind { + return candidates[i].Kind < candidates[j].Kind + } + return candidates[i].CLSID < candidates[j].CLSID + }) + + processLabel := "NT AUTHORITY\\SYSTEM / privileged COM client" + if len(processes) > 0 { + names := make([]string, 0, len(processes)) + for _, process := range internal.Limit(processes, 8) { + names = append(names, fmt.Sprintf("%s[%d]", process.Name, process.PID)) + } + processLabel = strings.Join(names, ", ") + } + + for _, candidate := range internal.Limit(candidates, maxProcMonCandidates) { + ctx.Logger.Success(fmt.Sprintf("%s > %s > %s", processLabel, candidate.Path, candidate.Result)) + ctx.Logger.Verbose(fmt.Sprintf("%s : CLSID=%s machine=%s", candidate.Kind, candidate.CLSID, candidate.MachineValue)) + } + if len(candidates) > maxProcMonCandidates { + ctx.Logger.Info(fmt.Sprintf("CLSID ProcMon candidate output limited to %d of %d rows.", maxProcMonCandidates, len(candidates))) + } + ctx.Logger.Info("These rows model the ProcMon filter Result=NAME NOT FOUND for HKCU COM override paths. Confirm live process access with ProcMon or ETW before treating a candidate as reachable.") +} + +func interestingProcesses(processes []processFinding) []processFinding { + out := make([]processFinding, 0) + for _, process := range processes { + if process.Interesting { + out = append(out, process) + } + } + return out +} + +type processFinding struct { + win.Process + Interesting bool + Priority int + Detail string +} + +func enrichProcesses(ctx *core.Context, processes []win.Process) []processFinding { + workers := runtime.NumCPU() + if workers > maxProcessWorkers { + workers = maxProcessWorkers + } + if workers < 1 { + workers = 1 + } + + jobs := make(chan win.Process) + results := make(chan processFinding, len(processes)) + var wg sync.WaitGroup + + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for process := range jobs { + finding := processFinding{Process: process} + token, err := win.InspectProcessToken(process.PID) + if err != nil { + finding.Detail = err.Error() + if strings.Contains(strings.ToLower(err.Error()), "access") { + ctx.Logger.Verbose(fmt.Sprintf("%s[%d] : %v", process.Name, process.PID, err)) + } + results <- finding + continue + } + + finding.User = token.User + finding.Integrity = token.Integrity + finding.Elevated = token.Elevated + finding.Privileges = token.Privileges + finding.Interesting, finding.Priority, finding.Detail = classify(token) + results <- finding + } + }() + } + + go func() { + for _, process := range processes { + jobs <- process + } + close(jobs) + wg.Wait() + close(results) + }() + + findings := make([]processFinding, 0, len(processes)) + for finding := range results { + findings = append(findings, finding) + } + return findings +} + +func classify(token win.TokenInfo) (bool, int, string) { + user := strings.ToUpper(token.User) + switch { + case strings.HasSuffix(user, `\SYSTEM`) || strings.EqualFold(token.User, "SYSTEM"): + return true, 100, detail(token) + case strings.HasSuffix(user, `\LOCAL SERVICE`) || strings.HasSuffix(user, `\LOCALSERVICE`): + return true, 90, detail(token) + case strings.HasSuffix(user, `\NETWORK SERVICE`) || strings.HasSuffix(user, `\NETWORKSERVICE`): + return true, 80, detail(token) + case token.Elevated: + return true, 70, detail(token) + default: + return false, 0, detail(token) + } +} + +func detail(token win.TokenInfo) string { + parts := []string{} + if token.User != "" { + parts = append(parts, "user="+token.User) + } + if token.Integrity != "" { + parts = append(parts, "integrity="+token.Integrity) + } + if token.Elevated { + parts = append(parts, "elevated=true") + } + if len(token.Privileges) > 0 { + parts = append(parts, "privileges="+strings.Join(internal.Limit(token.Privileges, 8), ",")) + } + return strings.Join(parts, " ") +} + +func sortProcesses(processes []processFinding) { + sort.Slice(processes, func(i, j int) bool { + if processes[i].Priority != processes[j].Priority { + return processes[i].Priority > processes[j].Priority + } + return strings.ToLower(processes[i].Name) < strings.ToLower(processes[j].Name) + }) +} + +func summarizeCLSID(entries []win.CLSIDEntry) []win.CLSIDEntry { + sort.Slice(entries, func(i, j int) bool { + if entries[i].CLSID != entries[j].CLSID { + return entries[i].CLSID < entries[j].CLSID + } + return entries[i].Kind < entries[j].Kind + }) + return internal.Limit(entries, maxVerboseCLSID) +} diff --git a/modules/dllsearch/module.go b/modules/dllsearch/module.go new file mode 100644 index 0000000..6159a95 --- /dev/null +++ b/modules/dllsearch/module.go @@ -0,0 +1,46 @@ +package dllsearch + +import ( + "fmt" + + "ferrum/core" + "ferrum/internal" + win "ferrum/windows/facade" +) + +func init() { core.Register(Module{}) } + +type Module struct{} + +func (Module) Name() string { return "dllsearch" } + +func (Module) Description() string { + return "Analyze DLL search path and KnownDLL context for hijack-prone surface" +} + +func (Module) Run(ctx *core.Context) error { + ctx.Logger.Info("Analyzing DLL search path surface...") + findings, err := win.EnumerateDLLSearchPathFindings() + if err != nil { + return err + } + reported := 0 + for _, finding := range findings { + if finding.Severity == "Info" { + ctx.Logger.Verbose(fmt.Sprintf("%s %s > %s", finding.Source, finding.Path, finding.Reason)) + continue + } + reported++ + ctx.Logger.Success(fmt.Sprintf("%s %s > %s", finding.Severity, finding.Source, finding.Reason)) + ctx.Logger.Verbose(fmt.Sprintf("%s : %s", finding.Source, finding.Path)) + } + if reported == 0 { + ctx.Logger.Info("No risky DLL search path entries matched the default heuristics.") + } + for _, finding := range internal.Limit(findings, 40) { + if finding.Severity == "Info" { + ctx.Logger.Verbose(fmt.Sprintf("KnownDLL : %s", finding.Path)) + } + } + return nil +} diff --git a/modules/drivers/module.go b/modules/drivers/module.go new file mode 100644 index 0000000..95e96fa --- /dev/null +++ b/modules/drivers/module.go @@ -0,0 +1,56 @@ +package drivers + +import ( + "fmt" + "strings" + + "ferrum/core" + win "ferrum/windows/facade" +) + +func init() { core.Register(Module{}) } + +type Module struct{} + +func (Module) Name() string { return "drivers" } + +func (Module) Description() string { + return "Enumerate kernel drivers and suspicious driver load paths" +} + +func (Module) Run(ctx *core.Context) error { + ctx.Logger.Info("Enumerating kernel driver services...") + drivers, err := win.EnumerateDrivers() + if err != nil { + return err + } + reported := 0 + for _, driver := range drivers { + reason := driverReason(driver) + if reason == "" { + continue + } + reported++ + ctx.Logger.Success(fmt.Sprintf("%s > %s", driver.Name, reason)) + ctx.Logger.Verbose(fmt.Sprintf("%s : state=%s start=%s path=%s", driver.Name, driver.State, driver.StartType, driver.BinaryPath)) + } + if reported == 0 { + ctx.Logger.Info("No driver load paths matched the suspicious-path heuristics.") + } + ctx.Logger.Verbose(fmt.Sprintf("Drivers enumerated: %d", len(drivers))) + return nil +} + +func driverReason(driver win.DriverInfo) string { + path := strings.ToLower(driver.BinaryPath) + switch { + case strings.Contains(path, `\users\`) || strings.Contains(path, `\temp\`) || strings.Contains(path, `\programdata\`): + return "driver image in user-writable-looking location" + case driver.StartType == "Boot" || driver.StartType == "System": + return "early-load driver" + case driver.State == "Running": + return "running kernel driver" + default: + return "" + } +} diff --git a/modules/env/module.go b/modules/env/module.go new file mode 100644 index 0000000..3c59105 --- /dev/null +++ b/modules/env/module.go @@ -0,0 +1,52 @@ +package env + +import ( + "fmt" + "strings" + + "ferrum/core" + win "ferrum/windows/facade" +) + +func init() { core.Register(Module{}) } + +type Module struct{} + +func (Module) Name() string { return "env" } + +func (Module) Description() string { + return "Inspect process environment variables for audit-relevant values" +} + +func (Module) Run(ctx *core.Context) error { + ctx.Logger.Info("Inspecting current process environment...") + vars, err := win.EnumerateEnvironment() + if err != nil { + return err + } + for _, env := range vars { + reason := envReason(env) + if reason == "" { + continue + } + ctx.Logger.Success(fmt.Sprintf("%s > %s", env.Name, reason)) + ctx.Logger.Verbose(fmt.Sprintf("%s=%s", env.Name, env.Value)) + } + ctx.Logger.Verbose(fmt.Sprintf("Environment variables inspected: %d", len(vars))) + return nil +} + +func envReason(env win.EnvVar) string { + name := strings.ToUpper(env.Name) + value := strings.ToLower(env.Value) + switch { + case name == "PATH" && (strings.Contains(value, `\users\`) || strings.Contains(value, `\temp\`) || strings.Contains(value, `.`)): + return "PATH contains user-writable-looking or relative element" + case strings.Contains(name, "TOKEN") || strings.Contains(name, "SECRET") || strings.Contains(name, "PASSWORD") || strings.Contains(name, "KEY"): + return "sensitive-looking variable name" + case strings.Contains(value, `\users\`) || strings.Contains(value, `\temp\`): + return "user-writable-looking value" + default: + return "" + } +} diff --git a/modules/mitigations/module.go b/modules/mitigations/module.go new file mode 100644 index 0000000..689f14c --- /dev/null +++ b/modules/mitigations/module.go @@ -0,0 +1,65 @@ +package mitigations + +import ( + "fmt" + "strings" + + "ferrum/core" + win "ferrum/windows/facade" +) + +func init() { core.Register(Module{}) } + +type Module struct{} + +func (Module) Name() string { return "mitigations" } + +func (Module) Description() string { + return "Sample process mitigation policy posture for running processes" +} + +func (Module) Run(ctx *core.Context) error { + ctx.Logger.Info("Sampling process mitigation policies...") + items, err := win.EnumerateProcessMitigations() + if err != nil { + return err + } + reported := 0 + for _, item := range items { + reason := mitigationReason(item) + if reason == "" { + continue + } + reported++ + ctx.Logger.Success(fmt.Sprintf("%s[%d] > %s", item.Name, item.PID, reason)) + ctx.Logger.Verbose(fmt.Sprintf("%s[%d] : DEP=%s ASLR=%s StrictHandle=%s CFG=%s", item.Name, item.PID, item.DEP, item.ASLR, item.Strict, item.CFG)) + } + if reported == 0 { + ctx.Logger.Info("No accessible process mitigation gaps matched the default heuristics.") + } + ctx.Logger.Verbose(fmt.Sprintf("Processes sampled: %d", len(items))) + return nil +} + +func mitigationReason(item win.ProcessMitigation) string { + values := []string{item.DEP, item.ASLR, item.Strict, item.CFG} + for _, value := range values { + if strings.HasPrefix(value, "error:") || strings.HasPrefix(value, "open:") { + return "" + } + } + gaps := []string{} + if item.DEP == "off" { + gaps = append(gaps, "DEP off") + } + if item.ASLR == "off" { + gaps = append(gaps, "ASLR off") + } + if item.Strict == "off" { + gaps = append(gaps, "strict handle checks off") + } + if item.CFG == "off" { + gaps = append(gaps, "CFG off") + } + return strings.Join(gaps, ", ") +} diff --git a/modules/pipes/module.go b/modules/pipes/module.go new file mode 100644 index 0000000..85705b6 --- /dev/null +++ b/modules/pipes/module.go @@ -0,0 +1,56 @@ +package pipes + +import ( + "fmt" + "strings" + + "ferrum/core" + "ferrum/internal" + win "ferrum/windows/facade" +) + +func init() { core.Register(Module{}) } + +type Module struct{} + +func (Module) Name() string { return "pipes" } + +func (Module) Description() string { + return "Enumerate named pipes and flag security-relevant pipe names" +} + +func (Module) Run(ctx *core.Context) error { + ctx.Logger.Info("Enumerating named pipes...") + pipes, err := win.EnumerateNamedPipes() + if err != nil { + return err + } + reported := 0 + for _, pipe := range pipes { + reason := pipeReason(pipe.Name) + if reason == "" { + continue + } + reported++ + ctx.Logger.Success(fmt.Sprintf("%s > %s", pipe.Name, reason)) + } + ctx.Logger.Verbose(fmt.Sprintf("Pipes enumerated: %d", len(pipes))) + for _, pipe := range internal.Limit(pipes, 50) { + ctx.Logger.Verbose(fmt.Sprintf("pipe : %s", pipe.Name)) + } + if reported == 0 { + ctx.Logger.Info("No named pipes matched the default high-signal name heuristics.") + } + return nil +} + +func pipeReason(name string) string { + lower := strings.ToLower(name) + keywords := []string{"svc", "service", "rpc", "spool", "lsass", "samr", "netlogon", "winreg", "atsvc", "epmapper", "browser"} + for _, keyword := range keywords { + if strings.Contains(lower, keyword) { + return "security-relevant IPC surface" + } + } + return "" +} diff --git a/modules/policy/module.go b/modules/policy/module.go new file mode 100644 index 0000000..068e446 --- /dev/null +++ b/modules/policy/module.go @@ -0,0 +1,34 @@ +package policy + +import ( + "fmt" + + "ferrum/core" + win "ferrum/windows/facade" +) + +func init() { core.Register(Module{}) } + +type Module struct{} + +func (Module) Name() string { return "policy" } + +func (Module) Description() string { + return "Summarize hardening policy posture such as UAC, AppLocker, and WDAC" +} + +func (Module) Run(ctx *core.Context) error { + ctx.Logger.Info("Checking hardening policy posture...") + findings, err := win.EnumeratePolicyFindings() + if err != nil { + return err + } + for _, finding := range findings { + ctx.Logger.Success(fmt.Sprintf("%s %s > %s", finding.Severity, finding.Name, finding.Reason)) + ctx.Logger.Verbose(fmt.Sprintf("%s = %s", finding.Name, finding.Value)) + } + if len(findings) == 0 { + ctx.Logger.Info("No policy findings returned from configured checks.") + } + return nil +} diff --git a/modules/registry/module.go b/modules/registry/module.go new file mode 100644 index 0000000..79b4027 --- /dev/null +++ b/modules/registry/module.go @@ -0,0 +1,37 @@ +package registry + +import ( + "fmt" + + "ferrum/core" + win "ferrum/windows/facade" +) + +func init() { core.Register(Module{}) } + +type Module struct{} + +func (Module) Name() string { return "registry" } + +func (Module) Description() string { + return "Audit sensitive registry persistence, policy, and interception surfaces" +} + +func (Module) Run(ctx *core.Context) error { + ctx.Logger.Info("Auditing sensitive registry surfaces...") + findings, err := win.EnumerateRegistryAuditFindings() + if err != nil { + return err + } + if len(findings) == 0 { + ctx.Logger.Info("No sensitive registry findings matched the configured checks.") + return nil + } + for _, finding := range findings { + ctx.Logger.Success(fmt.Sprintf("%s %s\\%s > %s: %s", finding.Severity, finding.Scope, finding.Path, finding.Name, finding.Reason)) + if finding.Value != "" { + ctx.Logger.Verbose(fmt.Sprintf("%s\\%s\\%s = %s", finding.Scope, finding.Path, finding.Name, finding.Value)) + } + } + return nil +} diff --git a/modules/scheduled/module.go b/modules/scheduled/module.go new file mode 100644 index 0000000..e035748 --- /dev/null +++ b/modules/scheduled/module.go @@ -0,0 +1,58 @@ +package scheduled + +import ( + "fmt" + "strings" + + "ferrum/core" + win "ferrum/windows/facade" +) + +func init() { core.Register(Module{}) } + +type Module struct{} + +func (Module) Name() string { return "scheduled" } + +func (Module) Description() string { + return "Inspect scheduled task XML for interesting execution paths" +} + +func (Module) Run(ctx *core.Context) error { + ctx.Logger.Info("Enumerating scheduled tasks...") + tasks, err := win.EnumerateScheduledTasks() + if err != nil { + return err + } + reported := 0 + for _, task := range tasks { + reason := taskReason(task) + if reason == "" { + continue + } + reported++ + ctx.Logger.Success(fmt.Sprintf("%s > %s", task.Path, reason)) + ctx.Logger.Verbose(fmt.Sprintf("%s : enabled=%s author=%s command=%s", task.Path, task.Enabled, task.Author, task.Command)) + } + if reported == 0 { + ctx.Logger.Info("No scheduled tasks matched the default interesting-command heuristics.") + } + ctx.Logger.Verbose(fmt.Sprintf("Tasks enumerated: %d", len(tasks))) + return nil +} + +func taskReason(task win.ScheduledTask) string { + lower := strings.ToLower(task.Command) + switch { + case task.Command == "": + return "" + case strings.Contains(lower, `\users\`) || strings.Contains(lower, `\temp\`) || strings.Contains(lower, `\programdata\`): + return "user-writable-looking task command" + case strings.Contains(lower, "powershell") || strings.Contains(lower, "wscript") || strings.Contains(lower, "cscript") || strings.Contains(lower, "rundll32") || strings.Contains(lower, "regsvr32"): + return "script or LOLBin task command" + case strings.EqualFold(task.Enabled, "false"): + return "disabled task with execution action" + default: + return "" + } +} diff --git a/modules/services/module.go b/modules/services/module.go new file mode 100644 index 0000000..eed35ea --- /dev/null +++ b/modules/services/module.go @@ -0,0 +1,67 @@ +package services + +import ( + "fmt" + "strings" + + "ferrum/core" + "ferrum/internal" + win "ferrum/windows/facade" +) + +func init() { core.Register(Module{}) } + +type Module struct{} + +func (Module) Name() string { return "services" } + +func (Module) Description() string { + return "Inventory Windows services and highlight audit-worthy configuration" +} + +func (Module) Run(ctx *core.Context) error { + ctx.Logger.Info("Enumerating Windows services...") + services, err := win.EnumerateServices() + if err != nil { + return err + } + reported := 0 + for _, service := range services { + reasons := serviceReasons(service) + if len(reasons) == 0 { + continue + } + reported++ + ctx.Logger.Success(fmt.Sprintf("%s > %s", service.Name, strings.Join(reasons, ", "))) + ctx.Logger.Verbose(fmt.Sprintf("%s : state=%s start=%s account=%s pid=%d path=%s", service.Name, service.State, service.StartType, service.Account, service.ProcessID, service.BinaryPath)) + } + if reported == 0 { + ctx.Logger.Info("No service configuration stood out from the default heuristics.") + } + ctx.Logger.Verbose(fmt.Sprintf("Services enumerated: %d", len(services))) + return nil +} + +func serviceReasons(service win.ServiceInfo) []string { + reasons := []string{} + path := strings.TrimSpace(service.BinaryPath) + lowerPath := strings.ToLower(path) + if service.State == "Running" && service.Account != "" && !strings.Contains(strings.ToLower(service.Account), "localsystem") { + reasons = append(reasons, "non-System running account") + } + if isUnquotedPathWithSpaces(path) { + reasons = append(reasons, "unquoted path with spaces") + } + if strings.Contains(lowerPath, `\users\`) || strings.Contains(lowerPath, `\temp\`) || strings.Contains(lowerPath, `\programdata\`) { + reasons = append(reasons, "user-writable-looking path") + } + if service.StartType == "Auto" && service.State != "Running" { + reasons = append(reasons, "auto-start not running") + } + return internal.Limit(reasons, 4) +} + +func isUnquotedPathWithSpaces(path string) bool { + path = strings.TrimSpace(path) + return strings.Contains(path, " ") && !strings.HasPrefix(path, `"`) +} diff --git a/modules/startup/module.go b/modules/startup/module.go new file mode 100644 index 0000000..329e500 --- /dev/null +++ b/modules/startup/module.go @@ -0,0 +1,48 @@ +package startup + +import ( + "fmt" + "strings" + + "ferrum/core" + win "ferrum/windows/facade" +) + +func init() { core.Register(Module{}) } + +type Module struct{} + +func (Module) Name() string { return "startup" } + +func (Module) Description() string { + return "Inspect Run keys and Startup folders for persistence surface" +} + +func (Module) Run(ctx *core.Context) error { + ctx.Logger.Info("Enumerating startup persistence locations...") + entries, err := win.EnumerateStartupEntries() + if err != nil { + return err + } + if len(entries) == 0 { + ctx.Logger.Info("No startup entries found in common locations.") + return nil + } + for _, entry := range entries { + ctx.Logger.Success(fmt.Sprintf("%s\\%s > %s", entry.Scope, entry.Name, startupReason(entry.Command))) + ctx.Logger.Verbose(fmt.Sprintf("%s : %s", entry.Location, entry.Command)) + } + return nil +} + +func startupReason(command string) string { + lower := strings.ToLower(command) + switch { + case strings.Contains(lower, `\users\`) || strings.Contains(lower, `\temp\`) || strings.Contains(lower, `\programdata\`): + return "user-writable-looking command" + case strings.Contains(lower, "powershell") || strings.Contains(lower, "wscript") || strings.Contains(lower, "cscript") || strings.Contains(lower, "cmd.exe"): + return "script interpreter startup" + default: + return "startup entry" + } +} diff --git a/modules/tokens/module.go b/modules/tokens/module.go new file mode 100644 index 0000000..e1b2884 --- /dev/null +++ b/modules/tokens/module.go @@ -0,0 +1,134 @@ +package tokens + +import ( + "fmt" + "runtime" + "sort" + "strings" + "sync" + + "ferrum/core" + "ferrum/internal" + win "ferrum/windows/facade" +) + +func init() { core.Register(Module{}) } + +type Module struct{} + +func (Module) Name() string { return "tokens" } + +func (Module) Description() string { + return "Hunt process tokens with sensitive privileges and elevated integrity" +} + +func (Module) Run(ctx *core.Context) error { + ctx.Logger.Info("Enumerating process tokens and sensitive privileges...") + processes, err := win.EnumerateProcesses() + if err != nil { + return err + } + findings := inspect(processes) + sort.Slice(findings, func(i, j int) bool { + if findings[i].Score != findings[j].Score { + return findings[i].Score > findings[j].Score + } + return findings[i].Name < findings[j].Name + }) + reported := 0 + for _, finding := range findings { + if finding.Score == 0 { + continue + } + reported++ + ctx.Logger.Success(fmt.Sprintf("%s[%d] > %s", finding.Name, finding.PID, strings.Join(finding.Reasons, ", "))) + ctx.Logger.Verbose(fmt.Sprintf("%s[%d] : %s privileges=%s", finding.Name, finding.PID, finding.Process.Label(), strings.Join(internal.Limit(finding.Privileges, 12), ","))) + } + if reported == 0 { + ctx.Logger.Info("No accessible process tokens matched the sensitive privilege heuristics.") + } + ctx.Logger.Verbose(fmt.Sprintf("Processes inspected: %d", len(processes))) + return nil +} + +type tokenFinding struct { + win.Process + Reasons []string + Score int +} + +func inspect(processes []win.Process) []tokenFinding { + workers := runtime.NumCPU() + if workers > 16 { + workers = 16 + } + jobs := make(chan win.Process) + results := make(chan tokenFinding, len(processes)) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for process := range jobs { + token, err := win.InspectProcessToken(process.PID) + finding := tokenFinding{Process: process} + if err == nil { + finding.User = token.User + finding.Integrity = token.Integrity + finding.Elevated = token.Elevated + finding.Privileges = token.Privileges + finding.Reasons, finding.Score = tokenReasons(token) + } + results <- finding + } + }() + } + go func() { + for _, process := range processes { + jobs <- process + } + close(jobs) + wg.Wait() + close(results) + }() + findings := make([]tokenFinding, 0, len(processes)) + for finding := range results { + findings = append(findings, finding) + } + return findings +} + +func tokenReasons(token win.TokenInfo) ([]string, int) { + score := 0 + reasons := []string{} + if token.Elevated { + reasons = append(reasons, "elevated token") + score += 20 + } + if token.Integrity == "System" || token.Integrity == "High" { + reasons = append(reasons, token.Integrity+" integrity") + score += 15 + } + interesting := map[string]int{ + "SeDebugPrivilege": 40, + "SeImpersonatePrivilege": 35, + "SeAssignPrimaryTokenPrivilege": 35, + "SeTcbPrivilege": 45, + "SeBackupPrivilege": 25, + "SeRestorePrivilege": 25, + "SeLoadDriverPrivilege": 35, + "SeCreateTokenPrivilege": 45, + "SeTakeOwnershipPrivilege": 20, + "SeManageVolumePrivilege": 20, + "SeTrustedCredManAccessPrivilege": 40, + "SeRelabelPrivilege": 30, + "SeCreateGlobalPrivilege": 10, + } + for _, privilege := range token.Privileges { + if points, ok := interesting[privilege]; ok { + reasons = append(reasons, privilege) + score += points + } + } + return internal.Limit(reasons, 8), score +} diff --git a/output/console.go b/output/console.go new file mode 100644 index 0000000..491ae6b --- /dev/null +++ b/output/console.go @@ -0,0 +1,53 @@ +package output + +import ( + "fmt" + "io" + "sync" +) + +type ConsoleLogger struct { + out io.Writer + artifact io.Writer + verbose bool + quiet bool + mu sync.Mutex +} + +func NewConsoleLogger(out io.Writer, verbose, quiet bool) *ConsoleLogger { + return &ConsoleLogger{out: out, verbose: verbose, quiet: quiet} +} + +func NewDualLogger(out, artifact io.Writer, verbose, quiet bool) *ConsoleLogger { + return &ConsoleLogger{out: out, artifact: artifact, verbose: verbose, quiet: quiet} +} + +func (l *ConsoleLogger) Info(message string) { + l.printf(!l.quiet, "[*] %s\n", message) +} + +func (l *ConsoleLogger) Success(message string) { + l.printf(!l.quiet, "[+] %s\n", message) +} + +func (l *ConsoleLogger) Error(message string) { + l.printf(true, "[-] %s\n", message) +} + +func (l *ConsoleLogger) Verbose(message string) { + if !l.verbose { + return + } + l.printf(!l.quiet, "[v] %s\n", message) +} + +func (l *ConsoleLogger) printf(writeConsole bool, format string, args ...any) { + l.mu.Lock() + defer l.mu.Unlock() + if writeConsole { + fmt.Fprintf(l.out, format, args...) + } + if l.artifact != nil { + fmt.Fprintf(l.artifact, format, args...) + } +} diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 new file mode 100644 index 0000000..829552e --- /dev/null +++ b/scripts/build-windows.ps1 @@ -0,0 +1,13 @@ +param( + [string]$Out = "ferrum.exe" +) + +$ErrorActionPreference = "Stop" + +$env:GOOS = "windows" +$env:GOARCH = "amd64" +$env:CGO_ENABLED = "0" + +Write-Host "[*] Building Ferrum for Windows x64..." +go build -trimpath -ldflags "-s -w" -o $Out ./cmd +Write-Host "[+] Built $Out" diff --git a/scripts/build-windows.sh b/scripts/build-windows.sh new file mode 100755 index 0000000..dcf11a6 --- /dev/null +++ b/scripts/build-windows.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +OUT="${1:-ferrum.exe}" + +echo "[*] Building Ferrum for Windows x64..." +GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$OUT" ./cmd +echo "[+] Built $OUT" diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..abf82ac --- /dev/null +++ b/windows/README.md @@ -0,0 +1,20 @@ +# Windows Package Layout + +The `ferrum/windows/facade` package is the compatibility layer used by modules. The +implementation lives in real subpackages under this directory. + +Modules import `ferrum/windows/facade`, while internal code is organized by Windows +research area. New low-level collectors should usually be added to the relevant +subpackage and re-exported from `facade/` only when modules need it. + +The package is organized by Windows research area: + +- `advanced/`: advanced scanner dispatcher, built-ins, profiles, and helpers. +- `audit/`: registry/policy/DLL-search audit collectors. +- `env/`, `pipes/`, `scheduled/`, `startup/`: focused collectors. +- `facade/`: module-facing compatibility API. +- `process/`: process enumeration and non-Windows stubs. +- `registry/`: registry helpers plus CLSID/COM registry enumeration. +- `services/`: Service Control Manager, service, and driver inventory. +- `token/`: access token, integrity, elevation, and privilege helpers. +- `types/`: grouped data models shared by modules. diff --git a/windows/advanced/advanced_windows.go b/windows/advanced/advanced_windows.go new file mode 100644 index 0000000..e63f660 --- /dev/null +++ b/windows/advanced/advanced_windows.go @@ -0,0 +1,88 @@ +//go:build windows + +package advanced + +import ( + "os" + "path/filepath" + + "ferrum/windows/registry" + wintypes "ferrum/windows/types" +) + +const advancedLimit = 120 + +type AdvancedFinding = wintypes.AdvancedFinding + +func EnumerateAdvancedFindings(check string) ([]AdvancedFinding, error) { + var findings []AdvancedFinding + switch check { + case "autoruns": + findings = autorunFindings() + case "ifeo": + findings = ifeoFindings() + case "silentexit": + findings = silentExitFindings() + case "winlogon": + findings = registryNamedValues("Winlogon", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon`, []string{"Shell", "Userinit", "Notify", "GinaDLL"}, "High", "logon execution surface") + case "lsa": + findings = registryNamedValues("LSA", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\Lsa`, []string{"Authentication Packages", "Notification Packages", "Security Packages", "RunAsPPL", "LsaCfgFlags"}, "High", "LSA package or protection setting") + case "appinit": + findings = registryNamedValues("AppInit", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows`, []string{"LoadAppInit_DLLs", "AppInit_DLLs", "RequireSignedAppInit_DLLs"}, "High", "AppInit DLL load surface") + case "appcert": + findings = registryAllValues("AppCert", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\Session Manager\AppCertDLLs`, "High", "AppCert DLL process creation hook") + case "uac": + findings = registryNamedValues("UAC", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System`, []string{"EnableLUA", "ConsentPromptBehaviorAdmin", "ConsentPromptBehaviorUser", "LocalAccountTokenFilterPolicy", "FilterAdministratorToken"}, "Medium", "UAC policy value") + case "installer": + findings = append(findings, registryNamedValues("Installer", registry.HkeyCurrentUser, "HKCU", `Software\Policies\Microsoft\Windows\Installer`, []string{"AlwaysInstallElevated", "DisableMSI"}, "High", "per-user installer elevation policy")...) + findings = append(findings, registryNamedValues("Installer", registry.HkeyLocalMachine, "HKLM", `Software\Policies\Microsoft\Windows\Installer`, []string{"AlwaysInstallElevated", "DisableMSI"}, "High", "machine installer elevation policy")...) + case "powershell": + findings = powershellFindings() + case "applocker": + findings = registrySubkeys("AppLocker", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Policies\Microsoft\Windows\SrpV2`, "Info", "AppLocker rule collection") + findings = append(findings, registrySubkeys("SRP", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Policies\Microsoft\Windows\Safer\CodeIdentifiers`, "Info", "Software Restriction Policy surface")...) + case "wdac": + findings = registrySubkeys("WDAC", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\CI\Policy`, "Info", "code integrity policy key") + findings = append(findings, fileGlobFindings("WDAC", filepath.Join(os.Getenv("WINDIR"), `System32\CodeIntegrity`, "*.cip"), "Info", "WDAC policy file")...) + case "defender": + findings = defenderFindings() + case "firewall": + findings = firewallFindings() + case "rdp": + findings = rdpFindings() + case "wmi": + findings = wmiFindings() + case "hosts": + findings = hostsFindings() + case "shares": + findings = registryAllValues("Shares", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Services\LanmanServer\Shares`, "Medium", "configured SMB share") + case "shell": + findings = shellFindings() + case "browser": + findings = browserFindings() + case "protocols": + findings = protocolFindings() + case "comlocal": + findings = comLocalFindings() + case "knowndlls": + findings = registryAllValues("KnownDLLs", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs`, "Info", "KnownDLL protected load name") + case "svcpaths": + findings = servicePathFindings() + case "driverpaths": + findings = driverPathFindings() + case "certificates": + findings = certificateFindings() + case "networkproviders": + findings = networkProviderFindings() + case "print": + findings = printFindings() + case "winsock": + findings = winsockFindings() + case "accessibility": + findings = accessibilityFindings() + default: + findings = genericSurfaceFindings(check) + } + sortAdvanced(findings) + return limitAdvanced(findings), nil +} diff --git a/windows/advanced/builtin_windows.go b/windows/advanced/builtin_windows.go new file mode 100644 index 0000000..6026164 --- /dev/null +++ b/windows/advanced/builtin_windows.go @@ -0,0 +1,255 @@ +//go:build windows + +package advanced + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "ferrum/windows/registry" + "ferrum/windows/services" +) + +func autorunFindings() []AdvancedFinding { + checks := []struct { + scope uintptr + root string + path string + }{ + {registry.HkeyCurrentUser, "HKCU", `Software\Microsoft\Windows\CurrentVersion\Run`}, + {registry.HkeyCurrentUser, "HKCU", `Software\Microsoft\Windows\CurrentVersion\RunOnce`}, + {registry.HkeyCurrentUser, "HKCU", `Software\Microsoft\Windows\CurrentVersion\RunServices`}, + {registry.HkeyLocalMachine, "HKLM", `Software\Microsoft\Windows\CurrentVersion\Run`}, + {registry.HkeyLocalMachine, "HKLM", `Software\Microsoft\Windows\CurrentVersion\RunOnce`}, + {registry.HkeyLocalMachine, "HKLM", `Software\Microsoft\Windows\CurrentVersion\RunServices`}, + {registry.HkeyLocalMachine, "HKLM", `Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run`}, + } + findings := []AdvancedFinding{} + for _, check := range checks { + findings = append(findings, registryAllValues("Autoruns", check.scope, check.root, check.path, "Medium", "autorun execution entry")...) + } + return findings +} + +func ifeoFindings() []AdvancedFinding { + findings := []AdvancedFinding{} + for _, root := range []struct { + scope uintptr + name string + }{{registry.HkeyLocalMachine, "HKLM"}, {registry.HkeyCurrentUser, "HKCU"}} { + base := `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options` + for _, key := range subkeys(root.scope, base) { + findings = append(findings, registryNamedValues("IFEO", root.scope, root.name, base+`\`+key, []string{"Debugger", "VerifierDlls", "GlobalFlag", "MitigationOptions"}, "High", "process interception or verifier setting")...) + } + } + return findings +} + +func silentExitFindings() []AdvancedFinding { + findings := []AdvancedFinding{} + base := `SOFTWARE\Microsoft\Windows NT\CurrentVersion\SilentProcessExit` + for _, key := range subkeys(registry.HkeyLocalMachine, base) { + findings = append(findings, registryNamedValues("SilentProcessExit", registry.HkeyLocalMachine, "HKLM", base+`\`+key, []string{"MonitorProcess", "ReportingMode", "LocalDumpFolder"}, "High", "process-exit monitor or dump surface")...) + } + return findings +} + +func powershellFindings() []AdvancedFinding { + findings := []AdvancedFinding{} + for _, root := range []struct { + scope uintptr + name string + }{{registry.HkeyLocalMachine, "HKLM"}, {registry.HkeyCurrentUser, "HKCU"}} { + findings = append(findings, registryAllValues("PowerShell", root.scope, root.name, `SOFTWARE\Policies\Microsoft\Windows\PowerShell`, "Medium", "PowerShell policy value")...) + findings = append(findings, registryAllValues("PowerShell", root.scope, root.name, `SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell`, "Medium", "PowerShell shell policy value")...) + } + profiles := []string{ + filepath.Join(os.Getenv("USERPROFILE"), `Documents\WindowsPowerShell\profile.ps1`), + filepath.Join(os.Getenv("USERPROFILE"), `Documents\PowerShell\profile.ps1`), + filepath.Join(os.Getenv("WINDIR"), `System32\WindowsPowerShell\v1.0\profile.ps1`), + } + for _, profile := range profiles { + if exists(profile) { + findings = append(findings, AdvancedFinding{Area: "PowerShell", Target: profile, Name: "profile", Severity: "Medium", Reason: "PowerShell profile script present"}) + } + } + return findings +} + +func defenderFindings() []AdvancedFinding { + findings := registryAllValues("Defender", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Policies\Microsoft\Windows Defender`, "High", "Defender policy value") + findings = append(findings, registryAllValues("DefenderExclusions", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows Defender\Exclusions\Paths`, "Medium", "Defender path exclusion")...) + findings = append(findings, registryAllValues("DefenderExclusions", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows Defender\Exclusions\Processes`, "Medium", "Defender process exclusion")...) + findings = append(findings, registryAllValues("DefenderFeatures", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows Defender\Features`, "Info", "Defender feature value")...) + return findings +} + +func firewallFindings() []AdvancedFinding { + base := `SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy` + profiles := []string{"DomainProfile", "PublicProfile", "StandardProfile"} + findings := []AdvancedFinding{} + for _, profile := range profiles { + findings = append(findings, registryNamedValues("Firewall", registry.HkeyLocalMachine, "HKLM", base+`\`+profile, []string{"EnableFirewall", "DefaultInboundAction", "DefaultOutboundAction", "DisableNotifications"}, "Medium", profile+" firewall policy")...) + } + return findings +} + +func rdpFindings() []AdvancedFinding { + findings := registryNamedValues("RDP", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\Terminal Server`, []string{"fDenyTSConnections", "fSingleSessionPerUser"}, "Medium", "Remote Desktop terminal server policy") + findings = append(findings, registryNamedValues("RDP", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp`, []string{"UserAuthentication", "SecurityLayer", "PortNumber"}, "Medium", "RDP listener policy")...) + return findings +} + +func wmiFindings() []AdvancedFinding { + findings := fileGlobFindings("WMI", filepath.Join(os.Getenv("WINDIR"), `System32\wbem\AutoRecover`, "*.mof"), "Medium", "WMI AutoRecover MOF") + findings = append(findings, registryAllValues("WMI", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\WBEM\CIMOM`, "Info", "WMI CIMOM configuration")...) + return findings +} + +func hostsFindings() []AdvancedFinding { + path := filepath.Join(os.Getenv("WINDIR"), `System32\drivers\etc\hosts`) + data, err := os.ReadFile(path) + if err != nil { + return nil + } + findings := []AdvancedFinding{} + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + findings = append(findings, AdvancedFinding{Area: "Hosts", Target: path, Name: "entry", Value: line, Severity: "Medium", Reason: "hosts file override"}) + } + return findings +} + +func shellFindings() []AdvancedFinding { + findings := registryAllValues("Shell", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows\CurrentVersion\ShellServiceObjectDelayLoad`, "Medium", "shell service object delay-load entry") + findings = append(findings, registryAllValues("Shell", registry.HkeyCurrentUser, "HKCU", `SOFTWARE\Microsoft\Windows\CurrentVersion\ShellServiceObjectDelayLoad`, "Medium", "per-user shell service object delay-load entry")...) + findings = append(findings, registrySubkeys("ShellExtensions", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows\CurrentVersion\Shell Extensions\Approved`, "Info", "approved shell extension")...) + findings = append(findings, registryAllValues("ExplorerPolicies", registry.HkeyCurrentUser, "HKCU", `SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer`, "Medium", "Explorer policy value")...) + return findings +} + +func browserFindings() []AdvancedFinding { + findings := registrySubkeys("Browser", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Browser Helper Objects`, "Medium", "Browser Helper Object") + findings = append(findings, registrySubkeys("Browser", registry.HkeyCurrentUser, "HKCU", `SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Browser Helper Objects`, "Medium", "per-user Browser Helper Object")...) + findings = append(findings, registrySubkeys("Browser", registry.HkeyCurrentUser, "HKCU", `SOFTWARE\Google\Chrome\NativeMessagingHosts`, "Medium", "Chrome native messaging host")...) + findings = append(findings, registrySubkeys("Browser", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Google\Chrome\NativeMessagingHosts`, "Medium", "machine Chrome native messaging host")...) + findings = append(findings, registrySubkeys("Browser", registry.HkeyCurrentUser, "HKCU", `SOFTWARE\Microsoft\Edge\NativeMessagingHosts`, "Medium", "Edge native messaging host")...) + return findings +} + +func protocolFindings() []AdvancedFinding { + findings := []AdvancedFinding{} + for _, root := range []struct { + scope uintptr + name string + }{{registry.HkeyCurrentUser, "HKCU"}, {registry.HkeyLocalMachine, "HKLM"}} { + base := `Software\Classes` + for _, key := range subkeys(root.scope, base) { + values, err := registry.Values(root.scope, base+`\`+key) + if err != nil { + continue + } + for _, value := range values { + if strings.EqualFold(value.Name, "URL Protocol") { + findings = append(findings, AdvancedFinding{Area: "Protocols", Target: root.name + `\` + base + `\` + key, Name: value.Name, Value: value.Value, Severity: "Medium", Reason: "custom URL protocol handler"}) + } + } + if len(findings) >= advancedLimit { + return findings + } + } + } + return findings +} + +func comLocalFindings() []AdvancedFinding { + findings := []AdvancedFinding{} + for _, entry := range mustCLSID() { + if entry.Kind == "InprocServer32" || entry.Kind == "LocalServer32" { + findings = append(findings, AdvancedFinding{Area: "COM", Target: `HKCU\Software\Classes\CLSID\` + entry.CLSID, Name: entry.Kind, Value: entry.Value, Severity: "Medium", Reason: "per-user COM server registration"}) + } + } + return findings +} + +func servicePathFindings() []AdvancedFinding { + services, err := services.EnumerateServices() + if err != nil { + return nil + } + findings := []AdvancedFinding{} + for _, service := range services { + if reason := pathRisk(service.BinaryPath); reason != "" { + findings = append(findings, AdvancedFinding{Area: "Services", Target: service.Name, Name: service.StartType, Value: service.BinaryPath, Severity: "High", Reason: reason}) + } + } + return findings +} + +func driverPathFindings() []AdvancedFinding { + drivers, err := services.EnumerateDrivers() + if err != nil { + return nil + } + findings := []AdvancedFinding{} + for _, driver := range drivers { + if reason := pathRisk(driver.BinaryPath); reason != "" { + findings = append(findings, AdvancedFinding{Area: "Drivers", Target: driver.Name, Name: driver.StartType, Value: driver.BinaryPath, Severity: "High", Reason: reason}) + } + } + return findings +} + +func certificateFindings() []AdvancedFinding { + checks := []struct { + scope uintptr + root string + path string + }{ + {registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\SystemCertificates\Root\Certificates`}, + {registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\SystemCertificates\AuthRoot\Certificates`}, + {registry.HkeyCurrentUser, "HKCU", `SOFTWARE\Microsoft\SystemCertificates\Root\Certificates`}, + {registry.HkeyCurrentUser, "HKCU", `SOFTWARE\Microsoft\SystemCertificates\My\Certificates`}, + } + findings := []AdvancedFinding{} + for _, check := range checks { + keys := subkeys(check.scope, check.path) + findings = append(findings, AdvancedFinding{Area: "Certificates", Target: check.root + `\` + check.path, Name: "count", Value: fmt.Sprintf("%d", len(keys)), Severity: "Info", Reason: "certificate store inventory"}) + } + return findings +} + +func networkProviderFindings() []AdvancedFinding { + findings := registryAllValues("NetworkProviders", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\NetworkProvider\Order`, "Medium", "network provider order") + findings = append(findings, registrySubkeys("CredentialProviders", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers`, "Medium", "credential provider")...) + findings = append(findings, registrySubkeys("CredentialProviderFilters", registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Provider Filters`, "Medium", "credential provider filter")...) + return findings +} + +func printFindings() []AdvancedFinding { + findings := registrySubkeys("Print", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\Print\Monitors`, "Medium", "print monitor load surface") + findings = append(findings, registrySubkeys("Print", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\Print\Providers`, "Medium", "print provider load surface")...) + findings = append(findings, registrySubkeys("Print", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\Print\Environments\Windows x64\Print Processors`, "Medium", "print processor load surface")...) + return findings +} + +func winsockFindings() []AdvancedFinding { + findings := registryAllValues("Winsock", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9`, "Info", "Winsock protocol catalog") + findings = append(findings, registryAllValues("Winsock", registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\NameSpace_Catalog5`, "Info", "Winsock namespace catalog")...) + return findings +} + +func accessibilityFindings() []AdvancedFinding { + binaries := []string{"sethc.exe", "utilman.exe", "osk.exe", "magnify.exe", "narrator.exe", "displayswitch.exe", "atbroker.exe"} + base := `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options` + findings := []AdvancedFinding{} + for _, binary := range binaries { + findings = append(findings, registryNamedValues("Accessibility", registry.HkeyLocalMachine, "HKLM", base+`\`+binary, []string{"Debugger", "VerifierDlls", "GlobalFlag"}, "High", "accessibility binary interception")...) + } + return findings +} diff --git a/windows/advanced/helpers_windows.go b/windows/advanced/helpers_windows.go new file mode 100644 index 0000000..d2310a2 --- /dev/null +++ b/windows/advanced/helpers_windows.go @@ -0,0 +1,155 @@ +//go:build windows + +package advanced + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "ferrum/windows/registry" +) + +func registryNamedValues(area string, scope uintptr, root, path string, names []string, severity, reason string) []AdvancedFinding { + values, err := registry.Values(scope, path) + if err != nil { + return nil + } + findings := []AdvancedFinding{} + for _, value := range values { + if containsName(names, value.Name) { + findings = append(findings, AdvancedFinding{Area: area, Target: root + `\` + path, Name: displayName(value.Name), Value: value.Value, Severity: severity, Reason: reason}) + } + } + return findings +} + +func registryAllValues(area string, scope uintptr, root, path, severity, reason string) []AdvancedFinding { + values, err := registry.Values(scope, path) + if err != nil { + return nil + } + findings := []AdvancedFinding{} + for _, value := range values { + findings = append(findings, AdvancedFinding{Area: area, Target: root + `\` + path, Name: displayName(value.Name), Value: value.Value, Severity: severity, Reason: reason}) + } + return findings +} + +func registrySubkeys(area string, scope uintptr, root, path, severity, reason string) []AdvancedFinding { + keys := subkeys(scope, path) + findings := make([]AdvancedFinding, 0, len(keys)) + for _, key := range keys { + findings = append(findings, AdvancedFinding{Area: area, Target: root + `\` + path + `\` + key, Name: key, Severity: severity, Reason: reason}) + } + return findings +} + +func subkeys(scope uintptr, path string) []string { + key, err := registry.OpenKey(scope, path) + if err != nil { + return nil + } + defer registry.CloseKey(key) + keys, err := registry.EnumSubkeys(key) + if err != nil { + return nil + } + return keys +} + +func fileGlobFindings(area, pattern, severity, reason string) []AdvancedFinding { + matches, err := filepath.Glob(pattern) + if err != nil { + return nil + } + findings := []AdvancedFinding{} + for _, match := range matches { + findings = append(findings, AdvancedFinding{Area: area, Target: match, Name: filepath.Base(match), Severity: severity, Reason: reason}) + } + return findings +} + +func pathRisk(path string) string { + trimmed := strings.TrimSpace(path) + lower := strings.ToLower(trimmed) + switch { + case trimmed == "": + return "" + case strings.Contains(trimmed, " ") && !strings.HasPrefix(trimmed, `"`) && strings.Contains(lower, ".exe"): + return "unquoted executable path with spaces" + case strings.Contains(lower, `\users\`) || strings.Contains(lower, `\temp\`) || strings.Contains(lower, `\downloads\`): + return "user-writable-looking image path" + case strings.Contains(lower, `\programdata\`): + return "commonly writable image path" + default: + return "" + } +} + +func exists(path string) bool { + if path == "" { + return false + } + _, err := os.Stat(path) + return err == nil +} + +func mustCLSID() []registry.CLSIDEntry { + entries, err := registry.EnumerateHKCUCLSID() + if err != nil { + return nil + } + return entries +} + +func displayName(name string) string { + if name == "" { + return "(Default)" + } + return name +} + +func containsName(names []string, name string) bool { + for _, item := range names { + if strings.EqualFold(item, name) { + return true + } + } + return false +} + +func severityRank(severity string) int { + switch severity { + case "High": + return 4 + case "Medium": + return 3 + case "Low": + return 2 + case "Info": + return 1 + default: + return 0 + } +} + +func sortAdvanced(findings []AdvancedFinding) { + sort.Slice(findings, func(i, j int) bool { + if findings[i].Severity != findings[j].Severity { + return severityRank(findings[i].Severity) > severityRank(findings[j].Severity) + } + if findings[i].Area != findings[j].Area { + return findings[i].Area < findings[j].Area + } + return findings[i].Target < findings[j].Target + }) +} + +func limitAdvanced(findings []AdvancedFinding) []AdvancedFinding { + if len(findings) <= advancedLimit { + return findings + } + return findings[:advancedLimit] +} diff --git a/windows/advanced/profiles_windows.go b/windows/advanced/profiles_windows.go new file mode 100644 index 0000000..1baaacf --- /dev/null +++ b/windows/advanced/profiles_windows.go @@ -0,0 +1,234 @@ +//go:build windows + +package advanced + +import ( + "fmt" + "os" + "strings" + + winprocess "ferrum/windows/process" + "ferrum/windows/registry" + "ferrum/windows/services" +) + +type surfaceProfile struct { + area string + severity string + reason string + registry []registryProbe + globs []string + services []string + processes []string +} + +type registryProbe struct { + scope uintptr + root string + path string +} + +func genericSurfaceFindings(check string) []AdvancedFinding { + profile, ok := surfaceProfiles()[check] + if !ok { + return []AdvancedFinding{{ + Area: strings.ToUpper(check), + Target: check, + Severity: "Info", + Reason: "registered research surface; add a specialized collector for deeper enumeration", + }} + } + + findings := []AdvancedFinding{} + for _, probe := range profile.registry { + findings = append(findings, registryAllValues(profile.area, probe.scope, probe.root, probe.path, profile.severity, profile.reason)...) + findings = append(findings, registrySubkeys(profile.area, probe.scope, probe.root, probe.path, profile.severity, profile.reason)...) + } + for _, pattern := range profile.globs { + findings = append(findings, fileGlobFindings(profile.area, expandEnvPath(pattern), profile.severity, profile.reason)...) + } + if len(profile.services) > 0 { + findings = append(findings, servicesMatching(profile.area, profile.services, profile.severity, profile.reason)...) + } + if len(profile.processes) > 0 { + findings = append(findings, processesMatching(profile.area, profile.processes, profile.severity, profile.reason)...) + } + if len(findings) == 0 { + findings = append(findings, AdvancedFinding{ + Area: profile.area, + Target: check, + Severity: "Info", + Reason: "no concrete artifacts found; surface remains registered for targeted research", + }) + } + return findings +} + +func surfaceProfiles() map[string]surfaceProfile { + return map[string]surfaceProfile{ + "comdcom": registryProfile("COM/DCOM", "Medium", "COM/DCOM registration or security surface", []string{`Software\Classes\AppID`, `Software\Classes\CLSID`, `SOFTWARE\Classes\AppID`, `SOFTWARE\Classes\CLSID`}), + "comelevation": registryProfile("COM Elevation", "High", "COM elevation or auto-approval surface", []string{`SOFTWARE\Microsoft\Windows NT\CurrentVersion\UAC\COMAutoApprovalList`, `Software\Classes\CLSID`}), + "dcomactivation": registryProfile("DCOM Activation", "Medium", "DCOM machine activation/security policy", []string{`SOFTWARE\Microsoft\Ole`, `Software\Classes\AppID`}), + "rpc": serviceProfile("RPC", "Medium", "RPC-capable service or endpoint surface", []string{"rpc", "RpcSs", "RpcEptMapper", "DcomLaunch"}), + "alpc": mixedProfile("ALPC", "Info", "ALPC broker or object namespace surface", []string{`SOFTWARE\Microsoft\WindowsRuntime\ActivatableClassId`}, nil, []string{"runtimebroker", "dllhost", "svchost"}), + "lpc": mixedProfile("LPC", "Info", "LPC/NT object boundary surface", []string{`SYSTEM\CurrentControlSet\Control\Session Manager`}, nil, []string{"csrss", "lsass", "winlogon"}), + "tokenimpersonation": serviceProfile("Token Impersonation", "High", "service process commonly relevant to impersonation research", []string{"RpcSs", "Spooler", "Schedule", "BITS", "WinRM", "WebClient"}), + "uacautoelevation": registryProfile("UAC Auto-Elevation", "High", "UAC auto-elevation approval or consent surface", []string{`SOFTWARE\Microsoft\Windows NT\CurrentVersion\UAC\COMAutoApprovalList`, `SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System`}), + "msi": registryProfile("MSI", "High", "Windows Installer policy or product registration surface", []string{`Software\Policies\Microsoft\Windows\Installer`, `SOFTWARE\Policies\Microsoft\Windows\Installer`, `SOFTWARE\Microsoft\Windows\CurrentVersion\Installer`}), + "installerrepair": registryProfile("Installer Repair", "Medium", "installer repair and advertised product surface", []string{`SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData`, `SOFTWARE\Classes\Installer\Products`}), + "wdacpolicy": registryProfile("WDAC/AppLocker Interfaces", "Info", "application control policy interface", []string{`SYSTEM\CurrentControlSet\Control\CI\Policy`, `SOFTWARE\Policies\Microsoft\Windows\SrpV2`}), + "brokers": mixedProfile("Broker Processes", "Medium", "privileged broker process or COM broker surface", []string{`SOFTWARE\Microsoft\WindowsRuntime\ActivatableClassId`}, nil, []string{"runtimebroker", "dllhost", "consent", "appinfo"}), + "appcontainerbrokers": mixedProfile("AppContainer Brokers", "Medium", "AppContainer capability or broker surface", []string{`SOFTWARE\Microsoft\SecurityManager\CapabilityClasses`, `SOFTWARE\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppContainer`}, nil, []string{"runtimebroker", "applicationframehost"}), + "urlmonikers": registryProfile("URL Monikers", "Medium", "URL protocol or moniker activation surface", []string{`Software\Classes`, `SOFTWARE\Classes`}), + "comhijack": registryProfile("COM Hijacking", "High", "per-user or machine COM registration hijack surface", []string{`Software\Classes\CLSID`, `Software\Classes\AppID`, `SOFTWARE\Classes\CLSID`}), + "custommarshal": registryProfile("Custom Marshaling", "High", "COM custom marshaling registration surface", []string{`Software\Classes\Interface`, `SOFTWARE\Classes\Interface`}), + "dllhijacking": mixedProfile("DLL Hijacking", "High", "DLL search or writable path surface", []string{`SYSTEM\CurrentControlSet\Control\Session Manager`, `SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs`}, []string{`%WINDIR%\System32\*.local`, `%ProgramData%\*.dll`}, nil), + "sxs": mixedProfile("SxS", "Medium", "side-by-side assembly or manifest surface", []string{`SOFTWARE\Microsoft\Windows\CurrentVersion\SideBySide`}, []string{`%WINDIR%\WinSxS\Manifests\*.manifest`, `%ProgramFiles%\*\*.manifest`}, nil), + "activationctx": mixedProfile("Activation Context", "Medium", "manifest activation context surface", []string{`SOFTWARE\Microsoft\Windows\CurrentVersion\SideBySide`}, []string{`%ProgramFiles%\*\*.manifest`, `%ProgramFiles(x86)%\*\*.manifest`}, nil), + "ntobjmgr": registryProfile("NT Object Manager", "Info", "object namespace-related session manager surface", []string{`SYSTEM\CurrentControlSet\Control\Session Manager`, `SYSTEM\CurrentControlSet\Control\Session Manager\DOS Devices`}), + "objdirs": registryProfile("Object Directories", "Info", "object directory namespace indicator", []string{`SYSTEM\CurrentControlSet\Control\Session Manager\DOS Devices`}), + "symlinks": registryProfile("Symbolic Links", "Medium", "DOS device symbolic link surface", []string{`SYSTEM\CurrentControlSet\Control\Session Manager\DOS Devices`}), + "hardlinks": fileProfile("Hard Links", "Info", "hard-link-prone writable area for manual race research", []string{`%TEMP%\*`, `%ProgramData%\*`}), + "junctions": fileProfile("Junctions", "Medium", "junction-prone directory surface", []string{`%TEMP%\*`, `%ProgramData%\*`}), + "mountpoints": registryProfile("Mount Points", "Medium", "mounted device and mount manager surface", []string{`SYSTEM\MountedDevices`, `SYSTEM\CurrentControlSet\Services\mountmgr`}), + "reparsepoints": fileProfile("Reparse Points", "Medium", "reparse-point-prone writable area", []string{`%TEMP%\*`, `%ProgramData%\*`}), + "oplocks": fileProfile("OpLocks", "Info", "oplock/race-prone writable area", []string{`%TEMP%\*`, `%ProgramData%\*`}), + "regsymlinks": registryProfile("Registry Symlinks", "Medium", "registry link or virtualization-related surface", []string{`Software\Classes\VirtualStore`, `SOFTWARE\Classes\VirtualStore`}), + "scm": serviceProfile("SCM", "Medium", "service control manager service configuration surface", []string{""}), + "minifilters": serviceProfile("Minifilters", "Medium", "file system minifilter driver surface", []string{"FltMgr", "WdFilter", "FileInfo", "luafv"}), + "ioctls": serviceProfile("IOCTLs", "High", "kernel driver device control surface", []string{"driver", "filter", "kbd", "mou", "disk", "ndis"}), + "deviceobjects": registryProfile("Device Objects", "Medium", "device class and driver object surface", []string{`SYSTEM\CurrentControlSet\Enum`, `SYSTEM\CurrentControlSet\Control\Class`}), + "etw": registryProfile("ETW", "Info", "ETW provider registration surface", []string{`SOFTWARE\Microsoft\Windows\CurrentVersion\WINEVT\Publishers`, `SOFTWARE\Microsoft\Windows\CurrentVersion\ETW`}), + "win32k": mixedProfile("Win32k", "Medium", "GUI subsystem boundary surface", []string{`SYSTEM\CurrentControlSet\Control\Session Manager\SubSystems`}, nil, []string{"dwm", "winlogon", "csrss"}), + "csrss": mixedProfile("CSRSS", "High", "client/server runtime subsystem boundary", []string{`SYSTEM\CurrentControlSet\Control\Session Manager\SubSystems`}, nil, []string{"csrss"}), + "lsassinterfaces": registryProfile("LSASS Interfaces", "High", "LSASS authentication and security package surface", []string{`SYSTEM\CurrentControlSet\Control\Lsa`, `SYSTEM\CurrentControlSet\Control\SecurityProviders`}), + "accesstokens": serviceProfile("Access Tokens", "High", "privileged service token surface", []string{"RpcSs", "Schedule", "Spooler", "WinRM", "BITS"}), + "handles": mixedProfile("Handles", "Info", "handle duplication/leak research target", nil, nil, []string{"lsass", "csrss", "services", "winlogon", "spoolsv"}), + "jobobjects": mixedProfile("Job Objects", "Info", "process containment and job object research target", nil, nil, []string{"svchost", "runtimebroker", "dllhost"}), + "sectionobjects": mixedProfile("Section Objects", "Info", "section object and image mapping research target", nil, []string{`%TEMP%\*.tmp`, `%ProgramData%\*.tmp`}, []string{"csrss", "lsass", "services"}), + "sharedmemory": mixedProfile("Shared Memory", "Info", "shared memory IPC research target", nil, []string{`%TEMP%\*`}, []string{"explorer", "runtimebroker", "dwm"}), + "mmap": fileProfile("Memory-Mapped Files", "Info", "memory-mapped file candidate", []string{`%TEMP%\*`, `%ProgramData%\*`}), + "wfp": registryProfile("WFP", "Medium", "Windows Filtering Platform provider surface", []string{`SYSTEM\CurrentControlSet\Services\BFE`, `SYSTEM\CurrentControlSet\Services\SharedAccess`}), + "hyperv": serviceProfile("Hyper-V", "Medium", "Hyper-V component service surface", []string{"vmms", "vmcompute", "vmicheartbeat", "HvHost", "hns"}), + "wsl": serviceProfile("WSL", "Medium", "Windows Subsystem for Linux component surface", []string{"LxssManager", "vmcompute", "hns"}), + "efsrpc": serviceProfile("EFSRPC", "High", "Encrypting File System RPC surface", []string{"EFS", "EFSRPC"}), + "taskrpc": serviceProfile("Task Scheduler RPC", "Medium", "Task Scheduler service RPC surface", []string{"Schedule"}), + "bits": serviceProfile("BITS", "Medium", "Background Intelligent Transfer Service surface", []string{"BITS"}), + "endpointmapper": serviceProfile("Endpoint Mapper", "Medium", "RPC Endpoint Mapper and DCOM launch surface", []string{"RpcEptMapper", "RpcSs", "DcomLaunch"}), + "winrm": serviceProfile("WinRM", "Medium", "Windows Remote Management service surface", []string{"WinRM"}), + "smbipc": serviceProfile("SMB Local IPC", "Medium", "SMB and IPC service surface", []string{"LanmanServer", "LanmanWorkstation", "srv2"}), + "credproviders": registryProfile("Credential Providers", "High", "credential provider registration surface", []string{`SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers`, `SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Provider Filters`}), + "authpackages": registryProfile("Authentication Packages", "High", "authentication package registration surface", []string{`SYSTEM\CurrentControlSet\Control\Lsa`}), + "lsaplugins": registryProfile("LSA Plugins", "High", "LSA plugin and notification package surface", []string{`SYSTEM\CurrentControlSet\Control\Lsa`, `SYSTEM\CurrentControlSet\Control\SecurityProviders`}), + "cloudap": registryProfile("CloudAP", "High", "cloud authentication package surface", []string{`SYSTEM\CurrentControlSet\Control\Lsa`, `SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication`}), + "ppl": registryProfile("PPL", "High", "Protected Process Light policy indicator", []string{`SYSTEM\CurrentControlSet\Control\Lsa`, `SYSTEM\CurrentControlSet\Control\CI`}), + "userprofilesvc": serviceProfile("User Profile Service", "Medium", "profile service and profile path surface", []string{"ProfSvc"}), + "updates": serviceProfile("Update Mechanisms", "Medium", "update service or repair/update mechanism surface", []string{"wuauserv", "UsoSvc", "BITS", "TrustedInstaller", "WaaSMedicSvc"}), + "recovery": registryProfile("Recovery", "Medium", "repair and recovery mechanism surface", []string{`SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce`, `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options`, `SYSTEM\Setup`}), + "tempfiles": fileProfile("Temporary Files", "Medium", "temporary file handling surface", []string{`%TEMP%\*`, `%TMP%\*`, `%WINDIR%\Temp\*`}), + "toctou": fileProfile("TOCTOU", "Medium", "race-prone writable filesystem surface", []string{`%TEMP%\*`, `%ProgramData%\*`, `%WINDIR%\Temp\*`}), + "pathcanon": fileProfile("Path Canonicalization", "Medium", "path parsing and canonicalization research surface", []string{`%TEMP%\*`, `%ProgramData%\*`}), + "confuseddeputy": serviceProfile("Confused Deputy", "Medium", "privileged service that may act on caller-controlled paths", []string{"Spooler", "Schedule", "BITS", "msiserver", "TrustedInstaller", "WinRM"}), + "acl": mixedProfile("ACL Misconfigurations", "High", "path commonly worth ACL review", nil, []string{`%ProgramData%\*`, `%WINDIR%\Temp\*`}, nil), + "envinjection": registryProfile("Environment Injection", "Medium", "environment variable injection surface", []string{`SYSTEM\CurrentControlSet\Control\Session Manager\Environment`, `Environment`}), + "searchpoison": registryProfile("Search Path Poisoning", "High", "search path or App Paths poisoning surface", []string{`SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths`, `Software\Microsoft\Windows\CurrentVersion\App Paths`, `SYSTEM\CurrentControlSet\Control\Session Manager\Environment`}), + "propertyhandlers": registryProfile("Property Handlers", "Medium", "shell property handler registration surface", []string{`SOFTWARE\Microsoft\Windows\CurrentVersion\PropertySystem\PropertyHandlers`, `Software\Microsoft\Windows\CurrentVersion\PropertySystem\PropertyHandlers`}), + "explorerext": registryProfile("Explorer Extensions", "Medium", "Explorer shell extension registration surface", []string{`SOFTWARE\Microsoft\Windows\CurrentVersion\Shell Extensions`, `Software\Microsoft\Windows\CurrentVersion\Shell Extensions`}), + "thumbnailproviders": registryProfile("Thumbnail Providers", "Medium", "thumbnail provider registration surface", []string{`SOFTWARE\Classes\CLSID`, `Software\Classes\CLSID`}), + "previewhandlers": registryProfile("Preview Handlers", "Medium", "preview handler registration surface", []string{`SOFTWARE\Microsoft\Windows\CurrentVersion\PreviewHandlers`, `Software\Microsoft\Windows\CurrentVersion\PreviewHandlers`}), + "sessionisolation": mixedProfile("Session Isolation", "Info", "session boundary process surface", []string{`SYSTEM\CurrentControlSet\Control\Terminal Server`}, nil, []string{"winlogon", "csrss", "dwm", "rdpclip"}), + "windowstations": registryProfile("Window Stations", "Info", "desktop/window station policy surface", []string{`SYSTEM\CurrentControlSet\Control\Windows`, `SYSTEM\CurrentControlSet\Control\Session Manager\SubSystems`}), + "clipboard": mixedProfile("Clipboard IPC", "Info", "clipboard IPC process surface", nil, nil, []string{"rdpclip", "explorer", "applicationframehost"}), + "dragdrop": mixedProfile("Drag-and-Drop IPC", "Info", "drag-and-drop broker process surface", nil, nil, []string{"explorer", "runtimebroker", "applicationframehost"}), + "dde": registryProfile("DDE", "Medium", "DDE command registration surface", []string{`Software\Classes`, `SOFTWARE\Classes`}), + "ole": registryProfile("OLE", "Medium", "OLE/COM embedding registration surface", []string{`Software\Classes`, `SOFTWARE\Classes`, `SOFTWARE\Microsoft\Ole`}), + } +} + +func registryProfile(area, severity, reason string, paths []string) surfaceProfile { + probes := make([]registryProbe, 0, len(paths)) + for _, path := range paths { + scope := uintptr(registry.HkeyLocalMachine) + root := "HKLM" + if strings.HasPrefix(path, "Software\\") || path == "Environment" { + scope = uintptr(registry.HkeyCurrentUser) + root = "HKCU" + } + if strings.HasPrefix(path, "SOFTWARE\\") || strings.HasPrefix(path, "SYSTEM\\") { + scope = uintptr(registry.HkeyLocalMachine) + root = "HKLM" + } + probes = append(probes, registryProbe{scope: scope, root: root, path: path}) + } + return surfaceProfile{area: area, severity: severity, reason: reason, registry: probes} +} + +func serviceProfile(area, severity, reason string, services []string) surfaceProfile { + return surfaceProfile{area: area, severity: severity, reason: reason, services: services} +} + +func fileProfile(area, severity, reason string, globs []string) surfaceProfile { + return surfaceProfile{area: area, severity: severity, reason: reason, globs: globs} +} + +func mixedProfile(area, severity, reason string, paths []string, globs []string, processes []string) surfaceProfile { + profile := registryProfile(area, severity, reason, paths) + profile.globs = globs + profile.processes = processes + return profile +} + +func servicesMatching(area string, keywords []string, severity, reason string) []AdvancedFinding { + services, err := services.EnumerateServices() + if err != nil { + return nil + } + findings := []AdvancedFinding{} + for _, service := range services { + if len(keywords) == 1 && keywords[0] == "" || containsKeyword(service.Name, keywords) || containsKeyword(service.DisplayName, keywords) || containsKeyword(service.BinaryPath, keywords) { + findings = append(findings, AdvancedFinding{Area: area, Target: service.Name, Name: service.StartType, Value: service.BinaryPath, Severity: severity, Reason: reason}) + } + } + return findings +} + +func processesMatching(area string, keywords []string, severity, reason string) []AdvancedFinding { + processes, err := winprocess.EnumerateProcesses() + if err != nil { + return nil + } + findings := []AdvancedFinding{} + for _, process := range processes { + if containsKeyword(process.Name, keywords) { + findings = append(findings, AdvancedFinding{Area: area, Target: fmt.Sprintf("%s[%d]", process.Name, process.PID), Name: "process", Severity: severity, Reason: reason}) + } + } + return findings +} + +func containsKeyword(value string, keywords []string) bool { + value = strings.ToLower(value) + for _, keyword := range keywords { + if keyword == "" || strings.Contains(value, strings.ToLower(keyword)) { + return true + } + } + return false +} + +func expandEnvPath(path string) string { + replacements := map[string]string{ + "%TEMP%": os.Getenv("TEMP"), + "%TMP%": os.Getenv("TMP"), + "%WINDIR%": os.Getenv("WINDIR"), + "%ProgramData%": os.Getenv("ProgramData"), + "%ProgramFiles%": os.Getenv("ProgramFiles"), + "%ProgramFiles(x86)%": os.Getenv("ProgramFiles(x86)"), + } + for token, value := range replacements { + if value != "" { + path = strings.ReplaceAll(path, token, value) + } + } + return path +} diff --git a/windows/audit/audit_windows.go b/windows/audit/audit_windows.go new file mode 100644 index 0000000..48376a3 --- /dev/null +++ b/windows/audit/audit_windows.go @@ -0,0 +1,288 @@ +//go:build windows + +package audit + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "ferrum/windows/registry" + wintypes "ferrum/windows/types" +) + +type RegistryAuditFinding = registry.RegistryAuditFinding +type PolicyFinding = wintypes.PolicyFinding +type DLLSearchPathFinding = wintypes.DLLSearchPathFinding + +func EnumerateRegistryAuditFindings() ([]RegistryAuditFinding, error) { + findings := []RegistryAuditFinding{} + checkValueSet := []struct { + scope uintptr + scopeName string + path string + names []string + severity string + reason string + }{ + {registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows`, []string{"AppInit_DLLs", "LoadAppInit_DLLs"}, "High", "AppInit DLL injection surface"}, + {registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\Session Manager`, []string{"AppCertDLLs"}, "High", "process creation DLL injection surface"}, + {registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon`, []string{"Shell", "Userinit", "Notify"}, "High", "Winlogon execution surface"}, + {registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\Lsa`, []string{"Authentication Packages", "Notification Packages", "Security Packages"}, "High", "LSA package load surface"}, + {registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows NT\CurrentVersion\AeDebug`, []string{"Debugger", "Auto"}, "Medium", "post-crash debugger execution surface"}, + {registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System`, []string{"EnableLUA", "ConsentPromptBehaviorAdmin", "LocalAccountTokenFilterPolicy"}, "Medium", "UAC and remote token policy"}, + {registry.HkeyLocalMachine, "HKLM", `SYSTEM\CurrentControlSet\Control\Session Manager`, []string{"SafeDllSearchMode", "CWDIllegalInDllSearch"}, "Medium", "DLL search-order policy"}, + {registry.HkeyLocalMachine, "HKLM", `SOFTWARE\Policies\Microsoft\Windows\PowerShell`, []string{"EnableScripts", "ExecutionPolicy"}, "Medium", "PowerShell execution policy"}, + {registry.HkeyCurrentUser, "HKCU", `SOFTWARE\Policies\Microsoft\Windows\PowerShell`, []string{"EnableScripts", "ExecutionPolicy"}, "Medium", "per-user PowerShell policy"}, + } + for _, check := range checkValueSet { + values, err := registry.Values(check.scope, check.path) + if err != nil { + continue + } + for _, value := range values { + if !containsName(check.names, value.Name) { + continue + } + findings = append(findings, RegistryAuditFinding{ + Scope: check.scopeName, + Path: check.path, + Name: value.Name, + Value: value.Value, + Severity: check.severity, + Reason: check.reason, + }) + } + } + + findings = append(findings, enumerateIFEO(registry.HkeyLocalMachine, "HKLM")...) + findings = append(findings, enumerateIFEO(registry.HkeyCurrentUser, "HKCU")...) + findings = append(findings, enumerateSilentProcessExit()...) + findings = append(findings, enumerateCOMTreatAs()...) + sort.Slice(findings, func(i, j int) bool { + if findings[i].Severity != findings[j].Severity { + return severityRank(findings[i].Severity) > severityRank(findings[j].Severity) + } + return findings[i].Scope+findings[i].Path+findings[i].Name < findings[j].Scope+findings[j].Path+findings[j].Name + }) + return findings, nil +} + +func EnumeratePolicyFindings() ([]PolicyFinding, error) { + findings := []PolicyFinding{} + if enabled(registry.HkeyCurrentUser, `Software\Policies\Microsoft\Windows\Installer`, "AlwaysInstallElevated") && + enabled(registry.HkeyLocalMachine, `Software\Policies\Microsoft\Windows\Installer`, "AlwaysInstallElevated") { + findings = append(findings, PolicyFinding{Name: "AlwaysInstallElevated", Value: "HKCU=1 HKLM=1", Severity: "High", Reason: "MSI packages can install elevated for standard users"}) + } + if value := registryValue(registry.HkeyLocalMachine, `SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System`, "EnableLUA"); value == "00000000" || value == "0" { + findings = append(findings, PolicyFinding{Name: "EnableLUA", Value: value, Severity: "High", Reason: "UAC disabled"}) + } + if value := registryValue(registry.HkeyLocalMachine, `SYSTEM\CurrentControlSet\Control\Session Manager`, "SafeDllSearchMode"); value == "00000000" || value == "0" { + findings = append(findings, PolicyFinding{Name: "SafeDllSearchMode", Value: value, Severity: "Medium", Reason: "legacy DLL search behavior"}) + } + if hasSubkeys(registry.HkeyLocalMachine, `SOFTWARE\Policies\Microsoft\Windows\SrpV2`) { + findings = append(findings, PolicyFinding{Name: "AppLocker", Value: "Configured", Severity: "Info", Reason: "application control policy present"}) + } else { + findings = append(findings, PolicyFinding{Name: "AppLocker", Value: "Not detected", Severity: "Low", Reason: "no AppLocker policy keys found"}) + } + if hasSubkeys(registry.HkeyLocalMachine, `SYSTEM\CurrentControlSet\Control\CI\Policy`) { + findings = append(findings, PolicyFinding{Name: "WDAC", Value: "Policy keys present", Severity: "Info", Reason: "code integrity policy surface present"}) + } else { + findings = append(findings, PolicyFinding{Name: "WDAC", Value: "Not detected", Severity: "Low", Reason: "no WDAC policy keys found"}) + } + return findings, nil +} + +func EnumerateDLLSearchPathFindings() ([]DLLSearchPathFinding, error) { + findings := []DLLSearchPathFinding{} + pathValue := os.Getenv("PATH") + seen := map[string]bool{} + for _, path := range filepath.SplitList(pathValue) { + path = strings.TrimSpace(path) + if path == "" || seen[strings.ToLower(path)] { + continue + } + seen[strings.ToLower(path)] = true + reason, severity := classifySearchPath(path) + if reason == "" { + continue + } + findings = append(findings, DLLSearchPathFinding{Path: path, Source: "PATH", Severity: severity, Reason: reason}) + } + if cwd, err := os.Getwd(); err == nil { + reason, severity := classifySearchPath(cwd) + if reason != "" { + findings = append(findings, DLLSearchPathFinding{Path: cwd, Source: "CurrentDirectory", Severity: severity, Reason: reason}) + } + } + for _, known := range knownDLLs() { + findings = append(findings, DLLSearchPathFinding{Path: known, Source: "KnownDLLs", Severity: "Info", Reason: "KnownDLL protected load name"}) + } + sort.Slice(findings, func(i, j int) bool { + if findings[i].Severity != findings[j].Severity { + return severityRank(findings[i].Severity) > severityRank(findings[j].Severity) + } + return findings[i].Path < findings[j].Path + }) + return findings, nil +} + +func enumerateIFEO(scope uintptr, scopeName string) []RegistryAuditFinding { + base := `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options` + root, err := registry.OpenKey(scope, base) + if err != nil { + return nil + } + defer registry.CloseKey(root) + keys, err := registry.EnumSubkeys(root) + if err != nil { + return nil + } + findings := []RegistryAuditFinding{} + for _, key := range keys { + values, err := registry.Values(scope, base+`\`+key) + if err != nil { + continue + } + for _, value := range values { + if containsName([]string{"Debugger", "VerifierDlls", "GlobalFlag"}, value.Name) { + findings = append(findings, RegistryAuditFinding{Scope: scopeName, Path: base + `\` + key, Name: value.Name, Value: value.Value, Severity: "High", Reason: "IFEO process interception or verifier surface"}) + } + } + } + return findings +} + +func enumerateSilentProcessExit() []RegistryAuditFinding { + base := `SOFTWARE\Microsoft\Windows NT\CurrentVersion\SilentProcessExit` + root, err := registry.OpenKey(registry.HkeyLocalMachine, base) + if err != nil { + return nil + } + defer registry.CloseKey(root) + keys, err := registry.EnumSubkeys(root) + if err != nil { + return nil + } + findings := []RegistryAuditFinding{} + for _, key := range keys { + values, err := registry.Values(registry.HkeyLocalMachine, base+`\`+key) + if err != nil { + continue + } + for _, value := range values { + if containsName([]string{"MonitorProcess", "ReportingMode"}, value.Name) { + findings = append(findings, RegistryAuditFinding{Scope: "HKLM", Path: base + `\` + key, Name: value.Name, Value: value.Value, Severity: "High", Reason: "SilentProcessExit monitor execution surface"}) + } + } + } + return findings +} + +func enumerateCOMTreatAs() []RegistryAuditFinding { + base := `Software\Classes\CLSID` + root, err := registry.OpenKey(registry.HkeyCurrentUser, base) + if err != nil { + return nil + } + defer registry.CloseKey(root) + keys, err := registry.EnumSubkeys(root) + if err != nil { + return nil + } + findings := []RegistryAuditFinding{} + for _, key := range keys { + treatAs, err := registry.OpenKey(root, key+`\TreatAs`) + if err != nil { + continue + } + value, _ := registry.QueryDefaultValue(treatAs) + registry.CloseKey(treatAs) + findings = append(findings, RegistryAuditFinding{Scope: "HKCU", Path: base + `\` + key + `\TreatAs`, Name: "(Default)", Value: value, Severity: "Medium", Reason: "per-user COM TreatAs redirection"}) + } + return findings +} + +func classifySearchPath(path string) (string, string) { + lower := strings.ToLower(path) + switch { + case path == "." || !filepath.IsAbs(path): + return "relative DLL search path element", "High" + case strings.Contains(lower, `\users\`) || strings.Contains(lower, `\temp\`) || strings.Contains(lower, `\downloads\`): + return "user-writable-looking DLL search path element", "High" + case strings.Contains(lower, `\programdata\`): + return "commonly writable DLL search path element", "Medium" + } + if _, err := os.Stat(path); err != nil { + return "missing DLL search path element", "Low" + } + return "", "" +} + +func knownDLLs() []string { + values, err := registry.Values(registry.HkeyLocalMachine, `SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs`) + if err != nil { + return nil + } + names := []string{} + for _, value := range values { + if value.Name == "" || strings.HasPrefix(value.Name, "DllDirectory") { + continue + } + names = append(names, value.Value) + } + return names +} + +func registryValue(scope uintptr, path, name string) string { + values, err := registry.Values(scope, path) + if err != nil { + return "" + } + for _, value := range values { + if strings.EqualFold(value.Name, name) { + return value.Value + } + } + return "" +} + +func enabled(scope uintptr, path, name string) bool { + value := registryValue(scope, path, name) + return value == "1" || value == "00000001" +} + +func hasSubkeys(scope uintptr, path string) bool { + key, err := registry.OpenKey(scope, path) + if err != nil { + return false + } + defer registry.CloseKey(key) + keys, err := registry.EnumSubkeys(key) + return err == nil && len(keys) > 0 +} + +func containsName(names []string, name string) bool { + for _, item := range names { + if strings.EqualFold(item, name) { + return true + } + } + return false +} + +func severityRank(severity string) int { + switch severity { + case "High": + return 4 + case "Medium": + return 3 + case "Low": + return 2 + case "Info": + return 1 + default: + return 0 + } +} diff --git a/windows/env/env_windows.go b/windows/env/env_windows.go new file mode 100644 index 0000000..8257749 --- /dev/null +++ b/windows/env/env_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package env + +import ( + "os" + "sort" + "strings" + + wintypes "ferrum/windows/types" +) + +type EnvVar = wintypes.EnvVar + +func EnumerateEnvironment() ([]EnvVar, error) { + raw := os.Environ() + vars := make([]EnvVar, 0, len(raw)) + for _, item := range raw { + name, value, ok := strings.Cut(item, "=") + if !ok { + continue + } + vars = append(vars, EnvVar{Name: name, Value: value}) + } + sort.Slice(vars, func(i, j int) bool { return strings.ToUpper(vars[i].Name) < strings.ToUpper(vars[j].Name) }) + return vars, nil +} diff --git a/windows/errors/errors.go b/windows/errors/errors.go new file mode 100644 index 0000000..82f7383 --- /dev/null +++ b/windows/errors/errors.go @@ -0,0 +1,9 @@ +package errors + +import "fmt" + +type ErrUnsupported string + +func (e ErrUnsupported) Error() string { + return fmt.Sprintf("Windows API enumeration is not supported on %s", string(e)) +} diff --git a/windows/facade/facade_other.go b/windows/facade/facade_other.go new file mode 100644 index 0000000..e707d19 --- /dev/null +++ b/windows/facade/facade_other.go @@ -0,0 +1,46 @@ +//go:build !windows + +package facade + +import ( + "runtime" + + winerrors "ferrum/windows/errors" + winprocess "ferrum/windows/process" + "ferrum/windows/registry" + wintypes "ferrum/windows/types" +) + +type Process = winprocess.Process +type ProcessMitigation = winprocess.ProcessMitigation +type TokenInfo = wintypes.TokenInfo +type PolicyFinding = wintypes.PolicyFinding +type CLSIDEntry = registry.CLSIDEntry +type CLSIDProcMonCandidate = registry.CLSIDProcMonCandidate +type RegistryAuditFinding = registry.RegistryAuditFinding +type DLLSearchPathFinding = wintypes.DLLSearchPathFinding +type AdvancedFinding = wintypes.AdvancedFinding +type ServiceInfo = wintypes.ServiceInfo +type DriverInfo = wintypes.DriverInfo +type PipeInfo = wintypes.PipeInfo +type StartupEntry = wintypes.StartupEntry +type ScheduledTask = wintypes.ScheduledTask +type EnvVar = wintypes.EnvVar + +func unsupported() error { return winerrors.ErrUnsupported(runtime.GOOS) } + +func EnumerateProcesses() ([]Process, error) { return nil, unsupported() } +func InspectProcessToken(pid uint32) (TokenInfo, error) { return TokenInfo{}, unsupported() } +func EnumerateHKCUCLSID() ([]CLSIDEntry, error) { return nil, unsupported() } +func EnumerateCLSIDProcMonCandidates() ([]CLSIDProcMonCandidate, error) { return nil, unsupported() } +func EnumerateServices() ([]ServiceInfo, error) { return nil, unsupported() } +func EnumerateDrivers() ([]DriverInfo, error) { return nil, unsupported() } +func EnumerateNamedPipes() ([]PipeInfo, error) { return nil, unsupported() } +func EnumerateStartupEntries() ([]StartupEntry, error) { return nil, unsupported() } +func EnumerateScheduledTasks() ([]ScheduledTask, error) { return nil, unsupported() } +func EnumerateEnvironment() ([]EnvVar, error) { return nil, unsupported() } +func EnumerateProcessMitigations() ([]ProcessMitigation, error) { return nil, unsupported() } +func EnumerateRegistryAuditFindings() ([]RegistryAuditFinding, error) { return nil, unsupported() } +func EnumerateDLLSearchPathFindings() ([]DLLSearchPathFinding, error) { return nil, unsupported() } +func EnumeratePolicyFindings() ([]PolicyFinding, error) { return nil, unsupported() } +func EnumerateAdvancedFindings(check string) ([]AdvancedFinding, error) { return nil, unsupported() } diff --git a/windows/facade/facade_windows.go b/windows/facade/facade_windows.go new file mode 100644 index 0000000..0b6855d --- /dev/null +++ b/windows/facade/facade_windows.go @@ -0,0 +1,60 @@ +//go:build windows + +package facade + +import ( + "ferrum/windows/advanced" + "ferrum/windows/audit" + winenv "ferrum/windows/env" + "ferrum/windows/migrations" + "ferrum/windows/pipes" + winprocess "ferrum/windows/process" + "ferrum/windows/registry" + "ferrum/windows/scheduled" + "ferrum/windows/services" + "ferrum/windows/startup" + "ferrum/windows/token" + wintypes "ferrum/windows/types" +) + +type Process = winprocess.Process +type ProcessMitigation = winprocess.ProcessMitigation +type TokenInfo = wintypes.TokenInfo +type PolicyFinding = wintypes.PolicyFinding +type CLSIDEntry = registry.CLSIDEntry +type CLSIDProcMonCandidate = registry.CLSIDProcMonCandidate +type RegistryAuditFinding = registry.RegistryAuditFinding +type DLLSearchPathFinding = wintypes.DLLSearchPathFinding +type AdvancedFinding = wintypes.AdvancedFinding +type ServiceInfo = wintypes.ServiceInfo +type DriverInfo = wintypes.DriverInfo +type PipeInfo = wintypes.PipeInfo +type StartupEntry = wintypes.StartupEntry +type ScheduledTask = wintypes.ScheduledTask +type EnvVar = wintypes.EnvVar + +func EnumerateProcesses() ([]Process, error) { return winprocess.EnumerateProcesses() } +func InspectProcessToken(pid uint32) (TokenInfo, error) { return token.InspectProcessToken(pid) } +func EnumerateHKCUCLSID() ([]CLSIDEntry, error) { return registry.EnumerateHKCUCLSID() } +func EnumerateCLSIDProcMonCandidates() ([]CLSIDProcMonCandidate, error) { + return registry.EnumerateCLSIDProcMonCandidates() +} +func EnumerateServices() ([]ServiceInfo, error) { return services.EnumerateServices() } +func EnumerateDrivers() ([]DriverInfo, error) { return services.EnumerateDrivers() } +func EnumerateNamedPipes() ([]PipeInfo, error) { return pipes.EnumerateNamedPipes() } +func EnumerateStartupEntries() ([]StartupEntry, error) { return startup.EnumerateStartupEntries() } +func EnumerateScheduledTasks() ([]ScheduledTask, error) { return scheduled.EnumerateScheduledTasks() } +func EnumerateEnvironment() ([]EnvVar, error) { return winenv.EnumerateEnvironment() } +func EnumerateProcessMitigations() ([]ProcessMitigation, error) { + return migrations.EnumerateProcessMitigations() +} +func EnumerateRegistryAuditFindings() ([]RegistryAuditFinding, error) { + return audit.EnumerateRegistryAuditFindings() +} +func EnumerateDLLSearchPathFindings() ([]DLLSearchPathFinding, error) { + return audit.EnumerateDLLSearchPathFindings() +} +func EnumeratePolicyFindings() ([]PolicyFinding, error) { return audit.EnumeratePolicyFindings() } +func EnumerateAdvancedFindings(check string) ([]AdvancedFinding, error) { + return advanced.EnumerateAdvancedFindings(check) +} diff --git a/windows/migrations/migrations_windows.go b/windows/migrations/migrations_windows.go new file mode 100644 index 0000000..47b652f --- /dev/null +++ b/windows/migrations/migrations_windows.go @@ -0,0 +1,71 @@ +//go:build windows + +package migrations + +import ( + "fmt" + "sort" + "syscall" + "unsafe" + + winprocess "ferrum/windows/process" +) + +const ( + processDEPPolicy = 0 + processASLRPolicy = 1 + processStrictHandle = 2 + processControlFlowGuard = 7 +) + +type mitigationFlags struct { + Flags uint32 +} + +var procGetProcessMitigationPolicy = kernel32.NewProc("GetProcessMitigationPolicy") + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procOpenProcess = kernel32.NewProc("OpenProcess") + procCloseHandle = kernel32.NewProc("CloseHandle") +) + +type ProcessMitigation = winprocess.ProcessMitigation + +const processQueryLimitedInformation = 0x1000 + +func EnumerateProcessMitigations() ([]ProcessMitigation, error) { + processes, err := winprocess.EnumerateProcesses() + if err != nil { + return nil, err + } + results := make([]ProcessMitigation, 0, len(processes)) + for _, process := range processes { + handle, _, err := procOpenProcess.Call(processQueryLimitedInformation, 0, uintptr(process.PID)) + if handle == 0 { + results = append(results, ProcessMitigation{Process: process, DEP: fmt.Sprintf("open: %v", err)}) + continue + } + item := ProcessMitigation{Process: process} + item.DEP = mitigationValue(handle, processDEPPolicy) + item.ASLR = mitigationValue(handle, processASLRPolicy) + item.Strict = mitigationValue(handle, processStrictHandle) + item.CFG = mitigationValue(handle, processControlFlowGuard) + procCloseHandle.Call(handle) + results = append(results, item) + } + sort.Slice(results, func(i, j int) bool { return results[i].Name < results[j].Name }) + return results, nil +} + +func mitigationValue(handle uintptr, policy uintptr) string { + var flags mitigationFlags + ret, _, err := procGetProcessMitigationPolicy.Call(handle, policy, uintptr(unsafe.Pointer(&flags)), unsafe.Sizeof(flags)) + if ret == 0 { + return fmt.Sprintf("error:%v", err) + } + if flags.Flags == 0 { + return "off" + } + return fmt.Sprintf("0x%08x", flags.Flags) +} diff --git a/windows/pipes/pipes_windows.go b/windows/pipes/pipes_windows.go new file mode 100644 index 0000000..1780466 --- /dev/null +++ b/windows/pipes/pipes_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package pipes + +import ( + "os" + "sort" + "strings" + + wintypes "ferrum/windows/types" +) + +type PipeInfo = wintypes.PipeInfo + +func EnumerateNamedPipes() ([]PipeInfo, error) { + entries, err := os.ReadDir(`\\.\pipe\`) + if err != nil { + return nil, err + } + pipes := make([]PipeInfo, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if strings.TrimSpace(name) == "" { + continue + } + pipes = append(pipes, PipeInfo{Name: name}) + } + sort.Slice(pipes, func(i, j int) bool { return strings.ToLower(pipes[i].Name) < strings.ToLower(pipes[j].Name) }) + return pipes, nil +} diff --git a/windows/process/process_other.go b/windows/process/process_other.go new file mode 100644 index 0000000..5053e22 --- /dev/null +++ b/windows/process/process_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package process + +import ( + "runtime" + + winerrors "ferrum/windows/errors" +) + +func EnumerateProcesses() ([]Process, error) { + return nil, winerrors.ErrUnsupported(runtime.GOOS) +} diff --git a/windows/process/process_windows.go b/windows/process/process_windows.go new file mode 100644 index 0000000..4e2188a --- /dev/null +++ b/windows/process/process_windows.go @@ -0,0 +1,67 @@ +//go:build windows + +package process + +import ( + "fmt" + "syscall" + "unsafe" +) + +const ( + th32csSnapProcess = 0x00000002 + maxPath = 260 +) + +type processEntry32 struct { + Size uint32 + Usage uint32 + ProcessID uint32 + DefaultHeapID uintptr + ModuleID uint32 + Threads uint32 + ParentProcessID uint32 + PriClassBase int32 + Flags uint32 + ExeFile [maxPath]uint16 +} + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procCreateToolhelp32Snap = kernel32.NewProc("CreateToolhelp32Snapshot") + procProcess32FirstW = kernel32.NewProc("Process32FirstW") + procProcess32NextW = kernel32.NewProc("Process32NextW") + procCloseHandle = kernel32.NewProc("CloseHandle") +) + +func EnumerateProcesses() ([]Process, error) { + snapshot, _, err := procCreateToolhelp32Snap.Call(th32csSnapProcess, 0) + if snapshot == uintptr(syscall.InvalidHandle) { + return nil, fmt.Errorf("CreateToolhelp32Snapshot: %w", err) + } + defer procCloseHandle.Call(snapshot) + + var entry processEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + ret, _, err := procProcess32FirstW.Call(snapshot, uintptr(unsafe.Pointer(&entry))) + if ret == 0 { + return nil, fmt.Errorf("Process32FirstW: %w", err) + } + + processes := make([]Process, 0, 256) + for { + processes = append(processes, Process{ + PID: entry.ProcessID, + ParentPID: entry.ParentProcessID, + Name: syscall.UTF16ToString(entry.ExeFile[:]), + }) + ret, _, err = procProcess32NextW.Call(snapshot, uintptr(unsafe.Pointer(&entry))) + if ret == 0 { + if errno, ok := err.(syscall.Errno); ok && errno == syscall.ERROR_NO_MORE_FILES { + break + } + break + } + } + return processes, nil +} diff --git a/windows/process/types_process.go b/windows/process/types_process.go new file mode 100644 index 0000000..f48aec0 --- /dev/null +++ b/windows/process/types_process.go @@ -0,0 +1,31 @@ +package process + +type Process struct { + PID uint32 + ParentPID uint32 + Name string + Exe string + User string + Integrity string + Elevated bool + Privileges []string +} + +func (p Process) Label() string { + user := p.User + if user == "" { + user = "Token" + } + if p.Integrity != "" { + return user + " / " + p.Integrity + } + return user +} + +type ProcessMitigation struct { + Process + DEP string + ASLR string + Strict string + CFG string +} diff --git a/windows/registry/registry_windows.go b/windows/registry/registry_windows.go new file mode 100644 index 0000000..ba46ead --- /dev/null +++ b/windows/registry/registry_windows.go @@ -0,0 +1,228 @@ +//go:build windows + +package registry + +import ( + "fmt" + "syscall" + "unsafe" + + wintypes "ferrum/windows/types" +) + +const ( + hkeyCurrentUser = 0x80000001 + hkeyLocalMachine = 0x80000002 + keyRead = 0x20019 + errorNoMoreItems = 259 +) + +const HkeyCurrentUser = hkeyCurrentUser +const HkeyLocalMachine = hkeyLocalMachine + +var ( + advapi32 = syscall.NewLazyDLL("advapi32.dll") + procRegOpenKeyEx = advapi32.NewProc("RegOpenKeyExW") + procRegCloseKey = advapi32.NewProc("RegCloseKey") + procRegEnumKeyEx = advapi32.NewProc("RegEnumKeyExW") + procRegEnumValue = advapi32.NewProc("RegEnumValueW") + procRegQueryValueEx = advapi32.NewProc("RegQueryValueExW") +) + +type RegistryValue struct { + Name string + Type uint32 + Value string +} + +func EnumerateHKCUCLSID() ([]CLSIDEntry, error) { + root, err := openKey(hkeyCurrentUser, `Software\Classes\CLSID`) + if err != nil { + return nil, err + } + defer procRegCloseKey.Call(root) + + clsids, err := enumSubkeys(root) + if err != nil { + return nil, err + } + + entries := make([]CLSIDEntry, 0) + for _, clsid := range clsids { + for _, kind := range []string{"InprocServer32", "LocalServer32", "TreatAs", "ProgID"} { + subkey, err := openKey(root, clsid+`\`+kind) + if err != nil { + continue + } + value, _ := queryDefaultValue(subkey) + procRegCloseKey.Call(subkey) + entries = append(entries, CLSIDEntry{CLSID: clsid, Kind: kind, Value: value}) + } + } + return entries, nil +} + +func EnumerateCLSIDProcMonCandidates() ([]CLSIDProcMonCandidate, error) { + root, err := openKey(hkeyLocalMachine, `Software\Classes\CLSID`) + if err != nil { + return nil, err + } + defer procRegCloseKey.Call(root) + + clsids, err := enumSubkeys(root) + if err != nil { + return nil, err + } + + candidates := make([]CLSIDProcMonCandidate, 0) + for _, clsid := range clsids { + for _, kind := range []string{"InprocServer32", "LocalServer32"} { + machineKey, err := openKey(root, clsid+`\`+kind) + if err != nil { + continue + } + value, _ := queryDefaultValue(machineKey) + procRegCloseKey.Call(machineKey) + + userPath := `Software\Classes\CLSID\` + clsid + `\` + kind + if keyExists(hkeyCurrentUser, userPath) { + continue + } + + candidates = append(candidates, CLSIDProcMonCandidate{ + CLSID: clsid, + Kind: kind, + Path: `HKCU\` + userPath, + Result: "NAME NOT FOUND", + MachineValue: value, + }) + } + } + return candidates, nil +} + +func openKey(parent uintptr, path string) (uintptr, error) { + pathPtr, err := syscall.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + var handle uintptr + ret, _, callErr := procRegOpenKeyEx.Call(parent, uintptr(unsafe.Pointer(pathPtr)), 0, keyRead, uintptr(unsafe.Pointer(&handle))) + if ret != 0 { + return 0, syscall.Errno(ret) + } + _ = callErr + return handle, nil +} + +func OpenKey(parent uintptr, path string) (uintptr, error) { + return openKey(parent, path) +} + +func CloseKey(key uintptr) { + procRegCloseKey.Call(key) +} + +func enumSubkeys(key uintptr) ([]string, error) { + keys := []string{} + for index := uint32(0); ; index++ { + name := make([]uint16, 256) + length := uint32(len(name)) + ret, _, _ := procRegEnumKeyEx.Call(key, uintptr(index), uintptr(unsafe.Pointer(&name[0])), uintptr(unsafe.Pointer(&length)), 0, 0, 0, 0) + if ret == errorNoMoreItems { + break + } + if ret != 0 { + return keys, fmt.Errorf("RegEnumKeyEx: %w", syscall.Errno(ret)) + } + keys = append(keys, syscall.UTF16ToString(name[:length])) + } + return keys, nil +} + +func EnumSubkeys(key uintptr) ([]string, error) { + return enumSubkeys(key) +} + +func queryDefaultValue(key uintptr) (string, error) { + var typ uint32 + var needed uint32 + ret, _, _ := procRegQueryValueEx.Call(key, 0, 0, uintptr(unsafe.Pointer(&typ)), 0, uintptr(unsafe.Pointer(&needed))) + if ret != 0 || needed == 0 { + return "", syscall.Errno(ret) + } + buffer := make([]uint16, needed/2+1) + ret, _, _ = procRegQueryValueEx.Call(key, 0, 0, uintptr(unsafe.Pointer(&typ)), uintptr(unsafe.Pointer(&buffer[0])), uintptr(unsafe.Pointer(&needed))) + if ret != 0 { + return "", syscall.Errno(ret) + } + return wintypes.CleanRegistryString(syscall.UTF16ToString(buffer)), nil +} + +func QueryDefaultValue(key uintptr) (string, error) { + return queryDefaultValue(key) +} + +func keyExists(parent uintptr, path string) bool { + key, err := openKey(parent, path) + if err != nil { + return false + } + procRegCloseKey.Call(key) + return true +} + +func KeyExists(parent uintptr, path string) bool { + return keyExists(parent, path) +} + +func registryValues(parent uintptr, path string) ([]RegistryValue, error) { + key, err := openKey(parent, path) + if err != nil { + return nil, err + } + defer procRegCloseKey.Call(key) + + values := []RegistryValue{} + for index := uint32(0); ; index++ { + name := make([]uint16, 512) + nameLen := uint32(len(name)) + data := make([]byte, 8192) + dataLen := uint32(len(data)) + var typ uint32 + ret, _, _ := procRegEnumValue.Call(key, uintptr(index), uintptr(unsafe.Pointer(&name[0])), uintptr(unsafe.Pointer(&nameLen)), 0, uintptr(unsafe.Pointer(&typ)), uintptr(unsafe.Pointer(&data[0])), uintptr(unsafe.Pointer(&dataLen))) + if ret == errorNoMoreItems { + break + } + if ret != 0 { + continue + } + values = append(values, RegistryValue{ + Name: syscall.UTF16ToString(name[:nameLen]), + Type: typ, + Value: registryDataString(data[:dataLen], typ), + }) + } + return values, nil +} + +func Values(parent uintptr, path string) ([]RegistryValue, error) { + return registryValues(parent, path) +} + +func registryDataString(data []byte, typ uint32) string { + if len(data) == 0 { + return "" + } + if typ == 1 || typ == 2 { + if len(data)%2 != 0 { + data = data[:len(data)-1] + } + chars := make([]uint16, len(data)/2) + for i := range chars { + chars[i] = uint16(data[i*2]) | uint16(data[i*2+1])<<8 + } + return wintypes.CleanRegistryString(syscall.UTF16ToString(chars)) + } + return fmt.Sprintf("%x", data) +} diff --git a/windows/registry/types_registry.go b/windows/registry/types_registry.go new file mode 100644 index 0000000..30b8759 --- /dev/null +++ b/windows/registry/types_registry.go @@ -0,0 +1,24 @@ +package registry + +type CLSIDEntry struct { + CLSID string + Kind string + Value string +} + +type CLSIDProcMonCandidate struct { + CLSID string + Kind string + Path string + Result string + MachineValue string +} + +type RegistryAuditFinding struct { + Scope string + Path string + Name string + Value string + Severity string + Reason string +} diff --git a/windows/scheduled/scheduled_windows.go b/windows/scheduled/scheduled_windows.go new file mode 100644 index 0000000..b8c9bf8 --- /dev/null +++ b/windows/scheduled/scheduled_windows.go @@ -0,0 +1,72 @@ +//go:build windows + +package scheduled + +import ( + "encoding/xml" + "os" + "path/filepath" + "sort" + "strings" + + wintypes "ferrum/windows/types" +) + +type ScheduledTask = wintypes.ScheduledTask + +type taskXML struct { + RegistrationInfo struct { + Author string `xml:"Author"` + } `xml:"RegistrationInfo"` + Settings struct { + Enabled string `xml:"Enabled"` + } `xml:"Settings"` + Actions struct { + Exec []struct { + Command string `xml:"Command"` + Arguments string `xml:"Arguments"` + } `xml:"Exec"` + } `xml:"Actions"` +} + +func EnumerateScheduledTasks() ([]ScheduledTask, error) { + root := filepath.Join(os.Getenv("WINDIR"), "System32", "Tasks") + if os.Getenv("WINDIR") == "" { + root = `C:\Windows\System32\Tasks` + } + + tasks := []ScheduledTask{} + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var parsed taskXML + if err := xml.Unmarshal(data, &parsed); err != nil { + return nil + } + commands := []string{} + for _, exec := range parsed.Actions.Exec { + command := strings.TrimSpace(exec.Command + " " + exec.Arguments) + if command != "" { + commands = append(commands, command) + } + } + rel, _ := filepath.Rel(root, path) + tasks = append(tasks, ScheduledTask{ + Path: `\` + strings.ReplaceAll(rel, string(filepath.Separator), `\`), + Command: strings.Join(commands, " | "), + Author: parsed.RegistrationInfo.Author, + Enabled: parsed.Settings.Enabled, + }) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(tasks, func(i, j int) bool { return strings.ToLower(tasks[i].Path) < strings.ToLower(tasks[j].Path) }) + return tasks, nil +} diff --git a/windows/services/services_windows.go b/windows/services/services_windows.go new file mode 100644 index 0000000..c03acb3 --- /dev/null +++ b/windows/services/services_windows.go @@ -0,0 +1,219 @@ +//go:build windows + +package services + +import ( + "fmt" + "sort" + "syscall" + "unsafe" + + wintypes "ferrum/windows/types" +) + +const ( + scManagerEnumerateService = 0x0004 + serviceQueryConfig = 0x0001 + serviceWin32 = 0x00000030 + serviceDriver = 0x0000000b + serviceStateAll = 0x00000003 + scEnumProcessInfo = 0 + + serviceBootStart = 0 + serviceSystemStart = 1 + serviceAutoStart = 2 + serviceDemandStart = 3 + serviceDisabled = 4 +) + +type serviceStatusProcess struct { + ServiceType uint32 + CurrentState uint32 + ControlsAccepted uint32 + Win32ExitCode uint32 + ServiceSpecificExitCode uint32 + CheckPoint uint32 + WaitHint uint32 + ProcessID uint32 + ServiceFlags uint32 +} + +type enumServiceStatusProcess struct { + ServiceName *uint16 + DisplayName *uint16 + Status serviceStatusProcess +} + +type queryServiceConfig struct { + ServiceType uint32 + StartType uint32 + ErrorControl uint32 + BinaryPathName *uint16 + LoadOrderGroup *uint16 + TagID uint32 + Dependencies *uint16 + ServiceStartName *uint16 + DisplayName *uint16 +} + +type ServiceInfo = wintypes.ServiceInfo +type DriverInfo = wintypes.DriverInfo + +var ( + advapi32 = syscall.NewLazyDLL("advapi32.dll") + procOpenSCManager = advapi32.NewProc("OpenSCManagerW") + procEnumServicesStatusEx = advapi32.NewProc("EnumServicesStatusExW") + procOpenService = advapi32.NewProc("OpenServiceW") + procQueryServiceConfig = advapi32.NewProc("QueryServiceConfigW") + procCloseServiceHandle = advapi32.NewProc("CloseServiceHandle") +) + +func EnumerateServices() ([]ServiceInfo, error) { + services, err := enumerateSCM(serviceWin32) + if err != nil { + return nil, err + } + sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name }) + return services, nil +} + +func EnumerateDrivers() ([]DriverInfo, error) { + services, err := enumerateSCM(serviceDriver) + if err != nil { + return nil, err + } + drivers := make([]DriverInfo, 0, len(services)) + for _, service := range services { + drivers = append(drivers, DriverInfo{ + Name: service.Name, + State: service.State, + StartType: service.StartType, + BinaryPath: service.BinaryPath, + }) + } + sort.Slice(drivers, func(i, j int) bool { return drivers[i].Name < drivers[j].Name }) + return drivers, nil +} + +func enumerateSCM(serviceType uint32) ([]ServiceInfo, error) { + manager, _, err := procOpenSCManager.Call(0, 0, scManagerEnumerateService) + if manager == 0 { + return nil, fmt.Errorf("OpenSCManager: %w", err) + } + defer procCloseServiceHandle.Call(manager) + + var needed uint32 + var count uint32 + var resume uint32 + procEnumServicesStatusEx.Call(manager, scEnumProcessInfo, uintptr(serviceType), serviceStateAll, 0, 0, uintptr(unsafe.Pointer(&needed)), uintptr(unsafe.Pointer(&count)), uintptr(unsafe.Pointer(&resume)), 0) + if needed == 0 { + return nil, nil + } + + buffer := make([]byte, needed) + ret, _, err := procEnumServicesStatusEx.Call(manager, scEnumProcessInfo, uintptr(serviceType), serviceStateAll, uintptr(unsafe.Pointer(&buffer[0])), uintptr(needed), uintptr(unsafe.Pointer(&needed)), uintptr(unsafe.Pointer(&count)), uintptr(unsafe.Pointer(&resume)), 0) + if ret == 0 { + return nil, fmt.Errorf("EnumServicesStatusEx: %w", err) + } + + items := make([]ServiceInfo, 0, count) + base := uintptr(unsafe.Pointer(&buffer[0])) + itemSize := unsafe.Sizeof(enumServiceStatusProcess{}) + for i := uint32(0); i < count; i++ { + entry := (*enumServiceStatusProcess)(unsafe.Pointer(base + uintptr(i)*itemSize)) + info := ServiceInfo{ + Name: utf16PtrToString(entry.ServiceName), + DisplayName: utf16PtrToString(entry.DisplayName), + State: serviceStateName(entry.Status.CurrentState), + ProcessID: entry.Status.ProcessID, + ServiceType: entry.Status.ServiceType, + } + if cfg, err := queryService(manager, info.Name); err == nil { + info.StartType = serviceStartName(cfg.StartType) + info.Account = utf16PtrToString(cfg.ServiceStartName) + info.BinaryPath = utf16PtrToString(cfg.BinaryPathName) + if info.DisplayName == "" { + info.DisplayName = utf16PtrToString(cfg.DisplayName) + } + } + items = append(items, info) + } + return items, nil +} + +func queryService(manager uintptr, name string) (queryServiceConfig, error) { + namePtr, err := syscall.UTF16PtrFromString(name) + if err != nil { + return queryServiceConfig{}, err + } + service, _, err := procOpenService.Call(manager, uintptr(unsafe.Pointer(namePtr)), serviceQueryConfig) + if service == 0 { + return queryServiceConfig{}, err + } + defer procCloseServiceHandle.Call(service) + + var needed uint32 + procQueryServiceConfig.Call(service, 0, 0, uintptr(unsafe.Pointer(&needed))) + if needed == 0 { + return queryServiceConfig{}, fmt.Errorf("QueryServiceConfig: no buffer size") + } + buffer := make([]byte, needed) + ret, _, err := procQueryServiceConfig.Call(service, uintptr(unsafe.Pointer(&buffer[0])), uintptr(needed), uintptr(unsafe.Pointer(&needed))) + if ret == 0 { + return queryServiceConfig{}, err + } + return *(*queryServiceConfig)(unsafe.Pointer(&buffer[0])), nil +} + +func serviceStateName(state uint32) string { + switch state { + case 1: + return "Stopped" + case 2: + return "StartPending" + case 3: + return "StopPending" + case 4: + return "Running" + case 5: + return "ContinuePending" + case 6: + return "PausePending" + case 7: + return "Paused" + default: + return fmt.Sprintf("State:%d", state) + } +} + +func serviceStartName(start uint32) string { + switch start { + case serviceBootStart: + return "Boot" + case serviceSystemStart: + return "System" + case serviceAutoStart: + return "Auto" + case serviceDemandStart: + return "Manual" + case serviceDisabled: + return "Disabled" + default: + return fmt.Sprintf("Start:%d", start) + } +} + +func utf16PtrToString(ptr *uint16) string { + if ptr == nil { + return "" + } + var values []uint16 + for p := uintptr(unsafe.Pointer(ptr)); ; p += unsafe.Sizeof(*ptr) { + value := *(*uint16)(unsafe.Pointer(p)) + if value == 0 { + break + } + values = append(values, value) + } + return syscall.UTF16ToString(values) +} diff --git a/windows/startup/startup_windows.go b/windows/startup/startup_windows.go new file mode 100644 index 0000000..53ecb63 --- /dev/null +++ b/windows/startup/startup_windows.go @@ -0,0 +1,80 @@ +//go:build windows + +package startup + +import ( + "os" + "path/filepath" + "sort" + + "ferrum/windows/registry" + wintypes "ferrum/windows/types" +) + +type StartupEntry = wintypes.StartupEntry + +func EnumerateStartupEntries() ([]StartupEntry, error) { + entries := []StartupEntry{} + registryLocations := []struct { + scope uintptr + name string + path string + }{ + {registry.HkeyCurrentUser, "User", `Software\Microsoft\Windows\CurrentVersion\Run`}, + {registry.HkeyCurrentUser, "User", `Software\Microsoft\Windows\CurrentVersion\RunOnce`}, + {registry.HkeyLocalMachine, "Machine", `Software\Microsoft\Windows\CurrentVersion\Run`}, + {registry.HkeyLocalMachine, "Machine", `Software\Microsoft\Windows\CurrentVersion\RunOnce`}, + {registry.HkeyLocalMachine, "Machine32", `Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run`}, + } + + for _, location := range registryLocations { + values, err := registry.Values(location.scope, location.path) + if err != nil { + continue + } + for _, value := range values { + entries = append(entries, StartupEntry{ + Scope: location.name, + Location: location.path, + Name: value.Name, + Command: value.Value, + }) + } + } + + startupFolders := []struct { + scope string + path string + }{ + {"User", filepath.Join(os.Getenv("APPDATA"), `Microsoft\Windows\Start Menu\Programs\Startup`)}, + {"Machine", filepath.Join(os.Getenv("ProgramData"), `Microsoft\Windows\Start Menu\Programs\Startup`)}, + } + for _, folder := range startupFolders { + if folder.path == "" { + continue + } + files, err := os.ReadDir(folder.path) + if err != nil { + continue + } + for _, file := range files { + if file.IsDir() { + continue + } + entries = append(entries, StartupEntry{ + Scope: folder.scope, + Location: folder.path, + Name: file.Name(), + Command: filepath.Join(folder.path, file.Name()), + }) + } + } + + sort.Slice(entries, func(i, j int) bool { + if entries[i].Scope != entries[j].Scope { + return entries[i].Scope < entries[j].Scope + } + return entries[i].Name < entries[j].Name + }) + return entries, nil +} diff --git a/windows/token/token_windows.go b/windows/token/token_windows.go new file mode 100644 index 0000000..4bb2405 --- /dev/null +++ b/windows/token/token_windows.go @@ -0,0 +1,268 @@ +//go:build windows + +package token + +import ( + "fmt" + "sort" + "syscall" + "unsafe" + + wintypes "ferrum/windows/types" +) + +const ( + processQueryLimitedInformation = 0x1000 + tokenQuery = 0x0008 + + tokenUser = 1 + tokenPrivilegesClass = 3 + tokenElevation = 20 + tokenIntegrity = 25 + + securityMandatoryUntrustedRID = 0x00000000 + securityMandatoryLowRID = 0x00001000 + securityMandatoryMediumRID = 0x00002000 + securityMandatoryHighRID = 0x00003000 + securityMandatorySystemRID = 0x00004000 + + sePrivilegeEnabled = 0x00000002 +) + +type sidAndAttributes struct { + Sid uintptr + Attributes uint32 +} + +type tokenUserStruct struct { + User sidAndAttributes +} + +type tokenMandatoryLabel struct { + Label sidAndAttributes +} + +type luid struct { + LowPart uint32 + HighPart int32 +} + +type luidAndAttributes struct { + Luid luid + Attributes uint32 +} + +type tokenPrivilegesHeader struct { + PrivilegeCount uint32 + Privileges [1]luidAndAttributes +} + +type tokenElevationStruct struct { + TokenIsElevated uint32 +} + +type TokenInfo = wintypes.TokenInfo + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + advapi32 = syscall.NewLazyDLL("advapi32.dll") + procOpenProcess = kernel32.NewProc("OpenProcess") + procOpenProcessToken = advapi32.NewProc("OpenProcessToken") + procGetTokenInformation = advapi32.NewProc("GetTokenInformation") + procConvertSidToStringSid = advapi32.NewProc("ConvertSidToStringSidW") + procLookupAccountSid = advapi32.NewProc("LookupAccountSidW") + procLookupPrivilegeName = advapi32.NewProc("LookupPrivilegeNameW") + procLocalFree = kernel32.NewProc("LocalFree") + procGetLengthSid = advapi32.NewProc("GetLengthSid") + procGetSidSubAuthority = advapi32.NewProc("GetSidSubAuthority") + procGetSidSubAuthorityCnt = advapi32.NewProc("GetSidSubAuthorityCount") + procCloseHandle = kernel32.NewProc("CloseHandle") +) + +func InspectProcessToken(pid uint32) (TokenInfo, error) { + process, _, err := procOpenProcess.Call(processQueryLimitedInformation, 0, uintptr(pid)) + if process == 0 { + return TokenInfo{}, fmt.Errorf("open process: %w", err) + } + defer procCloseHandle.Call(process) + + var token uintptr + ret, _, err := procOpenProcessToken.Call(process, tokenQuery, uintptr(unsafe.Pointer(&token))) + if ret == 0 { + return TokenInfo{}, fmt.Errorf("open token: %w", err) + } + defer procCloseHandle.Call(token) + + info := TokenInfo{} + if user, err := tokenUserName(token); err == nil { + info.User = user + } + if elevated, err := tokenElevated(token); err == nil { + info.Elevated = elevated + } + if integrity, err := tokenIntegrityLevel(token); err == nil { + info.Integrity = integrity + } + if privileges, err := tokenPrivileges(token); err == nil { + info.Privileges = privileges + } + return info, nil +} + +func tokenUserName(token uintptr) (string, error) { + buffer, err := tokenInfoBuffer(token, tokenUser) + if err != nil { + return "", err + } + user := (*tokenUserStruct)(unsafe.Pointer(&buffer[0])) + if name := lookupAccount(user.User.Sid); name != "" { + return name, nil + } + return sidString(user.User.Sid) +} + +func tokenElevated(token uintptr) (bool, error) { + buffer, err := tokenInfoBuffer(token, tokenElevation) + if err != nil { + return false, err + } + elevation := (*tokenElevationStruct)(unsafe.Pointer(&buffer[0])) + return elevation.TokenIsElevated != 0, nil +} + +func tokenIntegrityLevel(token uintptr) (string, error) { + buffer, err := tokenInfoBuffer(token, tokenIntegrity) + if err != nil { + return "", err + } + label := (*tokenMandatoryLabel)(unsafe.Pointer(&buffer[0])) + rid := sidLastSubAuthority(label.Label.Sid) + switch { + case rid >= securityMandatorySystemRID: + return "System", nil + case rid >= securityMandatoryHighRID: + return "High", nil + case rid >= securityMandatoryMediumRID: + return "Medium", nil + case rid >= securityMandatoryLowRID: + return "Low", nil + case rid >= securityMandatoryUntrustedRID: + return "Untrusted", nil + default: + return "Unknown", nil + } +} + +func tokenPrivileges(token uintptr) ([]string, error) { + buffer, err := tokenInfoBuffer(token, tokenPrivilegesClass) + if err != nil { + return nil, err + } + header := (*tokenPrivilegesHeader)(unsafe.Pointer(&buffer[0])) + count := int(header.PrivilegeCount) + base := uintptr(unsafe.Pointer(&header.Privileges[0])) + size := unsafe.Sizeof(luidAndAttributes{}) + names := make([]string, 0, count) + for i := 0; i < count; i++ { + item := (*luidAndAttributes)(unsafe.Pointer(base + uintptr(i)*size)) + if item.Attributes&sePrivilegeEnabled == 0 { + continue + } + if name := lookupPrivilege(item.Luid); name != "" { + names = append(names, name) + } + } + sort.Strings(names) + return names, nil +} + +func tokenInfoBuffer(token uintptr, class uint32) ([]byte, error) { + var needed uint32 + procGetTokenInformation.Call(token, uintptr(class), 0, 0, uintptr(unsafe.Pointer(&needed))) + if needed == 0 { + return nil, fmt.Errorf("GetTokenInformation(%d): no buffer size", class) + } + buffer := make([]byte, needed) + ret, _, err := procGetTokenInformation.Call(token, uintptr(class), uintptr(unsafe.Pointer(&buffer[0])), uintptr(needed), uintptr(unsafe.Pointer(&needed))) + if ret == 0 { + return nil, fmt.Errorf("GetTokenInformation(%d): %w", class, err) + } + return buffer, nil +} + +func lookupAccount(sid uintptr) string { + var nameLen uint32 + var domainLen uint32 + var sidType uint32 + procLookupAccountSid.Call(0, sid, 0, uintptr(unsafe.Pointer(&nameLen)), 0, uintptr(unsafe.Pointer(&domainLen)), uintptr(unsafe.Pointer(&sidType))) + if nameLen == 0 { + return "" + } + name := make([]uint16, nameLen) + domain := make([]uint16, domainLen) + ret, _, _ := procLookupAccountSid.Call(0, sid, uintptr(unsafe.Pointer(&name[0])), uintptr(unsafe.Pointer(&nameLen)), uintptr(unsafe.Pointer(&domain[0])), uintptr(unsafe.Pointer(&domainLen)), uintptr(unsafe.Pointer(&sidType))) + if ret == 0 { + return "" + } + n := syscall.UTF16ToString(name) + d := syscall.UTF16ToString(domain) + if d != "" { + return d + `\` + n + } + return n +} + +func sidString(sid uintptr) (string, error) { + var out uintptr + ret, _, err := procConvertSidToStringSid.Call(sid, uintptr(unsafe.Pointer(&out))) + if ret == 0 { + return "", err + } + defer procLocalFree.Call(out) + return utf16PtrToString((*uint16)(unsafe.Pointer(out))), nil +} + +func lookupPrivilege(id luid) string { + var nameLen uint32 + procLookupPrivilegeName.Call(0, uintptr(unsafe.Pointer(&id)), 0, uintptr(unsafe.Pointer(&nameLen))) + if nameLen == 0 { + return "" + } + name := make([]uint16, nameLen+1) + ret, _, _ := procLookupPrivilegeName.Call(0, uintptr(unsafe.Pointer(&id)), uintptr(unsafe.Pointer(&name[0])), uintptr(unsafe.Pointer(&nameLen))) + if ret == 0 { + return "" + } + return syscall.UTF16ToString(name) +} + +func sidLastSubAuthority(sid uintptr) uint32 { + countPtr, _, _ := procGetSidSubAuthorityCnt.Call(sid) + if countPtr == 0 { + return 0 + } + count := *(*byte)(unsafe.Pointer(countPtr)) + if count == 0 { + return 0 + } + ridPtr, _, _ := procGetSidSubAuthority.Call(sid, uintptr(count-1)) + if ridPtr == 0 { + return 0 + } + return *(*uint32)(unsafe.Pointer(ridPtr)) +} + +func utf16PtrToString(ptr *uint16) string { + if ptr == nil { + return "" + } + var values []uint16 + for p := uintptr(unsafe.Pointer(ptr)); ; p += unsafe.Sizeof(*ptr) { + value := *(*uint16)(unsafe.Pointer(p)) + if value == 0 { + break + } + values = append(values, value) + } + return syscall.UTF16ToString(values) +} diff --git a/windows/types/types.go b/windows/types/types.go new file mode 100644 index 0000000..3b0fe90 --- /dev/null +++ b/windows/types/types.go @@ -0,0 +1,7 @@ +package types + +import "strings" + +func CleanRegistryString(value string) string { + return strings.TrimRight(value, "\x00") +} diff --git a/windows/types/types_audit.go b/windows/types/types_audit.go new file mode 100644 index 0000000..24a651e --- /dev/null +++ b/windows/types/types_audit.go @@ -0,0 +1,17 @@ +package types + +type DLLSearchPathFinding struct { + Path string + Source string + Severity string + Reason string +} + +type AdvancedFinding struct { + Area string + Target string + Name string + Value string + Severity string + Reason string +} diff --git a/windows/types/types_inventory.go b/windows/types/types_inventory.go new file mode 100644 index 0000000..11e4cb1 --- /dev/null +++ b/windows/types/types_inventory.go @@ -0,0 +1,42 @@ +package types + +type ServiceInfo struct { + Name string + DisplayName string + State string + StartType string + Account string + BinaryPath string + ProcessID uint32 + ServiceType uint32 +} + +type DriverInfo struct { + Name string + State string + StartType string + BinaryPath string +} + +type PipeInfo struct { + Name string +} + +type StartupEntry struct { + Scope string + Location string + Name string + Command string +} + +type ScheduledTask struct { + Path string + Command string + Author string + Enabled string +} + +type EnvVar struct { + Name string + Value string +} diff --git a/windows/types/types_security.go b/windows/types/types_security.go new file mode 100644 index 0000000..b5acfc5 --- /dev/null +++ b/windows/types/types_security.go @@ -0,0 +1,15 @@ +package types + +type TokenInfo struct { + User string + Integrity string + Elevated bool + Privileges []string +} + +type PolicyFinding struct { + Name string + Value string + Severity string + Reason string +}