diff --git a/winPEAS/winPEASexe/README.md b/winPEAS/winPEASexe/README.md index 8dd211c..eb49dc8 100755 --- a/winPEAS/winPEASexe/README.md +++ b/winPEAS/winPEASexe/README.md @@ -76,6 +76,7 @@ The goal of this project is to search for possible **Privilege Escalation Paths* New in this version: - Detect potential GPO abuse by flagging writable SYSVOL paths for GPOs applied to the current host and by highlighting membership in the "Group Policy Creator Owners" group. +- Detect SOAPwn-style .NET HTTP client proxies by flagging high-privilege services/processes that embed SoapHttpClientProtocol or ServiceDescriptionImporter indicators. It should take only a **few seconds** to execute almost all the checks and **some seconds/minutes during the lasts checks searching for known filenames** that could contain passwords (the time depened on the number of files in your home folder). By default only **some** filenames that could contain credentials are searched, you can use the **searchall** parameter to search all the list (this could will add some minutes). diff --git a/winPEAS/winPEASexe/winPEAS/Checks/Checks.cs b/winPEAS/winPEASexe/winPEAS/Checks/Checks.cs index 8e0a8d1..d2cfac0 100644 --- a/winPEAS/winPEASexe/winPEAS/Checks/Checks.cs +++ b/winPEAS/winPEASexe/winPEAS/Checks/Checks.cs @@ -88,6 +88,7 @@ namespace winPEAS.Checks new SystemCheck("userinfo", new UserInfo()), new SystemCheck("processinfo", new ProcessInfo()), new SystemCheck("servicesinfo", new ServicesInfo()), + new SystemCheck("soapclientinfo", new SoapClientInfo()), new SystemCheck("applicationsinfo", new ApplicationsInfo()), new SystemCheck("networkinfo", new NetworkInfo()), new SystemCheck("activedirectoryinfo", new ActiveDirectoryInfo()), diff --git a/winPEAS/winPEASexe/winPEAS/Checks/SoapClientInfo.cs b/winPEAS/winPEASexe/winPEAS/Checks/SoapClientInfo.cs new file mode 100644 index 0000000..4b3ce80 --- /dev/null +++ b/winPEAS/winPEASexe/winPEAS/Checks/SoapClientInfo.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using winPEAS.Helpers; +using winPEAS.Info.ApplicationInfo; + +namespace winPEAS.Checks +{ + internal class SoapClientInfo : ISystemCheck + { + public void PrintInfo(bool isDebug) + { + Beaprint.GreatPrint(".NET SOAP Client Proxies (SOAPwn)"); + + CheckRunner.Run(PrintSoapClientFindings, isDebug); + } + + private static void PrintSoapClientFindings() + { + try + { + Beaprint.MainPrint("Potential SOAPwn / HttpWebClientProtocol abuse surfaces"); + Beaprint.LinkPrint( + "https://labs.watchtowr.com/soapwn-pwning-net-framework-applications-through-http-client-proxies-and-wsdl/", + "Look for .NET services that let attackers control SoapHttpClientProtocol URLs or WSDL imports to coerce NTLM or drop files."); + + List findings = SoapClientProxyAnalyzer.CollectFindings(); + if (findings.Count == 0) + { + Beaprint.NotFoundPrint(); + return; + } + + foreach (SoapClientProxyFinding finding in findings) + { + string severity = finding.BinaryIndicators.Contains("ServiceDescriptionImporter") + ? "Dynamic WSDL import" + : "SOAP proxy usage"; + + Beaprint.BadPrint($" [{severity}] {finding.BinaryPath}"); + + foreach (SoapClientProxyInstance instance in finding.Instances) + { + string instanceInfo = $" -> {instance.SourceType}: {instance.Name}"; + if (!string.IsNullOrEmpty(instance.Account)) + { + instanceInfo += $" ({instance.Account})"; + } + if (!string.IsNullOrEmpty(instance.Extra)) + { + instanceInfo += $" | {instance.Extra}"; + } + + Beaprint.GrayPrint(instanceInfo); + } + + if (finding.BinaryIndicators.Count > 0) + { + Beaprint.BadPrint(" Binary indicators: " + string.Join(", ", finding.BinaryIndicators)); + } + + if (finding.ConfigIndicators.Count > 0) + { + string configLabel = string.IsNullOrEmpty(finding.ConfigPath) + ? "Config indicators" + : $"Config indicators ({finding.ConfigPath})"; + Beaprint.BadPrint(" " + configLabel + ": " + string.Join(", ", finding.ConfigIndicators)); + } + + if (finding.BinaryScanFailed) + { + Beaprint.GrayPrint(" (Binary scan skipped due to access/size limits)"); + } + + if (finding.ConfigScanFailed) + { + Beaprint.GrayPrint(" (Unable to read config file)"); + } + + Beaprint.PrintLineSeparator(); + } + } + catch (Exception ex) + { + Beaprint.PrintException(ex.Message); + } + } + } +} diff --git a/winPEAS/winPEASexe/winPEAS/Helpers/MyUtils.cs b/winPEAS/winPEASexe/winPEAS/Helpers/MyUtils.cs index 5fb7e50..e81ad77 100644 --- a/winPEAS/winPEASexe/winPEAS/Helpers/MyUtils.cs +++ b/winPEAS/winPEASexe/winPEAS/Helpers/MyUtils.cs @@ -24,36 +24,51 @@ namespace winPEAS.Helpers //////////////////////////////////// /////// MISC - Files & Paths /////// //////////////////////////////////// - public static bool CheckIfDotNet(string path) + public static bool CheckIfDotNet(string path, bool ignoreCompanyName = false) { bool isDotNet = false; - FileVersionInfo myFileVersionInfo = FileVersionInfo.GetVersionInfo(path); - string companyName = myFileVersionInfo.CompanyName; - if ((string.IsNullOrEmpty(companyName)) || - (!Regex.IsMatch(companyName, @"^Microsoft.*", RegexOptions.IgnoreCase))) + string companyName = string.Empty; + + try { - try + FileVersionInfo myFileVersionInfo = FileVersionInfo.GetVersionInfo(path); + companyName = myFileVersionInfo.CompanyName; + } + catch + { + // Unable to read version information, continue with assembly inspection + } + + bool shouldInspectAssembly = ignoreCompanyName || + (string.IsNullOrEmpty(companyName)) || + (!Regex.IsMatch(companyName, @"^Microsoft.*", RegexOptions.IgnoreCase)); + + if (!shouldInspectAssembly) + { + return false; + } + + try + { + AssemblyName.GetAssemblyName(path); + isDotNet = true; + } + catch (System.IO.FileNotFoundException) + { + // System.Console.WriteLine("The file cannot be found."); + } + catch (System.BadImageFormatException exception) + { + if (Regex.IsMatch(exception.Message, + ".*This assembly is built by a runtime newer than the currently loaded runtime and cannot be loaded.*", + RegexOptions.IgnoreCase)) { - AssemblyName myAssemblyName = AssemblyName.GetAssemblyName(path); isDotNet = true; } - catch (System.IO.FileNotFoundException) - { - // System.Console.WriteLine("The file cannot be found."); - } - catch (System.BadImageFormatException exception) - { - if (Regex.IsMatch(exception.Message, - ".*This assembly is built by a runtime newer than the currently loaded runtime and cannot be loaded.*", - RegexOptions.IgnoreCase)) - { - isDotNet = true; - } - } - catch - { - // System.Console.WriteLine("The assembly has already been loaded."); - } + } + catch + { + // System.Console.WriteLine("The assembly has already been loaded."); } return isDotNet; diff --git a/winPEAS/winPEASexe/winPEAS/Info/ApplicationInfo/SoapClientProxyAnalyzer.cs b/winPEAS/winPEASexe/winPEAS/Info/ApplicationInfo/SoapClientProxyAnalyzer.cs new file mode 100644 index 0000000..9a499a3 --- /dev/null +++ b/winPEAS/winPEASexe/winPEAS/Info/ApplicationInfo/SoapClientProxyAnalyzer.cs @@ -0,0 +1,366 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management; +using System.Text; +using winPEAS.Helpers; +using winPEAS.Info.ProcessInfo; + +namespace winPEAS.Info.ApplicationInfo +{ + internal class SoapClientProxyInstance + { + public string SourceType { get; set; } + public string Name { get; set; } + public string Account { get; set; } + public string Extra { get; set; } + } + + internal class SoapClientProxyFinding + { + public string BinaryPath { get; set; } + public List Instances { get; } = new List(); + public HashSet BinaryIndicators { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + public HashSet ConfigIndicators { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + public string ConfigPath { get; set; } + public bool BinaryScanFailed { get; set; } + public bool ConfigScanFailed { get; set; } + } + + internal static class SoapClientProxyAnalyzer + { + private class SoapClientProxyCandidate + { + public string BinaryPath { get; set; } + public string SourceType { get; set; } + public string Name { get; set; } + public string Account { get; set; } + public string Extra { get; set; } + } + + private static readonly string[] BinaryIndicatorStrings = new[] + { + "SoapHttpClientProtocol", + "HttpWebClientProtocol", + "DiscoveryClientProtocol", + "HttpSimpleClientProtocol", + "HttpGetClientProtocol", + "HttpPostClientProtocol", + "ServiceDescriptionImporter", + "System.Web.Services.Description.ServiceDescriptionImporter", + }; + + private static readonly Dictionary ConfigIndicatorMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "soap:address", "soap:address element present" }, + { "soap12:address", "soap12:address element present" }, + { "?wsdl", "?wsdl reference" }, + { " DotNetCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public static List CollectFindings() + { + var findings = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var candidate in EnumerateServiceCandidates().Concat(EnumerateProcessCandidates())) + { + if (string.IsNullOrEmpty(candidate.BinaryPath) || !File.Exists(candidate.BinaryPath)) + { + continue; + } + + if (!findings.TryGetValue(candidate.BinaryPath, out var finding)) + { + finding = new SoapClientProxyFinding + { + BinaryPath = candidate.BinaryPath, + }; + + findings.Add(candidate.BinaryPath, finding); + } + + finding.Instances.Add(new SoapClientProxyInstance + { + SourceType = candidate.SourceType, + Name = candidate.Name, + Account = string.IsNullOrEmpty(candidate.Account) ? "Unknown" : candidate.Account, + Extra = candidate.Extra ?? string.Empty, + }); + } + + foreach (var finding in findings.Values) + { + ScanBinaryIndicators(finding); + ScanConfigIndicators(finding); + } + + return findings.Values + .Where(f => f.BinaryIndicators.Count > 0 || f.ConfigIndicators.Count > 0) + .OrderByDescending(f => f.BinaryIndicators.Contains("ServiceDescriptionImporter")) + .ThenBy(f => f.BinaryPath, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static IEnumerable EnumerateServiceCandidates() + { + try + { + using (var searcher = new ManagementObjectSearcher(@"root\\cimv2", "SELECT Name, DisplayName, PathName, StartName FROM Win32_Service")) + using (var services = searcher.Get()) + { + foreach (ManagementObject service in services) + { + string pathName = service["PathName"]?.ToString(); + string binaryPath = MyUtils.GetExecutableFromPath(pathName ?? string.Empty); + if (string.IsNullOrEmpty(binaryPath) || !File.Exists(binaryPath)) + continue; + + if (!IsDotNetBinary(binaryPath)) + continue; + + yield return new SoapClientProxyCandidate + { + BinaryPath = binaryPath, + SourceType = "Service", + Name = service["Name"]?.ToString() ?? string.Empty, + Account = service["StartName"]?.ToString() ?? string.Empty, + Extra = service["DisplayName"]?.ToString() ?? string.Empty, + }; + } + } + } + catch (Exception ex) + { + Beaprint.GrayPrint("Error while enumerating services for SOAP client analysis: " + ex.Message); + } + } + + private static IEnumerable EnumerateProcessCandidates() + { + var results = new List(); + try + { + List> processes = ProcessesInfo.GetProcInfo(); + foreach (var proc in processes) + { + string path = proc.ContainsKey("ExecutablePath") ? proc["ExecutablePath"] : string.Empty; + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + continue; + + if (!IsDotNetBinary(path)) + continue; + + string owner = proc.ContainsKey("Owner") ? proc["Owner"] : string.Empty; + if (!IsInterestingProcessOwner(owner)) + continue; + + results.Add(new SoapClientProxyCandidate + { + BinaryPath = path, + SourceType = "Process", + Name = proc.ContainsKey("Name") ? proc["Name"] : string.Empty, + Account = owner, + Extra = proc.ContainsKey("ProcessID") ? $"PID {proc["ProcessID"]}" : string.Empty, + }); + } + } + catch (Exception ex) + { + Beaprint.GrayPrint("Error while enumerating processes for SOAP client analysis: " + ex.Message); + } + + return results; + } + + private static bool IsInterestingProcessOwner(string owner) + { + if (string.IsNullOrEmpty(owner)) + return true; + + string normalizedOwner = owner; + if (owner.Contains("\\")) + { + normalizedOwner = owner.Split('\\').Last(); + } + + return !normalizedOwner.Equals(Environment.UserName, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsDotNetBinary(string path) + { + lock (DotNetCacheLock) + { + if (DotNetCache.TryGetValue(path, out bool cached)) + { + return cached; + } + + bool result = false; + try + { + result = MyUtils.CheckIfDotNet(path, true); + } + catch + { + } + + DotNetCache[path] = result; + return result; + } + } + + private static void ScanBinaryIndicators(SoapClientProxyFinding finding) + { + try + { + FileInfo fi = new FileInfo(finding.BinaryPath); + if (!fi.Exists || fi.Length == 0) + return; + + if (fi.Length > MaxBinaryScanSize) + { + finding.BinaryScanFailed = true; + return; + } + + foreach (var indicator in BinaryIndicatorStrings) + { + if (FileContainsString(finding.BinaryPath, indicator)) + { + finding.BinaryIndicators.Add(indicator); + } + } + } + catch + { + finding.BinaryScanFailed = true; + } + } + + private static void ScanConfigIndicators(SoapClientProxyFinding finding) + { + string configPath = GetConfigPath(finding.BinaryPath); + if (!string.IsNullOrEmpty(configPath) && File.Exists(configPath)) + { + finding.ConfigPath = configPath; + try + { + string content = File.ReadAllText(configPath); + foreach (var kvp in ConfigIndicatorMap) + { + if (content.IndexOf(kvp.Key, StringComparison.OrdinalIgnoreCase) >= 0) + { + finding.ConfigIndicators.Add(kvp.Value); + } + } + } + catch + { + finding.ConfigScanFailed = true; + } + } + + string directory = Path.GetDirectoryName(finding.BinaryPath); + if (!string.IsNullOrEmpty(directory)) + { + try + { + var wsdlFiles = Directory.GetFiles(directory, "*.wsdl", SearchOption.TopDirectoryOnly); + if (wsdlFiles.Length > 0) + { + finding.ConfigIndicators.Add($"Found {wsdlFiles.Length} WSDL file(s) next to binary"); + } + } + catch + { + // ignore + } + } + } + + private static string GetConfigPath(string binaryPath) + { + if (string.IsNullOrEmpty(binaryPath)) + return string.Empty; + + string candidate = binaryPath + ".config"; + return File.Exists(candidate) ? candidate : string.Empty; + } + + private static bool FileContainsString(string path, string value) + { + const int bufferSize = 64 * 1024; + byte[] pattern = Encoding.UTF8.GetBytes(value); + if (pattern.Length == 0) + return false; + + try + { + using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + { + byte[] buffer = new byte[bufferSize + pattern.Length]; + int bufferLen = 0; + int bytesRead; + while ((bytesRead = fs.Read(buffer, bufferLen, bufferSize)) > 0) + { + int total = bufferLen + bytesRead; + if (IndexOf(buffer, total, pattern) >= 0) + { + return true; + } + + if (pattern.Length > 1) + { + bufferLen = Math.Min(pattern.Length - 1, total); + Buffer.BlockCopy(buffer, total - bufferLen, buffer, 0, bufferLen); + } + else + { + bufferLen = 0; + } + } + } + } + catch + { + return false; + } + + return false; + } + + private static int IndexOf(byte[] buffer, int bufferLength, byte[] pattern) + { + int limit = bufferLength - pattern.Length; + if (limit < 0) + return -1; + + for (int i = 0; i <= limit; i++) + { + bool match = true; + for (int j = 0; j < pattern.Length; j++) + { + if (buffer[i + j] != pattern[j]) + { + match = false; + break; + } + } + + if (match) + return i; + } + + return -1; + } + } +} diff --git a/winPEAS/winPEASexe/winPEAS/winPEAS.csproj b/winPEAS/winPEASexe/winPEAS/winPEAS.csproj index 4259210..a09519f 100644 --- a/winPEAS/winPEASexe/winPEAS/winPEAS.csproj +++ b/winPEAS/winPEASexe/winPEAS/winPEAS.csproj @@ -1197,6 +1197,7 @@ + @@ -1223,6 +1224,7 @@ +