diff --git a/baseline/cmd_windows.go b/baseline/cmd_windows.go index 6d44196..3431b6b 100644 --- a/baseline/cmd_windows.go +++ b/baseline/cmd_windows.go @@ -7,8 +7,24 @@ import ( "github.com/spf13/cobra" ) +var baselineComponents = map[string]string{ + "services": "services.ps1", + "processes": "processes.ps1", + "autoruns": "autoruns.ps1", + "ad-users": "ad-users.ps1", + "local-users": "local-users.ps1", + "ad-objects": "ad-objects.ps1", + "ports": "ports.ps1", +} + +var dcScripts = []string{"services", "processes", "autoruns", "ad-users", "adobjects", "ports"} +var localScripts = []string{"services", "processes", "autoruns", "local-users", "ports"} + +var sysinternalsDirectory = `C:\\ProgramData\\landschaft\\sysinternals` + func SetupCommand(cmd *cobra.Command) { - setupServicesCmd(cmd) + setupCompareCmd(cmd) + setupCreateCmd(cmd) } func Run(cmd *cobra.Command) { diff --git a/baseline/compare_windows.go b/baseline/compare_windows.go new file mode 100644 index 0000000..dc637f4 --- /dev/null +++ b/baseline/compare_windows.go @@ -0,0 +1,261 @@ +package baseline + +import ( + "encoding/csv" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +func setupCompareCmd(cmd *cobra.Command) { + compareCmd := &cobra.Command{ + Use: "compare", + Short: "Compare baselines", + } + + compareAllCmd := &cobra.Command{ + Use: "all ", + Short: "Compare two baseline directories", + Long: "Compare two directories produced by 'baseline create all' and report added/removed/changed entries for each component.", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + compareCSVDirs(args[0], args[1]) + }, + } + compareCmd.AddCommand(compareAllCmd) + + for name := range baselineComponents { + cmdCmp := &cobra.Command{ + Use: name + " ", + Short: fmt.Sprintf("Compare %s baselines", name), + Long: fmt.Sprintf("Compare the %s.csv files in two baseline directories and report added/removed/changed entries.", name), + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + err := compareCSVFiles(fmt.Sprintf("%s.csv", name), args[0], args[1]) + if err != nil { + fmt.Printf("Error comparing %s: %v\n", name, err) + } + }, + } + + compareCmd.AddCommand(cmdCmp) + } + + cmd.AddCommand(compareCmd) +} + +func getAllCSVFiles(dir string) ([]string, error) { + dir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("could not get absolute path of %s: %v", dir, err) + } + files := []string{} + dirEntries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("could not read directory %s: %v", dir, err) + } + for _, entry := range dirEntries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".csv") { + files = append(files, entry.Name()) + } + } + return files, nil +} + +func compareCSVFiles(fileName, dirA, dirB string) error { + fmt.Println() + fmt.Println(strings.Repeat("=", 60)) + fmt.Println() + + keyCols := keyColumnsForFile(fileName) + pathA := filepath.Join(dirA, fileName) + pathB := filepath.Join(dirB, fileName) + mapA, err := loadCSVWithKey(pathA, keyCols) + if err != nil { + return fmt.Errorf("could not load %s: %v", pathA, err) + } + mapB, err := loadCSVWithKey(pathB, keyCols) + if err != nil { + return fmt.Errorf("could not load %s: %v", pathB, err) + } + + fmt.Printf("Comparing %s\n", fileName) + + addedKeys, removedKeys, changed := diffMaps(mapA, mapB) + + if len(addedKeys) > 0 { + sort.Strings(addedKeys) + fmt.Printf("Added in %s:\n", dirB) + for _, k := range addedKeys { + fmt.Printf("\t%s\n", formatObject(k, mapB[k])) + } + } + if len(removedKeys) > 0 { + sort.Strings(removedKeys) + fmt.Printf("Removed from %s:\n", dirB) + for _, k := range removedKeys { + fmt.Printf("\t%s\n", formatObject(k, mapA[k])) + } + } + if len(changed) > 0 { + fmt.Println("Changed entries:") + for _, c := range changed { + fmt.Printf("\t%s\n", c) + } + } + return nil +} + +func compareCSVDirs(dirA, dirB string) { + filesA, err := getAllCSVFiles(dirA) + if err != nil { + fmt.Printf("Error getting CSV files from directory %s: %v\n", dirA, err) + return + } + filesB, err := getAllCSVFiles(dirB) + if err != nil { + fmt.Printf("Error getting CSV files from directory %s: %v\n", dirB, err) + return + } + + sharedFiles := []string{} + for _, fA := range filesA { + if slices.Contains(filesB, fA) { + sharedFiles = append(sharedFiles, fA) + } + } + + for _, f := range sharedFiles { + err := compareCSVFiles(f, dirA, dirB) + if err != nil { + fmt.Printf("Error comparing file %s: %v\n", f, err) + } + } +} + +func keyColumnsForFile(file string) []string { + switch file { + case "ad-objects.csv": + return []string{"DistinguishedName"} + case "autoruns.csv": + return []string{"Location", "Name", "LaunchString"} + case "ports.csv": + return []string{"LocalAddress", "LocalPort"} + case "processes.csv": + return []string{"Name", "Path"} + case "services.csv": + return []string{"Name"} + case "ad-users.csv": + return []string{"SamAccountName"} + case "local-users.csv": + return []string{"Name"} + default: + return nil + } +} + +func loadCSVWithKey(path string, keyCols []string) (map[string]map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + r := csv.NewReader(f) + recs, err := r.ReadAll() + if err != nil { + return nil, err + } + if len(recs) < 1 { + return nil, fmt.Errorf("empty csv: %s", path) + } + headers := recs[0] + out := make(map[string]map[string]string) + + buildKey := func(row []string) string { + if len(keyCols) == 0 { + if len(row) > 0 { + return row[0] + } + return "" + } + vals := make([]string, 0, len(keyCols)) + for _, kc := range keyCols { + idx := -1 + for i, h := range headers { + if h == kc { + idx = i + break + } + } + if idx >= 0 && idx < len(row) { + vals = append(vals, row[idx]) + } else { + vals = append(vals, "") + } + } + return strings.Join(vals, " | ") + } + + for _, row := range recs[1:] { + if len(row) == 0 { + continue + } + m := make(map[string]string) + for i, cell := range row { + if i < len(headers) { + m[headers[i]] = cell + } else { + m[fmt.Sprintf("col_%d", i)] = cell + } + } + key := buildKey(row) + out[key] = m + } + return out, nil +} + +func formatObject(key string, obj map[string]string) string { + if obj == nil { + return key + } + lines := []string{key} + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + lines = append(lines, fmt.Sprintf("\t\t%s: %s", k, obj[k])) + } + return strings.Join(lines, "\n") +} + +func diffMaps(a, b map[string]map[string]string) (added []string, removed []string, changed []string) { + for k := range a { + if _, ok := b[k]; !ok { + removed = append(removed, k) + } + } + for k := range b { + if _, ok := a[k]; !ok { + added = append(added, k) + } + } + for k := range a { + if vb, ok := b[k]; ok { + va := a[k] + for hk, hv := range va { + if vb[hk] != hv { + changed = append(changed, fmt.Sprintf("%s: %s changed from '%s' to '%s'", k, hk, hv, vb[hk])) + } + } + } + } + return +} diff --git a/baseline/create_windows.go b/baseline/create_windows.go new file mode 100644 index 0000000..969fa58 --- /dev/null +++ b/baseline/create_windows.go @@ -0,0 +1,83 @@ +package baseline + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + + "github.com/UT-CTF/landschaft/misc" + "github.com/UT-CTF/landschaft/util" + "github.com/spf13/cobra" +) + +func setupCreateCmd(cmd *cobra.Command) { + createCmd := &cobra.Command{ + Use: "create", + Short: "Create baselines", + } + + createAllCmd := &cobra.Command{ + Use: "all ", + Short: "Create all baselines into a directory", + Long: "Create all baselines (services, processes, autoruns, users, adobjects, ports) and save CSV files into the provided output directory.", + Args: cobra.ExactArgs(1), + Example: " landschaft baseline create all C:\\baselines", + Run: func(cmd *cobra.Command, args []string) { + out := args[0] + out, _ = filepath.Abs(out) + _ = misc.EnsureSysinternals(sysinternalsDirectory) + componentList := localScripts + if checkIfDomainController() { + componentList = dcScripts + } + + for _, component := range componentList { + fmt.Printf("Creating baseline for %s...\n", component) + createSingleBaseline(component, out) + } + }, + } + createCmd.AddCommand(createAllCmd) + + for name := range baselineComponents { + cmdC := &cobra.Command{ + Use: name + " ", + Short: fmt.Sprintf("Create %s baseline", name), + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + out := args[0] + out, err := filepath.Abs(out) + if err != nil { + fmt.Printf("Error getting absolute path for output directory: %v\n", err) + return + } + createSingleBaseline(name, out) + }, + } + + createCmd.AddCommand(cmdC) + } + + cmd.AddCommand(createCmd) +} + +func checkIfDomainController() bool { + cmd := exec.Command("wmic", "computersystem", "get", "domainrole") + output, err := cmd.Output() + if err != nil { + fmt.Printf("Error checking domain role: %v\n", err) + return false + } + outStr := string(output) + return strings.Contains(outStr, "4") || strings.Contains(outStr, "5") +} + +func createSingleBaseline(name, baselineDir string) { + if name == "autoruns" { + _ = misc.EnsureSysinternals(sysinternalsDirectory) + util.RunAndRedirectScript(fmt.Sprintf("baseline/%s", baselineComponents[name]), "-BaselinePath", fmt.Sprintf("'%s'", baselineDir), "-SysinternalsPath", fmt.Sprintf("'%s'", sysinternalsDirectory)) + } else { + util.RunAndRedirectScript(fmt.Sprintf("baseline/%s", baselineComponents[name]), "-BaselinePath", fmt.Sprintf("'%s'", baselineDir)) + } +} diff --git a/baseline/services_windows.go b/baseline/services_windows.go deleted file mode 100644 index 63f1754..0000000 --- a/baseline/services_windows.go +++ /dev/null @@ -1,141 +0,0 @@ -package baseline - -import ( - "encoding/csv" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/UT-CTF/landschaft/util" - "github.com/spf13/cobra" -) - -type baselineConfig struct { - create bool - compare bool - output string - files []string -} - -var baselineCfg baselineConfig - -var servicesCmd = &cobra.Command{ - Use: "services", - Short: "Create or compare services baseline", - Run: func(cmd *cobra.Command, args []string) { - baselineServices(cmd, baselineCfg) - }, -} - -func baselineServices(cmd *cobra.Command, cfg baselineConfig) { - if cfg.create { - createBaseline(cfg.output) - } else if cfg.compare { - if len(cfg.files) != 2 { - fmt.Printf("Expected 2 files, got %d ([%s])\n", len(cfg.files), strings.Join(cfg.files, ", ")) - _ = cmd.Usage() - return - } - compareServices(cfg.files[0], cfg.files[1]) - } else { - fmt.Println("Invalid options") - } -} - -func setupServicesCmd(cmd *cobra.Command) { - servicesCmd.Flags().BoolVarP(&baselineCfg.create, "create", "c", false, "Create baseline") - servicesCmd.Flags().BoolVarP(&baselineCfg.compare, "compare", "m", false, "Compare baseline") - servicesCmd.Flags().StringVarP(&baselineCfg.output, "output", "o", "", "Output file (expected .csv file)") - servicesCmd.Flags().StringSliceVarP(&baselineCfg.files, "files", "f", []string{}, "Files to compare (expected 2 .csv files)") - servicesCmd.MarkFlagsMutuallyExclusive("create", "compare") - servicesCmd.MarkFlagsOneRequired("create", "compare") - servicesCmd.MarkFlagsRequiredTogether("create", "output") - servicesCmd.MarkFlagsRequiredTogether("compare", "files") - - cmd.AddCommand(servicesCmd) -} - -func createBaseline(csvPath string) { - csvPath, err := filepath.Abs(csvPath) - if err != nil { - fmt.Println("Could not get absolute path: ", err) - return - } - util.RunAndPrintScript("baseline/services.ps1", "-ExportPath", fmt.Sprintf("'%s'", csvPath)) -} - -func compareServices(csvPath1 string, csvPath2 string) { - services1, err := loadServices(csvPath1) - if err != nil { - fmt.Println(err) - return - } - - services2, err := loadServices(csvPath2) - if err != nil { - fmt.Println(err) - return - } - - newServices := make([]string, 0) - removedServices := make([]string, 0) - commonServices := make([]string, 0) - - for name := range services1 { - if _, ok := services2[name]; !ok { - removedServices = append(removedServices, name) - } else { - commonServices = append(commonServices, name) - } - } - - for name := range services2 { - if _, ok := services1[name]; !ok { - newServices = append(newServices, name) - } - } - - fmt.Printf("New services: \n\t%s\n", strings.Join(newServices, "\n\t")) - fmt.Println(strings.Repeat("-", 50)) - fmt.Printf("Removed services: \n\t%s\n", strings.Join(removedServices, "\n\t")) - fmt.Println(strings.Repeat("-", 50)) - fmt.Println("Changed services:") - for _, name := range commonServices { - service1 := services1[name] - service2 := services2[name] - - for key, value := range service1 { - if service2[key] != value { - fmt.Printf("\tService %s: %s changed from %s to %s\n", name, key, value, service2[key]) - } - } - } -} - -func loadServices(csvFile string) (map[string]map[string]string, error) { - file, err := os.Open(csvFile) - if err != nil { - return nil, fmt.Errorf("could not open %s: %w", csvFile, err) - } - defer file.Close() - - reader := csv.NewReader(file) - records, err := reader.ReadAll() - if err != nil { - return nil, fmt.Errorf("could not read %s: %w", csvFile, err) - } - - services := make(map[string]map[string]string) - - rowNames := records[0] - for _, row := range records[1:] { - service := make(map[string]string) - for i, cell := range row { - service[rowNames[i]] = cell - } - // fmt.Println(service) - services[service["Name"]] = service - } - return services, nil -} diff --git a/embed/windows/baseline/ad-objects.ps1 b/embed/windows/baseline/ad-objects.ps1 new file mode 100644 index 0000000..178dd8a --- /dev/null +++ b/embed/windows/baseline/ad-objects.ps1 @@ -0,0 +1,17 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +Import-Module ActiveDirectory +Get-ADObject -Filter * -Properties * | ForEach-Object { + [PSCustomObject]@{ + Name = $_.Name + DistinguishedName = $_.DistinguishedName + ObjectClass = $_.ObjectClass + } +} | Sort-Object Name | Export-Csv "$BaselinePath\ad-objects.csv" -NoTypeInformation diff --git a/embed/windows/baseline/ad-users.ps1 b/embed/windows/baseline/ad-users.ps1 new file mode 100644 index 0000000..ac7f7e8 --- /dev/null +++ b/embed/windows/baseline/ad-users.ps1 @@ -0,0 +1,19 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +Import-Module ActiveDirectory +Get-ADUser -Filter * -Properties * | ForEach-Object { + $groupNames = $_.MemberOf | ForEach-Object { (Get-ADGroup $_).Name } | Sort-Object + [PSCustomObject]@{ + Name = $_.Name + SamAccountName = $_.SamAccountName + Enabled = $_.Enabled + Groups = $groupNames -join ';' + } +} | Sort-Object Name | Export-Csv "$BaselinePath\ad-users.csv" -NoTypeInformation diff --git a/embed/windows/baseline/autoruns.ps1 b/embed/windows/baseline/autoruns.ps1 new file mode 100644 index 0000000..25a3868 --- /dev/null +++ b/embed/windows/baseline/autoruns.ps1 @@ -0,0 +1,27 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath, + [Parameter(Mandatory = $true)] + [string]$SysinternalsPath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +& "$SysinternalsPath\autorunsc64.exe" -accepteula -a * -x * -h -nobanner > "$BaselinePath\autoruns.xml" +$xml = [xml](Get-Content "$BaselinePath\autoruns.xml") +$xml.autoruns.item | ForEach-Object { + [PSCustomObject]@{ + Location = $_.location + Name = $_.itemname + Enabled = $_.enabled + Profile = $_.profile + LaunchString = $_.launchstring + Description = $_.description + Company = $_.company + ImagePath = $_.imagepath + Hash = $_.sha256hash + } +} | Sort-Object Location, Name | Export-Csv "$BaselinePath\autoruns.csv" -NoTypeInformation +Remove-Item "$BaselinePath\autoruns.xml" -Force diff --git a/embed/windows/baseline/local-users.ps1 b/embed/windows/baseline/local-users.ps1 new file mode 100644 index 0000000..e2679fb --- /dev/null +++ b/embed/windows/baseline/local-users.ps1 @@ -0,0 +1,10 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +Get-LocalUser | Select-Object Name, Enabled, Description | Sort-Object Name | Export-Csv "$BaselinePath\local-users.csv" -NoTypeInformation diff --git a/embed/windows/baseline/ports.ps1 b/embed/windows/baseline/ports.ps1 new file mode 100644 index 0000000..5fd8a84 --- /dev/null +++ b/embed/windows/baseline/ports.ps1 @@ -0,0 +1,14 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +Get-NetTCPConnection -State Listen | + Select-Object LocalAddress, LocalPort, @{Name = "Process"; Expression = {(Get-Process -Id $_.OwningProcess -ea 0).ProcessName}} | + Where-Object {$_.LocalAddress -notin @("127.0.0.1","::1")} | + Sort-Object LocalPort | + Export-Csv "$BaselinePath\ports.csv" -NoTypeInformation diff --git a/embed/windows/baseline/processes.ps1 b/embed/windows/baseline/processes.ps1 new file mode 100644 index 0000000..def1414 --- /dev/null +++ b/embed/windows/baseline/processes.ps1 @@ -0,0 +1,30 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +try { + Get-Process -IncludeUserName | + Select-Object Name, Path, UserName | + Sort-Object Name | + Export-Csv "$BaselinePath\processes.csv" -NoTypeInformation +} +catch { + Get-Process | ForEach-Object { + try { + $owner = (Get-WmiObject -Class Win32_Process -Filter "ProcessId = $($_.Id)").GetOwner() + $user = "$($owner.Domain)\$($owner.User)" + } + catch { $user = "N/A" } + $path = try { $_.Path } catch { "N/A" } + [PSCustomObject]@{ + Name = $_.Name + Path = $path + User = $user + } + } | Sort-Object Name | Export-Csv "$BaselinePath\processes.csv" -NoTypeInformation +} diff --git a/embed/windows/baseline/services.ps1 b/embed/windows/baseline/services.ps1 index 9f72602..dd5fcd9 100644 --- a/embed/windows/baseline/services.ps1 +++ b/embed/windows/baseline/services.ps1 @@ -1,68 +1,10 @@ -param ( +param( [Parameter(Mandatory = $true)] - [String]$ExportPath + [string]$BaselinePath ) -function Get-HashBackup($filePath, $hashType = "SHA256") { - $hasher = [System.Security.Cryptography.HashAlgorithm]::Create($hashType) - $stream = [System.IO.File]::OpenRead($filePath) - $hashBytes = $hasher.ComputeHash($stream) - $stream.Close() - return -join ($hashBytes | ForEach-Object { "{0:X2}" -f $_ }) -} -function Get-ServiceInfoList { - $serviceList = Get-WmiObject -Class Win32_Service | Select-Object Name, StartMode, State, PathName, DisplayName - - $outList = @() - - foreach ($service in $serviceList) { - $pathName = $service.PathName - if ($null -eq $pathName) { - $pathName = "N/A" - } - $exePath = $pathName - if ($exePath -match '"([^"]+)"') { - $exePath = $Matches[1] - } - else { - $spaceInd = $exePath.IndexOf(' ') - if ($spaceInd -gt 0) { - $exePath = $exePath.Substring(0, $spaceInd) - } - } - - if ($exePath -eq "N/A") { - $tmpHash = "N/A" - } - else { - try { - $tmpHash = (Get-FileHash -Path $exePath -Algorithm SHA256 -ErrorAction SilentlyContinue).Hash - } - catch { - try { - $tmpHash = Get-HashBackup -filePath $exePath - } - catch { - $tmpHash = "N/A" - } - } - if ($null -eq $tmpHash) { - Write-Host "Error hashing $exePath" - $tmpHash = "N/A" - } - } - $outList += [PSCustomObject]@{ - Name = $service.Name - DisplayName = $service.DisplayName - StartMode = $service.StartMode - State = $service.State - Path = $pathName - Executable = $exePath - ExecutableHash = $tmpHash - } - } - return $outList +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null } -Get-ServiceInfoList | ConvertTo-Csv -NoTypeInformation | Set-Content -Path $ExportPath -Write-Host "Wrote service baseline to $ExportPath" +Get-Service | Select-Object Name, DisplayName, Status, StartType | Sort-Object Name | Export-Csv "$BaselinePath\services.csv" -NoTypeInformation diff --git a/embed/windows/harden/apply_firewall.ps1 b/embed/windows/harden/apply_firewall.ps1 new file mode 100644 index 0000000..e080e2c --- /dev/null +++ b/embed/windows/harden/apply_firewall.ps1 @@ -0,0 +1,76 @@ +param( + [switch]$ClearScheduledTask, + [string]$RulesFile, + [string]$BackupFile, + [string]$Direction +) + +$scheduledTaskName = "LSCWFW - Restore Firewall Rules" + +if ($ClearScheduledTask) { + try { + Unregister-ScheduledTask -TaskName $scheduledTaskName -Confirm:$false -ErrorAction Stop + Write-Host "Cleared scheduled task: $scheduledTaskName" + } + catch { + Write-Host "Error clearing scheduled task: $($_.Exception.Message)" + exit 1 + } + exit 0 +} + +try { + if(Test-Path $BackupFile) { + Write-Host "Backup file already exists at $BackupFile. Please remove it before running this script to avoid overwriting the backup." + exit 1 + } + netsh advfirewall export $BackupFile | Out-Null + Write-Host "Successfully backed up current firewall rules to $BackupFile" + + if (Get-ScheduledTask -TaskName $scheduledTaskName -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName $scheduledTaskName -Confirm:$false + } + + $action = New-ScheduledTaskAction -Execute "netsh" -Argument "advfirewall import $BackupFile" -ErrorAction Stop + $timeTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(3) -ErrorAction Stop + $rebootTrigger = New-ScheduledTaskTrigger -AtStartup -ErrorAction Stop + $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -ErrorAction Stop + Register-ScheduledTask -TaskName $scheduledTaskName -Action $action -Trigger $timeTrigger, $rebootTrigger -Principal $principal -ErrorAction Stop | Out-Null + Write-Host "Scheduled task created to restore firewall rules in 3 minutes or on next reboot: $scheduledTaskName" +} +catch { + Write-Host "Error creating scheduled backup task: $($_.Exception.Message)" + exit 1 +} + +$oldRules = Get-NetFirewallRule | Where-Object { $_.Direction -eq $Direction } + +try { + (Get-Content $RulesFile | ConvertFrom-Json -ErrorAction Stop) | ForEach-Object { + $params = @{} + $_.PSObject.Properties | ForEach-Object { + $params[$_.Name] = $_.Value + } + $params.Enabled = "True" + $params.Profile = "Any" + $params.ErrorAction = "Stop" + Write-Host "Creating rule: $($params.DisplayName)" + New-NetFirewallRule @params | Out-Null + } +} +catch { + Write-Host "Error applying firewall rules: $($_.Exception.Message)" + exit 1 +} + +Write-Host "Successfully added new firewall rules" +Write-Host "Removing old firewall rules ..." +try { + $oldRules | Remove-NetFirewallRule -ErrorAction Stop +} +catch { + Write-Host "Error removing old rules: $($_.Exception.Message)" + exit 1 +} +Write-Host "Successfully removed old firewall rules" +Write-Host "Firewall rules updated successfully. If you need to restore the previous rules, a scheduled task has been created to do so in 3 minutes. You can also manually run 'netsh advfirewall import $BackupFile' to restore the backup immediately." diff --git a/embed/windows/harden/firewall.ps1 b/embed/windows/harden/firewall.ps1 deleted file mode 100644 index e99aab5..0000000 --- a/embed/windows/harden/firewall.ps1 +++ /dev/null @@ -1,70 +0,0 @@ -param ( - [switch]$Apply, - [switch]$Prune, - [string]$RulePath, - [string]$BackupPath, - [string]$OldRulesPath, - [string]$Direction -) - -if ($Prune) { - Write-Host "Removing all old inbound rules ..." - Get-Content $OldRulesPath | % { - if ($_.Trim().Length -eq 0) { - return - } - Write-Host "Removing rule with ID: $_" - try { - Remove-NetFirewallRule $_ - } - catch { - Write-Host "Error removing rule: $($_.Exception.Message)" - } - } - exit 0 -} - -if (-not $Apply) { - Get-Content $RulePath | Write-Host - exit 0 -} - -try { - Write-Host "Backing up current rules to $BackupPath ..." - netsh advfirewall export $BackupPath -} -catch { - Write-Host "Error backing up rules: $($_.Exception.Message)" - exit 1 -} - -try { - $OldRules = Get-NetFirewallRule | ? { $_.Direction -eq $Direction } -} -catch { - Write-Host "Error getting current rules: $($_.Exception.Message)" - exit 1 -} - -try { -(Get-Content $RulePath | ConvertFrom-Json -ErrorAction Stop) | % { - $params = @{} - $_.PSObject.Properties | % { - $params[$_.Name] = $_.Value - } - $params.Enabled = "True" - $params.Profile = "Any" - $params.ErrorAction = "Stop" - Write-Host "Creating rule: $($params.DisplayName)" - New-NetFirewallRule @params - } -} -catch { - Write-Host "Error creating rules: $($_.Exception.Message)" - exit 1 -} - -Write-Host "Firewall rules applied successfully." - -Write-Host "Writing old rules IDs to $OldRulesPath" -$OldRules | Select-Object InstanceID | % {$_.InstanceID} | Out-File -FilePath $OldRulesPath diff --git a/embed/windows/harden/firewall_rules_inbound.json b/embed/windows/harden/firewall_rules_inbound.json index 74a11ee..93f5f96 100644 --- a/embed/windows/harden/firewall_rules_inbound.json +++ b/embed/windows/harden/firewall_rules_inbound.json @@ -3,203 +3,203 @@ "DisplayName": "DNS Server (UDP 53 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "53", + "LocalPort": 53, "Protocol": "UDP" }, { "DisplayName": "DNS Server Optional (TCP 53 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "53", + "LocalPort": 53, "Protocol": "TCP" }, { "DisplayName": "DHCP Server (UDP 67 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "67", + "LocalPort": 67, "Protocol": "UDP" }, { "DisplayName": "DHCP Client (UDP 68 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "68", + "LocalPort": 68, "Protocol": "UDP" }, { "DisplayName": "Kerberos (UDP 88 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "88", + "LocalPort": 88, "Protocol": "UDP" }, { "DisplayName": "Kerberos (TCP 88 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "88", + "LocalPort": 88, "Protocol": "TCP" }, { "DisplayName": "LDAP (TCP 389 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "389", + "LocalPort": 389, "Protocol": "TCP" }, { "DisplayName": "LDAP Optional (UDP 389 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "389", + "LocalPort": 389, "Protocol": "UDP" }, { "DisplayName": "LDAP SSL (TCP 636 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "636", + "LocalPort": 636, "Protocol": "TCP" }, { "DisplayName": "LDAP GC (TCP 3268 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "3268", + "LocalPort": 3268, "Protocol": "TCP" }, { "DisplayName": "LDAP GC SSL (TCP 3269 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "3269", + "LocalPort": 3269, "Protocol": "TCP" }, { "DisplayName": "RPC Endpoint Mapper (TCP 135 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "135", + "LocalPort": 135, "Protocol": "TCP" }, { "DisplayName": "SMB (TCP 445 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "445", + "LocalPort": 445, "Protocol": "TCP" }, { "DisplayName": "HTTP (TCP 80 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "80", + "LocalPort": 80, "Protocol": "TCP" }, { "DisplayName": "HTTPS (TCP 443 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "443", + "LocalPort": 443, "Protocol": "TCP" }, { "DisplayName": "MySQL (TCP 3306 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "3306", + "LocalPort": 3306, "Protocol": "TCP" }, { "DisplayName": "OpenSSH (TCP 22 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "22", + "LocalPort": 22, "Protocol": "TCP" }, { "DisplayName": "RDP (TCP 3389 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "3389", + "LocalPort": 3389, "Protocol": "TCP" }, { "DisplayName": "WinRM (TCP 5985 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "5985", + "LocalPort": 5985, "Protocol": "TCP" }, { "DisplayName": "WinRM SSL (TCP 5986 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "5986", + "LocalPort": 5986, "Protocol": "TCP" }, { "DisplayName": "SMTP (TCP 25 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "25", + "LocalPort": 25, "Protocol": "TCP" }, { "DisplayName": "POP3 (TCP 101 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "101", + "LocalPort": 101, "Protocol": "TCP" }, { "DisplayName": "NTP (UDP 123 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "123", + "LocalPort": 123, "Protocol": "UDP" }, { "DisplayName": "IMAP4 (TCP 143 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "143", + "LocalPort": 143, "Protocol": "TCP" }, { "DisplayName": "SMTP SSL (TCP 465 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "465", + "LocalPort": 465, "Protocol": "TCP" }, { "DisplayName": "SMTP (TCP 587 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "587", + "LocalPort": 587, "Protocol": "TCP" }, { "DisplayName": "IMAP4 SSL (TCP 993 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "993", + "LocalPort": 993, "Protocol": "TCP" }, { "DisplayName": "POP3 SSL (TCP 995 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "995", + "LocalPort": 995, "Protocol": "TCP" }, { "DisplayName": "MAPI (TCP 6001 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "6001", + "LocalPort": 6001, "Protocol": "TCP" }, { @@ -207,5 +207,12 @@ "Direction": "Inbound", "Action": "Allow", "Protocol": "ICMPv4" + }, + { + "DisplayName": "NetBIOS Legacy SMB (TCP 139 Inbound)", + "Direction": "Inbound", + "Action": "Allow", + "LocalPort": 139, + "Protocol": "TCP" } ] diff --git a/embed/windows/harden/get_firewall_rules.ps1 b/embed/windows/harden/get_firewall_rules.ps1 new file mode 100644 index 0000000..d6cbb82 --- /dev/null +++ b/embed/windows/harden/get_firewall_rules.ps1 @@ -0,0 +1,22 @@ +param( + [switch]$Outbound +) + +$inboundRulesFile = "firewall_rules_inbound.json" +$outboundRulesFile = "firewall_rules_outbound.json" + +if ($Outbound) { + $openPorts = @() + $rulesFile = $outboundRulesFile +} +else { + $openPorts = (Get-NetTCPConnection -State Listen | + Where-Object { $_.LocalAddress -notin @('127.0.0.1', '::1') } | + Select-Object -Unique LocalPort).LocalPort + $rulesFile = $inboundRulesFile +} + +(Get-Content $rulesFile | ConvertFrom-Json -ErrorAction Stop) | +Select-Object *, @{Name = "Enabled"; Expression = { $_.LocalPort -in $openPorts } } | +Sort-Object @{Expression = "Enabled"; Desc = $true }, @{Expression = "LocalPort"; Descending = $false } | +ConvertTo-Json -Depth 10 diff --git a/harden/firewall_rules.go b/harden/firewall_rules.go deleted file mode 100644 index 01c9bdd..0000000 --- a/harden/firewall_rules.go +++ /dev/null @@ -1,61 +0,0 @@ -package harden - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var firewallArgs struct { - outbound bool - inbound bool - apply bool - ruleFile string - oldRuleFile string - backupPath string - removeOld bool - oldRulesIn string -} - -var firewallCmd = &cobra.Command{ - Use: "firewall", - Short: "Setup firewall rules", - Run: func(cmd *cobra.Command, args []string) { - if firewallArgs.apply { - if firewallArgs.inbound == firewallArgs.outbound { - fmt.Println("Error: You must specify either --outbound or --inbound, but not both.") - cmd.Usage() - return - } - - direction := "inbound" - if firewallArgs.outbound { - direction = "outbound" - } - - applyFirewallRules(firewallArgs.ruleFile, firewallArgs.backupPath, firewallArgs.oldRuleFile, direction) - } else if firewallArgs.removeOld { - removeOldFirewallRules(firewallArgs.oldRulesIn) - } else { - generateFirewallRules(firewallArgs.outbound) - } - }, -} - -func setupFirewallCmd(cmd *cobra.Command) { - firewallCmd.Flags().BoolVar(&firewallArgs.outbound, "outbound", false, "Configure/apply outbound rules") - firewallCmd.Flags().BoolVar(&firewallArgs.inbound, "inbound", false, "Configure/apply outbound rules") - firewallCmd.Flags().BoolVar(&firewallArgs.apply, "apply", false, "Apply the rules") - firewallCmd.Flags().StringVarP(&firewallArgs.ruleFile, "file", "f", "", "Path to the rules file") - firewallCmd.Flags().StringVarP(&firewallArgs.oldRuleFile, "out", "o", "old_rules.txt", "Path to output old rules after applying new ones") - firewallCmd.Flags().StringVarP(&firewallArgs.backupPath, "backup", "b", "firewall_backup.wfw", "Path to backup the current rules") - firewallCmd.Flags().BoolVar(&firewallArgs.removeOld, "remove-old", false, "Remove old rules after applying new ones") - firewallCmd.Flags().StringVarP(&firewallArgs.oldRulesIn, "old-rules", "i", "", "Path to the old rules file") - - firewallCmd.MarkFlagsRequiredTogether("file", "apply") - firewallCmd.MarkFlagsRequiredTogether("remove-old", "old-rules") - firewallCmd.MarkFlagsMutuallyExclusive("apply", "remove-old") - firewallCmd.MarkFlagsMutuallyExclusive("outbound", "inbound") - - cmd.AddCommand(firewallCmd) -} diff --git a/harden/firewall_rules_linux.go b/harden/firewall_rules_linux.go index 705a7ac..11d489c 100644 --- a/harden/firewall_rules_linux.go +++ b/harden/firewall_rules_linux.go @@ -1,13 +1,7 @@ package harden -func generateFirewallRules(outbound bool) { - // placeholder -} - -func applyFirewallRules(rulesFile string, backupPath string, oldRulesFile string, direction string) { - // placeholder -} +import "github.com/spf13/cobra" -func removeOldFirewallRules(oldRulesFile string) { - // placeholder +func setupFirewallCmd(cmd *cobra.Command) { + // No firewall rules for Linux yet, so this is a no-op } diff --git a/harden/firewall_rules_windows.go b/harden/firewall_rules_windows.go index 4131e4d..a4b398e 100644 --- a/harden/firewall_rules_windows.go +++ b/harden/firewall_rules_windows.go @@ -5,69 +5,138 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "github.com/UT-CTF/landschaft/embed" - "github.com/UT-CTF/landschaft/util" "github.com/charmbracelet/huh" + "github.com/spf13/cobra" ) -const OutboundRulesPath = "firewall_rules_outbound.json" -const InboundRulesPath = "firewall_rules_inbound.json" +var configFirewallArgs struct { + outbound bool + outputPath string + skipPrompt bool +} + +var applyFirewallArgs struct { + outbound bool + rulesFile string + backupPath string +} + +func setupFirewallCmd(cmd *cobra.Command) { + var firewallCmd = &cobra.Command{ + Use: "firewall", + Short: "Setup firewall rules", + } + + var firewallConfigCmd = &cobra.Command{ + Use: "config", + Short: "Configure firewall rules to apply", + Run: func(cmd *cobra.Command, args []string) { + generateFirewallRules(configFirewallArgs.outbound, configFirewallArgs.outputPath, configFirewallArgs.skipPrompt) + }, + } + + firewallConfigCmd.Flags().BoolVar(&configFirewallArgs.outbound, "outbound", false, "Configure outbound rules instead of inbound") + firewallConfigCmd.Flags().StringVarP(&configFirewallArgs.outputPath, "output", "o", "", "Path to output the generated rules") + firewallConfigCmd.Flags().BoolVar(&configFirewallArgs.skipPrompt, "skip", false, "Skip the confirmation prompt and select all rules") + + firewallConfigCmd.MarkFlagRequired("output") + + firewallCmd.AddCommand(firewallConfigCmd) + + var firewallApplyCmd = &cobra.Command{ + Use: "apply", + Short: "Apply firewall rules from a file, create a backup of existing rules, and create a scheduled task to re-apply old rules in 3 minutes", + Run: func(cmd *cobra.Command, args []string) { + applyFirewallRules(applyFirewallArgs.outbound, applyFirewallArgs.rulesFile, applyFirewallArgs.backupPath) + }, + } + + firewallApplyCmd.Flags().BoolVar(&applyFirewallArgs.outbound, "outbound", false, "Apply outbound rules instead of inbound") + firewallApplyCmd.Flags().StringVarP(&applyFirewallArgs.rulesFile, "rules", "f", "", "Path to the firewall rules file") + firewallApplyCmd.Flags().StringVarP(&applyFirewallArgs.backupPath, "backup", "b", "firewall_backup.wfw", "Path to backup existing firewall rules") + + firewallApplyCmd.MarkFlagRequired("rules") + + firewallCmd.AddCommand(firewallApplyCmd) + + firewallFinalizeCmd := &cobra.Command{ + Use: "finalize", + Short: "Finalize firewall rules application by clearing the scheduled task that restores the previous firewall rules", + Run: func(cmd *cobra.Command, args []string) { + finalizeFirewallRules() + }, + } + + firewallCmd.AddCommand(firewallFinalizeCmd) + + cmd.AddCommand(firewallCmd) +} + +func generateFirewallRules(outbound bool, outputPath string, skipPrompt bool) { + jsonStr := "" + err := error(nil) -func generateFirewallRules(outbound bool) { - var rulesPath = InboundRulesPath if outbound { - rulesPath = OutboundRulesPath + jsonStr, err = embed.ExecuteScript("harden/get_firewall_rules.ps1", false, "-Outbound") + } else { + jsonStr, err = embed.ExecuteScript("harden/get_firewall_rules.ps1", false) } - jsonStr, err := embed.ExecuteScript("harden/firewall.ps1", false, "-RulePath", rulesPath) if err != nil { fmt.Println("Error executing script: ", err) return } - var rules []map[string]string - err = json.Unmarshal([]byte(jsonStr), &rules) + var raw []map[string]interface{} + err = json.Unmarshal([]byte(jsonStr), &raw) if err != nil { fmt.Println("Error parsing JSON: ", err) + fmt.Println("Raw JSON: ", jsonStr) return } - sort.Slice(rules, func(i, j int) bool { - return rules[i]["DisplayName"] < rules[j]["DisplayName"] - }) + var rules []map[string]string + for _, item := range raw { + converted := make(map[string]string) + for k, v := range item { + converted[k] = fmt.Sprintf("%v", v) + } + rules = append(rules, converted) + } + + // any rules with enabled set to true should be pre-selected in the form + // and then remove the enabled field for all rules since it's not needed for the actual application of the rules var selected []int + for i, rule := range rules { + if strings.ToLower(rule["Enabled"]) == "true" { + selected = append(selected, i) + } + delete(rule, "Enabled") + } - ruleSelect := huh.NewMultiSelect[int]().OptionsFunc(func() []huh.Option[int] { - var ruleNames []huh.Option[int] + if !skipPrompt { + var options []huh.Option[int] for i, rule := range rules { - ruleNames = append(ruleNames, huh.NewOption(rule["DisplayName"], i)) + options = append(options, huh.NewOption(rule["DisplayName"], i)) } - return ruleNames - }, - nil, - ).Title("Select Firewall Rules to Enable"). - Value(&selected) - - var outputPath string - defaultPath := rulesPath - pathInput := huh.NewInput().Title("Rules file path").Placeholder(defaultPath).Value(&outputPath) - - fullForm := huh.NewForm( - huh.NewGroup(ruleSelect, pathInput), - ) - err = fullForm.Run() - if err != nil { - fmt.Println("Error running form: ", err) - return - } - outputPath = strings.TrimSpace(outputPath) - if len(outputPath) == 0 { - outputPath = defaultPath + ruleSelect := huh.NewMultiSelect[int](). + Options(options...). + Title("Select Firewall Rules to Enable"). + Value(&selected) + + fullForm := huh.NewForm( + huh.NewGroup(ruleSelect), + ) + err = fullForm.Run() + if err != nil { + fmt.Println("Error running form: ", err) + return + } } var rulesToEnable []map[string]string @@ -96,7 +165,7 @@ func generateFirewallRules(outbound bool) { fmt.Println("Rules written to: ", outputPath) } -func applyFirewallRules(rulesFile string, backupPath string, oldRulesFile string, direction string) { +func applyFirewallRules(outbound bool, rulesFile string, backupPath string) { rulesFile, err := filepath.Abs(rulesFile) if err != nil { fmt.Println("Error getting absolute path: ", err) @@ -109,41 +178,22 @@ func applyFirewallRules(rulesFile string, backupPath string, oldRulesFile string return } - oldRulesFile, err = filepath.Abs(oldRulesFile) - if err != nil { - fmt.Println("Error getting absolute path: ", err) - return + direction := "Inbound" + if outbound { + direction = "Outbound" } - fmt.Println("This will save IDs for all existing rules to a file and apply the selected rules.") - fmt.Println("This will also create a backup of the current rules.") - fmt.Println("The new rules are in the file: ", rulesFile) - fmt.Print("Are you sure you want to continue? (y/n) ") - var confirm string - fmt.Scanln(&confirm) - if confirm != "y" { - fmt.Println("Aborting.") + _, err = embed.ExecuteScript("harden/apply_firewall.ps1", true, "-Direction", direction, "-RulesFile", rulesFile, "-BackupFile", backupPath) + if err != nil { + fmt.Println("Error executing script: ", err) return } - - util.RunAndPrintScript("harden/firewall.ps1", "-RulePath", "'"+rulesFile+"'", "-BackupPath", "'"+backupPath+"'", "-OldRulesPath", "'"+oldRulesFile+"'", "-Direction", "'"+direction+"'", "-Apply") } -func removeOldFirewallRules(oldRulesFile string) { - oldRulesFile, err := filepath.Abs(oldRulesFile) +func finalizeFirewallRules() { + _, err := embed.ExecuteScript("harden/apply_firewall.ps1", true, "-ClearScheduledTask") if err != nil { - fmt.Println("Error getting absolute path: ", err) - return - } - - fmt.Println("This will remove ALL the old rules from the system.") - fmt.Print("Are you sure you want to continue? (y/n) ") - var confirm string - fmt.Scanln(&confirm) - if confirm != "y" { - fmt.Println("Aborting.") + fmt.Println("Error executing script: ", err) return } - - util.RunAndPrintScript("harden/firewall.ps1", "-OldRulesPath", "'"+oldRulesFile+"'", "-Prune") } diff --git a/misc/tools_windows.go b/misc/tools_windows.go index fe33354..39b04f7 100644 --- a/misc/tools_windows.go +++ b/misc/tools_windows.go @@ -100,6 +100,29 @@ func installSysinternals(targetDir string) { fmt.Println("Sysinternals Suite installed successfully") } +// EnsureSysinternals ensures the Sysinternals suite is present at targetDir. If not +// present it will download and extract it. Returns the path to the directory or an +// error string printed to stdout. +func EnsureSysinternals(targetDir string) error { + // If target exists and looks populated, assume it's fine + info, err := os.Stat(targetDir) + if err == nil && info.IsDir() { + // quick check: expect autorunsc64.exe to exist + if _, err := os.Stat(filepath.Join(targetDir, "autorunsc64.exe")); err == nil { + return nil + } + } + + fmt.Printf("Sysinternals not found at %s, downloading and extracting...\n", targetDir) + installSysinternals(targetDir) + + // final check + if _, err := os.Stat(filepath.Join(targetDir, "autorunsc64.exe")); err != nil { + return fmt.Errorf("sysinternals extraction failed or autorunsc64.exe missing: %w", err) + } + return nil +} + // func installPython() { // fmt.Println("Not implemented") // }