mirror of
https://github.com/peass-ng/PEASS-ng.git
synced 2025-12-12 15:49:51 -08:00
Compare commits
6 Commits
80318c5005
...
update_PEA
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6100bfaceb | ||
|
|
94e84dec91 | ||
|
|
ac80ce3a9a | ||
|
|
313fe6bef5 | ||
|
|
11c0d14561 | ||
|
|
49db1df468 |
@@ -0,0 +1,72 @@
|
||||
# Title: Software Information - PostgreSQL Event Triggers
|
||||
# ID: SI_Postgresql_Event_Triggers
|
||||
# Author: HT Bot
|
||||
# Last Update: 19-11-2025
|
||||
# Description: Detect unsafe PostgreSQL event triggers and postgres_fdw custom scripts that grant temporary SUPERUSER
|
||||
# License: GNU GPL
|
||||
# Version: 1.0
|
||||
# Functions Used: echo_not_found, print_2title, print_info
|
||||
# Global Variables: $DEBUG, $E, $SED_GREEN, $SED_RED, $SED_YELLOW, $TIMEOUT
|
||||
# Initial Functions:
|
||||
# Generated Global Variables: $psql_bin, $psql_evt_output, $psql_evt_status, $psql_evt_err_line, $postgres_fdw_dirs, $postgres_fdw_hits, $old_ifs, $evtname, $enabled, $owner, $owner_is_super, $func, $func_owner, $func_owner_is_super, $IFS
|
||||
# Fat linpeas: 0
|
||||
# Small linpeas: 1
|
||||
|
||||
|
||||
if [ "$DEBUG" ] || { [ "$TIMEOUT" ] && [ "$(command -v psql 2>/dev/null || echo -n '')" ]; }; then
|
||||
print_2title "PostgreSQL event trigger ownership & postgres_fdw hooks"
|
||||
print_info "https://book.hacktricks.wiki/en/linux-hardening/privilege-escalation/index.html#postgresql-event-triggers"
|
||||
|
||||
psql_bin="$(command -v psql 2>/dev/null || echo -n '')"
|
||||
if [ "$TIMEOUT" ] && [ "$psql_bin" ]; then
|
||||
psql_evt_output="$($TIMEOUT 5 "$psql_bin" -w -X -q -A -t -d postgres -c "WITH evt AS ( SELECT e.evtname, e.evtenabled, pg_get_userbyid(e.evtowner) AS trig_owner, tr.rolsuper AS trig_owner_super, n.nspname || '.' || p.proname AS function_name, pg_get_userbyid(p.proowner) AS func_owner, fr.rolsuper AS func_owner_super FROM pg_event_trigger e JOIN pg_proc p ON e.evtfoid = p.oid JOIN pg_namespace n ON p.pronamespace = n.oid LEFT JOIN pg_roles tr ON tr.oid = e.evtowner LEFT JOIN pg_roles fr ON fr.oid = p.proowner ) SELECT evtname || '|' || evtenabled || '|' || COALESCE(trig_owner,'?') || '|' || COALESCE(CASE WHEN trig_owner_super THEN 'yes' ELSE 'no' END,'unknown') || '|' || function_name || '|' || COALESCE(func_owner,'?') || '|' || COALESCE(CASE WHEN func_owner_super THEN 'yes' ELSE 'no' END,'unknown') FROM evt WHERE COALESCE(trig_owner_super,false) = false OR COALESCE(func_owner_super,false) = false;" 2>&1)"
|
||||
psql_evt_status=$?
|
||||
if [ $psql_evt_status -eq 0 ]; then
|
||||
if [ "$psql_evt_output" ]; then
|
||||
echo "Non-superuser-owned event triggers were found (trigger|enabled?|owner|owner_is_super|function|function_owner|fn_owner_is_super):" | sed -${E} "s,.*,${SED_RED},"
|
||||
printf "%s\n" "$psql_evt_output" | while IFS='|' read evtname enabled owner owner_is_super func func_owner func_owner_is_super; do
|
||||
case "$enabled" in
|
||||
O) enabled="enabled" ;;
|
||||
D) enabled="disabled" ;;
|
||||
*) enabled="status_$enabled" ;;
|
||||
esac
|
||||
echo " - $evtname ($enabled) uses $func owned by $func_owner (superuser:$func_owner_is_super); trigger owner: $owner (superuser:$owner_is_super)" | sed -${E} "s,superuser:no,${SED_RED},g"
|
||||
done
|
||||
else
|
||||
echo "No event triggers owned by non-superusers were returned." | sed -${E} "s,.*,${SED_GREEN},"
|
||||
fi
|
||||
else
|
||||
psql_evt_err_line=$(printf '%s\n' "$psql_evt_output" | head -n1)
|
||||
echo "Could not query pg_event_trigger (psql exit $psql_evt_status): $psql_evt_err_line" | sed -${E} "s,.*,${SED_YELLOW},"
|
||||
fi
|
||||
else
|
||||
if ! [ "$TIMEOUT" ]; then
|
||||
echo_not_found "timeout"
|
||||
fi
|
||||
if ! [ "$psql_bin" ]; then
|
||||
echo_not_found "psql"
|
||||
fi
|
||||
fi
|
||||
|
||||
postgres_fdw_dirs="/etc/postgresql /var/lib/postgresql /var/lib/postgres /usr/lib/postgresql /usr/local/lib/postgresql /opt/supabase /opt/postgres /srv/postgres"
|
||||
postgres_fdw_hits=""
|
||||
for d in $postgres_fdw_dirs; do
|
||||
if [ -d "$d" ]; then
|
||||
old_ifs="$IFS"
|
||||
IFS="\n"
|
||||
for f in $(find "$d" -maxdepth 5 -type f \( -name '*postgres_fdw*.sql' -o -name '*postgres_fdw*.psql' -o -name 'after-create.sql' \) 2>/dev/null); do
|
||||
if [ -f "$f" ] && grep -qiE "alter[[:space:]]+role[[:space:]]+postgres[[:space:]]+superuser" "$f" 2>/dev/null; then
|
||||
postgres_fdw_hits="$postgres_fdw_hits\n$f"
|
||||
fi
|
||||
done
|
||||
IFS="$old_ifs"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$postgres_fdw_hits" ]; then
|
||||
echo "Detected postgres_fdw custom scripts granting postgres SUPERUSER (check for SupaPwn-style window):" | sed -${E} "s,.*,${SED_RED},"
|
||||
printf "%s\n" "$postgres_fdw_hits" | sed "s,^, - ,"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -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).
|
||||
|
||||
@@ -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()),
|
||||
|
||||
88
winPEAS/winPEASexe/winPEAS/Checks/SoapClientInfo.cs
Normal file
88
winPEAS/winPEASexe/winPEAS/Checks/SoapClientInfo.cs
Normal file
@@ -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<SoapClientProxyFinding> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SoapClientProxyInstance> Instances { get; } = new List<SoapClientProxyInstance>();
|
||||
public HashSet<string> BinaryIndicators { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
public HashSet<string> ConfigIndicators { get; } = new HashSet<string>(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<string, string> ConfigIndicatorMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "soap:address", "soap:address element present" },
|
||||
{ "soap12:address", "soap12:address element present" },
|
||||
{ "?wsdl", "?wsdl reference" },
|
||||
{ "<wsdl:", "WSDL schema embedded in config" },
|
||||
{ "servicedescriptionimporter", "ServiceDescriptionImporter referenced in config" },
|
||||
{ "system.web.services.description", "System.Web.Services.Description namespace referenced" },
|
||||
{ "new-webserviceproxy", "PowerShell New-WebServiceProxy referenced" },
|
||||
{ "file://", "file:// scheme referenced" },
|
||||
};
|
||||
|
||||
private const long MaxBinaryScanSize = 200 * 1024 * 1024; // 200MB
|
||||
private static readonly object DotNetCacheLock = new object();
|
||||
private static readonly Dictionary<string, bool> DotNetCache = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static List<SoapClientProxyFinding> CollectFindings()
|
||||
{
|
||||
var findings = new Dictionary<string, SoapClientProxyFinding>(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<SoapClientProxyCandidate> 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<SoapClientProxyCandidate> EnumerateProcessCandidates()
|
||||
{
|
||||
var results = new List<SoapClientProxyCandidate>();
|
||||
try
|
||||
{
|
||||
List<Dictionary<string, string>> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1197,6 +1197,7 @@
|
||||
<Compile Include="Checks\NetworkInfo.cs" />
|
||||
<Compile Include="Checks\ProcessInfo.cs" />
|
||||
<Compile Include="Checks\ServicesInfo.cs" />
|
||||
<Compile Include="Checks\SoapClientInfo.cs" />
|
||||
<Compile Include="Checks\SystemInfo.cs" />
|
||||
<Compile Include="Checks\UserInfo.cs" />
|
||||
<Compile Include="Checks\WindowsCreds.cs" />
|
||||
@@ -1223,6 +1224,7 @@
|
||||
<Compile Include="Info\ApplicationInfo\ApplicationInfoHelper.cs" />
|
||||
<Compile Include="Info\ApplicationInfo\AutoRuns.cs" />
|
||||
<Compile Include="Info\ApplicationInfo\DeviceDrivers.cs" />
|
||||
<Compile Include="Info\ApplicationInfo\SoapClientProxyAnalyzer.cs" />
|
||||
<Compile Include="Info\ApplicationInfo\InstalledApps.cs" />
|
||||
<Compile Include="Helpers\Beaprint.cs" />
|
||||
<Compile Include="Info\CloudInfo\AWSInfo.cs" />
|
||||
|
||||
@@ -19,6 +19,14 @@ Download the **[latest releas from here](https://github.com/peass-ng/PEASS-ng/re
|
||||
powershell "IEX(New-Object Net.WebClient).downloadString('https://raw.githubusercontent.com/peass-ng/PEASS-ng/master/winPEAS/winPEASps1/winPEAS.ps1')"
|
||||
```
|
||||
|
||||
|
||||
## Recent Updates
|
||||
|
||||
- Added Active Directory awareness checks to highlight Kerberos-only environments (NTLM restrictions) and time skew issues before attempting ticket-based attacks.
|
||||
- winPEAS.ps1 now reviews AD-integrated DNS ACLs to flag zones where low-privileged users can register/modify records (dynamic DNS hijack risk).
|
||||
- Enumerates high-value SPN accounts and weak gMSA password readers so you can immediately target Kerberoastable admins or abused service accounts.
|
||||
- Surfaces Schannel certificate mapping settings to warn about ESC10-style certificate abuse opportunities when UPN mapping is enabled.
|
||||
|
||||
## Advisory
|
||||
|
||||
All the scripts/binaries of the PEAS Suite should be used for authorized penetration testing and/or educational purposes only. Any misuse of this software will not be the responsibility of the author or of any other collaborator. Use it at your own networks and/or with the network owner's permission.
|
||||
|
||||
@@ -148,6 +148,244 @@ function Get-ClipBoardText {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DomainContext {
|
||||
try {
|
||||
return [System.DirectoryServices.ActiveDirectory.Domain]::GetComputerDomain()
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-SidToName {
|
||||
param(
|
||||
$SidInput
|
||||
)
|
||||
if ($null -eq $SidInput) { return $null }
|
||||
try {
|
||||
if ($SidInput -is [System.Security.Principal.SecurityIdentifier]) {
|
||||
$sidObject = $SidInput
|
||||
}
|
||||
else {
|
||||
$sidObject = New-Object System.Security.Principal.SecurityIdentifier($SidInput)
|
||||
}
|
||||
return $sidObject.Translate([System.Security.Principal.NTAccount]).Value
|
||||
}
|
||||
catch {
|
||||
try { return $sidObject.Value }
|
||||
catch { return [string]$SidInput }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WeakDnsUpdateFindings {
|
||||
param(
|
||||
[System.DirectoryServices.ActiveDirectory.Domain]$DomainContext
|
||||
)
|
||||
if (-not $DomainContext) { return @() }
|
||||
$domainDN = $DomainContext.GetDirectoryEntry().distinguishedName
|
||||
$forestDN = $DomainContext.Forest.RootDomain.GetDirectoryEntry().distinguishedName
|
||||
$paths = @(
|
||||
"LDAP://CN=MicrosoftDNS,DC=DomainDnsZones,$domainDN",
|
||||
"LDAP://CN=MicrosoftDNS,DC=ForestDnsZones,$forestDN",
|
||||
"LDAP://CN=MicrosoftDNS,$domainDN"
|
||||
)
|
||||
$weakPatterns = @(
|
||||
"authenticated users",
|
||||
"everyone",
|
||||
"domain users"
|
||||
)
|
||||
$dangerousRights = @("GenericAll", "GenericWrite", "CreateChild", "WriteProperty", "WriteDacl", "WriteOwner")
|
||||
$findings = @()
|
||||
foreach ($path in $paths) {
|
||||
try {
|
||||
$container = New-Object System.DirectoryServices.DirectoryEntry($path)
|
||||
$null = $container.NativeGuid
|
||||
}
|
||||
catch { continue }
|
||||
$searcher = New-Object System.DirectoryServices.DirectorySearcher($container)
|
||||
$searcher.Filter = "(objectClass=dnsZone)"
|
||||
$searcher.PageSize = 500
|
||||
$results = $searcher.FindAll()
|
||||
foreach ($result in $results) {
|
||||
try {
|
||||
$zoneEntry = $result.GetDirectoryEntry()
|
||||
$zoneEntry.Options.SecurityMasks = [System.DirectoryServices.SecurityMasks]::Dacl
|
||||
$sd = $zoneEntry.ObjectSecurity
|
||||
foreach ($ace in $sd.Access) {
|
||||
if ($ace.AccessControlType -ne 'Allow') { continue }
|
||||
$principal = Convert-SidToName $ace.IdentityReference
|
||||
if (-not $principal) { continue }
|
||||
$principalLower = $principal.ToLower()
|
||||
if (-not ($weakPatterns | Where-Object { $principalLower -like "*${_}*" })) { continue }
|
||||
$rights = $ace.ActiveDirectoryRights.ToString()
|
||||
if (-not ($dangerousRights | Where-Object { $rights -like "*${_}*" })) { continue }
|
||||
$findings += [pscustomobject]@{
|
||||
Zone = $zoneEntry.Properties["name"].Value
|
||||
Partition = $path.Split(',')[1]
|
||||
Principal = $principal
|
||||
Rights = $rights
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { continue }
|
||||
}
|
||||
}
|
||||
return ($findings | Sort-Object Zone, Principal -Unique)
|
||||
}
|
||||
|
||||
function Get-GmsaReadersReport {
|
||||
param(
|
||||
[System.DirectoryServices.ActiveDirectory.Domain]$DomainContext
|
||||
)
|
||||
if (-not $DomainContext) { return @() }
|
||||
$domainDN = $DomainContext.GetDirectoryEntry().distinguishedName
|
||||
try {
|
||||
$searcher = New-Object System.DirectoryServices.DirectorySearcher
|
||||
$searcher.SearchRoot = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$domainDN")
|
||||
$searcher.Filter = "(&(objectClass=msDS-GroupManagedServiceAccount))"
|
||||
$searcher.PageSize = 500
|
||||
[void]$searcher.PropertiesToLoad.Add("sAMAccountName")
|
||||
[void]$searcher.PropertiesToLoad.Add("msDS-GroupMSAMembership")
|
||||
$results = $searcher.FindAll()
|
||||
}
|
||||
catch { return @() }
|
||||
$report = @()
|
||||
foreach ($result in $results) {
|
||||
$name = $result.Properties["samaccountname"]
|
||||
$blobs = $result.Properties["msds-groupmsamembership"]
|
||||
if (-not $blobs) { continue }
|
||||
$principals = @()
|
||||
foreach ($blob in $blobs) {
|
||||
try {
|
||||
$raw = New-Object System.Security.AccessControl.RawSecurityDescriptor (, $blob)
|
||||
foreach ($ace in $raw.DiscretionaryAcl) {
|
||||
$sid = Convert-SidToName $ace.SecurityIdentifier
|
||||
if ($sid) { $principals += $sid }
|
||||
}
|
||||
}
|
||||
catch { continue }
|
||||
}
|
||||
if ($principals.Count -eq 0) { continue }
|
||||
$principals = $principals | Sort-Object -Unique
|
||||
$weak = $principals | Where-Object { $_ -match 'Domain Users|Authenticated Users|Everyone' }
|
||||
$report += [pscustomobject]@{
|
||||
Account = ($name | Select-Object -First 1)
|
||||
Allowed = ($principals -join ", ")
|
||||
WeakPrincipals = if ($weak) { $weak -join ", " } else { "" }
|
||||
}
|
||||
}
|
||||
return $report
|
||||
}
|
||||
|
||||
function Get-PrivilegedSpnTargets {
|
||||
param(
|
||||
[System.DirectoryServices.ActiveDirectory.Domain]$DomainContext
|
||||
)
|
||||
if (-not $DomainContext) { return @() }
|
||||
$domainDN = $DomainContext.GetDirectoryEntry().distinguishedName
|
||||
$keywords = @(
|
||||
"Domain Admin",
|
||||
"Enterprise Admin",
|
||||
"Administrators",
|
||||
"Exchange",
|
||||
"IT_",
|
||||
"Schema Admin",
|
||||
"Account Operator",
|
||||
"Server Operator",
|
||||
"Backup Operator",
|
||||
"DnsAdmin"
|
||||
)
|
||||
try {
|
||||
$searcher = New-Object System.DirectoryServices.DirectorySearcher
|
||||
$searcher.SearchRoot = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$domainDN")
|
||||
$searcher.Filter = "(&(objectClass=user)(servicePrincipalName=*))"
|
||||
$searcher.PageSize = 500
|
||||
[void]$searcher.PropertiesToLoad.Add("sAMAccountName")
|
||||
[void]$searcher.PropertiesToLoad.Add("memberOf")
|
||||
$results = $searcher.FindAll()
|
||||
}
|
||||
catch { return @() }
|
||||
$findings = @()
|
||||
foreach ($res in $results) {
|
||||
$groups = $res.Properties["memberof"]
|
||||
if (-not $groups) { continue }
|
||||
$matchedGroups = @()
|
||||
foreach ($group in $groups) {
|
||||
$cn = ($group -split ',')[0] -replace '^CN=',''
|
||||
if ($keywords | Where-Object { $cn -like "*${_}*" }) {
|
||||
$matchedGroups += $cn
|
||||
}
|
||||
}
|
||||
if ($matchedGroups.Count -gt 0) {
|
||||
$findings += [pscustomobject]@{
|
||||
User = ($res.Properties["samaccountname"] | Select-Object -First 1)
|
||||
Groups = ($matchedGroups | Sort-Object -Unique) -join ', '
|
||||
}
|
||||
}
|
||||
}
|
||||
return ($findings | Sort-Object User | Select-Object -First 12)
|
||||
}
|
||||
|
||||
function Get-NtlmPolicySummary {
|
||||
try {
|
||||
$msv = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' -ErrorAction Stop
|
||||
}
|
||||
catch { return $null }
|
||||
$lsa = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -ErrorAction SilentlyContinue
|
||||
return [pscustomobject]@{
|
||||
RestrictReceiving = $msv.RestrictReceivingNTLMTraffic
|
||||
RestrictSending = $msv.RestrictSendingNTLMTraffic
|
||||
LmCompatibility = if ($lsa) { $lsa.LmCompatibilityLevel } else { $null }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-TimeSkewInfo {
|
||||
param(
|
||||
[System.DirectoryServices.ActiveDirectory.Domain]$DomainContext
|
||||
)
|
||||
if (-not $DomainContext) { return $null }
|
||||
try {
|
||||
$pdc = $DomainContext.PdcRoleOwner.Name
|
||||
}
|
||||
catch { return $null }
|
||||
try {
|
||||
$stripchart = w32tm /stripchart /computer:$pdc /dataonly /samples:3 2>$null
|
||||
$sample = $stripchart | Where-Object { $_ -match ',' } | Select-Object -Last 1
|
||||
if (-not $sample) { return $null }
|
||||
$parts = $sample.Split(',')
|
||||
if ($parts.Count -lt 2) { return $null }
|
||||
$offsetString = $parts[1].Trim().TrimEnd('s')
|
||||
[double]$offsetSeconds = 0
|
||||
if (-not [double]::TryParse($offsetString, [ref]$offsetSeconds)) { return $null }
|
||||
return [pscustomobject]@{
|
||||
Source = $pdc
|
||||
OffsetSeconds = $offsetSeconds
|
||||
RawSample = $sample
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AdcsSchannelInfo {
|
||||
$info = [ordered]@{
|
||||
MappingValue = $null
|
||||
UpnMapping = $false
|
||||
ServiceState = $null
|
||||
}
|
||||
try {
|
||||
$schannel = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL' -Name 'CertificateMappingMethods' -ErrorAction Stop
|
||||
$info.MappingValue = $schannel.CertificateMappingMethods
|
||||
if (($schannel.CertificateMappingMethods -band 0x4) -eq 0x4) { $info.UpnMapping = $true }
|
||||
}
|
||||
catch { }
|
||||
$svc = Get-Service -Name certsrv -ErrorAction SilentlyContinue
|
||||
if ($svc) { $info.ServiceState = $svc.Status }
|
||||
return [pscustomobject]$info
|
||||
}
|
||||
|
||||
|
||||
function Search-Excel {
|
||||
[cmdletbinding()]
|
||||
Param (
|
||||
@@ -1226,6 +1464,95 @@ Write-Host -ForegroundColor Blue "=========|| LISTENING PORTS"
|
||||
Start-Process NETSTAT.EXE -ArgumentList "-ano" -Wait -NoNewWindow
|
||||
|
||||
|
||||
######################## ACTIVE DIRECTORY / IDENTITY MISCONFIG CHECKS ########################
|
||||
Write-Host ""
|
||||
if ($TimeStamp) { TimeElapsed }
|
||||
Write-Host -ForegroundColor Blue "=========|| ACTIVE DIRECTORY / IDENTITY MISCONFIG CHECKS"
|
||||
|
||||
$domainContext = Get-DomainContext
|
||||
if (-not $domainContext) {
|
||||
Write-Host "Host appears to be in a workgroup or the AD context could not be resolved. Skipping domain-specific checks." -ForegroundColor DarkGray
|
||||
}
|
||||
else {
|
||||
$ntlmStatus = Get-NtlmPolicySummary
|
||||
if ($ntlmStatus) {
|
||||
$recvValue = if ($ntlmStatus.RestrictReceiving -ne $null) { [int]$ntlmStatus.RestrictReceiving } else { -1 }
|
||||
$sendValue = if ($ntlmStatus.RestrictSending -ne $null) { [int]$ntlmStatus.RestrictSending } else { -1 }
|
||||
$lmValue = if ($ntlmStatus.LmCompatibility -ne $null) { [int]$ntlmStatus.LmCompatibility } else { -1 }
|
||||
$ntlmMsg = "Receiving:{0} Sending:{1} LMCompat:{2}" -f $recvValue, $sendValue, $lmValue
|
||||
if ($recvValue -ge 1 -or $sendValue -ge 1 -or $lmValue -ge 5) {
|
||||
Write-Host "[!] NTLM is restricted/disabled ($ntlmMsg). Expect Kerberos-only auth paths (sync time before Kerberoasting)." -ForegroundColor Yellow
|
||||
}
|
||||
else {
|
||||
Write-Host "[i] NTLM restrictions appear relaxed ($ntlmMsg)."
|
||||
}
|
||||
}
|
||||
|
||||
$timeSkew = Get-TimeSkewInfo -DomainContext $domainContext
|
||||
if ($timeSkew) {
|
||||
$offsetAbs = [math]::Abs($timeSkew.OffsetSeconds)
|
||||
$timeMsg = "Offset vs {0}: {1:N3}s (sample: {2})" -f $timeSkew.Source, $timeSkew.OffsetSeconds, $timeSkew.RawSample.Trim()
|
||||
if ($offsetAbs -gt 5) {
|
||||
Write-Host "[!] Significant Kerberos time skew detected - $timeMsg" -ForegroundColor Yellow
|
||||
}
|
||||
else {
|
||||
Write-Host "[i] Kerberos time offset looks OK - $timeMsg"
|
||||
}
|
||||
}
|
||||
|
||||
$dnsFindings = @(Get-WeakDnsUpdateFindings -DomainContext $domainContext)
|
||||
if ($dnsFindings.Count -gt 0) {
|
||||
Write-Host "[!] AD-integrated DNS zones allow low-priv principals to write records (dynamic DNS hijack / service MITM risk)." -ForegroundColor Yellow
|
||||
$dnsFindings | Format-Table Zone,Partition,Principal,Rights -AutoSize | Out-String | Write-Host
|
||||
}
|
||||
else {
|
||||
Write-Host "[i] No obvious insecure dynamic DNS ACLs found with current privileges."
|
||||
}
|
||||
|
||||
$spnFindings = @(Get-PrivilegedSpnTargets -DomainContext $domainContext)
|
||||
if ($spnFindings.Count -gt 0) {
|
||||
Write-Host "[!] High-value SPN accounts identified (prime Kerberoast targets):" -ForegroundColor Yellow
|
||||
$spnFindings | Format-Table User,Groups -AutoSize | Out-String | Write-Host
|
||||
}
|
||||
else {
|
||||
Write-Host "[i] No privileged SPN users detected via quick LDAP search."
|
||||
}
|
||||
|
||||
$gmsaReport = @(Get-GmsaReadersReport -DomainContext $domainContext)
|
||||
if ($gmsaReport.Count -gt 0) {
|
||||
$weakGmsa = $gmsaReport | Where-Object { $_.WeakPrincipals -ne "" }
|
||||
if ($weakGmsa) {
|
||||
Write-Host "[!] gMSA passwords readable by low-priv groups/principals: " -ForegroundColor Yellow
|
||||
$weakGmsa | Select-Object Account, WeakPrincipals | Format-Table -AutoSize | Out-String | Write-Host
|
||||
}
|
||||
else {
|
||||
Write-Host "[i] gMSA accounts discovered (review allowed readers below)."
|
||||
$gmsaReport | Select-Object Account, Allowed | Sort-Object Account | Select-Object -First 5 | Format-Table -Wrap | Out-String | Write-Host
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "[i] No gMSA objects found via LDAP."
|
||||
}
|
||||
|
||||
$adcsInfo = Get-AdcsSchannelInfo
|
||||
if ($adcsInfo.MappingValue -ne $null) {
|
||||
$hex = ('0x{0:X}' -f [int]$adcsInfo.MappingValue)
|
||||
if ($adcsInfo.UpnMapping) {
|
||||
Write-Host ("[!] Schannel CertificateMappingMethods={0} (UPN mapping allowed) - ESC10 certificate abuse possible if you can edit another user's UPN." -f $hex) -ForegroundColor Yellow
|
||||
}
|
||||
else {
|
||||
Write-Host ("[i] Schannel CertificateMappingMethods={0} (UPN mapping flag not set)." -f $hex)
|
||||
}
|
||||
if ($adcsInfo.ServiceState) {
|
||||
Write-Host ("[i] AD CS service state: {0}" -f $adcsInfo.ServiceState)
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "[i] Could not read Schannel certificate mapping configuration." -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Write-Host ""
|
||||
if ($TimeStamp) { TimeElapsed }
|
||||
Write-Host -ForegroundColor Blue "=========|| ARP Table"
|
||||
|
||||
Reference in New Issue
Block a user